- MessageBubble & ToolCallCard: detect path-like strings in rendered text via regex requiring slash+extension; clicks dispatch open_file_in_browser - Session.tsx: now renders <Workspace sessionId projectId>; on mount, emits session_loaded so sidebar can highlight even deep-linked sessions not in the recent_sessions cache - ProjectSidebar: active project's chevron visually disabled (50% opacity, cursor-not-allowed) and click no-op; activeSession from useSidebar used as fallback when active session isn't in cache Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
103 lines
3.3 KiB
TypeScript
103 lines
3.3 KiB
TypeScript
import { useState } from 'react';
|
|
import type { ReactNode } from 'react';
|
|
import { ChevronRight, Wrench } from 'lucide-react';
|
|
import type { Message, ToolCall } from '@/api/types';
|
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
|
|
|
interface Props {
|
|
message?: Message;
|
|
toolCall?: ToolCall;
|
|
}
|
|
|
|
// Same regex/heuristic as MessageBubble: paths ending in `.ext` with at
|
|
// least one `/`. Linkifies file paths emitted by tools like grep / find_files
|
|
// so they're clickable.
|
|
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];
|
|
}
|
|
|
|
export function ToolCallCard({ message, toolCall }: Props) {
|
|
const [open, setOpen] = useState(false);
|
|
const tc = toolCall ?? message?.tool_calls?.[0];
|
|
const result = message?.tool_results;
|
|
|
|
const name = tc?.name ?? 'tool';
|
|
const args = tc?.args ?? {};
|
|
const error = result?.error;
|
|
const output = result?.output;
|
|
const truncated = result?.truncated;
|
|
|
|
return (
|
|
<div className="rounded-md border border-border bg-muted/30 text-sm overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((v) => !v)}
|
|
className="w-full flex items-center gap-2 px-2.5 py-1.5 hover:bg-muted/60 text-left"
|
|
>
|
|
<ChevronRight
|
|
className={`size-3.5 transition-transform ${open ? 'rotate-90' : ''}`}
|
|
/>
|
|
<Wrench className="size-3.5 opacity-70" />
|
|
<span className="font-mono font-medium">{name}</span>
|
|
<span className="font-mono text-xs text-muted-foreground truncate min-w-0 flex-1">
|
|
{JSON.stringify(args)}
|
|
</span>
|
|
{error && (
|
|
<span className="text-xs text-destructive font-medium ml-2">error</span>
|
|
)}
|
|
{truncated && (
|
|
<span className="text-xs text-muted-foreground ml-2">truncated</span>
|
|
)}
|
|
</button>
|
|
{open && (
|
|
<div className="px-2.5 py-2 border-t bg-background/40">
|
|
{error ? (
|
|
<pre className="text-xs text-destructive font-mono whitespace-pre-wrap">
|
|
{error}
|
|
</pre>
|
|
) : output !== undefined ? (
|
|
<pre className="text-xs font-mono whitespace-pre-wrap overflow-x-auto max-h-72 overflow-y-auto">
|
|
{linkifyOutput(
|
|
typeof output === 'string'
|
|
? output
|
|
: JSON.stringify(output, null, 2)
|
|
)}
|
|
</pre>
|
|
) : (
|
|
<div className="text-xs text-muted-foreground">no result yet</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|