@vvedantb/ui 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +253 -3
- package/package.json +13 -5
- package/shell/src/SidebarResize.tsx +1 -4
- package/src/ai-elements/__snapshots__/queue.test.tsx.snap +11 -0
- package/src/ai-elements/activity-question-utils.ts +26 -0
- package/src/ai-elements/activity-question.tsx +159 -0
- package/src/ai-elements/activity-shared.tsx +459 -0
- package/src/ai-elements/activity-step-detail.tsx +115 -0
- package/src/ai-elements/activity-step-label.ts +981 -0
- package/src/ai-elements/activity-tasks-utils.ts +199 -0
- package/src/ai-elements/activity-tasks.tsx +770 -0
- package/src/ai-elements/artifact.tsx +162 -52
- package/src/ai-elements/attachments.tsx +62 -32
- package/src/ai-elements/code-block.tsx +76 -22
- package/src/ai-elements/context.tsx +370 -0
- package/src/ai-elements/conversation.tsx +186 -62
- package/src/ai-elements/file-chip.tsx +53 -0
- package/src/ai-elements/message.tsx +161 -50
- package/src/ai-elements/model-picker-content.tsx +505 -0
- package/src/ai-elements/model-picker-types.ts +56 -0
- package/src/ai-elements/model-picker.tsx +301 -0
- package/src/ai-elements/model-selector.tsx +102 -21
- package/src/ai-elements/plan.tsx +130 -0
- package/src/ai-elements/prompt-input-settings.tsx +144 -0
- package/src/ai-elements/prompt-input-speech.tsx +189 -0
- package/src/ai-elements/prompt-input-traits.tsx +281 -0
- package/src/ai-elements/prompt-input.tsx +320 -134
- package/src/ai-elements/provider-icon.tsx +133 -0
- package/src/ai-elements/queue.tsx +293 -9
- package/src/ai-elements/shimmer.tsx +72 -1
- package/src/ai-elements/simple-model-ladder.tsx +108 -0
- package/src/ai-elements/test-results.tsx +510 -0
- package/src/ai-elements/trace.tsx +243 -0
- package/src/ai-elements/use-streaming-disclosure.ts +50 -0
- package/src/ai-elements/web-preview.tsx +349 -0
- package/src/composer/input-area/ComposerSlashMenu.tsx +1 -1
- package/src/index.ts +66 -12
- package/src/kibo/avatar-stack.tsx +40 -0
- package/src/kibo/contribution-graph.tsx +539 -0
- package/src/kibo/cursor.tsx +72 -0
- package/src/kibo/gantt-features.tsx +439 -0
- package/src/kibo/gantt-header.tsx +181 -0
- package/src/kibo/gantt-provider.tsx +657 -0
- package/src/kibo/gantt-sidebar.tsx +157 -0
- package/src/kibo/gantt-timeline.tsx +154 -0
- package/src/kibo/index.ts +151 -0
- package/src/kibo/kanban.tsx +338 -0
- package/src/kibo/kanbanDropTarget.ts +25 -0
- package/src/kibo/list.tsx +162 -0
- package/src/kibo/theme-switcher.tsx +109 -0
- package/src/kibo/video-player.tsx +237 -0
- package/src/markdown/Markdown.tsx +112 -0
- package/src/markdown/MarkdownBlockquote.tsx +45 -0
- package/src/markdown/MarkdownCodeBlock.tsx +197 -0
- package/src/markdown/MarkdownInlineCode.tsx +40 -0
- package/src/markdown/MarkdownLink.tsx +92 -0
- package/src/markdown/codeFence.ts +57 -0
- package/src/markdown/codeHighlight.ts +138 -0
- package/src/markdown/incrementalBlocks.ts +182 -0
- package/src/markdown/index.ts +3 -0
- package/src/markdown/markdownClipboard.ts +178 -0
- package/src/markdown/mermaid.ts +155 -0
- package/src/markdown/rehypeGithubAlerts.ts +80 -0
- package/src/motion/presets.ts +24 -0
- package/src/ui/__snapshots__/alt-wrappers.test.tsx.snap +19 -0
- package/src/ui/__snapshots__/sonner-dom.test.tsx.snap +5 -0
- package/src/ui/_menu-parts.tsx +29 -0
- package/src/ui/accordion.alt.tsx +46 -53
- package/src/ui/accordion.tsx +59 -10
- package/src/ui/avatar.tsx +56 -0
- package/src/ui/badge.tsx +45 -21
- package/src/ui/border-beam.tsx +97 -0
- package/src/ui/button-group.tsx +50 -21
- package/src/ui/button.tsx +67 -45
- package/src/ui/calendar.tsx +48 -5
- package/src/ui/card.tsx +77 -8
- package/src/ui/carousel.tsx +60 -0
- package/src/ui/centered-spinner.tsx +30 -0
- package/src/ui/checkbox.tsx +34 -16
- package/src/ui/clear-input.alt.tsx +33 -75
- package/src/ui/clear-input.tsx +140 -15
- package/src/ui/collapsible.tsx +40 -2
- package/src/ui/command.tsx +143 -70
- package/src/ui/context-menu.tsx +159 -93
- package/src/ui/crossfade-icon.tsx +123 -0
- package/src/ui/dialog.tsx +154 -70
- package/src/ui/dropdown-menu.tsx +177 -117
- package/src/ui/hover-card.tsx +59 -17
- package/src/ui/input-group.alt.tsx +83 -83
- package/src/ui/input-group.tsx +129 -58
- package/src/ui/input.tsx +13 -4
- package/src/ui/label.tsx +11 -8
- package/src/ui/list-row.tsx +232 -0
- package/src/ui/loading-state.tsx +215 -0
- package/src/ui/popover.tsx +56 -23
- package/src/ui/progress.tsx +28 -16
- package/src/ui/refresh-spin-icon.tsx +41 -0
- package/src/ui/search-input.tsx +66 -15
- package/src/ui/select.tsx +171 -100
- package/src/ui/separator.tsx +23 -14
- package/src/ui/slider.tsx +21 -7
- package/src/ui/sonner.tsx +68 -30
- package/src/ui/spinner.tsx +229 -11
- package/src/ui/status-dot.tsx +84 -0
- package/src/ui/surface.tsx +49 -0
- package/src/ui/switch.tsx +29 -15
- package/src/ui/table.tsx +38 -16
- package/src/ui/tabs.tsx +123 -35
- package/src/ui/tabsSliding.ts +33 -1
- package/src/ui/textarea.tsx +13 -4
- package/src/ui/tooltip.tsx +50 -14
- package/src/ui/ui-config.tsx +149 -0
- package/src/utils/cat-color.ts +123 -0
- package/src/utils/dayjs.ts +6 -0
- package/src/utils/gesture.ts +59 -0
- package/src/utils/runtimeVisibility.ts +167 -0
- package/src/utils/sharedClock.ts +96 -0
- package/src/utils/surface-radius.ts +16 -0
- package/src/utils/useDragSensors.ts +58 -0
- package/tokens/examples/tokens-moved/Fixture.tsx +283 -0
- package/tokens/examples/tokens-moved/README.md +20 -0
- package/tokens/examples/tokens-moved/index.html +12 -0
- package/tokens/examples/tokens-moved/main.tsx +6 -0
- package/tokens/examples/tokens-moved/package.json +14 -0
- package/tokens/examples/tokens-moved/styles.css +12 -0
- package/tokens/examples/tokens-moved/tsconfig.json +14 -0
- package/tokens/examples/tokens-moved/vite.config.ts +11 -0
- package/tokens/src/css/border-beam.css +116 -0
- package/tokens/src/css/compat-shadcn-rgb.css +6 -0
- package/tokens/src/css/markdown.css +318 -0
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, memo, useCallback, useContext, useRef, useState } from "react";
|
|
4
|
+
import type { ComponentProps, ReactNode, Ref } from "react";
|
|
5
|
+
import { flushSync } from "react-dom";
|
|
6
|
+
|
|
7
|
+
import { Collapsible, CollapsibleContent } from "../ui/collapsible";
|
|
8
|
+
import {
|
|
9
|
+
IconBrain,
|
|
10
|
+
IconChevronRight,
|
|
11
|
+
IconCircle,
|
|
12
|
+
IconCircleCheck,
|
|
13
|
+
IconGitBranch,
|
|
14
|
+
IconSearch,
|
|
15
|
+
IconTerminal2,
|
|
16
|
+
} from "@tabler/icons-react";
|
|
17
|
+
import { cjk } from "@streamdown/cjk";
|
|
18
|
+
import { code } from "@streamdown/code";
|
|
19
|
+
import { math } from "@streamdown/math";
|
|
20
|
+
import { mermaid } from "@streamdown/mermaid";
|
|
21
|
+
import { Streamdown } from "streamdown";
|
|
22
|
+
import { useCn, useUiConfig } from "../ui/ui-config";
|
|
23
|
+
import { Spinner } from "../ui/spinner";
|
|
24
|
+
import { CrossfadeIconSlot } from "../ui/crossfade-icon";
|
|
25
|
+
import { staggerDelay, tokenTransition } from "../motion/presets";
|
|
26
|
+
import { m } from "motion/react";
|
|
27
|
+
import { Shimmer } from "./shimmer";
|
|
28
|
+
import {
|
|
29
|
+
type ActivityMarkdownProps,
|
|
30
|
+
type ActivityStep,
|
|
31
|
+
type ActivityTodoItem,
|
|
32
|
+
activityStepConfig,
|
|
33
|
+
activityStepHasRichDetail,
|
|
34
|
+
useActivitySpinnerVerb,
|
|
35
|
+
useActivityElapsedSeconds,
|
|
36
|
+
formatActivityElapsed,
|
|
37
|
+
} from "./activity-shared";
|
|
38
|
+
import {
|
|
39
|
+
type ActivityRow,
|
|
40
|
+
type ActivitySegment,
|
|
41
|
+
activityRowKey,
|
|
42
|
+
activitySegmentKey,
|
|
43
|
+
buildActivityRows,
|
|
44
|
+
groupActivityRows,
|
|
45
|
+
} from "./activity-tasks-utils";
|
|
46
|
+
import {
|
|
47
|
+
deriveActionGroupSummary,
|
|
48
|
+
deriveStepRowPresentation,
|
|
49
|
+
resolveCommandVisualKind,
|
|
50
|
+
type CommandVisualKind,
|
|
51
|
+
} from "./activity-step-label";
|
|
52
|
+
import { ActivityStepDetail } from "./activity-step-detail";
|
|
53
|
+
import { FileChip } from "./file-chip";
|
|
54
|
+
import {
|
|
55
|
+
AgentTrace,
|
|
56
|
+
AgentTraceCollapsible,
|
|
57
|
+
AgentTraceCollapsibleContent,
|
|
58
|
+
AgentTraceCollapsibleTrigger,
|
|
59
|
+
AgentTraceDescription,
|
|
60
|
+
AgentTraceIcon,
|
|
61
|
+
AgentTraceLine,
|
|
62
|
+
AgentTraceTitle,
|
|
63
|
+
type AgentTraceStatus,
|
|
64
|
+
} from "./trace";
|
|
65
|
+
import { useStreamingDisclosure } from "./use-streaming-disclosure";
|
|
66
|
+
|
|
67
|
+
const streamdownPlugins = { cjk, code, math, mermaid };
|
|
68
|
+
|
|
69
|
+
function StreamdownMarkdown({ className, mode, children }: ActivityMarkdownProps) {
|
|
70
|
+
return (
|
|
71
|
+
<Streamdown className={className} mode={mode} plugins={streamdownPlugins}>
|
|
72
|
+
{children}
|
|
73
|
+
</Streamdown>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The app's Markdown renderer (`UiConfig.ActivityTasks.markdown`), or Streamdown. */
|
|
78
|
+
function useActivityMarkdown() {
|
|
79
|
+
return useUiConfig("ActivityTasks")?.markdown ?? StreamdownMarkdown;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const ActivityStreamingHeader = memo(function ActivityStreamingHeader({
|
|
83
|
+
steps,
|
|
84
|
+
name,
|
|
85
|
+
startedAt,
|
|
86
|
+
}: {
|
|
87
|
+
steps: ActivityStep[];
|
|
88
|
+
name?: string;
|
|
89
|
+
startedAt?: number;
|
|
90
|
+
}) {
|
|
91
|
+
const verb = useActivitySpinnerVerb(true);
|
|
92
|
+
const elapsed = useActivityElapsedSeconds(startedAt, true);
|
|
93
|
+
const defaultName = useUiConfig("ActivityTasks")?.name ?? "Agent";
|
|
94
|
+
// Reply text is not a status: label the header from the work steps only.
|
|
95
|
+
const work = steps.filter((s) => s.type !== "response");
|
|
96
|
+
const activeStep = work.find((s) => s.status === "active") ?? work[0];
|
|
97
|
+
const headerText = `${
|
|
98
|
+
activeStep?.label ?? `${name ?? defaultName} is ${verb.toLowerCase()}...`
|
|
99
|
+
}${startedAt ? ` (${formatActivityElapsed(elapsed)})` : ""}`;
|
|
100
|
+
return (
|
|
101
|
+
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
|
102
|
+
<Spinner size="sm" />
|
|
103
|
+
<Shimmer as="span" duration={2.5} spread={1.5}>
|
|
104
|
+
{headerText}
|
|
105
|
+
</Shimmer>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** Max timeline blocks shown before the overflow toggle appears. */
|
|
111
|
+
const MAX_VISIBLE_ROWS = 8;
|
|
112
|
+
|
|
113
|
+
/** Max nested child rows shown under a subtask before "+N more". */
|
|
114
|
+
const MAX_VISIBLE_CHILDREN = 8;
|
|
115
|
+
|
|
116
|
+
const HIDDEN_TYPES = new Set<ActivityStep["type"]>(["thinking", "response"]);
|
|
117
|
+
|
|
118
|
+
/** Inline layout shows mid-turn reply text in the timeline; thinking stays hidden. */
|
|
119
|
+
const INLINE_HIDDEN_TYPES = new Set<ActivityStep["type"]>(["thinking"]);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* `folded` (Codex-style): a settled turn collapses behind "Worked for …",
|
|
123
|
+
* long timelines cap behind "Show N more", and mid-turn text is hidden.
|
|
124
|
+
* `inline` (Claude-style): every step and the text between them stay open.
|
|
125
|
+
*/
|
|
126
|
+
export type ActivityLayout = "folded" | "inline";
|
|
127
|
+
|
|
128
|
+
/** Nearest ancestor that actually scrolls — used to keep the overflow toggle pinned. */
|
|
129
|
+
function findScrollParent(el: HTMLElement): HTMLElement | null {
|
|
130
|
+
let current = el.parentElement;
|
|
131
|
+
while (current) {
|
|
132
|
+
const { overflowY } = getComputedStyle(current);
|
|
133
|
+
if (
|
|
134
|
+
(overflowY === "auto" ||
|
|
135
|
+
overflowY === "scroll" ||
|
|
136
|
+
overflowY === "overlay") &&
|
|
137
|
+
current.scrollHeight > current.clientHeight
|
|
138
|
+
) {
|
|
139
|
+
return current;
|
|
140
|
+
}
|
|
141
|
+
current = current.parentElement;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Expand/collapse while keeping `anchor` visually fixed (t3code work-group
|
|
148
|
+
* toggle pattern). Content inserted above the control would otherwise shove it
|
|
149
|
+
* down the viewport.
|
|
150
|
+
*/
|
|
151
|
+
function toggleWithScrollCompensation(
|
|
152
|
+
anchor: HTMLElement,
|
|
153
|
+
toggle: () => void,
|
|
154
|
+
): void {
|
|
155
|
+
const scrollParent = findScrollParent(anchor);
|
|
156
|
+
const bottomBefore = anchor.getBoundingClientRect().bottom;
|
|
157
|
+
flushSync(toggle);
|
|
158
|
+
const delta = anchor.getBoundingClientRect().bottom - bottomBefore;
|
|
159
|
+
if (!scrollParent || Math.abs(delta) < 0.5) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
scrollParent.scrollTop += delta;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export type { ActivityStep };
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Asks the owner of the payload for the untrimmed steps. Passed by context
|
|
169
|
+
* rather than props because all three collapsibles that can reveal a stripped
|
|
170
|
+
* row sit at different depths, and none of them otherwise needs the callback.
|
|
171
|
+
*/
|
|
172
|
+
const RequestFullDetailContext = createContext<(() => void) | undefined>(
|
|
173
|
+
undefined,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Opens a subagent's child thread by its `Agent` tool_use id. Provided by the
|
|
178
|
+
* chat surface that can show the thread; absent elsewhere, so subtask rows only
|
|
179
|
+
* offer "View" where it works.
|
|
180
|
+
*/
|
|
181
|
+
export const OpenSubagentContext = createContext<
|
|
182
|
+
((toolUseId: string) => void) | undefined
|
|
183
|
+
>(undefined);
|
|
184
|
+
|
|
185
|
+
/** "View" link on a subtask row that opens its child thread. */
|
|
186
|
+
function OpenSubagentButton({ toolUseId }: { toolUseId: string | undefined }) {
|
|
187
|
+
const open = useContext(OpenSubagentContext);
|
|
188
|
+
if (!open || !toolUseId) return null;
|
|
189
|
+
return (
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
onClick={(event) => {
|
|
193
|
+
event.stopPropagation();
|
|
194
|
+
open(toolUseId);
|
|
195
|
+
}}
|
|
196
|
+
className="motion-press ml-auto flex shrink-0 items-center text-xs text-primary hover:underline active:scale-[0.97]"
|
|
197
|
+
>
|
|
198
|
+
View
|
|
199
|
+
<IconChevronRight aria-hidden className="size-3" />
|
|
200
|
+
</button>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** `onOpenChange` handler that asks for the full payload the first time a fold opens. */
|
|
205
|
+
function useRequestFullDetailOnOpen(): (open: boolean) => void {
|
|
206
|
+
const request = useContext(RequestFullDetailContext);
|
|
207
|
+
return (open: boolean) => {
|
|
208
|
+
if (open) request?.();
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface ActivityTasksProps extends ComponentProps<"div"> {
|
|
213
|
+
steps: ActivityStep[];
|
|
214
|
+
isStreaming?: boolean;
|
|
215
|
+
name?: string;
|
|
216
|
+
icon?: ReactNode;
|
|
217
|
+
startedAt?: number;
|
|
218
|
+
duration?: string;
|
|
219
|
+
/** @deprecated Unused; kept so existing call sites compile unchanged. */
|
|
220
|
+
finalText?: string;
|
|
221
|
+
/**
|
|
222
|
+
* When provided, file chips with a known full path become clickable and call
|
|
223
|
+
* this with the path. Pass a stable callback — {@link ActivityTasks} is memoised.
|
|
224
|
+
*/
|
|
225
|
+
onOpenFile?: (path: string) => void;
|
|
226
|
+
/**
|
|
227
|
+
* Called when a reader opens a fold that may contain step detail the
|
|
228
|
+
* transcript query stripped (see {@link ActivityStep.hasHiddenDetail}). Fires
|
|
229
|
+
* on every open, so the owner is responsible for fetching once.
|
|
230
|
+
*/
|
|
231
|
+
onRequestFullDetail?: () => void;
|
|
232
|
+
/** Defaults to `folded`. See {@link ActivityLayout}. */
|
|
233
|
+
layout?: ActivityLayout;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Status glyph for one todo row. */
|
|
237
|
+
function TodoStatusIcon({ status }: { status: ActivityTodoItem["status"] }) {
|
|
238
|
+
return (
|
|
239
|
+
<CrossfadeIconSlot
|
|
240
|
+
iconKey={status}
|
|
241
|
+
variant="soft"
|
|
242
|
+
className="relative mt-0.5 flex size-3.5 shrink-0 items-center justify-center"
|
|
243
|
+
>
|
|
244
|
+
{status === "completed" ? (
|
|
245
|
+
<IconCircleCheck className="size-3.5 text-primary" />
|
|
246
|
+
) : status === "in_progress" ? (
|
|
247
|
+
<Spinner variant="circle" size="sm" className="size-3.5" />
|
|
248
|
+
) : (
|
|
249
|
+
<IconCircle className="size-3.5 text-muted-foreground" />
|
|
250
|
+
)}
|
|
251
|
+
</CrossfadeIconSlot>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Renders a todo checklist, or a plain fallback line for legacy/empty snapshots. */
|
|
256
|
+
function TodoChecklist({
|
|
257
|
+
todos,
|
|
258
|
+
fallback,
|
|
259
|
+
}: {
|
|
260
|
+
todos: ActivityTodoItem[];
|
|
261
|
+
fallback: string;
|
|
262
|
+
}) {
|
|
263
|
+
const merge = useCn();
|
|
264
|
+
if (todos.length === 0) {
|
|
265
|
+
return <p className="text-muted-foreground text-sm">{fallback}</p>;
|
|
266
|
+
}
|
|
267
|
+
return (
|
|
268
|
+
<ul className="flex flex-col gap-2">
|
|
269
|
+
{todos.map((todo, i) => (
|
|
270
|
+
<li
|
|
271
|
+
key={i}
|
|
272
|
+
className={merge(
|
|
273
|
+
"flex items-start gap-2 text-sm",
|
|
274
|
+
todo.status === "pending" && "opacity-60",
|
|
275
|
+
)}
|
|
276
|
+
>
|
|
277
|
+
<TodoStatusIcon status={todo.status} />
|
|
278
|
+
<span
|
|
279
|
+
className={merge(
|
|
280
|
+
"leading-snug",
|
|
281
|
+
todo.status === "completed" &&
|
|
282
|
+
"text-muted-foreground line-through",
|
|
283
|
+
todo.status === "in_progress" && "font-medium text-foreground",
|
|
284
|
+
todo.status === "pending" && "text-muted-foreground",
|
|
285
|
+
)}
|
|
286
|
+
>
|
|
287
|
+
{todo.content}
|
|
288
|
+
</span>
|
|
289
|
+
</li>
|
|
290
|
+
))}
|
|
291
|
+
</ul>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function bashIconForKind(kind: CommandVisualKind) {
|
|
296
|
+
if (kind === "inspect") return IconSearch;
|
|
297
|
+
if (kind === "git") return IconGitBranch;
|
|
298
|
+
return IconTerminal2;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Glyph for a step — shell steps swap in a glyph matching what they ran. */
|
|
302
|
+
function iconForStep(step: ActivityStep) {
|
|
303
|
+
if (step.type === "bash" && step.detail) {
|
|
304
|
+
return bashIconForKind(resolveCommandVisualKind(step.detail));
|
|
305
|
+
}
|
|
306
|
+
return (activityStepConfig[step.type] ?? activityStepConfig.tool).icon;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function traceStatusForStep(step: ActivityStep): AgentTraceStatus | undefined {
|
|
310
|
+
if (step.isError) return "error";
|
|
311
|
+
if (step.status === "active") return "running";
|
|
312
|
+
if (step.status === "pending") return "pending";
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Whole seconds a finished step took; undefined when unknown or under 1s. */
|
|
317
|
+
function stepSeconds(step: ActivityStep): number | undefined {
|
|
318
|
+
if (step.status !== "complete" || !step.durationMs) return undefined;
|
|
319
|
+
const seconds = Math.round(step.durationMs / 1000);
|
|
320
|
+
return seconds >= 1 ? seconds : undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* One thinking block behind a "Thought 12s" line. Streaming steps open
|
|
325
|
+
* themselves so live thinking is readable as it lands, then auto-close.
|
|
326
|
+
*/
|
|
327
|
+
function ActivityReasoningLine({ step }: { step: ActivityStep }) {
|
|
328
|
+
const thoughts = step.detail?.trim() ?? "";
|
|
329
|
+
const isActive = step.status === "active";
|
|
330
|
+
const disclosure = useStreamingDisclosure(isActive);
|
|
331
|
+
const Markdown = useActivityMarkdown();
|
|
332
|
+
const seconds = stepSeconds(step);
|
|
333
|
+
const line = (
|
|
334
|
+
<AgentTraceLine status={isActive ? "running" : undefined}>
|
|
335
|
+
<AgentTraceIcon>
|
|
336
|
+
<IconBrain />
|
|
337
|
+
</AgentTraceIcon>
|
|
338
|
+
<AgentTraceTitle shimmer={isActive}>
|
|
339
|
+
{isActive ? "Thinking..." : "Thought"}
|
|
340
|
+
</AgentTraceTitle>
|
|
341
|
+
{seconds ? (
|
|
342
|
+
<AgentTraceDescription>{formatActivityElapsed(seconds)}</AgentTraceDescription>
|
|
343
|
+
) : null}
|
|
344
|
+
</AgentTraceLine>
|
|
345
|
+
);
|
|
346
|
+
if (!thoughts) return line;
|
|
347
|
+
return (
|
|
348
|
+
<AgentTraceCollapsible open={disclosure.open} onOpenChange={disclosure.onOpenChange}>
|
|
349
|
+
<AgentTraceCollapsibleTrigger>{line}</AgentTraceCollapsibleTrigger>
|
|
350
|
+
<AgentTraceCollapsibleContent>
|
|
351
|
+
<Markdown className="text-sm">{thoughts}</Markdown>
|
|
352
|
+
</AgentTraceCollapsibleContent>
|
|
353
|
+
</AgentTraceCollapsible>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The to-do snapshot as one line with done/total, folding open to the checklist. */
|
|
358
|
+
function ActivityTodosLine({ step }: { step: ActivityStep }) {
|
|
359
|
+
const isActive = step.status === "active";
|
|
360
|
+
const todos = step.todos ?? [];
|
|
361
|
+
const done = todos.filter((todo) => todo.status === "completed").length;
|
|
362
|
+
const presentation = deriveStepRowPresentation(step, isActive);
|
|
363
|
+
const Icon = activityStepConfig.todos.icon;
|
|
364
|
+
return (
|
|
365
|
+
<AgentTraceCollapsible defaultOpen={isActive}>
|
|
366
|
+
<AgentTraceCollapsibleTrigger>
|
|
367
|
+
<AgentTraceLine status={isActive ? "running" : undefined}>
|
|
368
|
+
<AgentTraceIcon>
|
|
369
|
+
<Icon />
|
|
370
|
+
</AgentTraceIcon>
|
|
371
|
+
<AgentTraceTitle shimmer={isActive}>{presentation.text}</AgentTraceTitle>
|
|
372
|
+
{todos.length > 0 ? (
|
|
373
|
+
<AgentTraceDescription>
|
|
374
|
+
{done}/{todos.length}
|
|
375
|
+
</AgentTraceDescription>
|
|
376
|
+
) : null}
|
|
377
|
+
</AgentTraceLine>
|
|
378
|
+
</AgentTraceCollapsibleTrigger>
|
|
379
|
+
<AgentTraceCollapsibleContent>
|
|
380
|
+
<TodoChecklist todos={todos} fallback={step.detail ?? step.label} />
|
|
381
|
+
</AgentTraceCollapsibleContent>
|
|
382
|
+
</AgentTraceCollapsible>
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** One per-call activity line with Synara-style humanized label. */
|
|
387
|
+
function ActivityStepRow({
|
|
388
|
+
row,
|
|
389
|
+
onOpenFile,
|
|
390
|
+
}: {
|
|
391
|
+
row: ActivityRow;
|
|
392
|
+
onOpenFile?: (path: string) => void;
|
|
393
|
+
}) {
|
|
394
|
+
const { step, children } = row;
|
|
395
|
+
const isActive = step.status === "active";
|
|
396
|
+
const presentation = deriveStepRowPresentation(step, isActive);
|
|
397
|
+
const requestFullDetail = useRequestFullDetailOnOpen();
|
|
398
|
+
|
|
399
|
+
if (step.type === "todos") return <ActivityTodosLine step={step} />;
|
|
400
|
+
if (step.type === "reasoning") return <ActivityReasoningLine step={step} />;
|
|
401
|
+
|
|
402
|
+
const Icon = iconForStep(step);
|
|
403
|
+
// A subagent's own text (`response` children) belongs to its child thread
|
|
404
|
+
// view, not the parent's timeline.
|
|
405
|
+
const childRows = (children ?? []).filter(
|
|
406
|
+
(child) => !HIDDEN_TYPES.has(child.step.type),
|
|
407
|
+
);
|
|
408
|
+
const openSubagent =
|
|
409
|
+
step.type === "subtask" ? (
|
|
410
|
+
<OpenSubagentButton toolUseId={step.toolUseId} />
|
|
411
|
+
) : null;
|
|
412
|
+
const childOverflow = childRows.length - MAX_VISIBLE_CHILDREN;
|
|
413
|
+
const shownChildren =
|
|
414
|
+
childOverflow > 0 ? childRows.slice(0, MAX_VISIBLE_CHILDREN) : childRows;
|
|
415
|
+
const seconds = stepSeconds(step);
|
|
416
|
+
const description =
|
|
417
|
+
childRows.length > 0
|
|
418
|
+
? `${childRows.length} ${childRows.length === 1 ? "step" : "steps"}`
|
|
419
|
+
: seconds
|
|
420
|
+
? formatActivityElapsed(seconds)
|
|
421
|
+
: undefined;
|
|
422
|
+
|
|
423
|
+
const fileChip = presentation.fileChip ? (
|
|
424
|
+
<FileChip
|
|
425
|
+
name={presentation.fileChip.name}
|
|
426
|
+
path={presentation.fileChip.path}
|
|
427
|
+
onOpenFile={onOpenFile}
|
|
428
|
+
/>
|
|
429
|
+
) : undefined;
|
|
430
|
+
const lineHead = (
|
|
431
|
+
<>
|
|
432
|
+
<AgentTraceIcon iconKey={`${step.type}-${step.status}`}>
|
|
433
|
+
<Icon />
|
|
434
|
+
</AgentTraceIcon>
|
|
435
|
+
<AgentTraceTitle shimmer={isActive} title={presentation.title}>
|
|
436
|
+
{presentation.text}
|
|
437
|
+
</AgentTraceTitle>
|
|
438
|
+
</>
|
|
439
|
+
);
|
|
440
|
+
const descriptionNode = description ? (
|
|
441
|
+
<AgentTraceDescription>{description}</AgentTraceDescription>
|
|
442
|
+
) : null;
|
|
443
|
+
|
|
444
|
+
const hasDetail = activityStepHasRichDetail(step);
|
|
445
|
+
if (!hasDetail && shownChildren.length === 0) {
|
|
446
|
+
return (
|
|
447
|
+
<AgentTraceLine status={traceStatusForStep(step)}>
|
|
448
|
+
{lineHead}
|
|
449
|
+
{fileChip}
|
|
450
|
+
{descriptionNode}
|
|
451
|
+
{openSubagent}
|
|
452
|
+
</AgentTraceLine>
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Subagent steps start open so their inner steps stay visible, as before.
|
|
457
|
+
return (
|
|
458
|
+
<AgentTraceCollapsible
|
|
459
|
+
defaultOpen={shownChildren.length > 0}
|
|
460
|
+
onOpenChange={requestFullDetail}
|
|
461
|
+
>
|
|
462
|
+
{/* The file chip opens the file viewer, so it sits beside the trigger:
|
|
463
|
+
a button inside the trigger button would be invalid HTML. */}
|
|
464
|
+
<AgentTraceCollapsibleTrigger
|
|
465
|
+
trailing={
|
|
466
|
+
fileChip || openSubagent ? (
|
|
467
|
+
<>
|
|
468
|
+
{fileChip}
|
|
469
|
+
{fileChip ? descriptionNode : null}
|
|
470
|
+
{openSubagent}
|
|
471
|
+
</>
|
|
472
|
+
) : undefined
|
|
473
|
+
}
|
|
474
|
+
>
|
|
475
|
+
<AgentTraceLine status={traceStatusForStep(step)}>
|
|
476
|
+
{lineHead}
|
|
477
|
+
{fileChip ? null : descriptionNode}
|
|
478
|
+
</AgentTraceLine>
|
|
479
|
+
</AgentTraceCollapsibleTrigger>
|
|
480
|
+
<AgentTraceCollapsibleContent className="flex flex-col gap-2">
|
|
481
|
+
{hasDetail ? (
|
|
482
|
+
<ActivityStepDetail step={step} onOpenFile={onOpenFile} />
|
|
483
|
+
) : null}
|
|
484
|
+
{shownChildren.map((child, i) => (
|
|
485
|
+
<ActivityStepRow
|
|
486
|
+
key={activityRowKey(child, i)}
|
|
487
|
+
row={child}
|
|
488
|
+
onOpenFile={onOpenFile}
|
|
489
|
+
/>
|
|
490
|
+
))}
|
|
491
|
+
{childOverflow > 0 ? (
|
|
492
|
+
<p className="text-xs text-muted-foreground">+{childOverflow} more</p>
|
|
493
|
+
) : null}
|
|
494
|
+
</AgentTraceCollapsibleContent>
|
|
495
|
+
</AgentTraceCollapsible>
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Model reasoning, shown as ordinary prose rather than another muted row. */
|
|
500
|
+
/** Reply text the agent wrote between tool runs, set like the final reply. */
|
|
501
|
+
function ActivityResponseBlock({ step }: { step: ActivityStep }) {
|
|
502
|
+
const Markdown = useActivityMarkdown();
|
|
503
|
+
const text = step.detail?.trim();
|
|
504
|
+
if (!text) return null;
|
|
505
|
+
return (
|
|
506
|
+
<Markdown
|
|
507
|
+
className="my-1 text-foreground"
|
|
508
|
+
mode={step.status === "active" ? "streaming" : "static"}
|
|
509
|
+
>
|
|
510
|
+
{text}
|
|
511
|
+
</Markdown>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function ActivityReasoningBlock({ step }: { step: ActivityStep }) {
|
|
516
|
+
const Markdown = useActivityMarkdown();
|
|
517
|
+
const thoughts = step.detail?.trim();
|
|
518
|
+
if (!thoughts) return null;
|
|
519
|
+
return (
|
|
520
|
+
<Markdown className="my-1 text-foreground text-sm leading-relaxed">
|
|
521
|
+
{thoughts}
|
|
522
|
+
</Markdown>
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** A run of tool calls, folded behind one muted "what happened" summary line. */
|
|
527
|
+
function ActivityActionGroup({
|
|
528
|
+
rows,
|
|
529
|
+
onOpenFile,
|
|
530
|
+
}: {
|
|
531
|
+
rows: ActivityRow[];
|
|
532
|
+
onOpenFile?: (path: string) => void;
|
|
533
|
+
}) {
|
|
534
|
+
const steps = rows.map((row) => row.step);
|
|
535
|
+
const isActive = steps.some((step) => step.status === "active");
|
|
536
|
+
const summary = deriveActionGroupSummary(steps);
|
|
537
|
+
const firstStep = steps[0];
|
|
538
|
+
const Icon = firstStep ? iconForStep(firstStep) : IconTerminal2;
|
|
539
|
+
const requestFullDetail = useRequestFullDetailOnOpen();
|
|
540
|
+
|
|
541
|
+
return (
|
|
542
|
+
<AgentTraceCollapsible defaultOpen={isActive} onOpenChange={requestFullDetail}>
|
|
543
|
+
{/* Summary stays muted even when a call inside failed: agents run failing
|
|
544
|
+
commands on purpose, so one non-zero exit should not paint the run red.
|
|
545
|
+
The failed line itself is still red once the fold is open. */}
|
|
546
|
+
<AgentTraceCollapsibleTrigger>
|
|
547
|
+
<AgentTraceLine status={isActive ? "running" : undefined}>
|
|
548
|
+
<AgentTraceIcon iconKey={isActive ? "group-active" : "group-idle"}>
|
|
549
|
+
<Icon />
|
|
550
|
+
</AgentTraceIcon>
|
|
551
|
+
<AgentTraceTitle shimmer={isActive} title={summary}>
|
|
552
|
+
{summary}
|
|
553
|
+
</AgentTraceTitle>
|
|
554
|
+
</AgentTraceLine>
|
|
555
|
+
</AgentTraceCollapsibleTrigger>
|
|
556
|
+
<AgentTraceCollapsibleContent className="flex flex-col gap-2">
|
|
557
|
+
{rows.map((row, i) => (
|
|
558
|
+
<ActivityStepRow
|
|
559
|
+
key={activityRowKey(row, i)}
|
|
560
|
+
row={row}
|
|
561
|
+
onOpenFile={onOpenFile}
|
|
562
|
+
/>
|
|
563
|
+
))}
|
|
564
|
+
</AgentTraceCollapsibleContent>
|
|
565
|
+
</AgentTraceCollapsible>
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function ActivitySegmentBlock({
|
|
570
|
+
segment,
|
|
571
|
+
onOpenFile,
|
|
572
|
+
}: {
|
|
573
|
+
segment: ActivitySegment;
|
|
574
|
+
onOpenFile?: (path: string) => void;
|
|
575
|
+
}) {
|
|
576
|
+
if (segment.kind === "reasoning") {
|
|
577
|
+
return <ActivityReasoningBlock step={segment.step} />;
|
|
578
|
+
}
|
|
579
|
+
if (segment.kind === "response") {
|
|
580
|
+
return <ActivityResponseBlock step={segment.step} />;
|
|
581
|
+
}
|
|
582
|
+
if (segment.kind === "row") {
|
|
583
|
+
return <ActivityStepRow row={segment.row} onOpenFile={onOpenFile} />;
|
|
584
|
+
}
|
|
585
|
+
return <ActivityActionGroup rows={segment.rows} onOpenFile={onOpenFile} />;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Renders timeline blocks with an overflow cap. Settled turns cap from the front
|
|
590
|
+
* (first N); streaming turns cap from the end (newest N) so latest stays visible.
|
|
591
|
+
*/
|
|
592
|
+
function ActivityRowList({
|
|
593
|
+
rows,
|
|
594
|
+
isStreaming,
|
|
595
|
+
capped,
|
|
596
|
+
onOpenFile,
|
|
597
|
+
}: {
|
|
598
|
+
rows: ActivityRow[];
|
|
599
|
+
isStreaming?: boolean;
|
|
600
|
+
capped: boolean;
|
|
601
|
+
onOpenFile?: (path: string) => void;
|
|
602
|
+
}) {
|
|
603
|
+
const [expanded, setExpanded] = useState(false);
|
|
604
|
+
const toggleRef = useRef<HTMLButtonElement>(null);
|
|
605
|
+
const segments = groupActivityRows(rows);
|
|
606
|
+
const keyedSegments = segments.map((segment, index) => ({
|
|
607
|
+
segment,
|
|
608
|
+
key: activitySegmentKey(segment, index),
|
|
609
|
+
}));
|
|
610
|
+
const overflow = capped ? keyedSegments.length - MAX_VISIBLE_ROWS : 0;
|
|
611
|
+
const isCapped = overflow > 0 && !expanded;
|
|
612
|
+
const visible = isCapped
|
|
613
|
+
? isStreaming
|
|
614
|
+
? keyedSegments.slice(keyedSegments.length - MAX_VISIBLE_ROWS)
|
|
615
|
+
: keyedSegments.slice(0, MAX_VISIBLE_ROWS)
|
|
616
|
+
: keyedSegments;
|
|
617
|
+
|
|
618
|
+
const handleToggle = useCallback(() => {
|
|
619
|
+
const anchor = toggleRef.current;
|
|
620
|
+
if (!anchor) {
|
|
621
|
+
setExpanded((value) => !value);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
toggleWithScrollCompensation(anchor, () => {
|
|
625
|
+
setExpanded((value) => !value);
|
|
626
|
+
});
|
|
627
|
+
}, []);
|
|
628
|
+
|
|
629
|
+
const toggle =
|
|
630
|
+
overflow > 0 ? (
|
|
631
|
+
<OverflowToggle
|
|
632
|
+
ref={toggleRef}
|
|
633
|
+
expanded={expanded}
|
|
634
|
+
overflow={overflow}
|
|
635
|
+
onToggle={handleToggle}
|
|
636
|
+
/>
|
|
637
|
+
) : null;
|
|
638
|
+
|
|
639
|
+
return (
|
|
640
|
+
<>
|
|
641
|
+
{isStreaming && toggle}
|
|
642
|
+
{visible.map(({ segment, key }, index) => (
|
|
643
|
+
<m.div
|
|
644
|
+
key={key}
|
|
645
|
+
initial={{ opacity: 0, y: 6 }}
|
|
646
|
+
animate={{ opacity: 1, y: 0 }}
|
|
647
|
+
transition={{
|
|
648
|
+
...tokenTransition.fast,
|
|
649
|
+
delay: staggerDelay(index, 0.02, 0.08),
|
|
650
|
+
}}
|
|
651
|
+
>
|
|
652
|
+
<ActivitySegmentBlock segment={segment} onOpenFile={onOpenFile} />
|
|
653
|
+
</m.div>
|
|
654
|
+
))}
|
|
655
|
+
{!isStreaming && toggle}
|
|
656
|
+
</>
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function OverflowToggle({
|
|
661
|
+
ref,
|
|
662
|
+
expanded,
|
|
663
|
+
overflow,
|
|
664
|
+
onToggle,
|
|
665
|
+
}: {
|
|
666
|
+
ref?: Ref<HTMLButtonElement>;
|
|
667
|
+
expanded: boolean;
|
|
668
|
+
overflow: number;
|
|
669
|
+
onToggle: () => void;
|
|
670
|
+
}) {
|
|
671
|
+
return (
|
|
672
|
+
<button
|
|
673
|
+
ref={ref}
|
|
674
|
+
type="button"
|
|
675
|
+
onClick={onToggle}
|
|
676
|
+
className="w-fit text-muted-foreground text-sm transition-colors hover:text-foreground"
|
|
677
|
+
>
|
|
678
|
+
{expanded ? "Show less" : `Show ${overflow} more`}
|
|
679
|
+
</button>
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
export const ActivityTasks = memo(
|
|
684
|
+
({
|
|
685
|
+
steps,
|
|
686
|
+
isStreaming,
|
|
687
|
+
name,
|
|
688
|
+
icon: _icon,
|
|
689
|
+
className,
|
|
690
|
+
startedAt,
|
|
691
|
+
duration,
|
|
692
|
+
finalText,
|
|
693
|
+
onOpenFile,
|
|
694
|
+
onRequestFullDetail,
|
|
695
|
+
layout = "folded",
|
|
696
|
+
...props
|
|
697
|
+
}: ActivityTasksProps) => {
|
|
698
|
+
const merge = useCn();
|
|
699
|
+
const inline = layout === "inline";
|
|
700
|
+
const hiddenTypes = inline ? INLINE_HIDDEN_TYPES : HIDDEN_TYPES;
|
|
701
|
+
const rows = buildActivityRows(steps).filter(
|
|
702
|
+
(row) =>
|
|
703
|
+
!hiddenTypes.has(row.step.type) &&
|
|
704
|
+
// A subagent's text whose Agent row is missing still belongs to its
|
|
705
|
+
// child thread, never to the parent's inline reply.
|
|
706
|
+
!(row.step.type === "response" && row.step.parentToolUseId),
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
if (rows.length === 0 && !isStreaming) return null;
|
|
710
|
+
|
|
711
|
+
void finalText;
|
|
712
|
+
|
|
713
|
+
if (!isStreaming && duration && !inline) {
|
|
714
|
+
return (
|
|
715
|
+
<RequestFullDetailContext value={onRequestFullDetail}>
|
|
716
|
+
<Collapsible
|
|
717
|
+
className={merge("group text-sm", className)}
|
|
718
|
+
defaultOpen={false}
|
|
719
|
+
// Opening the turn prefetches the stripped detail, so the rows
|
|
720
|
+
// inside already have their bodies by the time one is clicked.
|
|
721
|
+
onOpenChange={(open) => {
|
|
722
|
+
if (open) onRequestFullDetail?.();
|
|
723
|
+
}}
|
|
724
|
+
{...props}
|
|
725
|
+
>
|
|
726
|
+
<div className="border-b border-border pb-1.5">
|
|
727
|
+
<AgentTraceCollapsibleTrigger>
|
|
728
|
+
<AgentTraceLine>
|
|
729
|
+
<AgentTraceTitle>{`Worked for ${duration}`}</AgentTraceTitle>
|
|
730
|
+
</AgentTraceLine>
|
|
731
|
+
</AgentTraceCollapsibleTrigger>
|
|
732
|
+
</div>
|
|
733
|
+
<CollapsibleContent>
|
|
734
|
+
<AgentTrace className="mt-2">
|
|
735
|
+
<ActivityRowList
|
|
736
|
+
rows={rows}
|
|
737
|
+
isStreaming={false}
|
|
738
|
+
capped
|
|
739
|
+
onOpenFile={onOpenFile}
|
|
740
|
+
/>
|
|
741
|
+
</AgentTrace>
|
|
742
|
+
</CollapsibleContent>
|
|
743
|
+
</Collapsible>
|
|
744
|
+
</RequestFullDetailContext>
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
return (
|
|
749
|
+
<RequestFullDetailContext value={onRequestFullDetail}>
|
|
750
|
+
<AgentTrace className={className} {...props}>
|
|
751
|
+
{isStreaming && rows.length === 0 ? (
|
|
752
|
+
<ActivityStreamingHeader
|
|
753
|
+
steps={steps}
|
|
754
|
+
name={name}
|
|
755
|
+
startedAt={startedAt}
|
|
756
|
+
/>
|
|
757
|
+
) : null}
|
|
758
|
+
<ActivityRowList
|
|
759
|
+
rows={rows}
|
|
760
|
+
isStreaming={isStreaming}
|
|
761
|
+
capped={!inline}
|
|
762
|
+
onOpenFile={onOpenFile}
|
|
763
|
+
/>
|
|
764
|
+
</AgentTrace>
|
|
765
|
+
</RequestFullDetailContext>
|
|
766
|
+
);
|
|
767
|
+
},
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
ActivityTasks.displayName = "ActivityTasks";
|