refactor: codebase audit cleanup — dead code, dedup, module splits

Multi-agent audit + aggressive cleanup across server/web/coder/booterm,
delivered behind a DEFER discipline so none of the in-flight files were
touched. Removes dead code/deps/columns, dedups server + coder helpers,
and splits the oversized modules (tools.ts, opencode-server.ts,
sentinel-summaries, turn.ts, TerminalPane.tsx) behind stable contracts.
Adds 78 parity/unit tests (server 587, coder 323); fixes two latent bugs
(ChatPane queue keys, FileViewerOverlay blank-line parity).

Intended tag: v2.7.12-audit-cleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 21:10:06 +00:00
parent e5ce01ae72
commit 8c200216eb
143 changed files with 6729 additions and 6087 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { formatAgo } from '@/lib/format';
import { Check, ChevronDown } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
@@ -191,12 +192,3 @@ function formatK(n: number): string {
return `${Math.round(n / 1000)}k`;
}
function formatAgo(iso: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return '—';
const diff = Date.now() - then;
if (diff < 60_000) return 'just now';
if (diff < 3_600_000) return `${Math.round(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)}h ago`;
return `${Math.round(diff / 86_400_000)}d ago`;
}

View File

@@ -0,0 +1,63 @@
import { Check, Copy, Download, X } from 'lucide-react';
interface Props {
title: string;
defaultTitle: string;
onDownload: () => void;
downloadDisabled: boolean;
onClose: () => void;
// Optional copy button (Markdown pane only; HTML pane omits it).
onCopy?: () => void;
justCopied?: boolean;
copyDisabled?: boolean;
}
export function ArtifactPaneHeader({
title,
defaultTitle,
onDownload,
downloadDisabled,
onClose,
onCopy,
justCopied,
copyDisabled,
}: Props) {
return (
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
<span className="text-xs text-muted-foreground truncate flex-1" title={title}>
{title || defaultTitle}
</span>
{onCopy && (
<button
type="button"
onClick={onCopy}
disabled={copyDisabled}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Copy source"
title="Copy"
>
{justCopied ? <Check size={12} /> : <Copy size={12} />}
</button>
)}
<button
type="button"
onClick={onDownload}
disabled={downloadDisabled}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Download"
title="Download"
>
<Download size={12} />
</button>
<button
type="button"
onClick={onClose}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Close artifact pane"
title="Close"
>
<X size={12} />
</button>
</div>
);
}

View File

