Multi-topic batch. The big-ticket item is the skills audit; the rest are smaller patches that compounded during the audit work. ## Skills audit (rules→recipes split) Vendored all 26 skills from /home/samkintop/opt/skills/ into data/skills/ (the boocode-repo-local skill library — see docker-compose change below). Audited via 5 parallel Claude Code agent-teams running the mgechev/skills-best-practices 4-step protocol (Discovery → Logic → Edge Case → self-Architecture-Refinement) per skill, ~2 min wall-clock vs the ~3.7-hour serial estimate. Result: 14 skills surviving (renamed to gerund form, frontmatter matched), 11 deleted (duplicates, BooCode-irrelevant patterns, Claude-already-does- natively), 1 migrated to BOOCHAT.md/BOOCODER.md as an always-true rule (verification-before-completion). Each surviving skill had its description refined to fix specific trigger gaps surfaced by the protocol — 4 real-bug findings landed (dead refs, stale tags, broken sub-file references in the original vendored content). Audit decisions documented in openspec/changes/v1.13.12-skills-audit/ audit-notes.md. Convention codified in BOOCHAT.md/BOOCODER.md "rules vs recipes" sections — future workflow rules go to those files (100% present), recipes stay in data/skills/ (~6% invoke rate in multi-turn per the Codeminer42 measurement). ## Token tracking + stale-stream banner fix (same root cause) ws-frames.ts IsoTimestamp was z.string().min(1) but postgres returns timestamp columns as JS Date objects. Every message_complete / session_updated / chat_updated frame was failing the v1.13.11 Zod gate and being silently dropped. Symptoms: token tracking blank in the UI (no usage frames landed); the 60s no-token-activity timer tripped the stale-stream banner because the frontend's local message state never saw status='streaming' flip to 'complete'. Fix: z.preprocess(v => v instanceof Date ? v.toISOString() : v, z.string().min(1)) applied to the IsoTimestamp primitive. Centralized, no publisher changes, works identically server + web (the parity test still passes). ## Codecontext .codecontextignore auto-install services/codecontext_client.ts now copies the codecontext/.codecontextignore.template into any project's root on the first call to that project if no .codecontextignore exists. One file written per project, idempotent (in-memory Set guard + access-check), silent fallback on read-only project. Stops the upstream empty-source- file parser crash on foreign projects' node_modules — previously required manually copying the template per project. ## Tool-call budget cap 30 → 50 services/inference/budget.ts: BUDGET_READ_ONLY and BUDGET_NO_AGENT bumped to 50 (from 30). BUDGET_NON_READ_ONLY stays at 10 (no write tools landed yet). Real recon sessions were hitting 30 with ~3 turns wasted on codecontext parse failures; legitimate need was ~27, and Architect-class system overviews want deeper recon. Headroom of 20 absorbs failure-retry turns without changing the safety floor — the doom-loop guard (3 identical calls → abort) catches the actual failure mode this cap was guarding against. v1.14 (Phase C outer agent loop) will supersede this via per-agent agent.steps. Throwaway-ish patch but unblocks deeper recon today. ## UI cleanups - ChatPane queued-message dropdown removed. Each queued message now has three buttons: edit (pop back into ChatInput via sendToChat event), force-send (was the dropdown's only useful action), and cancel. Default behavior (send when streaming completes) needs no UI — it's the implicit do-nothing path. - ChatThroughput removed from desktop tab strip (ChatTabBar.tsx). Mobile tab switcher still shows it. ## Plumbing - .gitignore: data/* + !data/AGENTS.md + !data/skills/ negation patterns so the vendored skill library + agent registry become git-tracked while session DB state stays out. - docker-compose.yml: removed /opt/skills:/data/skills override mount. Skills now live in the boocode repo at data/skills/, auditable per-batch. The host-level /opt/skills/ is preserved untouched for any other tools that read from it. - .codecontextignore at repo root: auto-installed when codecontext was first called against /opt/boocode itself; matches the template. - CLAUDE.md: updated to document the v1.13.11 publishFrame wrapper + message_parts table + tool_cost_stats view + DB-integration test pattern + host-side smoke endpoint quirk. (Pre-existing in working tree before this batch; shipped here for completeness.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
223 lines
8.1 KiB
TypeScript
223 lines
8.1 KiB
TypeScript
import { useState } from 'react';
|
|
import { Bot, History, MessageSquare, Plus, Terminal, X } from 'lucide-react';
|
|
import type { Chat, WorkspacePane } from '@/api/types';
|
|
import { StatusDot } from '@/components/StatusDot';
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuSeparator,
|
|
ContextMenuTrigger,
|
|
} from '@/components/ui/context-menu';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { useLongPress } from '@/hooks/useLongPress';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface Props {
|
|
pane: WorkspacePane;
|
|
tabs: Chat[];
|
|
onSwitchTab: (tabIdx: number) => void;
|
|
onRemoveTab: (chatId: string) => void;
|
|
onCloseOthers: (chatId: string) => void;
|
|
onCloseToRight: (chatId: string) => void;
|
|
onCloseAll: () => void;
|
|
onAddPane: (kind: 'chat' | 'terminal' | 'agent') => void;
|
|
onShowHistory: () => void;
|
|
onRename: (chatId: string, name: string) => Promise<void>;
|
|
onRemovePane?: () => void;
|
|
}
|
|
|
|
export function ChatTabBar({
|
|
pane,
|
|
tabs,
|
|
onSwitchTab,
|
|
onRemoveTab,
|
|
onCloseOthers,
|
|
onCloseToRight,
|
|
onCloseAll,
|
|
onAddPane,
|
|
onShowHistory,
|
|
onRename,
|
|
onRemovePane,
|
|
}: Props) {
|
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
const [renameValue, setRenameValue] = useState('');
|
|
|
|
// Long-press: dispatch a synthetic contextmenu event on the tab so the
|
|
// existing Radix ContextMenuTrigger opens at the touch coordinates. Works
|
|
// because asChild composition makes the tab div the trigger element.
|
|
const longPress = useLongPress(({ clientX, clientY, target }) => {
|
|
if (!target || !(target instanceof Element)) return;
|
|
const tab = target.closest('[data-tab-id]') as HTMLElement | null;
|
|
if (!tab) return;
|
|
tab.dispatchEvent(
|
|
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX, clientY }),
|
|
);
|
|
});
|
|
|
|
function startRename(chatId: string, currentName: string | null) {
|
|
setRenamingId(chatId);
|
|
setRenameValue(currentName ?? '');
|
|
}
|
|
|
|
async function finishRename() {
|
|
if (renamingId && renameValue.trim()) {
|
|
await onRename(renamingId, renameValue.trim());
|
|
}
|
|
setRenamingId(null);
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center border-b border-border bg-muted/20 h-8 shrink-0 overflow-x-auto max-md:hidden">
|
|
{tabs.map((chat, tabIdx) => {
|
|
const isActive = tabIdx === pane.activeChatIdx;
|
|
const isLast = tabIdx === tabs.length - 1;
|
|
const onlyTab = tabs.length === 1;
|
|
const label = chat.name ?? 'New chat';
|
|
return (
|
|
<ContextMenu key={chat.id}>
|
|
<ContextMenuTrigger asChild>
|
|
<div
|
|
data-tab-id={chat.id}
|
|
onClick={() => onSwitchTab(tabIdx)}
|
|
onTouchStart={longPress.onTouchStart}
|
|
onTouchMove={longPress.onTouchMove}
|
|
onTouchEnd={longPress.onTouchEnd}
|
|
onTouchCancel={longPress.onTouchCancel}
|
|
style={{ WebkitTouchCallout: 'none' }}
|
|
className={cn(
|
|
'group flex items-center gap-1.5 px-3 py-1.5 text-xs border-r border-border cursor-default select-none shrink-0',
|
|
isActive
|
|
? 'bg-background text-foreground'
|
|
: 'bg-muted/30 text-muted-foreground hover:bg-muted/60'
|
|
)}
|
|
>
|
|
<MessageSquare size={12} className="shrink-0" />
|
|
<StatusDot chatId={chat.id} />
|
|
{renamingId === chat.id ? (
|
|
<input
|
|
autoFocus
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
onBlur={() => void finishRename()}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') void finishRename();
|
|
if (e.key === 'Escape') setRenamingId(null);
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="bg-transparent border-b border-border text-xs outline-none w-28"
|
|
/>
|
|
) : (
|
|
<span className="truncate max-w-[140px]" title={label}>
|
|
{label}
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRemoveTab(chat.id);
|
|
}}
|
|
className="inline-flex items-center justify-center p-0.5 hover:bg-muted rounded opacity-0 group-hover:opacity-60 hover:!opacity-100 shrink-0 max-md:min-h-[44px] max-md:min-w-[44px] max-md:opacity-100"
|
|
aria-label="Close tab"
|
|
>
|
|
<X size={10} />
|
|
</button>
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
<ContextMenuItem onSelect={() => onAddPane('chat')}>
|
|
New chat
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
|
|
Rename
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem onSelect={() => onRemoveTab(chat.id)}>
|
|
Close
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
disabled={onlyTab}
|
|
onSelect={() => onCloseOthers(chat.id)}
|
|
>
|
|
Close others
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
disabled={isLast}
|
|
onSelect={() => onCloseToRight(chat.id)}
|
|
>
|
|
Close to right
|
|
</ContextMenuItem>
|
|
<ContextMenuItem onSelect={() => onCloseAll()}>
|
|
Close all
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
);
|
|
})}
|
|
|
|
{tabs.length === 0 && (
|
|
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground">
|
|
<History size={12} className="shrink-0" />
|
|
<span>Session</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
|
aria-label="New pane"
|
|
title="New pane"
|
|
>
|
|
<Plus size={12} />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="min-w-40">
|
|
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
|
<MessageSquare size={14} /> New chat
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
|
|
<Terminal size={14} /> New terminal
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => onAddPane('agent')}>
|
|
<Bot size={14} /> New agent
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<button
|
|
type="button"
|
|
onClick={onShowHistory}
|
|
className={cn(
|
|
'inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]',
|
|
pane.kind === 'empty' && 'text-foreground bg-muted/50'
|
|
)}
|
|
aria-label="Session history"
|
|
title="Session history"
|
|
>
|
|
<History size={12} />
|
|
</button>
|
|
{onRemovePane && (
|
|
<button
|
|
type="button"
|
|
onClick={onRemovePane}
|
|
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
|
aria-label="Close pane"
|
|
title="Close pane"
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|