feat: phase 3-5 — workflow engine, background subagents, multi-modal, cache shape, inline diff
Phase 3: Dynamic Workflow Engine - VM sandbox (node:vm) with agent/parallel/pipeline API, Claude Code compatible - Workflow file discovery (.boocode/workflows/*.js + ~/.boocode/workflows/*.js) - Workflow manager with session/chat creation and inference dispatch - Built-in catalog: deep-research, review-code, find-issues - Resumability cache: SHA-256 hash of agent spec, in-memory Map Phase 4: Background Subagents - background-task.ts service: spawn/poll/cancel lifecycle - spawn_subagent, subagent_status, subagent_result tools in ALL_TOOLS Phase 5: Multi-modal + Cache Shape - Multi-modal stub with type defs and hook point in payload.ts - CacheShapeBadge component in trace viewer (colored bar + %)
This commit is contained in:
38
apps/web/src/components/CacheShapeBadge.tsx
Normal file
38
apps/web/src/components/CacheShapeBadge.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
// vDeepSeek: cache shape telemetry badge. Displays cache token count with
|
||||
// a colored hit-rate bar in the trace viewer. Color thresholds are relative
|
||||
// to output tokens (tokens_used) since the trace doesn't carry prompt miss
|
||||
// tokens separately: green > 50%, yellow > 10%, red ≤ 10%.
|
||||
|
||||
export interface CacheShapeBadgeProps {
|
||||
cacheTokens: number | null | undefined;
|
||||
totalTokens: number | null | undefined;
|
||||
}
|
||||
|
||||
function hitRate(cache: number, total: number): number {
|
||||
if (cache <= 0 || total <= 0) return 0;
|
||||
return cache / (cache + total);
|
||||
}
|
||||
|
||||
function barColor(rate: number): string {
|
||||
if (rate > 0.5) return 'bg-green-500';
|
||||
if (rate > 0.1) return 'bg-yellow-500';
|
||||
return 'bg-red-500';
|
||||
}
|
||||
|
||||
export function CacheShapeBadge({ cacheTokens, totalTokens }: CacheShapeBadgeProps) {
|
||||
if (cacheTokens == null || cacheTokens <= 0) return null;
|
||||
|
||||
const rate = hitRate(cacheTokens, totalTokens ?? 0);
|
||||
const pct = Math.round(rate * 100);
|
||||
const color = barColor(rate);
|
||||
|
||||
return (
|
||||
<span className="shrink-0 inline-flex items-center gap-1 font-mono tabular-nums text-[10px] text-muted-foreground/60" title={`cache hit rate ${pct}%`}>
|
||||
<span className={`inline-block w-1.5 h-3 rounded-sm ${color}`} />
|
||||
<span>{cacheTokens}c</span>
|
||||
{totalTokens != null && totalTokens > 0 && (
|
||||
<span className="text-muted-foreground/40">{pct}%</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
88
apps/web/src/components/DiffSnippet.tsx
Normal file
88
apps/web/src/components/DiffSnippet.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, FileCode } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
diff: string;
|
||||
}
|
||||
|
||||
const INITIAL_LINES = 10;
|
||||
|
||||
export function DiffSnippet({ diff }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const lines = useMemo(() => diff.split('\n'), [diff]);
|
||||
const totalLines = lines.length;
|
||||
|
||||
// Find the first and last content lines (skip leading ---/+++ headers)
|
||||
const firstContentIdx = lines.findIndex(
|
||||
(l) => l.startsWith('+') || l.startsWith('-') || l.startsWith(' '),
|
||||
);
|
||||
|
||||
// Count content lines that are either +, -, or context lines
|
||||
const contentLineCount = lines.filter(
|
||||
(l) => l.startsWith('+') || l.startsWith('-') || l.startsWith(' '),
|
||||
).length;
|
||||
|
||||
// Show first N content lines, plus header lines
|
||||
const displayLines = useMemo(() => {
|
||||
const sliceEnd = expanded ? lines.length : Math.min(firstContentIdx + INITIAL_LINES + contentLineCount, lines.length);
|
||||
return lines.slice(0, sliceEnd);
|
||||
}, [lines, expanded, firstContentIdx, contentLineCount]);
|
||||
|
||||
const hasMore = totalLines > displayLines.length;
|
||||
|
||||
if (totalLines === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-1 rounded border border-border/40 bg-muted/20 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex items-center gap-1.5 w-full px-2 py-1 text-left hover:bg-muted/30 text-[10px] font-mono text-muted-foreground"
|
||||
>
|
||||
<FileCode className="size-3 shrink-0" />
|
||||
<span className="font-medium">diff</span>
|
||||
<span className="text-muted-foreground/60">
|
||||
— {contentLineCount} line{contentLineCount === 1 ? '' : 's'} changed
|
||||
</span>
|
||||
<span className="ml-auto shrink-0">
|
||||
{expanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||
</span>
|
||||
</button>
|
||||
<div className="px-0 pb-0.5">
|
||||
{displayLines.map((line, i) => {
|
||||
// Determine color class based on line prefix
|
||||
let colorClass = 'text-muted-foreground/60';
|
||||
if (line.startsWith('+')) colorClass = 'text-emerald-600 dark:text-emerald-400';
|
||||
else if (line.startsWith('-')) colorClass = 'text-red-500 dark:text-red-400';
|
||||
else if (line.startsWith('@@')) colorClass = 'text-muted-foreground';
|
||||
else if (line.startsWith('---') || line.startsWith('+++')) colorClass = 'text-muted-foreground/50';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`leading-[1.3] px-2 text-[10px] font-mono whitespace-pre ${colorClass} ${
|
||||
line.startsWith('+')
|
||||
? 'bg-emerald-500/5'
|
||||
: line.startsWith('-')
|
||||
? 'bg-red-500/5'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hasMore && !expanded && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="w-full text-left px-2 py-0.5 text-[10px] font-mono text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/30"
|
||||
>
|
||||
Show {totalLines - displayLines.length} more lines…
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, ChevronRight, Loader2, X } from 'lucide-react';
|
||||
import { Check, ChevronRight, Loader2, ShieldAlert, X } from 'lucide-react';
|
||||
import type { ToolCall, ToolResult } from '@/api/types';
|
||||
import { linkifyPaths } from '@/lib/linkify-paths';
|
||||
import { DiffSnippet } from './DiffSnippet';
|
||||
import { McpPermissionDialog } from './McpPermissionDialog';
|
||||
|
||||
// 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.
|
||||
@@ -105,14 +107,18 @@ interface Props {
|
||||
// When rendered inside a ToolCallGroup the line is already nested under a
|
||||
// shared header, so the leading arrow is dropped to avoid double indent.
|
||||
insideGroup?: boolean;
|
||||
chatId?: string;
|
||||
}
|
||||
|
||||
export function ToolCallLine({ run, insideGroup }: Props) {
|
||||
export function ToolCallLine({ run, insideGroup, chatId }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [approveOpen, setApproveOpen] = useState(false);
|
||||
const status = runStatus(run);
|
||||
const args = run.call.args ?? {};
|
||||
const summary = formatToolArgs(run.call.name, args);
|
||||
|
||||
const needsApproval = run.result?.error?.startsWith('requires approval:') === true;
|
||||
|
||||
return (
|
||||
<div className="text-xs">
|
||||
<button
|
||||
@@ -129,7 +135,7 @@ export function ToolCallLine({ run, insideGroup }: Props) {
|
||||
/>
|
||||
)}
|
||||
<ChevronRight
|
||||
className={`size-3 text-muted-foreground/60 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`}
|
||||
className={`size-3 text-muted-foreground/60 shrink-0 motion-reduce:transition-none transition-transform ${open ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<span className="font-mono text-foreground/90 shrink-0">{run.call.name}</span>
|
||||
{summary && (
|
||||
@@ -158,7 +164,27 @@ export function ToolCallLine({ run, insideGroup }: Props) {
|
||||
{run.result && (
|
||||
<pre className="text-[11px] font-mono whitespace-pre-wrap bg-muted/30 rounded px-2 py-1 max-h-72 overflow-y-auto">
|
||||
{run.result.error ? (
|
||||
<span className="text-destructive">{run.result.error}</span>
|
||||
needsApproval ? (
|
||||
<span className="flex flex-col gap-2">
|
||||
<span className="text-amber-600 dark:text-amber-400">
|
||||
This tool requires your approval
|
||||
</span>
|
||||
{chatId && (
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApproveOpen(true)}
|
||||
className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-2 py-1 text-xs font-medium text-amber-600 hover:bg-amber-500/20 dark:text-amber-400"
|
||||
>
|
||||
<ShieldAlert className="size-3" />
|
||||
Approve
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-destructive">{run.result.error}</span>
|
||||
)
|
||||
) : (
|
||||
linkifyPaths(
|
||||
typeof run.result.output === 'string'
|
||||
@@ -171,6 +197,17 @@ export function ToolCallLine({ run, insideGroup }: Props) {
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
{needsApproval && chatId && (
|
||||
<McpPermissionDialog
|
||||
toolCallId={run.call.id}
|
||||
toolName={run.call.name}
|
||||
toolArgs={run.call.args ?? {}}
|
||||
chatId={chatId}
|
||||
open={approveOpen}
|
||||
onClose={() => setApproveOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{run.result?.diff && <DiffSnippet diff={run.result.diff} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import type { ToolTrace } from '@/api/types';
|
||||
import { CacheShapeBadge } from '@/components/CacheShapeBadge';
|
||||
|
||||
interface Props {
|
||||
chatId: string;
|
||||
@@ -58,11 +59,7 @@ function TraceRow({ trace }: { trace: ToolTrace }) {
|
||||
{trace.tokens_used}t
|
||||
</span>
|
||||
)}
|
||||
{trace.cache_tokens != null && trace.cache_tokens > 0 && (
|
||||
<span className="shrink-0 text-muted-foreground/60 font-mono tabular-nums text-[10px]">
|
||||
c{trace.cache_tokens}
|
||||
</span>
|
||||
)}
|
||||
<CacheShapeBadge cacheTokens={trace.cache_tokens} totalTokens={trace.tokens_used} />
|
||||
{trace.reasoning_tokens != null && trace.reasoning_tokens > 0 && (
|
||||
<span className="shrink-0 text-muted-foreground/60 font-mono tabular-nums text-[10px]">
|
||||
r{trace.reasoning_tokens}
|
||||
|
||||
Reference in New Issue
Block a user