@@ -9,9 +9,7 @@ interface Props {
path: string;
content: string;
lang: string | null;
projectId: string;
onClose: () => void;
onNavigate: (path: string) => void;
}
const SHIKI_THEME = 'github-dark';
@@ -21,7 +19,11 @@ function splitShikiLines(html: string): string[] {
if (!match) return [];
const inner = match[1]!;
const lines = inner.split(/(?=<span class="line">)/);
return lines.filter(l => l.trim().length > 0);
// Keep every line segment (including blank `<span class="line"></span>`
// entries) so lineHtmls.length matches the raw line count and line-number
// indexing stays in sync. The first element before the first line span is
// discarded (it's always empty or whitespace).
return lines.filter(l => l.startsWith('<span class="line">'));
}
function basename(path: string): string {

View File

@@ -10,10 +10,10 @@
// sessions.workspace_panes jsonb stays small and message_parts.payload is the
// single source of truth.
import { useEffect, useState } from 'react';
import { Download, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { HtmlArtifactState } from '@/api/types';
import { ArtifactPaneHeader } from './ArtifactPaneHeader';
import { useArtifactDownload } from '@/hooks/useArtifactDownload';
interface Props {
chatId: string;
@@ -22,9 +22,9 @@ interface Props {
}
export function HtmlArtifactPane({ chatId, state, onClose }: Props) {
const [downloading, setDownloading] = useState(false);
const [htmlContent, setHtmlContent] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const { downloading, download } = useArtifactDownload(chatId, state.message_id, 'html');
useEffect(() => {
let cancelled = false;
@@ -45,53 +45,15 @@ export function HtmlArtifactPane({ chatId, state, onClose }: Props) {
};
}, [chatId, state.message_id]);
async function download() {
if (downloading) return;
setDownloading(true);
try {
const { url, path } = await api.messages.downloadArtifact(
chatId,
state.message_id,
'html',
);
const a = document.createElement('a');
a.href = url;
a.rel = 'noopener';
a.click();
toast.success(`Saved to ${path}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'download failed');
} finally {
setDownloading(false);
}
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
<span className="text-xs text-muted-foreground truncate flex-1" title={state.title}>
{state.title || 'HTML artifact'}
</span>
<button
type="button"
onClick={() => void download()}
disabled={downloading || htmlContent === null}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Download HTML"
title="Download"
>
<Download size={12} />
</button>
<button
type="button"
onClick={onClose}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Close artifact pane"
title="Close"
>
<X size={12} />
</button>
</div>
<ArtifactPaneHeader
title={state.title}
defaultTitle="HTML artifact"
onDownload={() => void download()}
downloadDisabled={downloading || htmlContent === null}
onClose={onClose}
/>
<div className="flex-1 min-h-0 overflow-hidden bg-background">
{loadError ? (
<div className="p-4 text-sm text-destructive">Failed to load: {loadError}</div>

View File

@@ -8,11 +8,12 @@
// the matching message_id. This keeps sessions.workspace_panes jsonb small
// and the assistant message row remains the single source of truth.
import { useEffect, useState } from 'react';
import { Check, Copy, Download, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { MarkdownArtifactState } from '@/api/types';
import { MarkdownRenderer } from './MarkdownRenderer';
import { ArtifactPaneHeader } from './ArtifactPaneHeader';
import { useArtifactDownload } from '@/hooks/useArtifactDownload';
interface Props {
chatId: string;
@@ -22,9 +23,9 @@ interface Props {
export function MarkdownArtifactPane({ chatId, state, onClose }: Props) {
const [justCopied, setJustCopied] = useState(false);
const [downloading, setDownloading] = useState(false);
const [content, setContent] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const { downloading, download } = useArtifactDownload(chatId, state.message_id, 'md');
useEffect(() => {
let cancelled = false;
@@ -64,65 +65,18 @@ export function MarkdownArtifactPane({ chatId, state, onClose }: Props) {
}
}
async function download() {
if (downloading) return;
setDownloading(true);
try {
const { url, path } = await api.messages.downloadArtifact(
chatId,
state.message_id,
'md',
);
// Trigger browser download from the returned URL. The endpoint stamps
// Content-Disposition: attachment so the click lands as a save.
const a = document.createElement('a');
a.href = url;
a.rel = 'noopener';
a.click();
toast.success(`Saved to ${path}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'download failed');
} finally {
setDownloading(false);
}
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
<span className="text-xs text-muted-foreground truncate flex-1" title={state.title}>
{state.title || 'Markdown artifact'}
</span>
<button
type="button"
onClick={() => void copy()}
disabled={content === null}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Copy markdown source"
title="Copy"
>
{justCopied ? <Check size={12} /> : <Copy size={12} />}
</button>
<button
type="button"
onClick={() => void download()}
disabled={downloading || content === null}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Download markdown"
title="Download"
>
<Download size={12} />
</button>
<button
type="button"
onClick={onClose}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Close artifact pane"
title="Close"
>
<X size={12} />
</button>
</div>
<ArtifactPaneHeader
title={state.title}
defaultTitle="Markdown artifact"
onDownload={() => void download()}
downloadDisabled={downloading || content === null}
onClose={onClose}
onCopy={() => void copy()}
justCopied={justCopied}
copyDisabled={content === null}
/>
<div className="flex-1 min-h-0 overflow-auto px-4 py-3 text-sm">
{loadError ? (
<div className="text-destructive">Failed to load: {loadError}</div>

View File

@@ -2,53 +2,13 @@
// in-chat bubble renderer and the MarkdownArtifactPane share the same Shiki +
// remark-gfm + path-linkifier pipeline. Behavior preserved byte-for-byte from
// the original MessageBubble.MarkdownBody helper (and its linkify helpers).
import { Children, cloneElement, isValidElement } from 'react';
import { memo, Children, cloneElement, isValidElement } from 'react';
import type { ReactElement, ReactNode } from 'react';
import Markdown from 'react-markdown';
import type { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { CodeBlock } from './CodeBlock';
import { sessionEvents } from '@/hooks/sessionEvents';
// Match path-shaped substrings ending in `.ext`. Additionally require a `/`
// in the match to reduce false positives in prose (e.g. plain `foo.ts` won't
// match, but `src/foo.ts` will). False positives at the edges are accepted
// per Sam's design decision (2026-05-14).
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
function isPathLike(s: string): boolean {
return s.includes('/');
}
function emitOpenFile(path: string): void {
sessionEvents.emit({ type: 'open_file_in_browser', path });
}
function linkifyPaths(text: string, keyPrefix: string): ReactNode {
const out: ReactNode[] = [];
let lastIdx = 0;
let idx = 0;
for (const match of text.matchAll(PATH_REGEX)) {
const matchedText = match[0];
const start = match.index ?? 0;
if (!isPathLike(matchedText)) continue;
if (start > lastIdx) out.push(text.slice(lastIdx, start));
out.push(
<button
key={`${keyPrefix}-${idx}`}
type="button"
onClick={() => emitOpenFile(matchedText)}
className="text-primary underline cursor-pointer hover:text-primary/80"
>
{matchedText}
</button>
);
lastIdx = start + matchedText.length;
idx += 1;
}
if (out.length === 0) return text;
if (lastIdx < text.length) out.push(text.slice(lastIdx));
return out;
}
import { linkifyPaths } from '@/lib/linkify-paths';
function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
const arr = Children.toArray(children);
@@ -92,57 +52,58 @@ const codeRenderer = (props: { children?: unknown; className?: string }) => {
);
};
export function MarkdownRenderer({ content }: { content: string }) {
return (
<Markdown
remarkPlugins={[remarkGfm]}
components={{
pre: ({ children }) => <>{children}</>,
code: codeRenderer,
a: ({ children, href }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="underline decoration-muted-foreground/40 underline-offset-2 hover:decoration-foreground"
>
{children}
</a>
),
ul: ({ children }) => (
<ul className="list-disc pl-5 space-y-1">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal pl-5 space-y-1">{children}</ol>
),
li: ({ children }) => <li>{linkifyChildren(children)}</li>,
p: ({ children }) => (
<p className="leading-relaxed">{linkifyChildren(children)}</p>
),
h1: ({ children }) => <h1 className="text-base font-semibold mt-2">{children}</h1>,
h2: ({ children }) => <h2 className="text-sm font-semibold mt-2">{children}</h2>,
h3: ({ children }) => <h3 className="text-sm font-semibold mt-1">{children}</h3>,
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-border pl-3 text-muted-foreground">
{children}
</blockquote>
),
table: ({ children }) => (
<div className="overflow-x-auto">
<table className="border-collapse text-xs">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border border-border px-2 py-1 text-left font-medium">{children}</th>
),
td: ({ children }) => (
<td className="border border-border px-2 py-1">
{linkifyChildren(children)}
</td>
),
}}
// Hoisted to module level — closes over nothing render-scoped, so a stable
// reference avoids react-markdown reconstructing its vdom on every token delta.
const MARKDOWN_COMPONENTS: Components = {
pre: ({ children }) => <>{children}</>,
code: codeRenderer,
a: ({ children, href }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="underline decoration-muted-foreground/40 underline-offset-2 hover:decoration-foreground"
>
{children}
</a>
),
ul: ({ children }) => (
<ul className="list-disc pl-5 space-y-1">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal pl-5 space-y-1">{children}</ol>
),
li: ({ children }) => <li>{linkifyChildren(children)}</li>,
p: ({ children }) => (
<p className="leading-relaxed">{linkifyChildren(children)}</p>
),
h1: ({ children }) => <h1 className="text-base font-semibold mt-2">{children}</h1>,
h2: ({ children }) => <h2 className="text-sm font-semibold mt-2">{children}</h2>,
h3: ({ children }) => <h3 className="text-sm font-semibold mt-1">{children}</h3>,
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-border pl-3 text-muted-foreground">
{children}
</blockquote>
),
table: ({ children }) => (
<div className="overflow-x-auto">
<table className="border-collapse text-xs">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border border-border px-2 py-1 text-left font-medium">{children}</th>
),
td: ({ children }) => (
<td className="border border-border px-2 py-1">
{linkifyChildren(children)}
</td>
),
};
export const MarkdownRenderer = memo(function MarkdownRenderer({ content }: { content: string }) {
return (
<Markdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
{content}
</Markdown>
);
}
});

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, Brain, History, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
@@ -708,7 +708,7 @@ function MistakeRecoverySentinel({ message }: { message: Message }) {
);
}
export function MessageBubble({
export const MessageBubble = memo(function MessageBubble({
message,
sessionChats,
capHitInfo,
@@ -717,7 +717,7 @@ export function MessageBubble({
hasCheckpoint,
restoreDisabled,
}: Props) {
const hiddenSet = new Set(hideActions ?? []);
const hiddenSet = useMemo(() => new Set(hideActions ?? []), [hideActions]);
// v1.11: anchored rolling summary row. Checked BEFORE the kind==='compact'
// branch because summary=true never coexists with kind='compact' (new
// compactions emit role='assistant' rows with kind='message'+summary=true).
@@ -846,4 +846,4 @@ export function MessageBubble({
)}
</div>
);
}
});

View File

@@ -29,6 +29,7 @@ import { usePullToRefresh } from '@/hooks/usePullToRefresh';
import type { SidebarProject, WorktreeRiskReport } from '@/api/types';
import { giteaUrlFor } from '@/lib/projectUrls';
import { isCoderSessionName } from '@/lib/coder-session';
import { relTime } from '@/lib/format';
import { cn } from '@/lib/utils';
const EXPANDED_KEY = 'boocode.sidebar.expanded';
@@ -54,22 +55,6 @@ function writeExpanded(ids: Set<string>): void {
}
}
function relTime(iso: string): string {
const now = Date.now();
const t = Date.parse(iso);
if (Number.isNaN(t)) return '';
const sec = Math.max(0, Math.floor((now - t) / 1000));
if (sec < 60) return `${sec}s`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h`;
const day = Math.floor(hr / 24);
if (day < 30) return `${day}d`;
const mo = Math.floor(day / 30);
if (mo < 12) return `${mo}mo`;
return `${Math.floor(mo / 12)}y`;
}
function activeProjectId(
pathname: string,

View File

@@ -274,9 +274,7 @@ export function RightRail({ projectId, sessionId }: Props) {
path={viewerFile.path}
content={viewerFile.content}
lang={inferLanguage(viewerFile.path)}
projectId={projectId}
onClose={() => setViewerFile(null)}
onNavigate={(path) => void openFile(path)}
/>
)}

View File

@@ -12,6 +12,7 @@ import {
} from '@/components/ui/dialog';
import { api } from '@/api/client';
import type { Chat } from '@/api/types';
import { formatRelative } from '@/lib/format';
interface Props {
projectId: string;
@@ -34,19 +35,6 @@ interface Props {
onDeleteChat: (chatId: string) => Promise<void>;
}
function formatRelative(iso: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return '';
const s = Math.max(0, Math.round((Date.now() - then) / 1000));
if (s < 60) return 'just now';
const m = Math.round(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.round(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.round(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(iso).toLocaleDateString();
}
function byRecent(a: Chat, b: Chat): number {
return (b.updated_at ?? '').localeCompare(a.updated_at ?? '');

View File

@@ -1,103 +0,0 @@
import { useRef, useState } from 'react';
import type { TouchEvent } from 'react';
import { cn } from '@/lib/utils';
interface Props {
label: string;
isActive: boolean;
onTap: () => void;
onClose: () => void;
canClose: boolean;
}
const CLOSE_THRESHOLD = 60;
const MAX_TRAVEL = 120;
const VERTICAL_BAIL = 30;
// Pane tab with horizontal swipe-to-close (mobile only). Tracks horizontal
// finger movement; if vertical exceeds VERTICAL_BAIL the gesture is cancelled
// (so vertical scroll still works). On release past CLOSE_THRESHOLD, the
// onClose callback fires. Otherwise the tab snaps back. Hand-rolled per spec.
export function SwipeablePaneTab({ label, isActive, onTap, onClose, canClose }: Props) {
const [translateX, setTranslateX] = useState(0);
const [dragging, setDragging] = useState(false);
const startRef = useRef<{ x: number; y: number; bailed: boolean } | null>(null);
const onTouchStart = (e: TouchEvent) => {
if (!canClose) return;
const t = e.touches[0];
if (!t) return;
startRef.current = { x: t.clientX, y: t.clientY, bailed: false };
setDragging(true);
};
const onTouchMove = (e: TouchEvent) => {
const start = startRef.current;
if (!start || start.bailed) return;
const t = e.touches[0];
if (!t) return;
const dx = t.clientX - start.x;
const dy = t.clientY - start.y;
if (Math.abs(dy) > VERTICAL_BAIL) {
start.bailed = true;
setTranslateX(0);
setDragging(false);
return;
}
if (dx < 0) {
setTranslateX(Math.max(dx, -MAX_TRAVEL));
} else {
setTranslateX(0);
}
};
const onTouchEnd = () => {
const start = startRef.current;
startRef.current = null;
setDragging(false);
if (!start || start.bailed) {
setTranslateX(0);
return;
}
const tx = translateX;
if (tx <= -CLOSE_THRESHOLD) {
onClose();
// Don't reset translateX; the parent will unmount this tab.
} else {
setTranslateX(0);
}
};
// Opacity fades from 1 -> 0.4 as the tab approaches the close threshold.
const opacity =
translateX < 0
? Math.max(0.4, 1 - (Math.abs(translateX) / CLOSE_THRESHOLD) * 0.6)
: 1;
return (
<button
type="button"
onClick={onTap}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
style={{
transform: `translateX(${translateX}px)`,
opacity,
// Only animate when releasing (snap-back); during drag the transform
// tracks the finger 1:1 for a tight feel.
transition: dragging ? undefined : 'transform 0.15s ease, opacity 0.15s ease',
}}
className={cn(
'shrink-0 px-3 py-2 text-xs rounded min-h-[44px] min-w-[44px]',
isActive
? 'bg-background text-foreground border'
: 'text-muted-foreground hover:bg-muted/40',
)}
aria-current={isActive ? 'true' : undefined}
>
<span className="truncate max-w-[140px] inline-block">{label}</span>
</button>
);
}

View File

@@ -1,8 +1,7 @@
import { useState } from 'react';
import type { ReactNode } from 'react';
import { Check, ChevronRight, Loader2, X } from 'lucide-react';
import type { ToolCall, ToolResult } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
import { linkifyPaths } from '@/lib/linkify-paths';
// v1.8.2: cap on the inline arg-summary length. Expanded view shows full
// args + full result, so this is purely a single-line render budget.
@@ -22,7 +21,7 @@ function truncate(s: string, n: number): string {
// Per-tool argument summary mapping from the v1.8.2 spec. Goal is a single
// scannable line that surfaces the *what* (path / pattern) without
// overwhelming the chat with full JSON.
export function formatToolArgs(name: string, args: Record<string, unknown>): string {
function formatToolArgs(name: string, args: Record<string, unknown>): string {
if (name === 'view_file') {
const path = String(args.path ?? '');
const start = args.start_line;
@@ -98,36 +97,8 @@ export function runStatus(run: ToolRun): 'pending' | 'success' | 'error' {
return 'success';
}
// Path-shaped paths in tool output text get a click handler so users can
// jump to the file. Same heuristic as MessageBubble.linkifyPaths.
const PATH_REGEX = /([a-zA-Z0-9._/-]+\.[a-zA-Z0-9]+)/g;
function linkifyOutput(text: string): ReactNode[] {
const out: ReactNode[] = [];
let lastIdx = 0;
let idx = 0;
for (const match of text.matchAll(PATH_REGEX)) {
const matchedText = match[0];
const start = match.index ?? 0;
if (!matchedText.includes('/')) continue;
if (start > lastIdx) out.push(text.slice(lastIdx, start));
out.push(
<button
key={idx}
type="button"
onClick={() =>
sessionEvents.emit({ type: 'open_file_in_browser', path: matchedText })
}
className="text-primary underline cursor-pointer hover:text-primary/80"
>
{matchedText}
</button>
);
lastIdx = start + matchedText.length;
idx += 1;
}
if (lastIdx < text.length) out.push(text.slice(lastIdx));
return out.length > 0 ? out : [text];
}
// Path-shaped substrings in tool output get a click handler via the shared
// linkifyPaths util (lib/linkify-paths.tsx).
interface Props {
run: ToolRun;
@@ -189,7 +160,7 @@ export function ToolCallLine({ run, insideGroup }: Props) {
{run.result.error ? (
<span className="text-destructive">{run.result.error}</span>
) : (
linkifyOutput(
linkifyPaths(
typeof run.result.output === 'string'
? run.result.output
: JSON.stringify(run.result.output, null, 2)

View File

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Terminal, Clipboard } from 'lucide-react';
import { api } from '@/api/client';
import type { Chat, Project, Session, WorkspacePane } from '@/api/types';
import { MAX_PANES, activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
import { activePaneChatId, type UseWorkspacePanesResult } from '@/hooks/useWorkspacePanes';
import type { UseSessionChatsResult } from '@/hooks/useSessionChats';
import { useViewport } from '@/hooks/useViewport';
import { terminalsRegistry } from '@/lib/events';
@@ -32,7 +32,6 @@ interface Props {
project: Project | null;
/** New BooCode opens a fresh coder session; chat/terminal split in-place. */
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
onCoderConnectedChange?: (paneId: string, connected: boolean) => void;
}
export function Workspace({
@@ -44,7 +43,6 @@ export function Workspace({
chatsHook,
session,
project,
onCoderConnectedChange,
onAddPane,
}: Props) {
const {
@@ -141,10 +139,6 @@ export function Workspace({
return out;
}, [panes]);
// Per-coder-pane WS connection (status dot lives in the pane header).
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
const [coderLabels, setCoderLabels] = useState<Record<string, string>>({});
return (
<div className="flex flex-col h-full min-h-0">
<div
@@ -195,41 +189,21 @@ export function Workspace({
onDragStart={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragStart(idx) : undefined}
onDragEnd={!isMobile && !isChromeless && panes.length > 1 ? handlePaneDragEnd : undefined}
>
{/* Hidden on mobile per v1.8; settings + terminal panes own
their own header (no chats, so no ChatTabBar). */}
{!isMobile && !isChromeless && (
{/* Hidden on mobile; settings/terminal/artifact panes own their
own header. Chat and coder panes share this strip — tabKind
and onNewTab differ between the two. */}
{!isMobile && (isCoder || !isChromeless) && (
<ChatTabBar
pane={pane}
tabs={chatsForPane(pane)}
tabKind={isCoder ? 'coder' : undefined}
tabNumbers={tabNumbers}
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
onRemoveTab={(chatId) => removeTab(idx, chatId)}
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
onCloseAll={() => closeAllTabs(idx)}
onNewTab={() => void createChat(idx)}
onSplitPane={(kind) => onAddPane(kind)}
onReopenPane={hasClosedPanes ? reopenPane : undefined}
onShowHistory={() => showLandingPage(idx)}
onRename={renameChat}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
)}
{/* Coder panes host BooCode tabs (one chat = one agent context,
all sharing the session worktree). "+" adds a tab; the split
button adds a pane. Same tab strip as chat panes (tabKind). */}
{isCoder && !isMobile && (
<ChatTabBar
pane={pane}
tabs={chatsForPane(pane)}
tabKind="coder"
tabNumbers={tabNumbers}
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
onRemoveTab={(chatId) => removeTab(idx, chatId)}
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
onCloseAll={() => closeAllTabs(idx)}
onNewTab={() => void createCoderTab(idx)}
onNewTab={isCoder ? () => void createCoderTab(idx) : () => void createChat(idx)}
onSplitPane={(kind) => onAddPane(kind)}
onReopenPane={hasClosedPanes ? reopenPane : undefined}
onShowHistory={() => showLandingPage(idx)}
@@ -296,17 +270,6 @@ export function Workspace({
chatId={activePaneChatId(pane)}
chatPending={isPaneChatPending(pane.id)}
projectPath={project?.path}
onConnectedChange={(connected) => {
setCoderConnected((prev) =>
prev[pane.id] === connected ? prev : { ...prev, [pane.id]: connected },
);
onCoderConnectedChange?.(pane.id, connected);
}}
onAgentLabelChange={(label) =>
setCoderLabels((prev) =>
prev[pane.id] === label ? prev : { ...prev, [pane.id]: label },
)
}
/>
) : pane.kind === 'markdown_artifact' && pane.markdown_artifact_state ? (
<MarkdownArtifactPane

View File

@@ -25,7 +25,8 @@ interface Props {
export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled }: Props) {
const stream = useSessionStream(sessionId);
const lastErrorRef = useRef<string | null>(null);
const [queue, setQueue] = useState<string[]>([]);
const [queue, setQueue] = useState<{ id: string; text: string }[]>([]);
const queueIdRef = useRef(0);
const processingRef = useRef(false);
useEffect(() => {
@@ -84,7 +85,7 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
processingRef.current = true;
const next = queue[0]!;
setQueue((prev) => prev.slice(1));
api.messages.send(chatId, next)
api.messages.send(chatId, next.text)
.catch((err) => toast.error(err instanceof Error ? err.message : 'queue send failed'))
.finally(() => { processingRef.current = false; });
}, [streaming, queue, chatId]);
@@ -101,7 +102,7 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
return;
}
if (streaming) {
setQueue((prev) => [...prev, trimmed]);
setQueue((prev) => [...prev, { id: String(++queueIdRef.current), text: trimmed }]);
return;
}
await api.messages.send(chatId, trimmed);
@@ -185,18 +186,18 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
// into ChatInput via sendToChat. ChatInput appends (or sets, if empty) and
// focuses; user re-sends, which re-queues if streaming is still active.
function editQueued(idx: number) {
const msg = queue[idx];
if (!msg) return;
const item = queue[idx];
if (!item) return;
setQueue((prev) => prev.filter((_, i) => i !== idx));
sendToChat.emit({ chat_id: chatId, text: msg });
sendToChat.emit({ chat_id: chatId, text: item.text });
}
async function forceSendQueued(idx: number) {
const msg = queue[idx];
if (!msg) return;
const item = queue[idx];
if (!item) return;
setQueue((prev) => prev.filter((_, i) => i !== idx));
try {
await api.chats.forceSend(chatId, msg);
await api.chats.forceSend(chatId, item.text);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'force send failed');
}
@@ -211,10 +212,10 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
{queue.length > 0 && (
<div className="border-t">
<div className="max-w-[1000px] mx-auto w-full px-4 py-1 space-y-1">
{queue.map((msg, i) => (
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/30 rounded px-2 py-1">
{queue.map((item, i) => (
<div key={item.id} className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/30 rounded px-2 py-1">
<span className="font-medium shrink-0">Queued:</span>
<span className="truncate flex-1">{msg}</span>
<span className="truncate flex-1">{item.text}</span>
<button
type="button"
onClick={() => editQueued(i)}

View File

@@ -266,9 +266,6 @@ function SessionSection({ session, project }: { session: Session; project: Proje
Inherit project default ({project.default_web_search_enabled ? 'on' : 'off'})
</label>
</div>
<p className="text-xs text-muted-foreground italic">
Plumbed for Batch 8 (web_search tool). No effect yet.
</p>
</div>
<AllowedReadPathsSection session={session} />
@@ -532,7 +529,7 @@ function ProjectSection({ project }: { project: Project }) {
/>
</div>
<p className="text-xs text-muted-foreground italic">
Applies to new sessions only. Plumbed for Batch 8.
Applies to new sessions only.
</p>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,155 @@
import { useEffect, useState } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { type ChatInputRegistration } from '@/lib/events';
// ============================================================
// FloatingMenu — kept from v1.10.4 (mobile long-press + desktop right-click)
// ============================================================
interface FloatingMenuProps {
x: number;
y: number;
hasSelection: boolean;
chatInputs: ChatInputRegistration[];
onCopy: () => void;
onPaste: () => void;
onSelectAll: () => void;
onSearch: () => void;
onSendToChat: (chatId: string) => void;
onDismiss: () => void;
}
export function FloatingMenu({
x,
y,
hasSelection,
chatInputs,
onCopy,
onPaste,
onSelectAll,
onSearch,
onSendToChat,
onDismiss,
}: FloatingMenuProps) {
const [submenu, setSubmenu] = useState(false);
const MENU_W = 200;
const MENU_H = 220;
const left = Math.min(x, window.innerWidth - MENU_W - 8);
const top = Math.min(y, window.innerHeight - MENU_H - 8);
useEffect(() => {
function onKey(ev: KeyboardEvent): void {
if (ev.key === 'Escape') onDismiss();
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onDismiss]);
const flatChat = chatInputs.length === 1 ? chatInputs[0] : null;
return (
<div
data-term-menu="1"
role="menu"
style={{
position: 'fixed',
top,
left,
background: '#1a1d24',
border: '1px solid #2a2d34',
borderRadius: 8,
padding: 4,
fontSize: 14,
minWidth: MENU_W,
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
zIndex: 50,
color: '#d6deeb',
}}
>
<MenuItem disabled={!hasSelection} onClick={onCopy}>Copy</MenuItem>
<MenuItem onClick={onPaste}>Paste</MenuItem>
<MenuItem onClick={onSelectAll}>Select All</MenuItem>
<MenuItem onClick={onSearch}>Search</MenuItem>
{flatChat && (
<MenuItem disabled={!hasSelection} onClick={() => onSendToChat(flatChat.chatId)}>
Send to {flatChat.label}
</MenuItem>
)}
{chatInputs.length > 1 && (
<div>
<button
type="button"
disabled={!hasSelection}
onClick={() => setSubmenu((v) => !v)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '10px 12px',
minHeight: 44,
background: 'transparent',
border: 0,
color: hasSelection ? '#d6deeb' : '#575656',
cursor: hasSelection ? 'pointer' : 'not-allowed',
borderRadius: 6,
textAlign: 'left',
}}
>
<span>Send to chat</span>
{submenu ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{submenu && hasSelection && (
<div style={{ paddingLeft: 8 }}>
{chatInputs.map((c) => (
<MenuItem key={c.chatId} onClick={() => onSendToChat(c.chatId)}>
{c.label}
</MenuItem>
))}
</div>
)}
</div>
)}
<div style={{ height: 1, background: '#2a2d34', margin: '4px 0' }} />
<MenuItem onClick={onDismiss}>Dismiss</MenuItem>
</div>
);
}
function MenuItem({
children,
onClick,
disabled = false,
}: {
children: React.ReactNode;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={onClick}
style={{
display: 'block',
width: '100%',
padding: '10px 12px',
minHeight: 44,
background: 'transparent',
border: 0,
color: disabled ? '#575656' : '#d6deeb',
cursor: disabled ? 'not-allowed' : 'pointer',
textAlign: 'left',
borderRadius: 6,
fontSize: 14,
}}
onMouseEnter={(ev) => {
if (!disabled) (ev.currentTarget as HTMLButtonElement).style.background = '#2a2d34';
}}
onMouseLeave={(ev) => {
(ev.currentTarget as HTMLButtonElement).style.background = 'transparent';
}}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,163 @@
import { useEffect, useRef, useState } from 'react';
import type { SearchAddon } from '@xterm/addon-search';
import { ChevronDown, ChevronUp, X } from 'lucide-react';
import { type TermTheme } from './theme';
// ============================================================
// SearchBar — kept from v1.10.4
// ============================================================
interface SearchBarProps {
searchRef: React.MutableRefObject<SearchAddon | null>;
theme: TermTheme;
onClose: () => void;
}
export function SearchBar({ searchRef, theme, onClose }: SearchBarProps) {
const [q, setQ] = useState('');
const [counts, setCounts] = useState<{ idx: number; total: number }>({ idx: -1, total: 0 });
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
const addon = searchRef.current;
if (!addon) return;
const sub = addon.onDidChangeResults(({ resultIndex, resultCount }) => {
setCounts({ idx: resultIndex, total: resultCount });
});
return () => sub.dispose();
}, [searchRef]);
useEffect(() => {
const addon = searchRef.current;
if (!addon) return;
if (q.length === 0) {
addon.clearDecorations?.();
setCounts({ idx: -1, total: 0 });
return;
}
addon.findNext(q, {
incremental: true,
decorations: {
matchBackground: theme.selectionBackground,
matchOverviewRuler: theme.cursor,
activeMatchBackground: theme.cursor,
activeMatchColorOverviewRuler: theme.cursor,
},
});
}, [q, searchRef, theme]);
function findNext(): void {
if (!q) return;
searchRef.current?.findNext(q);
}
function findPrev(): void {
if (!q) return;
searchRef.current?.findPrevious(q);
}
function onKey(ev: React.KeyboardEvent<HTMLInputElement>): void {
if (ev.key === 'Escape') {
ev.preventDefault();
onClose();
return;
}
if (ev.key === 'Enter') {
ev.preventDefault();
if (ev.shiftKey) findPrev();
else findNext();
}
}
return (
<div
style={{
position: 'absolute',
top: 8,
right: 8,
background: '#1a1d24',
border: '1px solid #2a2d34',
borderRadius: 8,
padding: 4,
display: 'flex',
alignItems: 'center',
gap: 4,
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
zIndex: 40,
}}
>
<input
ref={inputRef}
value={q}
onChange={(ev) => setQ(ev.target.value)}
onKeyDown={onKey}
placeholder="Search…"
style={{
background: 'transparent',
border: 0,
outline: 'none',
color: '#d6deeb',
padding: '8px 8px',
fontSize: 13,
width: 160,
minHeight: 36,
}}
/>
{q.length > 0 && (
<span
style={{
fontSize: 11,
color: counts.total === 0 ? '#ef5350' : '#575656',
minWidth: 56,
textAlign: 'right',
padding: '0 4px',
whiteSpace: 'nowrap',
}}
>
{counts.total === 0
? 'No match'
: counts.idx === -1
? `${counts.total}+`
: `${counts.idx + 1} of ${counts.total}`}
</span>
)}
<button
type="button"
onClick={findPrev}
aria-label="Previous match"
title="Previous (Shift+Enter)"
style={iconBtnStyle}
>
<ChevronUp size={16} />
</button>
<button
type="button"
onClick={findNext}
aria-label="Next match"
title="Next (Enter)"
style={iconBtnStyle}
>
<ChevronDown size={16} />
</button>
<button
type="button"
onClick={onClose}
aria-label="Close search"
title="Close (Esc)"
style={iconBtnStyle}
>
<X size={16} />
</button>
</div>
);
}
const iconBtnStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 44,
height: 44,
background: 'transparent',
border: 0,
color: '#d6deeb',
cursor: 'pointer',
borderRadius: 6,
};

View File

@@ -0,0 +1,115 @@
import { useCallback } from 'react';
import { Maximize2 } from 'lucide-react';
import { cn } from '@/lib/utils';
// ============================================================
// TerminalHotkeyBar — v1.10.8d port of boolab's TerminalHotkeyBar.jsx +
// terminalHotkeysStore.js DEFAULT_BAR. The catalog is hardcoded inline (no
// zustand store, no settings UI) — single-user homelab doesn't need either.
// Add new entries by extending HOTKEY_BAR below.
// ============================================================
type Hotkey =
| { id: string; label: string; bytes: string; sticky?: undefined }
| { id: string; label: string; sticky: 'ctrl'; bytes?: undefined };
const HOTKEY_BAR: Hotkey[] = [
{ id: 'esc', label: 'Esc', bytes: '\x1b' },
{ id: 'shift-tab', label: '⇧Tab', bytes: '\x1b[Z' },
{ id: 'tab', label: 'Tab', bytes: '\t' },
{ id: 'ctrl', label: 'Ctrl', sticky: 'ctrl' },
{ id: 'ctrl-c', label: 'Ctrl+C', bytes: '\x03' },
{ id: 'arrow-up', label: '↑', bytes: '\x1b[A' },
{ id: 'arrow-down', label: '↓', bytes: '\x1b[B' },
{ id: 'arrow-left', label: '←', bytes: '\x1b[D' },
{ id: 'arrow-right', label: '→', bytes: '\x1b[C' },
];
interface TerminalHotkeyBarProps {
ctrlArmed: boolean;
onSendBytes: (bytes: string) => void;
onArmCtrl: () => void;
onFit: () => void;
}
export function TerminalHotkeyBar({
ctrlArmed,
onSendBytes,
onArmCtrl,
onFit,
}: TerminalHotkeyBarProps) {
// Stop the touch from reaching the terminal pane below (which calls
// preventDefault on touchmove to suppress page-scroll). Without this a
// tap-and-drag on a hotkey button would also scroll the terminal buffer.
const stopTouch = useCallback((e: React.TouchEvent) => e.stopPropagation(), []);
const press = useCallback(
(entry: Hotkey) => {
if (entry.sticky === 'ctrl') {
onArmCtrl();
} else if (entry.bytes !== undefined) {
onSendBytes(entry.bytes);
}
},
[onArmCtrl, onSendBytes],
);
return (
<div
role="toolbar"
aria-label="Terminal hotkeys"
className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-border bg-muted/30 px-2 py-1"
style={{
scrollbarWidth: 'thin',
WebkitOverflowScrolling: 'touch',
// Suppress iOS native swipe-back gesture starting on the bar; pinch
// and other multi-touch gestures still pass through.
touchAction: 'pan-x',
}}
onTouchStart={stopTouch}
onTouchMove={stopTouch}
>
{HOTKEY_BAR.map((entry) => {
const isCtrl = entry.sticky === 'ctrl';
const armed = isCtrl && ctrlArmed;
return (
<button
key={entry.id}
type="button"
onClick={() => press(entry)}
aria-pressed={isCtrl ? armed : undefined}
aria-label={entry.label}
className={cn(
'shrink-0 rounded border px-2 py-0.5 text-xs font-mono transition-colors',
armed
? 'border-primary bg-primary text-primary-foreground'
: 'border-border text-foreground hover:bg-muted',
)}
style={{
minHeight: 28,
minWidth: 36,
WebkitTouchCallout: 'none',
userSelect: 'none',
}}
>
{entry.label}
</button>
);
})}
<button
type="button"
onClick={onFit}
aria-label="Fit terminal"
title="Fit terminal to container"
className="shrink-0 inline-flex items-center justify-center rounded border border-border text-foreground hover:bg-muted"
style={{
minHeight: 28,
minWidth: 36,
paddingInline: 8,
WebkitTouchCallout: 'none',
userSelect: 'none',
}}
>
<Maximize2 size={14} />
</button>
</div>
);
}

View File

@@ -0,0 +1,32 @@
// xterm color theme + background, shared by the Terminal construction
// (TerminalPane) and the SearchBar decorations. Extracted verbatim from the
// pre-Phase-9 TerminalPane.
// Terminal background matches the pane container's `bg-[#0b0f14]` so any
// sub-cell rounding remainder is invisible. Update both if the theme changes.
export const TERM_BG = '#0b0f14';
export const XTERM_THEME = {
background: TERM_BG,
foreground: '#d6deeb',
cursor: '#82aaff',
selectionBackground: '#1d3b53',
black: '#011627',
red: '#ef5350',
green: '#22da6e',
yellow: '#c5e478',
blue: '#82aaff',
magenta: '#c792ea',
cyan: '#7fdbca',
white: '#d6deeb',
brightBlack: '#575656',
brightRed: '#ef5350',
brightGreen: '#22da6e',
brightYellow: '#ffeb95',
brightBlue: '#82aaff',
brightMagenta: '#c792ea',
brightCyan: '#7fdbca',
brightWhite: '#ffffff',
};
export type TermTheme = typeof XTERM_THEME;