History

fix: reconcile main's round-19 features after the rebase

Adopts main's round-19 features into the v2 stack: payload-budgeted orchestration replay (#8992), sidebar row subscription leases (#9052), tool group virtualization and scroll anchoring (#9106), repeated-command and browser-group presentation, inline assistant citations (#9146), per-cwd provider skills discovery (#8778), Claude composer skill dispatch (#9128), grok health probe and model negotiation (#9154), and the failed-tool thinking fallback (#9165).

Julius Marminge committed 2 months ago

Commit 446c14a1 parent 41a58ee51 files · +2078 −575Browse files

Unified · Split

51 files
  1. apps/mobile/src/features/threads/ThreadFeed.tsx +38 −7
  2. apps/mobile/src/features/threads/threadPresentation.ts +0 −11
  3. apps/mobile/src/lib/projectThreadStartTurn.test.ts +1 −1
  4. apps/mobile/src/lib/projectThreadStartTurn.ts +2 −1
  5. apps/mobile/src/lib/threadActivity.test.ts +1 −3
  6. apps/mobile/src/lib/threadActivity.ts +42 −23
  7. apps/server/scripts/acp-mock-agent.ts +15 −5
  8. apps/server/src/httpCors.ts +2 −7
  9. apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +1 −1
  10. apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +14 −0
  11. apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +67 −4
  12. apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts +14 −0
  13. apps/server/src/orchestration/Layers/OrchestrationEngine.ts +3 −0
  14. apps/server/src/provider/Drivers/AcpRegistryDriver.ts +2 −2
  15. apps/server/src/provider/Drivers/CodexDriver.ts +35 −1
  16. apps/server/src/provider/Drivers/CursorDriver.ts +1 −1
  17. apps/server/src/provider/Drivers/OpenCodeDriver.ts +50 −0
  18. apps/server/src/provider/Layers/ProviderRegistry.test.ts +195 −0
  19. apps/server/src/provider/Layers/ProviderRegistry.ts +24 −0
  20. apps/server/src/provider/ProviderDriver.ts +4 −2
  21. apps/server/src/provider/acp/AcpSessionRuntime.ts +18 −3
  22. apps/server/src/provider/acp/GrokAcpSupport.ts +5 −3
  23. apps/server/src/provider/acp/XAiAcpExtension.ts +11 −8
  24. apps/server/src/ws.ts +37 −2
  25. apps/web/src/components/ChatView.logic.test.ts +1 −1
  26. apps/web/src/components/ChatView.logic.ts +16 −0
  27. apps/web/src/components/ChatView.tsx +67 −16
  28. apps/web/src/components/DiffPanel.tsx +7 −0
  29. apps/web/src/components/GitActionsControl.tsx +14 −7
  30. apps/web/src/components/Sidebar.tsx +11 −4
  31. apps/web/src/components/chat/ChatComposer.tsx +156 −39
  32. apps/web/src/components/chat/ComposerBannerStack.tsx +1 −0
  33. apps/web/src/components/chat/ComposerPrimaryActions.test.tsx +0 −21
  34. apps/web/src/components/chat/ComposerStashBadge.tsx +6 −1
  35. apps/web/src/components/chat/MessagesTimeline.logic.test.ts +315 −57
  36. apps/web/src/components/chat/MessagesTimeline.logic.ts +119 −40
  37. apps/web/src/components/chat/MessagesTimeline.test.tsx +35 −21
  38. apps/web/src/components/chat/MessagesTimeline.tsx +381 −109
  39. apps/web/src/components/chat/useAssistantCitationTarget.ts +3 −3
  40. apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +1 −0
  41. apps/web/src/index.css +8 −2
  42. apps/web/src/lib/contextWindow.ts +0 −22
  43. apps/web/src/state/entities.ts +0 −34
  44. packages/client-runtime/package.json +4 −0
  45. packages/client-runtime/src/connection/registry.test.ts +1 −0
  46. packages/client-runtime/src/operations/threadTitle.ts +2 −1
  47. packages/client-runtime/src/state/models.ts +0 −10
  48. packages/client-runtime/src/work-log/commandLabel.ts +24 −1
  49. packages/client-runtime/src/work-log/presentation.test.ts +44 −92
  50. packages/client-runtime/src/work-log/presentation.ts +261 −8
  51. packages/effect-acp/src/protocol.ts +19 −1
apps/mobile/src/features/threads/ThreadFeed.tsx+38−7
@@ -85,6 +85,7 @@ import Animated, { · 84 unmodified lines
8585 import { useUniwindTheme } from "../../lib/useUniwindTheme";
8686 import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
8787 import { useFontFamily } from "../../lib/useFontFamily";
88+import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";
8889 import { scopedThreadKey } from "../../lib/scopedEntities";
8990 import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
9091 import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
@@ -123,7 +124,11 @@ import { · 32 unmodified lines
123124 } from "../review/nativeReviewDiffAdapter";
124125 import { buildReviewParsedDiff } from "../review/reviewModel";
125126 import { cn } from "../../lib/cn";
126−import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../../lib/layout";
127+import {
128+ deriveCenteredContentHorizontalPadding,
129+ deriveThreadWorkLogSizing,
130+ type LayoutVariant,
131+} from "../../lib/layout";
127132 import { uuidv4 } from "../../lib/uuid";
128133 import {
129134 resolveMarkdownFontSizes,
@@ -147,6 +152,7 @@ import type { ThreadContentPresentation } from "./threadContentPresentation"; · 17 unmodified lines
147152 import {
148153 resolveThreadFeedLiveFollow,
149154 type ThreadFeedLiveFollowEvent,
155+ type ThreadWorkGroupScrollPosition,
150156 } from "./thread-feed-live-follow";
151157 import {
152158 ThreadDisclosureChevron,
@@ -171,7 +177,12 @@ import { appAtomRegistry } from "../../state/atom-registry"; · 18 unmodified lines
171177 import { environmentThreadShells, threadEnvironment } from "../../state/threads";
172178 import { useAtomCommand } from "../../state/use-atom-command";
173179 import { useV2ItemSupport } from "../../state/v2-item-support";
174−import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
180+import {
181+ basename,
182+ fileRoutePathSegments,
183+ isAbsolutePath,
184+ resolveWorkspaceRelativeFilePath,
185+} from "../files/filePath";
175186 import { waitForThreadShellReady } from "./threadForkNavigation";
176187 import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge";
177188
@@ -1818,15 +1829,12 @@ function renderFeedEntry( · 1640 unmodified lines
18181829 activities={entry.activities}
18191830 anchorKey={entry.id}
18201831 copiedRowId={props.copiedRowId}
1821− currentThreadId={props.threadId}
1822− environmentId={props.environmentId}
18231832 expandedRows={props.expandedWorkRows}
18241833 rowSizing={props.workRowSizing}
18251834 scrollPositions={props.workGroupScrollPositions}
18261835 iconSubtleColor={iconSubtleColor}
18271836 onCopyRow={props.onCopyWorkRow}
1828− onToggleRow={(rowId) => props.onToggleWorkRow(rowId, entry.id)}
1829− workspaceRoot={props.workspaceRoot}
1837+ onToggleRow={props.onToggleWorkRow}
18301838 renderImage={props.renderViewedImage}
18311839 />
18321840 );
@@ -2145,7 +2153,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 312 unmodified lines
21452153 const previousLatestTurnRef = useRef(props.latestRun);
21462154 const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
21472155 const headerMaterialVisibleRef = useRef(false);
2148− const { width: windowWidth } = useWindowDimensions();
2156+ const { width: windowWidth, fontScale } = useWindowDimensions();
2157+ const { appearance } = useAppearancePreferences();
2158+ const workRowSizing = useMemo(
2159+ () => deriveThreadWorkLogSizing({ baseFontSize: appearance.baseFontSize, fontScale }),
2160+ [appearance.baseFontSize, fontScale],
2161+ );
2162+ const previousTextSize = useRef(workRowSizing.textSizeKey);
2163+ useLayoutEffect(() => {
2164+ if (previousTextSize.current === workRowSizing.textSizeKey) {
2165+ return;
2166+ }
2167+ previousTextSize.current = workRowSizing.textSizeKey;
2168+ // Text-size changes invalidate the outer list's fixed-height cache too.
2169+ // This never runs for scrolling, streamed output, or disclosure toggles.
2170+ props.listRef.current?.clearCaches({ mode: "sizes" });
2171+ }, [workRowSizing.textSizeKey, props.listRef]);
21492172 const [viewportWidth, setViewportWidth] = useState(() =>
21502173 props.layoutVariant === "split" ? 0 : windowWidth,
21512174 );
@@ -2516,6 +2539,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 364 unmodified lines
25162539 // ThreadId, and keying resets (or the list mount) on the bare id would
25172540 // carry stale scroll/follow state across an environment switch.
25182541 const feedThreadKey = scopedThreadKey(props.environmentId, props.threadId);
2542+ // Virtualized groups can unmount without losing the reader's place. This cache
2543+ // belongs to this thread view only and never causes per-scroll React updates.
2544+ const workGroupScrollPositions = useMemo(
2545+ () => new Map<string, ThreadWorkGroupScrollPosition>(),
2546+ [feedThreadKey],
2547+ );
25192548 // A thread switch opens pinned to the end; a send explicitly returns to the
25202549 // live edge (ThreadDetailScreen scrolls the new message into place). Both
25212550 // re-arm follow regardless of where the user had scrolled before.
@@ -2829,6 +2858,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 307 unmodified lines
28292858 threadId: props.threadId,
28302859 copiedRowId,
28312860 expandedWorkRows,
2861+ workRowSizing,
2862+ workGroupScrollPositions,
28322863 terminalAssistantMessageIds,
28332864 unsettledTurnId,
28342865 onCopyWorkRow,
apps/mobile/src/features/threads/threadPresentation.ts+0−11
@@ -4,11 +4,6 @@ import { · 3 unmodified lines
44 type EnvironmentThreadShell,
55 } from "@t3tools/client-runtime/state/shell";
66
7−export function threadSortValue(thread: EnvironmentThreadShell): number {
8− const candidate = Date.parse(thread.updatedAt ?? thread.createdAt);
9− return Number.isNaN(candidate) ? 0 : candidate;
10−}
11−
127 export type ThreadStatusKind =
138 | "pending-approval"
149 | "awaiting-input"
@@ -27,12 +22,6 @@ export interface ThreadStatusPresentation extends StatusTone { · 12 unmodified lines
2722 readonly pulse: boolean;
2823 }
2924
30−/** Neutral icon colors for threads with no actionable status. */
31−export const THREAD_STATUS_NEUTRAL_ICON = {
32− iconColor: "#8e8e93",
33− iconBackground: "rgba(142,142,147,0.22)",
34−} as const;
35−
3625 function isLatestRunSettled(thread: EnvironmentThreadShell): boolean {
3726 if (!thread.latestRun?.startedAt) return false;
3827 if (!thread.latestRun.completedAt) return false;
apps/mobile/src/lib/projectThreadStartTurn.test.ts+1−1
@@ -26,7 +26,7 @@ describe("project thread title", () => { · 25 unmodified lines
2626 },
2727 {
2828 comment: 'Why "shared"?',
29− title: 'Keep `cache[key]` & <parser> shared. Retry! Comment: Why "shared"?',
29+ title: "Keep `cache[key]` & <parser> shared. Retry! Commen...",
3030 },
3131 ])("uses readable titles and intact links with comment $comment", ({ comment, title }) => {
3232 const quoteText = "Keep `cache[key]` & <parser> shared.\n Retry!";
apps/mobile/src/lib/projectThreadStartTurn.ts+2−1
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 type RuntimeMode,
99 } from "@t3tools/contracts";
1010 import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";
11+import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
1112
1213 import type { UploadedMobileAttachment } from "./attachmentUpload";
1314
@@ -82,7 +83,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe · 68 unmodified lines
8283 }
8384
8485 export function deriveThreadTitleFromPrompt(value: string): string {
85− const trimmed = value.trim();
86+ const trimmed = assistantCitationsToPlainText(value).trim();
8687 if (trimmed.length === 0) {
8788 return "New thread";
8889 }
apps/mobile/src/lib/threadActivity.test.ts+1−3
@@ -679,9 +679,7 @@ describe("buildThreadFeed", () => { · 678 unmodified lines
679679 );
680680 expect(expanded.map((entry) => entry.id)).toEqual([
681681 "work-toggle:work-group:activity-neutral",
682− "activity-1",
683− "activity-2",
684− "activity-3",
682+ "work-details:work-group:activity-neutral",
685683 ]);
686684 expect(expanded[0]).toMatchObject({
687685 type: "work-toggle",
apps/mobile/src/lib/threadActivity.ts+42−23
@@ -6,6 +6,8 @@ import type { · 5 unmodified lines
66 import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation";
77 import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
88 import {
9+ workEntryDisplayIndicatesToolFailure,
10+ resolveWorkEntryToolPresentation,
911 summarizeToolGroup,
1012 toolGroupSummaryKind,
1113 type ToolGroupSummaryKind,
@@ -868,7 +870,7 @@ function appendToolGroupRows( · 856 unmodified lines
868870 : (latestInProgressActivity ?? activities.at(-1)!);
869871 const groupSummary = summarizeToolGroup(activities.map((activity) => activity.workEntry));
870872 const summary = live
871− ? liveToolActivitySummary(latestActivity)
873+ ? liveToolActivitySummary(latestActivity, latestInProgressActivity !== undefined)
872874 : activities.length === 1 && !activities[0]!.toolLike
873875 ? activities[0]!.workEntry.label
874876 : groupSummary.summary;
@@ -884,7 +886,12 @@ function appendToolGroupRows( · 9 unmodified lines
884886 summaryKind: toolGroupSummaryKind(
885887 (live ? [latestActivity] : activities).map((activity) => activity.workEntry),
886888 ),
887− hasFailure: groupSummary.hasFailure,
889+ hasFailure: (() => {
890+ const lastToolLike = activities.findLast((activity) => activity.toolLike);
891+ return (
892+ lastToolLike !== undefined && workEntryDisplayIndicatesToolFailure(lastToolLike.workEntry)
893+ );
894+ })(),
888895 live,
889896 shimmer:
890897 isWorking &&
@@ -892,32 +899,44 @@ function appendToolGroupRows( · 1 unmodified line
892899 latestActivity.runId === activeRunId,
893900 });
894901 if (!expanded) return;
895− for (const activity of activities) {
896− result.push({
897− type: "activity-group",
898− id: activity.id,
899− createdAt: activity.createdAt,
900− runId: activity.runId,
901− activities: [
902− {
903− ...activity,
904− groupedToolDetail: true,
905− live:
906− isWorking &&
907− activity.id === latestActivity.id &&
908− activity.lifecycleStatus === "inProgress" &&
909− activity.runId === activeRunId,
910− },
911− ],
912− });
913− }
902+ result.push({
903+ type: "activity-group",
904+ id: `work-details:${groupId}`,
905+ createdAt: activities[0]!.createdAt,
906+ runId: activities[0]!.runId,
907+ activities: activities.map((activity) => ({
908+ ...activity,
909+ groupedToolDetail: true,
910+ live:
911+ isWorking &&
912+ activity.id === latestActivity.id &&
913+ activity.lifecycleStatus === "inProgress" &&
914+ activity.runId === activeRunId,
915+ })),
916+ });
914917 }
915918
916−function liveToolActivitySummary(activity: ThreadFeedActivity): string {
919+function liveToolActivitySummary(activity: ThreadFeedActivity, active: boolean): string {
920+ const presentation = resolveWorkEntryToolPresentation(
921+ activity.workEntry,
922+ active ? "inProgress" : "completed",
923+ );
924+ if (presentation) return presentation.displayName;
917925 const command = activity.workEntry.command?.trim();
918926 if (command) {
919927 const program = commandProgramName(command);
920− return program ? `Running ${program}` : "Running command";
928+ const status = activity.lifecycleStatus ?? (active ? "inProgress" : "completed");
929+ const verb =
930+ status === "inProgress"
931+ ? "Running"
932+ : status === "failed"
933+ ? "Failed"
934+ : status === "declined"
935+ ? "Declined"
936+ : status === "stopped"
937+ ? "Stopped"
938+ : "Ran";
939+ return `${verb} ${program ?? "command"}`;
921940 }
922941 return activity.detail ?? activity.summary;
923942 }
apps/server/scripts/acp-mock-agent.ts+15−5
@@ -303,11 +303,18 @@ function modeState(): AcpSchema.SessionModeState { · 302 unmodified lines
303303 // "grok-build" product name, and it rejects unknown ids in session/set_model.
304304 const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [
305305 {
306− modelId: "grok-build",
307− name: "Grok Build",
308− ...(initialGrokReasoningEffort
309− ? { _meta: { reasoningEffort: initialGrokReasoningEffort } }
310− : {}),
306+ modelId: "grok-4.6",
307+ name: "Grok 4.6",
308+ _meta: {
309+ totalContextTokens: 500_000,
310+ supportsReasoningEffort: true,
311+ reasoningEffort: initialGrokReasoningEffort ?? "high",
312+ reasoningEfforts: [
313+ { id: "xhigh", value: "xhigh", label: "Extra High Effort", default: false },
314+ { id: "high", value: "high", label: "High Effort", default: true },
315+ { id: "low", value: "low", label: "Low Effort", default: false },
316+ ],
317+ },
311318 },
312319 { modelId: "grok-mock-alt", name: "Grok Mock Alt" },
313320 ];
@@ -331,6 +338,9 @@ const program = Effect.gen(function* () { · 17 unmodified lines
331338 request.clientCapabilities?._meta?.parameterizedModelPicker === true;
332339 return {
333340 protocolVersion: 1,
341+ // Grok advertises model state before any session exists; the provider
342+ // health check reads it from here without authenticating.
343+ _meta: { modelState: modelState() },
334344 agentCapabilities: {
335345 loadSession: true,
336346 ...(supportsSessionLifecycle
apps/server/src/httpCors.ts+2−7
@@ -1,3 +1,5 @@
1+import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts";
2+
13 export const browserApiCorsAllowedMethods = ["GET", "POST", "OPTIONS"] as const;
24 export const browserApiCorsAllowedHeaders = [
35 "authorization",
@@ -7,10 +9,3 @@ export const browserApiCorsAllowedHeaders = [ · 3 unmodified lines
79 "dpop",
810 ORCHESTRATION_PROTOCOL_HEADER,
911 ] as const;
10−
11−export const browserApiCorsHeaders = {
12− "access-control-allow-origin": "*",
13− "access-control-allow-methods": browserApiCorsAllowedMethods.join(", "),
14− "access-control-allow-headers": browserApiCorsAllowedHeaders.join(", "),
15−} as const;
16−import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts";
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+1−1
@@ -1406,7 +1406,7 @@ describe("AcpAdapterV2", () => { · 1405 unmodified lines
14061406 instanceId,
14071407 model: "grok-mock-alt",
14081408 } satisfies ModelSelection;
1409− const originalSelection = { instanceId, model: "grok-build" } satisfies ModelSelection;
1409+ const originalSelection = { instanceId, model: "grok-4.6" } satisfies ModelSelection;
14101410 const runtime = yield* adapter.openSession({
14111411 threadId: firstThreadId,
14121412 providerSessionId: ProviderSessionId.make("provider-session-acp-active-setup"),
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+14−0
@@ -884,6 +884,7 @@ describe("ClaudeAdapterV2 resume compaction", () => { · 883 unmodified lines
884884 environment: {},
885885 attachmentsDir,
886886 fileSystem,
887+ path: yield* Path.Path,
887888 idAllocator,
888889 queryRunner: {
889890 allocateSessionId: Effect.succeed("native-thread-claude-resume"),
@@ -1100,6 +1101,7 @@ describe("ClaudeAdapterV2 attachments", () => { · 210 unmodified lines
11001101 environment: {},
11011102 attachmentsDir,
11021103 fileSystem,
1104+ path: yield* Path.Path,
11031105 idAllocator,
11041106 queryRunner: {
11051107 allocateSessionId: Effect.succeed("native-thread-claude-attachments"),
@@ -1237,6 +1239,7 @@ describe("ClaudeAdapterV2 attachments", () => { · 131 unmodified lines
12371239 environment: {},
12381240 attachmentsDir,
12391241 fileSystem,
1242+ path: yield* Path.Path,
12401243 idAllocator,
12411244 queryRunner: {
12421245 allocateSessionId: Effect.succeed("native-thread-claude-unsupported-attachment"),
@@ -1328,6 +1331,7 @@ describe("ClaudeAdapterV2 native fork", () => { · 85 unmodified lines
13281331 environment: {},
13291332 attachmentsDir,
13301333 fileSystem,
1334+ path: yield* Path.Path,
13311335 idAllocator,
13321336 queryRunner: {
13331337 allocateSessionId: Effect.succeed("source-native-session"),
@@ -1497,6 +1501,7 @@ describe("ClaudeAdapterV2 native session identity", () => { · 163 unmodified lines
14971501 environment: {},
14981502 attachmentsDir,
14991503 fileSystem,
1504+ path: yield* Path.Path,
15001505 idAllocator,
15011506 queryRunner: {
15021507 allocateSessionId: Effect.succeed("native-session-identity"),
@@ -1719,6 +1724,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 216 unmodified lines
17191724 environment: {},
17201725 attachmentsDir,
17211726 fileSystem,
1727+ path: yield* Path.Path,
17221728 idAllocator,
17231729 continuationRequests: {
17241730 offer: (request) =>
@@ -2301,6 +2307,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 576 unmodified lines
23012307 environment: {},
23022308 attachmentsDir,
23032309 fileSystem,
2310+ path: yield* Path.Path,
23042311 idAllocator,
23052312 continuationRequests: { offer: () => Effect.void },
23062313 queryRunner: {
@@ -2412,6 +2419,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 105 unmodified lines
24122419 environment: {},
24132420 attachmentsDir,
24142421 fileSystem,
2422+ path: yield* Path.Path,
24152423 idAllocator,
24162424 continuationRequests: {
24172425 offer: () => Effect.void,
@@ -4481,6 +4489,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 2063 unmodified lines
44814489 environment: {},
44824490 attachmentsDir,
44834491 fileSystem,
4492+ path: yield* Path.Path,
44844493 idAllocator,
44854494 continuationRequests: {
44864495 offer: (request) =>
@@ -4747,6 +4756,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 260 unmodified lines
47474756 environment: {},
47484757 attachmentsDir,
47494758 fileSystem,
4759+ path: yield* Path.Path,
47504760 idAllocator,
47514761 continuationRequests: {
47524762 offer: (request) =>
@@ -4979,6 +4989,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 226 unmodified lines
49794989 environment: {},
49804990 attachmentsDir,
49814991 fileSystem,
4992+ path: yield* Path.Path,
49824993 idAllocator,
49834994 continuationRequests: {
49844995 offer: (request) =>
@@ -5169,6 +5180,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 184 unmodified lines
51695180 environment: {},
51705181 attachmentsDir,
51715182 fileSystem,
5183+ path: yield* Path.Path,
51725184 idAllocator,
51735185 continuationRequests: {
51745186 offer: () => Effect.void,
@@ -5302,6 +5314,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 127 unmodified lines
53025314 environment: {},
53035315 attachmentsDir,
53045316 fileSystem,
5317+ path: yield* Path.Path,
53055318 idAllocator,
53065319 continuationRequests: {
53075320 offer: (request) =>
@@ -5496,6 +5509,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 188 unmodified lines
54965509 environment: {},
54975510 attachmentsDir,
54985511 fileSystem,
5512+ path: yield* Path.Path,
54995513 idAllocator,
55005514 continuationRequests: {
55015515 offer: () => Effect.void,
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+67−4
@@ -74,6 +74,8 @@ import * as Schema from "effect/Schema"; · 73 unmodified lines
7474 import * as Stream from "effect/Stream";
7575
7676 import { resolveAttachmentPath } from "../../attachmentStore.ts";
77+import { planClaudeSkillDispatch } from "../../provider/Drivers/ClaudeSkillDispatch.ts";
78+import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts";
7779 import { compileClaudeModelSelection } from "../../claudeModelOptions.ts";
7880 import { ServerConfig } from "../../config.ts";
7981 import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts";
@@ -1064,12 +1066,28 @@ function isSupportedClaudeImageMimeType( · 984 unmodified lines
10641066 export function makeClaudeUserMessage(input: {
10651067 readonly text: string;
10661068 readonly priority?: SDKUserMessage["priority"];
1069+ readonly skillNames?: ReadonlySet<string>;
10671070 }): SDKUserMessage {
1071+ // Claude Code expands a skill only from the LAST text block, and only when
1072+ // `/name` is its first character. A `$skill` chip anywhere in the prompt is
1073+ // therefore split into [leading text, "/name trailing text"] so the CLI
1074+ // runs it natively and the prose around it survives. See ClaudeSkillDispatch.
1075+ const dispatch =
1076+ input.skillNames === undefined
1077+ ? undefined
1078+ : planClaudeSkillDispatch(input.text, input.skillNames);
10681079 return {
10691080 type: "user",
10701081 message: {
10711082 role: "user",
1072− content: input.text,
1083+ content: dispatch
1084+ ? [
1085+ ...(dispatch.leadingText === undefined
1086+ ? []
1087+ : [{ type: "text" as const, text: dispatch.leadingText }]),
1088+ { type: "text" as const, text: dispatch.commandText },
1089+ ]
1090+ : input.text,
10731091 },
10741092 parent_tool_use_id: null,
10751093 ...(input.priority === undefined ? {} : { priority: input.priority }),
@@ -1082,10 +1100,12 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: · 6 unmodified lines
10821100 readonly priority?: SDKUserMessage["priority"];
10831101 readonly attachmentsDir: string;
10841102 readonly fileSystem: FileSystem.FileSystem;
1103+ readonly skillNames?: ReadonlySet<string>;
10851104 }) {
10861105 if (input.attachments.length === 0) {
10871106 return makeClaudeUserMessage({
10881107 text: input.text,
1108+ ...(input.skillNames === undefined ? {} : { skillNames: input.skillNames }),
10891109 ...(input.priority === undefined ? {} : { priority: input.priority }),
10901110 });
10911111 }
@@ -1099,8 +1119,16 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: · 7 unmodified lines
10991119 attachmentsDir: input.attachmentsDir,
11001120 });
11011121
1122+ const dispatch =
1123+ input.skillNames === undefined
1124+ ? undefined
1125+ : planClaudeSkillDispatch(textWithAttachmentPaths, input.skillNames);
11021126 const content: Array<ClaudeUserContentBlock> = [];
1103− if (textWithAttachmentPaths.length > 0) {
1127+ if (dispatch) {
1128+ if (dispatch.leadingText !== undefined) {
1129+ content.push({ type: "text", text: dispatch.leadingText });
1130+ }
1131+ } else if (textWithAttachmentPaths.length > 0) {
11041132 content.push({ type: "text", text: textWithAttachmentPaths });
11051133 }
11061134
@@ -1146,6 +1174,12 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: · 39 unmodified lines
11461174 });
11471175 }
11481176
1177+ // Images go before the command block: a text block after them still
1178+ // expands, a command block followed by an image does not.
1179+ if (dispatch) {
1180+ content.push({ type: "text", text: dispatch.commandText });
1181+ }
1182+
11491183 return {
11501184 type: "user",
11511185 message: {
@@ -2317,6 +2351,7 @@ export interface ClaudeAdapterV2Options { · 1165 unmodified lines
23172351 readonly environment: NodeJS.ProcessEnv;
23182352 readonly attachmentsDir: string;
23192353 readonly fileSystem: FileSystem.FileSystem;
2354+ readonly path: Path.Path;
23202355 readonly idAllocator: IdAllocatorV2Shape;
23212356 readonly queryRunner: ClaudeAgentSdkQueryRunnerShape;
23222357 /** Sink for wake-turn continuation requests; defaults to dropping them. */
@@ -2328,11 +2363,33 @@ export interface ClaudeAdapterV2Options { · 5 unmodified lines
23282363 export function makeClaudeAdapterV2(
23292364 adapterOptions: ClaudeAdapterV2Options,
23302365 ): ProviderAdapterV2Shape {
2331− const { attachmentsDir, fileSystem, idAllocator, queryRunner } = adapterOptions;
2366+ const { attachmentsDir, fileSystem, path, idAllocator, queryRunner } = adapterOptions;
23322367 const continuationRequests = adapterOptions.continuationRequests ?? {
23332368 offer: () => Effect.void,
23342369 };
23352370
2371+ // Re-scan on every send: skills are added and switched off mid-session, and
2372+ // the scan is a few directory reads. A skill switched off via skillOverrides,
2373+ // or reserved for the agent with `user-invocable: false`, is left as prose:
2374+ // the CLI would answer `/name` with a notice instead of running it.
2375+ const userInvocableSkillNames = (cwd: string | null) =>
2376+ discoverClaudeSkills(
2377+ adapterOptions.settings,
2378+ cwd ?? undefined,
2379+ adapterOptions.environment,
2380+ ).pipe(
2381+ Effect.map(
2382+ (skills) =>
2383+ new Set(
2384+ skills
2385+ .filter((skill) => skill.enabled && skill.userInvocable !== false)
2386+ .map((skill) => skill.name),
2387+ ),
2388+ ),
2389+ Effect.provideService(FileSystem.FileSystem, fileSystem),
2390+ Effect.provideService(Path.Path, path),
2391+ );
2392+
23362393 return ProviderAdapterV2.of({
23372394 instanceId: adapterOptions.instanceId,
23382395 driver: CLAUDE_PROVIDER,
@@ -5101,6 +5158,7 @@ export function makeClaudeAdapterV2( · 2762 unmodified lines
51015158 attachments: turnInput.message.attachments,
51025159 attachmentsDir,
51035160 fileSystem,
5161+ skillNames: yield* userInvocableSkillNames(turnInput.runtimePolicy.cwd),
51045162 });
51055163 const querySession = yield* openQuery(turnInput, nativeThreadId);
51065164 yield* Ref.set(activeTurn, context);
@@ -5274,6 +5332,7 @@ export function makeClaudeAdapterV2( · 167 unmodified lines
52745332 priority: "now",
52755333 attachmentsDir,
52765334 fileSystem,
5335+ skillNames: yield* userInvocableSkillNames(currentTurn.input.runtimePolicy.cwd),
52775336 });
52785337 yield* Ref.update(steeredTurns, (current) => {
52795338 const next = new Set(current);
@@ -5664,12 +5723,14 @@ export const ClaudeAdapterV2Driver: ProviderAdapterDriver< · 384 unmodified lines
56645723 const continuationRequests = yield* ProviderContinuationRequests;
56655724 const baseEnvironment = mergeProviderInstanceEnvironment(environment, hostEnvironment);
56665725 const claudeEnvironment = yield* makeClaudeEnvironment(config, baseEnvironment);
5726+ const path = yield* Path.Path;
56675727 return makeClaudeAdapterV2({
56685728 instanceId,
56695729 settings: { ...config, enabled },
56705730 environment: claudeEnvironment,
56715731 attachmentsDir: serverConfig.attachmentsDir,
56725732 fileSystem,
5733+ path,
56735734 idAllocator,
56745735 queryRunner,
56755736 continuationRequests,
@@ -5692,6 +5753,7 @@ export const ClaudeAdapterV2Driver: ProviderAdapterDriver< · 16 unmodified lines
56925753
56935754 const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function* () {
56945755 const fileSystem = yield* FileSystem.FileSystem;
5756+ const path = yield* Path.Path;
56955757 const hostEnvironment = yield* HostProcessEnvironment;
56965758 const idAllocator = yield* IdAllocatorV2;
56975759 const queryRunner = yield* ClaudeAgentSdkQueryRunner;
@@ -5704,6 +5766,7 @@ const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function* · 6 unmodified lines
57045766 environment: hostEnvironment,
57055767 attachmentsDir: serverConfig.attachmentsDir,
57065768 fileSystem,
5769+ path,
57075770 idAllocator,
57085771 queryRunner,
57095772 continuationRequests,
@@ -5713,5 +5776,5 @@ const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function* · 3 unmodified lines
57135776 export const layer: Layer.Layer<
57145777 ProviderAdapterV2,
57155778 never,
5716− ClaudeAgentSdkQueryRunner | FileSystem.FileSystem | IdAllocatorV2 | ServerConfig
5779+ ClaudeAgentSdkQueryRunner | FileSystem.FileSystem | IdAllocatorV2 | Path.Path | ServerConfig
57175780 > = Layer.effect(ProviderAdapterV2, makeDefaultClaudeAdapterV2());
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.testkit.ts+14−0
@@ -342,6 +342,20 @@ function makeOpenCodeReplayRuntimeLayer(transcript: OpenCodeSdkReplayTranscript) · 341 unmodified lines
342342 detail: "OpenCode replay does not load inventory.",
343343 }),
344344 ),
345+ loadOpenCodeSkills: () =>
346+ Effect.fail(
347+ new OpenCodeRuntimeError({
348+ operation: "loadOpenCodeSkills",
349+ detail: "OpenCode replay does not load skills.",
350+ }),
351+ ),
352+ loadSkillsFromCli: () =>
353+ Effect.fail(
354+ new OpenCodeRuntimeError({
355+ operation: "loadSkillsFromCli",
356+ detail: "OpenCode replay does not load skills.",
357+ }),
358+ ),
345359 } satisfies OpenCodeRuntimeShape);
346360 }),
347361 );
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+3−0
@@ -242,6 +242,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 241 unmodified lines
242242 );
243243
244244 commandReadModel = committedCommand.nextCommandReadModel;
245+ for (const cleanup of committedCommand.attachmentCleanups) {
246+ yield* cleanup;
247+ }
245248 yield* eventStore.publishCommitted(
246249 committedCommand.committedEvents.filter(
247250 (event) =>
apps/server/src/provider/Drivers/AcpRegistryDriver.ts+2−2
@@ -13,7 +13,7 @@ import { · 12 unmodified lines
1313 AcpRegistryAdapterV2Driver,
1414 type AcpRegistryAdapterV2DriverEnv,
1515 } from "../../orchestration-v2/Adapters/AcpRegistryAdapterV2.ts";
16−import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts";
16+import type { TextGeneration } from "../../textGeneration/TextGeneration.ts";
1717 import { ProviderDriverError } from "../Errors.ts";
1818 import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
1919 import {
@@ -25,7 +25,7 @@ import { · 5 unmodified lines
2525 const DRIVER_KIND = ProviderDriverKind.make("acpRegistry");
2626 const decodeSettings = Schema.decodeSync(AcpRegistrySettings);
2727
28−const makeUnsupportedTextGeneration = (): TextGenerationShape => {
28+const makeUnsupportedTextGeneration = (): TextGeneration["Service"] => {
2929 const unsupported = (operation: string) =>
3030 Effect.fail(
3131 new TextGenerationError({
apps/server/src/provider/Drivers/CodexDriver.ts+35−1
@@ -40,7 +40,12 @@ import { · 39 unmodified lines
4040 } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts";
4141 import { ServerSettingsService } from "../../serverSettings.ts";
4242 import { ProviderDriverError } from "../Errors.ts";
43−import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts";
43+import {
44+ checkCodexProviderStatus,
45+ makePendingCodexProvider,
46+ probeCodexSkillsForCwd,
47+} from "../Layers/CodexProvider.ts";
48+import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts";
4449 import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
4550 import * as ModelManifest from "../ModelManifest.ts";
4651 import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts";
@@ -237,6 +242,34 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { · 190 unmodified lines
237242 }),
238243 ),
239244 );
245+ const snapshotForCwd = (cwd: string) =>
246+ !effectiveConfig.enabled
247+ ? snapshot.getSnapshot
248+ : Effect.all([
249+ snapshot.getSnapshot,
250+ probeCodexSkillsForCwd({
251+ binaryPath: effectiveConfig.binaryPath,
252+ homePath: effectiveConfig.homePath,
253+ launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv),
254+ cwd,
255+ environment: processEnv,
256+ }).pipe(
257+ Effect.scoped,
258+ Effect.timeout("20 seconds"),
259+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
260+ ),
261+ ]).pipe(
262+ Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })),
263+ Effect.mapError(
264+ (cause) =>
265+ new ProviderDriverError({
266+ driver: DRIVER_KIND,
267+ instanceId,
268+ detail: `Failed to probe Codex skills for '${cwd}'`,
269+ cause,
270+ }),
271+ ),
272+ );
240273
241274 return {
242275 instanceId,
@@ -246,6 +279,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { · 3 unmodified lines
246279 accentColor,
247280 enabled,
248281 snapshot,
282+ snapshotForCwd,
249283 orchestrationAdapter,
250284 textGeneration,
251285 } satisfies ProviderInstance;
apps/server/src/provider/Drivers/CursorDriver.ts+1−1
@@ -7,7 +7,7 @@ · 6 unmodified lines
77 *
88 * @module provider/Drivers/CursorDriver
99 */
10−import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
10+import { CursorSettings, ProviderDriverKind } from "@t3tools/contracts";
1111 import * as Effect from "effect/Effect";
1212 import * as Schema from "effect/Schema";
1313
apps/server/src/provider/Drivers/OpenCodeDriver.ts+50−0
@@ -33,6 +33,7 @@ import { ProviderDriverError } from "../Errors.ts"; · 32 unmodified lines
3333 import {
3434 checkOpenCodeProviderStatus,
3535 makePendingOpenCodeProvider,
36+ openCodeSkillsToServerProviderSkills,
3637 } from "../Layers/OpenCodeProvider.ts";
3738 import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
3839 import { OpenCodeRuntime } from "../opencodeRuntime.ts";
@@ -169,6 +170,34 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> · 130 unmodified lines
169170 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
170171 Effect.provideService(OpenCodeRuntime, openCodeRuntime),
171172 );
173+ const loadSkillsForCwd = (cwd: string) =>
174+ effectiveConfig.serverUrl.trim().length > 0
175+ ? Effect.scoped(
176+ Effect.gen(function* () {
177+ const server = yield* openCodeRuntime.connectToOpenCodeServer({
178+ binaryPath: effectiveConfig.binaryPath,
179+ directory: cwd,
180+ serverUrl: effectiveConfig.serverUrl,
181+ ...(effectiveConfig.serverPassword
182+ ? { serverPassword: effectiveConfig.serverPassword }
183+ : {}),
184+ environment: processEnv,
185+ });
186+ const client = openCodeRuntime.createOpenCodeSdkClient({
187+ baseUrl: server.url,
188+ directory: cwd,
189+ ...(effectiveConfig.serverPassword
190+ ? { serverPassword: effectiveConfig.serverPassword }
191+ : {}),
192+ });
193+ return yield* openCodeRuntime.loadOpenCodeSkills(client);
194+ }),
195+ )
196+ : openCodeRuntime.loadSkillsFromCli({
197+ binaryPath: effectiveConfig.binaryPath,
198+ cwd,
199+ environment: processEnv,
200+ });
172201
173202 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
174203 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>(
@@ -213,6 +242,27 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> · 38 unmodified lines
213242 accentColor,
214243 enabled,
215244 snapshot,
245+ snapshotForCwd: (cwd) =>
246+ !effectiveConfig.enabled
247+ ? snapshot.getSnapshot
248+ : Effect.all([
249+ snapshot.getSnapshot,
250+ loadSkillsForCwd(cwd).pipe(Effect.timeout("20 seconds")),
251+ ]).pipe(
252+ Effect.map(([machineSnapshot, skills]) => ({
253+ ...machineSnapshot,
254+ skills: openCodeSkillsToServerProviderSkills(skills),
255+ })),
256+ Effect.mapError(
257+ (cause) =>
258+ new ProviderDriverError({
259+ driver: DRIVER_KIND,
260+ instanceId,
261+ detail: `Failed to probe OpenCode skills for '${cwd}'`,
262+ cause,
263+ }),
264+ ),
265+ ),
216266 orchestrationAdapter,
217267 textGeneration,
218268 } satisfies ProviderInstance;
apps/server/src/provider/Layers/ProviderRegistry.test.ts+195−0
@@ -1,6 +1,7 @@
11 import * as NodeServices from "@effect/platform-node/NodeServices";
22 import { describe, it, assert } from "@effect/vitest";
33 import * as DateTime from "effect/DateTime";
4+import * as Deferred from "effect/Deferred";
45 import * as Effect from "effect/Effect";
56 import * as Path from "effect/Path";
67 import * as Exit from "effect/Exit";
@@ -43,6 +44,7 @@ import { · 36 unmodified lines
4344 haveProvidersChanged,
4445 mergeProviderSnapshot,
4546 mergeProviderSnapshots,
47+ upsertProviderWorkspaceSnapshot,
4648 ProviderRegistryLive,
4749 selectProvidersByKind,
4850 } from "./ProviderRegistry.ts";
@@ -581,6 +583,41 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 532 unmodified lines
581583 assert.strictEqual(haveProvidersChanged(providers, [...providers]), false);
582584 });
583585
586+ it("stores workspace skills and commands without changing machine metadata", () => {
587+ const provider = {
588+ instanceId: ProviderInstanceId.make("codex"),
589+ driver: ProviderDriverKind.make("codex"),
590+ status: "ready",
591+ enabled: true,
592+ installed: true,
593+ auth: { status: "authenticated" },
594+ checkedAt: "2026-03-25T00:00:00.000Z",
595+ version: "1.0.0",
596+ models: [],
597+ slashCommands: [{ name: "global" }],
598+ skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }],
599+ } satisfies ServerProvider;
600+ const scopedSnapshot = {
601+ ...provider,
602+ checkedAt: "2026-03-25T00:01:00.000Z",
603+ slashCommands: [{ name: "project" }],
604+ skills: [{ name: "project", path: "/project/SKILL.md", enabled: true }],
605+ } satisfies ServerProvider;
606+
607+ const result = upsertProviderWorkspaceSnapshot(provider, "/project", scopedSnapshot);
608+
609+ assert.deepStrictEqual(result.slashCommands, provider.slashCommands);
610+ assert.deepStrictEqual(result.skills, provider.skills);
611+ assert.deepStrictEqual(result.workspaceSnapshots, [
612+ {
613+ cwd: "/project",
614+ checkedAt: scopedSnapshot.checkedAt,
615+ slashCommands: scopedSnapshot.slashCommands,
616+ skills: scopedSnapshot.skills,
617+ },
618+ ]);
619+ });
620+
584621 it("preserves previously discovered provider models when a refresh returns none", () => {
585622 const previousProvider = {
586623 instanceId: ProviderInstanceId.make("cursor"),
@@ -1004,6 +1041,164 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 417 unmodified lines
10041041 }),
10051042 );
10061043
1044+ it.effect("deduplicates cwd probes and clears snapshots when an instance rebuilds", () =>
1045+ Effect.gen(function* () {
1046+ const driver = ProviderDriverKind.make("codex");
1047+ const instanceId = ProviderInstanceId.make("codex");
1048+ const machineProvider = {
1049+ instanceId,
1050+ driver,
1051+ status: "ready",
1052+ enabled: true,
1053+ installed: true,
1054+ auth: { status: "authenticated" },
1055+ checkedAt: "2026-06-10T00:00:00.000Z",
1056+ version: "1.0.0",
1057+ models: [],
1058+ slashCommands: [{ name: "global" }],
1059+ skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }],
1060+ } as const satisfies ServerProvider;
1061+ const scopedProvider = {
1062+ ...machineProvider,
1063+ checkedAt: "2026-06-10T00:01:00.000Z",
1064+ slashCommands: [{ name: "project" }],
1065+ skills: [{ name: "project", path: "/workspace/SKILL.md", enabled: true }],
1066+ } as const satisfies ServerProvider;
1067+ const pendingScopedProvider = {
1068+ ...scopedProvider,
1069+ status: "error",
1070+ installed: false,
1071+ slashCommands: [],
1072+ } as const satisfies ServerProvider;
1073+ const snapshotCalls = yield* Ref.make(0);
1074+ const returnPendingSnapshot = yield* Ref.make(true);
1075+ const probeStarted = yield* Deferred.make<void>();
1076+ const releaseProbe = yield* Deferred.make<void>();
1077+ const makeInstance = (
1078+ provider: ServerProvider,
1079+ snapshotForCwd: NonNullable<ProviderInstance["snapshotForCwd"]>,
1080+ ): ProviderInstance => ({
1081+ instanceId,
1082+ driverKind: driver,
1083+ continuationIdentity: {
1084+ driverKind: driver,
1085+ continuationKey: "codex:instance:codex",
1086+ },
1087+ displayName: undefined,
1088+ enabled: true,
1089+ snapshot: {
1090+ maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({
1091+ provider: driver,
1092+ packageName: null,
1093+ }),
1094+ getSnapshot: Effect.succeed(provider),
1095+ refresh: Effect.succeed(provider),
1096+ streamChanges: Stream.empty,
1097+ },
1098+ snapshotForCwd,
1099+ orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"],
1100+ textGeneration: {} as ProviderInstance["textGeneration"],
1101+ });
1102+ const firstInstance = makeInstance(machineProvider, () =>
1103+ Effect.gen(function* () {
1104+ yield* Ref.update(snapshotCalls, (count) => count + 1);
1105+ if (yield* Ref.get(returnPendingSnapshot)) return pendingScopedProvider;
1106+ yield* Deferred.succeed(probeStarted, undefined);
1107+ yield* Deferred.await(releaseProbe);
1108+ return scopedProvider;
1109+ }),
1110+ );
1111+ const rebuiltProvider = {
1112+ ...machineProvider,
1113+ checkedAt: "2026-06-10T00:02:00.000Z",
1114+ status: "warning",
1115+ installed: false,
1116+ auth: { status: "unknown" },
1117+ } satisfies ServerProvider;
1118+ const rebuiltInstance = makeInstance(rebuiltProvider, () =>
1119+ Ref.update(snapshotCalls, (count) => count + 1).pipe(Effect.as(scopedProvider)),
1120+ );
1121+ const registryChanges = yield* PubSub.unbounded<void>();
1122+ const instancesRef = yield* Ref.make<ReadonlyArray<ProviderInstance>>([firstInstance]);
1123+ const instanceRegistryLayer = Layer.succeed(
1124+ ProviderInstanceRegistry.ProviderInstanceRegistry,
1125+ {
1126+ getInstance: (requestedId) =>
1127+ Ref.get(instancesRef).pipe(
1128+ Effect.map((instances) =>
1129+ instances.find((instance) => instance.instanceId === requestedId),
1130+ ),
1131+ ),
1132+ listInstances: Ref.get(instancesRef),
1133+ listUnavailable: Effect.succeed([]),
1134+ streamChanges: Stream.fromPubSub(registryChanges),
1135+ subscribeChanges: PubSub.subscribe(registryChanges),
1136+ },
1137+ );
1138+ const scope = yield* Scope.make();
1139+ yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
1140+ const runtimeServices = yield* Layer.build(
1141+ ProviderRegistryLive.pipe(
1142+ Layer.provideMerge(instanceRegistryLayer),
1143+ Layer.provideMerge(
1144+ ServerConfig.layerTest(process.cwd(), {
1145+ prefix: "t3-provider-registry-workspace-snapshot-",
1146+ }),
1147+ ),
1148+ Layer.provideMerge(NodeServices.layer),
1149+ ),
1150+ ).pipe(Scope.provide(scope));
1151+
1152+ yield* Effect.gen(function* () {
1153+ const registry = yield* ProviderRegistry.ProviderRegistry;
1154+ yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });
1155+ assert.strictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, undefined);
1156+ yield* Ref.set(returnPendingSnapshot, false);
1157+ const workspaceUpdate = yield* registry.streamChanges.pipe(
1158+ Stream.runHead,
1159+ Effect.forkChild,
1160+ );
1161+ yield* Effect.yieldNow;
1162+ const firstRefresh = yield* registry
1163+ .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" })
1164+ .pipe(Effect.forkChild);
1165+ yield* Deferred.await(probeStarted);
1166+ const duplicateRefresh = yield* registry
1167+ .refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" })
1168+ .pipe(Effect.forkChild);
1169+ yield* Effect.yieldNow;
1170+ assert.strictEqual(yield* Ref.get(snapshotCalls), 2);
1171+ yield* Deferred.succeed(releaseProbe, undefined);
1172+ yield* Fiber.join(firstRefresh);
1173+ yield* Fiber.join(duplicateRefresh);
1174+ const published = yield* Fiber.join(workspaceUpdate);
1175+ assert.strictEqual(published._tag, "Some");
1176+ const providers = yield* registry.getProviders;
1177+ assert.deepStrictEqual(providers[0]?.skills, machineProvider.skills);
1178+ assert.deepStrictEqual(
1179+ providers[0]?.workspaceSnapshots?.[0]?.skills,
1180+ scopedProvider.skills,
1181+ );
1182+ yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" });
1183+ assert.strictEqual(yield* Ref.get(snapshotCalls), 2);
1184+
1185+ yield* Ref.set(instancesRef, [rebuiltInstance]);
1186+ yield* PubSub.publish(registryChanges, undefined);
1187+ let rebuilt = yield* registry.getProviders;
1188+ for (
1189+ let attempt = 0;
1190+ attempt < 50 && rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt;
1191+ attempt += 1
1192+ ) {
1193+ yield* Effect.yieldNow;
1194+ rebuilt = yield* registry.getProviders;
1195+ }
1196+ assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt);
1197+ assert.strictEqual(rebuilt[0]?.workspaceSnapshots, undefined);
1198+ }).pipe(Effect.provide(runtimeServices));
1199+ }),
1200+ );
1201+
10071202 it.effect("refreshes OpenCode catalogs and preserves other providers", () =>
10081203 Effect.gen(function* () {
10091204 const codexDriver = ProviderDriverKind.make("codex");
apps/server/src/provider/Layers/ProviderRegistry.ts+24−0
@@ -188,6 +188,30 @@ export const mergeProviderSnapshot = ( · 187 unmodified lines
188188 : {}),
189189 };
190190
191+export const mergeProviderSnapshots = (
192+ previousProviders: ReadonlyArray<ServerProvider>,
193+ nextProviders: ReadonlyArray<ServerProvider>,
194+): ReadonlyArray<ServerProvider> => {
195+ const mergedProviders = new Map(
196+ previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const),
197+ );
198+
199+ for (const provider of nextProviders) {
200+ mergedProviders.set(
201+ snapshotInstanceKey(provider),
202+ mergeProviderSnapshot(mergedProviders.get(snapshotInstanceKey(provider)), provider),
203+ );
204+ }
205+
206+ return orderProviderSnapshots([...mergedProviders.values()]);
207+};
208+
209+export const selectProvidersByKind = (
210+ providers: ReadonlyArray<ServerProvider>,
211+ providerKinds: ReadonlySet<ProviderDriverKind>,
212+): ReadonlyArray<ServerProvider> =>
213+ providers.filter((provider) => providerKinds.has(provider.driver));
214+
191215 export const haveProvidersChanged = (
192216 previousProviders: ReadonlyArray<ServerProvider>,
193217 nextProviders: ReadonlyArray<ServerProvider>,
apps/server/src/provider/ProviderDriver.ts+4−2
@@ -25,12 +25,13 @@ import type { · 24 unmodified lines
2525 ProviderDriverKind,
2626 ProviderInstanceEnvironment,
2727 ProviderInstanceId,
28+ ServerProvider,
2829 } from "@t3tools/contracts";
2930 import type * as Effect from "effect/Effect";
3031 import type * as Schema from "effect/Schema";
3132 import type * as Scope from "effect/Scope";
3233
33−import type { TextGenerationShape } from "../textGeneration/TextGeneration.ts";
34+import type { TextGeneration } from "../textGeneration/TextGeneration.ts";
3435 import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts";
3536 import type { ProviderDriverError } from "./Errors.ts";
3637 import type { ServerProviderShape } from "./Services/ServerProvider.ts";
@@ -69,8 +70,9 @@ export interface ProviderInstance { · 32 unmodified lines
6970 readonly accentColor?: string | undefined;
7071 readonly enabled: boolean;
7172 readonly snapshot: ServerProviderShape;
73+ readonly snapshotForCwd?: (cwd: string) => Effect.Effect<ServerProvider, ProviderDriverError>;
7274 readonly orchestrationAdapter: ProviderAdapterV2Shape;
73− readonly textGeneration: TextGenerationShape;
75+ readonly textGeneration: TextGeneration["Service"];
7476 }
7577
7678 export interface ProviderContinuationIdentity {
apps/server/src/provider/acp/AcpSessionRuntime.ts+18−3
@@ -1155,6 +1155,16 @@ export class AcpSessionRuntime extends Context.Service< · 1154 unmodified lines
11551155 * @see https://agentclientprotocol.com/protocol/extensibility
11561156 */
11571157 readonly handleExtNotification: EffectAcpClient.AcpClient["Service"]["handleExtNotification"];
1158+ /**
1159+ * Sends only `initialize` and returns the agent's response. Health probes use this to read
1160+ * advertised capabilities without authenticating or opening a session, so a probe can never
1161+ * start an interactive login or boot MCP servers.
1162+ * @see https://agentclientprotocol.com/protocol/schema#initialize
1163+ */
1164+ readonly initialize: () => Effect.Effect<
1165+ EffectAcpSchema.InitializeResponse,
1166+ EffectAcpErrors.AcpError
1167+ >;
11581168 /**
11591169 * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session.
11601170 * Concurrent calls share the same in-flight startup and a failed startup may be retried.
@@ -1187,11 +1197,14 @@ export class AcpSessionRuntime extends Context.Service< · 26 unmodified lines
11871197 sessionId?: string,
11881198 ) => Effect.Effect<EffectAcpSchema.CloseSessionResponse, EffectAcpErrors.AcpError>;
11891199 /**
1190− * Sends a prompt turn to the active session.
1200+ * Sends a prompt turn to the active session. `options.dispatched` settles once the
1201+ * `session/prompt` RPC is registered as the active prompt, so a caller that forks this
1202+ * effect knows when a later `cancel` will target this prompt.
11911203 * @see https://agentclientprotocol.com/protocol/schema#session/prompt
11921204 */
11931205 readonly prompt: (
11941206 payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">,
1207+ options?: { readonly dispatched?: Deferred.Deferred<void> },
11951208 ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>;
11961209 /**
11971210 * Sends a real ACP `session/cancel` notification for the active session.
@@ -2146,7 +2159,7 @@ export const make = ( · 948 unmodified lines
21462159 );
21472160 }),
21482161 ),
2149− prompt: (payload) =>
2162+ prompt: (payload, promptOptions?) =>
21502163 promptSerializationSemaphore.withPermit(
21512164 Effect.gen(function* () {
21522165 const started = yield* getStartedState;
@@ -2203,12 +2216,14 @@ export const make = ( · 50 unmodified lines
22032216 if (Option.isSome(activePromptFiber)) {
22042217 yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore);
22052218 }
2219+ // Await the notification write so a replacement session/prompt
2220+ // cannot race ahead of session/cancel on the wire.
22062221 yield* acp.agent
22072222 .cancel({
22082223 sessionId: started.sessionId,
22092224 ...(options.cancelMeta === undefined ? {} : { _meta: options.cancelMeta }),
22102225 })
2211− .pipe(Effect.ignore, Effect.forkIn(runtimeScope));
2226+ .pipe(Effect.ignore);
22122227 }),
22132228 ),
22142229 ),
apps/server/src/provider/acp/GrokAcpSupport.ts+5−3
@@ -185,15 +185,17 @@ export function applyGrokAcpModelSelection<E>(input: { · 184 unmodified lines
185185 readonly requestedReasoningEffort?: string | undefined;
186186 readonly mapError: (cause: EffectAcpErrors.AcpError) => E;
187187 }): Effect.Effect<string | undefined, E> {
188− const modelChanged =
189− input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId;
188+ // The product slug is never sent over the wire; it keeps the session's current model.
189+ const requestedModelId =
190+ input.requestedModelId === GROK_DEFAULT_MODEL_SLUG ? undefined : input.requestedModelId;
191+ const modelChanged = requestedModelId !== undefined && requestedModelId !== input.currentModelId;
190192 const reasoningProvided = input.requestedReasoningEffort !== undefined;
191193 const reasoningEffort = reasoningProvided
192194 ? normalizeGrokReasoningEffort(input.requestedReasoningEffort)
193195 : undefined;
194196 const reasoningEffortChanged =
195197 reasoningProvided && reasoningEffort !== input.currentReasoningEffort;
196− const targetModelId = input.requestedModelId ?? input.currentModelId;
198+ const targetModelId = requestedModelId ?? input.currentModelId;
197199 if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) {
198200 return Effect.succeed(input.currentModelId);
199201 }
apps/server/src/provider/acp/XAiAcpExtension.ts+11−8
@@ -1428,7 +1428,7 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion · 1427 unmodified lines
14281428
14291429 return {
14301430 ...runtime,
1431− prompt: (payload) =>
1431+ prompt: (payload, promptOptions?) =>
14321432 Effect.gen(function* () {
14331433 const started = yield* runtime.start();
14341434 const promptId = yield* allocatePromptFallbackId;
@@ -1444,14 +1444,17 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion · 9 unmodified lines
14441444 agentResult: null,
14451445 });
14461446 const promptRpcFiber = yield* runtime
1447− .prompt({
1448− ...payload,
1449− _meta: {
1450− ...payload._meta,
1451− promptId: fallback.promptId,
1452− requestId: fallback.promptId,
1447+ .prompt(
1448+ {
1449+ ...payload,
1450+ _meta: {
1451+ ...payload._meta,
1452+ promptId: fallback.promptId,
1453+ requestId: fallback.promptId,
1454+ },
14531455 },
1454− })
1456+ promptOptions,
1457+ )
14551458 .pipe(Effect.forkChild);
14561459 return yield* Effect.raceFirst(
14571460 Fiber.join(promptRpcFiber).pipe(
apps/server/src/ws.ts+37−2
@@ -387,6 +387,10 @@ const ServerWsRpcGroup = WsRpcGroup; · 386 unmodified lines
387387 // past this gap a single O(active-threads) snapshot is cheaper and bounded.
388388 // Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT).
389389 const SHELL_RESUME_MAX_GAP = 1_000;
390+// Row count alone does not bound replay memory: a few events with large tool
391+// payloads can decode to gigabytes. Before replaying, sum the serialized
392+// payload bytes of the range in SQL and reset with a snapshot past this budget.
393+const ORCHESTRATION_REPLAY_PAYLOAD_BUDGET_BYTES = 8 * 1024 * 1024;
390394
391395 function toAuthAccessStreamEvent(
392396 change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange,
@@ -523,6 +527,32 @@ const makeWsRpcLayer = ( · 130 unmodified lines
523527 const threadManagement = yield* ThreadManagementService.ThreadManagementService;
524528 const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore;
525529 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
530+ const canReplayPersistedRange = Effect.fnUntraced(function* (
531+ afterSequence: number,
532+ headSequence: number,
533+ maxGap: number,
534+ ) {
535+ const replayGap = headSequence - afterSequence;
536+ if (replayGap < 0 || replayGap > maxGap) {
537+ return false;
538+ }
539+ const stats = yield* projectionSnapshotQuery.getEventReplayStats({
540+ fromSequenceExclusive: afterSequence,
541+ toSequenceInclusive: headSequence,
542+ });
543+ if (stats.payloadBytes > ORCHESTRATION_REPLAY_PAYLOAD_BUDGET_BYTES) {
544+ yield* Effect.logDebug("orchestration replay replaced by snapshot", {
545+ afterSequence,
546+ headSequence,
547+ replayGap,
548+ eventCount: stats.eventCount,
549+ payloadBytes: stats.payloadBytes,
550+ payloadBudgetBytes: ORCHESTRATION_REPLAY_PAYLOAD_BUDGET_BYTES,
551+ });
552+ return false;
553+ }
554+ return true;
555+ });
526556 const providerSessionsV2 = yield* ProviderSessionManagerV2;
527557 const analytics = yield* AnalyticsService.AnalyticsService;
528558 // Client-origin attribution (#7774): every thread/turn the connecting
@@ -1065,8 +1095,13 @@ const makeWsRpcLayer = ( · 536 unmodified lines
10651095 }
10661096
10671097 const highWater = yield* applicationEvents.latestApplicationSequence;
1068− const replayGap = highWater - input.afterSequence;
1069− if (replayGap < 0 || replayGap > SHELL_RESUME_MAX_GAP) {
1098+ if (
1099+ !(yield* canReplayPersistedRange(
1100+ input.afterSequence,
1101+ highWater,
1102+ SHELL_RESUME_MAX_GAP,
1103+ ))
1104+ ) {
10701105 const loaded = yield* loadSnapshot();
10711106 return composeShellStreamWithEnrichment({
10721107 initial: initialSnapshotItems(loaded),
apps/web/src/components/ChatView.logic.test.ts+1−1
@@ -10,7 +10,7 @@ import { · 9 unmodified lines
1010 } from "@t3tools/contracts";
1111 import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
1212 import * as DateTime from "effect/DateTime";
13−import { describe, expect, it } from "vite-plus/test";
13+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
1414
1515 import type { Thread } from "../types";
1616 import { makeThreadFixture } from "../test-fixtures";
apps/web/src/components/ChatView.logic.ts+16−0
@@ -87,6 +87,22 @@ export function shouldDockDraftHeroForSubmission(input: { · 86 unmodified lines
8787 );
8888 }
8989
90+export function toolGroupConsumesUpwardNavigation(target: EventTarget | null): boolean {
91+ const elementTarget = target instanceof Element ? target : null;
92+ const group = elementTarget?.closest<HTMLElement>("[data-tool-group-scroll]");
93+ if (!group) return false;
94+
95+ // A nested result or the group itself can consume an upward scroll.
96+ for (let element = elementTarget; element; element = element.parentElement) {
97+ if (element.scrollTop > 0) {
98+ const overflowY = getComputedStyle(element).overflowY;
99+ if (overflowY === "auto" || overflowY === "scroll") return true;
100+ }
101+ if (element === group) break;
102+ }
103+ return false;
104+}
105+
90106 export function resolveDraftHeroState(input: {
91107 isLocalDraftThread: boolean;
92108 hasTimelineEntries: boolean;
apps/web/src/components/ChatView.tsx+67−16
@@ -1,4 +1,5 @@
11 import {
2+ type AssistantCitation,
23 type ApprovalRequestId,
34 type ChatFileAttachment,
45 DEFAULT_MODEL,
@@ -268,6 +269,7 @@ import { · 263 unmodified lines
268269 } from "../state/server";
269270 import { terminalEnvironment } from "../state/terminal";
270271 import { threadEnvironment } from "../state/threads";
272+import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
271273 import { vcsEnvironment } from "../state/vcs";
272274 import { useEnvironments, usePrimaryEnvironment } from "../state/environments";
273275 import {
@@ -287,6 +289,7 @@ import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; · 13 unmodified lines
287289 import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
288290 import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
289291 import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
292+import type { AssistantCitationRequest } from "./chat/AssistantCitationSource";
290293 import { MessagesTimeline, type MessagesTimelineHistoryControls } from "./chat/MessagesTimeline";
291294 import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";
292295 import { ChatHeader } from "./chat/ChatHeader";
@@ -372,6 +375,7 @@ import { · 79 unmodified lines
372375 shouldShowComposerContextStrip,
373376 startNewThreadForProject,
374377 codexArtifactTemplatePromptToAppend,
378+ toolGroupConsumesUpwardNavigation,
375379 waitForStartedServerThread,
376380 } from "./ChatView.logic";
377381 import { useLocalStorage } from "~/hooks/useLocalStorage";
@@ -1410,6 +1414,7 @@ function ChatViewContent(props: ChatViewProps) { · 1032 unmodified lines
14101414 shellExists: serverThread !== null,
14111415 status: threadStatus,
14121416 });
1417+ const threadDetailLoading = threadSyncPhase === "loading";
14131418 // Latest provider-reported context usage (#8144): the newest turn that has
14141419 // a report wins; stale turns keep the meter alive between turns.
14151420 const activeThreadLiveTokenUsage = useMemo(() => {
@@ -1547,6 +1552,21 @@ function ChatViewContent(props: ChatViewProps) { · 131 unmodified lines
15471552 const composerElementContextsRef = useRef<ElementContextDraft[]>([]);
15481553 const localComposerRef = useRef<ChatComposerHandle | null>(null);
15491554 const composerRef = useComposerHandleContext() ?? localComposerRef;
1555+ const citeAssistantText = useCallback(
1556+ (citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => {
1557+ const inserted = composerRef.current?.citeAssistantText(citation, sourceAnchor) ?? false;
1558+ if (!inserted) {
1559+ toastManager.add({
1560+ type: "warning",
1561+ title: "The composer is not ready",
1562+ description:
1563+ "Try citing the selection after the connection or pending input is resolved.",
1564+ });
1565+ }
1566+ return inserted;
1567+ },
1568+ [composerRef],
1569+ );
15501570 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
15511571 const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false);
15521572 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
@@ -2394,6 +2414,7 @@ function ChatViewContent(props: ChatViewProps) { · 841 unmodified lines
23942414 }, [setDismissedVersionMismatchKey, versionMismatchDismissKey]);
23952415 const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;
23962416 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
2417+ const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig);
23972418 const serverUpdateState = useAtomValue(
23982419 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),
23992420 );
@@ -2437,21 +2458,20 @@ function ChatViewContent(props: ChatViewProps) { · 37 unmodified lines
24372458 />
24382459 ),
24392460 title: `${unavailableConnection.phase === "connecting" ? "Connecting" : "Reconnecting"} to ${activeEnvironmentUnavailableState.label}`,
2440− description: "It may be finishing an update. One moment.",
2461+ description: "Finishing an update",
24412462 });
24422463 } else {
24432464 items.push({
24442465 id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
24452466 variant: unavailableConnection.phase === "error" ? "error" : "warning",
24462467 icon: <WifiOffIcon />,
2447− title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusTitle(unavailableConnection)}`,
2448− description:
2449− unavailableConnection.error ??
2450− "Reconnect this environment before sending messages or running actions.",
2468+ title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`,
2469+ description: environmentReconnecting ? "Trying again" : "Reconnect to continue",
24512470 actions: (
24522471 <>
24532472 <Button
24542473 size="xs"
2474+ variant="ghost"
24552475 disabled={environmentReconnecting}
24562476 onClick={() =>
24572477 void handleReconnectActiveEnvironment(
@@ -2463,7 +2483,7 @@ function ChatViewContent(props: ChatViewProps) { · 5 unmodified lines
24632483 </Button>
24642484 <Button
24652485 size="xs"
2466− variant="outline"
2486+ variant="ghost"
24672487 onClick={() => void navigate({ to: "/settings/connections" })}
24682488 >
24692489 Connections
@@ -2512,21 +2532,25 @@ function ChatViewContent(props: ChatViewProps) { · 42 unmodified lines
25122532 "Server update available"
25132533 ),
25142534 description:
2515− !updateInProgress && !updateFailed && versionMismatchSelfUpdate === "desktop-managed"
2516− ? serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)
2535+ !updateInProgress &&
2536+ !updateFailed &&
2537+ versionMismatchSelfUpdate !== null &&
2538+ (versionMismatchSelfUpdate !== "desktop-managed" || !versionMismatchDesktopAppUpdate)
2539+ ? serverUpdateGuidance(versionMismatchSelfUpdate)
25172540 : undefined,
2518− // The desktop-managed guidance is already the description; the action
2519− // slot would only repeat it.
25202541 actions:
25212542 updateInProgress ||
25222543 !versionMismatch ||
2523− versionMismatchSelfUpdate === "desktop-managed" ? undefined : (
2544+ (versionMismatchSelfUpdate === "desktop-managed" &&
2545+ !versionMismatchDesktopAppUpdate) ? undefined : (
25242546 <ServerUpdateAction
25252547 environmentId={serverUpdateEnvironmentId}
25262548 serverLabel={versionMismatchServerLabel}
25272549 selfUpdate={versionMismatchSelfUpdate}
2550+ desktopAppUpdate={versionMismatchDesktopAppUpdate}
25282551 targetVersion={versionMismatch.clientVersion}
25292552 label={updateFailed ? "Retry" : "Update"}
2553+ variant="ghost"
25302554 />
25312555 ),
25322556 ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey)
@@ -2557,6 +2581,7 @@ function ChatViewContent(props: ChatViewProps) { · 24 unmodified lines
25572581 versionMismatchDismissKey,
25582582 serverUpdateEnvironmentId,
25592583 versionMismatchSelfUpdate,
2584+ versionMismatchDesktopAppUpdate,
25602585 versionMismatchServerLabel,
25612586 ]);
25622587 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
@@ -4469,7 +4494,11 @@ function ChatViewContent(props: ChatViewProps) { · 1906 unmodified lines
44694494 // Only an upward wheel is a navigation intent; wheeling down while
44704495 // following either does nothing (at the end) or moves toward it.
44714496 const handleWheel = (event: WheelEvent) => {
4472− if (event.deltaY < 0 && contentScrollsUp()) {
4497+ if (
4498+ event.deltaY < 0 &&
4499+ contentScrollsUp() &&
4500+ !toolGroupConsumesUpwardNavigation(event.target)
4501+ ) {
44734502 handleManualNavigation();
44744503 }
44754504 };
@@ -4506,7 +4535,7 @@ function ChatViewContent(props: ChatViewProps) { · 30 unmodified lines
45064535 case "PageUp":
45074536 case "Home":
45084537 case "ArrowUp":
4509− if (contentScrollsUp()) {
4538+ if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) {
45104539 handleManualNavigation();
45114540 }
45124541 break;
@@ -5188,6 +5217,23 @@ function ChatViewContent(props: ChatViewProps) { · 675 unmodified lines
51885217 // The stack renders items[0] front-most and tucks the rest behind hover, so
51895218 // ordering is priority: system banners, then the branch-mismatch notice,
51905219 // and the informational parked-thread banner last — it must never cover another.
5220+ // A woken thread announces itself in the open view, not just the sidebar
5221+ // pill. Dismissing marks the wake as seen (same acknowledgment as the
5222+ // pill); sending a message clears it as a side effect of the send path.
5223+ const wokeThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
5224+ if (!activeThreadWokeVisible) {
5225+ return null;
5226+ }
5227+ return {
5228+ id: `thread-woke:${activeThread?.id ?? "unknown"}`,
5229+ variant: "info",
5230+ icon: <AlarmClockIcon />,
5231+ title: "Thread woke from snooze",
5232+ description: "Send a message to continue",
5233+ dismissLabel: "Dismiss Woke notification",
5234+ onDismiss: acknowledgeActiveThreadWoke,
5235+ };
5236+ }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]);
51915237 const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
51925238 if (!activeThreadSnoozed && !activeThreadSettled) {
51935239 return null;
@@ -7440,6 +7486,9 @@ function ChatViewContent(props: ChatViewProps) { · 2246 unmodified lines
74407486 <div className="relative flex min-h-0 flex-1 flex-col">
74417487 {/* Messages — LegendList handles virtualization and scrolling internally */}
74427488 <MessagesTimeline
7489+ citationRequest={citationRequest}
7490+ citationHistoryLoading={threadDetailLoading}
7491+ onCiteAssistantText={citeAssistantText}
74437492 key={activeThread.id}
74447493 isWorking={isWorking}
74457494 activeTurnInProgress={isWorking || !latestRunSettled}
@@ -7467,9 +7516,11 @@ function ChatViewContent(props: ChatViewProps) { · 21 unmodified lines
74677516 resolvedTheme={resolvedTheme}
74687517 timestampFormat={timestampFormat}
74697518 workspaceRoot={activeWorkspaceRoot}
7470− skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}
7471− providerStatuses={providerStatuses}
7472− runs={serverProjection?.runs ?? EMPTY_PROJECTION_RUNS}
7519+ skills={
7520+ activeProviderStatus
7521+ ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd)
7522+ : EMPTY_PROVIDER_SKILLS
7523+ }
74737524 anchorMessageId={timelineAnchorMessageId}
74747525 onAnchorReady={onTimelineAnchorReady}
74757526 onAnchorSizeChanged={onTimelineAnchorSizeChanged}
apps/web/src/components/DiffPanel.tsx+7−0
@@ -330,6 +330,13 @@ export default function DiffPanel({ · 329 unmodified lines
330330 cacheKey: selectedGitSource.diffHash,
331331 });
332332 }, [activeThread, branchDiffPreview.data, getDiffFileContents, selectedGitSource, selectedRunId]);
333+ const loadDiffFilesRef = useRef(currentLoadDiffFiles);
334+ loadDiffFilesRef.current = currentLoadDiffFiles;
335+ const loadDiffFiles = useCallback<FileDiffContentsLoader>(async (fileDiff) => {
336+ const loader = loadDiffFilesRef.current;
337+ if (!loader) throw new Error("Diff file contents are unavailable for this selection.");
338+ return loader(fileDiff);
339+ }, []);
333340 const localBranchRefs = useEnvironmentQuery(
334341 selectedRunId === null &&
335342 selectedGitScope === "branch" &&
apps/web/src/components/GitActionsControl.tsx+14−7
@@ -16,7 +16,15 @@ import type { · 15 unmodified lines
1616 } from "@t3tools/contracts";
1717 import { useNavigate } from "@tanstack/react-router";
1818 import * as Option from "effect/Option";
19−import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
19+import {
20+ type MouseEvent,
21+ useCallback,
22+ useEffect,
23+ useEffectEvent,
24+ useMemo,
25+ useRef,
26+ useState,
27+} from "react";
2028 import { flushSync } from "react-dom";
2129 import {
2230 CheckIcon,
@@ -37,7 +45,7 @@ import { RadioGroup } from "~/components/ui/radio-group"; · 14 unmodified lines
3745 import { Spinner } from "~/components/ui/spinner";
3846 import { toggleVariants } from "~/components/ui/toggle";
3947 import { cn } from "~/lib/utils";
40−import { openPullRequestLink } from "~/lib/openPullRequestLink";
48+import { openPullRequestLink, useOpenPrLink } from "~/lib/openPullRequestLink";
4149 import {
4250 buildMenuItems,
4351 formatGitActionElapsed,
@@ -1117,6 +1125,7 @@ export default function GitActionsControl({ · 1073 unmodified lines
11171125 [activeThreadRef],
11181126 );
11191127 const activeServerThread = useThreadShell(activeThreadRef);
1128+ const openPrLink = useOpenPrLink(activeThreadRef ?? undefined);
11201129 const activeDraftThread = useComposerDraftStore((store) =>
11211130 draftId
11221131 ? store.getDraftSession(draftId)
@@ -1457,7 +1466,7 @@ export default function GitActionsControl({ · 334 unmodified lines
14571466 const toastCta = actionResult.toast.cta;
14581467 let toastActionProps: {
14591468 children: string;
1460− onClick: () => void;
1469+ onClick: (event: MouseEvent<HTMLButtonElement>) => void;
14611470 } | null = null;
14621471 if (toastCta.kind === "run_action") {
14631472 toastActionProps = {
@@ -1472,11 +1481,9 @@ export default function GitActionsControl({ · 8 unmodified lines
14721481 } else if (toastCta.kind === "open_pr") {
14731482 toastActionProps = {
14741483 children: toastCta.label,
1475− onClick: () => {
1476− const api = readLocalApi();
1477− if (!api) return;
1484+ onClick: (event) => {
14781485 closeResultToast();
1479− void api.shell.openExternal(toastCta.url);
1486+ openPrLink(event, toastCta.url);
14801487 },
14811488 };
14821489 }
apps/web/src/components/Sidebar.tsx+11−4
@@ -146,6 +146,8 @@ import { · 145 unmodified lines
146146 sortPinnedThreadsForSidebar,
147147 sortSettledThreadsForSidebar,
148148 sortThreadsForSidebar,
149+ useRetainedValue,
150+ useSidebarRowSubscriptionLease,
149151 } from "./Sidebar.logic";
150152 import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
151153 import {
@@ -791,6 +793,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 639 unmodified lines
791793 [thread.environmentId, thread.id],
792794 );
793795 const threadKey = scopedThreadKey(threadRef);
796+ const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive);
794797 const isRegeneratingTitle = thread.titleRegeneration != null;
795798 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
796799 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
@@ -824,10 +827,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 27 unmodified lines
824827 })
825828 : null,
826829 );
830+ const visibleGitStatus = useRetainedValue(
831+ JSON.stringify([thread.environmentId, gitCwd]),
832+ gitStatus.data,
833+ );
827834 const retainTerminalOnBranchMismatch = thread.worktreePath === null;
828835 const pr = resolveDisplayedThreadPr({
829836 threadBranch: thread.branch,
830− gitStatus: gitStatus.data,
837+ gitStatus: visibleGitStatus,
831838 snapshot: changeRequestSnapshot,
832839 retainTerminalOnBranchMismatch,
833840 });
@@ -926,7 +933,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 92 unmodified lines
926933 });
927934 const prProvider = resolveDisplayedThreadPrProvider({
928935 threadBranch: thread.branch,
929− gitStatus: gitStatus.data,
936+ gitStatus: visibleGitStatus,
930937 snapshot: changeRequestSnapshot,
931938 retainTerminalOnBranchMismatch,
932939 });
@@ -935,7 +942,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 2 unmodified lines
935942 useEffect(() => {
936943 const nextSnapshot = nextThreadChangeRequestSnapshot({
937944 threadBranch: thread.branch,
938− gitStatus: gitStatus.data,
945+ gitStatus: visibleGitStatus,
939946 snapshot: changeRequestSnapshot,
940947 retainTerminalOnBranchMismatch,
941948 });
@@ -943,7 +950,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 1 unmodified line
943950 onChangeRequestSnapshot(threadKey, nextSnapshot);
944951 }, [
945952 changeRequestSnapshot,
946− gitStatus.data,
953+ visibleGitStatus,
947954 onChangeRequestSnapshot,
948955 retainTerminalOnBranchMismatch,
949956 thread.branch,
apps/web/src/components/chat/ChatComposer.tsx+156−39
@@ -1,4 +1,5 @@
11 import type {
2+ AssistantCitation,
23 ChatAttachment as ContractChatAttachment,
34 EnvironmentId,
45 ModelSelection,
@@ -40,6 +41,7 @@ import { · 35 unmodified lines
4041 composerSubmissionIntentForEnter,
4142 detectComposerTrigger,
4243 expandCollapsedComposerCursor,
44+ formatAssistantCitationForComposer,
4345 replaceTextRange,
4446 } from "../../composer-logic";
4547 import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";
@@ -118,6 +120,7 @@ import { · 72 unmodified lines
118120 } from "../../lib/attachmentUploadState";
119121 import { isCommandPaletteOpen } from "../../commandPaletteBus";
120122 import { getTerminalFocusOwner } from "../../lib/terminalFocus";
123+import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
121124 import { resolveShortcutCommand, shortcutLabelForCommand } from "../../keybindings";
122125 import {
123126 type TerminalContextDraft,
@@ -336,9 +339,13 @@ import { · 212 unmodified lines
336339 formatProviderSkillDisplayName,
337340 getProviderSlashCommandsForSlashMenu,
338341 getProviderSkillsForSlashMenu,
342+ resolveProviderSkillsForCwd,
343+ resolveProviderSlashCommandsForCwd,
339344 } from "@t3tools/client-runtime/providerSkills";
340345 import { searchProviderSkills } from "../../providerSkillSearch";
341346 import { useMediaQuery } from "../../hooks/useMediaQuery";
347+import { useAtomCommand } from "../../state/use-atom-command";
348+import { serverEnvironment } from "../../state/server";
342349 import type { ReviewCommentContext } from "../../reviewCommentContext";
343350
344351 const runtimeModeConfig: Record<
@@ -574,6 +581,10 @@ export interface ChatComposerHandle { · 229 unmodified lines
574581 focusAt: (cursor: number) => void;
575582 addDroppedFiles: (files: File[]) => void;
576583 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;
584+ citeAssistantText: (
585+ citation: AssistantCitation,
586+ sourceAnchor: AssistantCitationSourceAnchor,
587+ ) => boolean;
577588 openModelPicker: () => void;
578589 toggleModelPicker: () => void;
579590 isModelPickerOpen: () => boolean;
@@ -1145,6 +1156,58 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 565 unmodified lines
11451156 () => selectedProviderEntry?.snapshot ?? null,
11461157 [selectedProviderEntry],
11471158 );
1159+ const selectedProviderSkills = selectedProviderStatus
1160+ ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd)
1161+ : [];
1162+ const selectedProviderSlashCommands = selectedProviderStatus
1163+ ? resolveProviderSlashCommandsForCwd(selectedProviderStatus, gitCwd)
1164+ : [];
1165+ const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
1166+ reportFailure: false,
1167+ });
1168+ const workspaceRefreshKeyRef = useRef<string | null>(null);
1169+ const hadWorkspaceSnapshotRef = useRef(false);
1170+ useEffect(() => {
1171+ const hasWorkspaceSnapshot = Boolean(
1172+ gitCwd &&
1173+ selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd),
1174+ );
1175+ if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) {
1176+ workspaceRefreshKeyRef.current = null;
1177+ }
1178+ hadWorkspaceSnapshotRef.current = hasWorkspaceSnapshot;
1179+ }, [gitCwd, selectedProviderStatus]);
1180+ useEffect(() => {
1181+ if (!gitCwd || !selectedProviderEntry) return;
1182+ const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`;
1183+ const hasWorkspaceSnapshot = selectedProviderStatus?.workspaceSnapshots?.some(
1184+ (snapshot) => snapshot.cwd === gitCwd,
1185+ );
1186+ if (workspaceRefreshKeyRef.current === key) return;
1187+ if (hasWorkspaceSnapshot) {
1188+ workspaceRefreshKeyRef.current = key;
1189+ return;
1190+ }
1191+ workspaceRefreshKeyRef.current = key;
1192+ void refreshProviders({
1193+ environmentId,
1194+ input: { instanceId: selectedProviderEntry.instanceId, cwd: gitCwd },
1195+ }).then(
1196+ (result) => {
1197+ const hasWorkspaceSnapshot =
1198+ result._tag === "Success" &&
1199+ result.value.providers
1200+ .find((provider) => provider.instanceId === selectedProviderEntry.instanceId)
1201+ ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd);
1202+ if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) {
1203+ workspaceRefreshKeyRef.current = null;
1204+ }
1205+ },
1206+ () => {
1207+ if (workspaceRefreshKeyRef.current === key) workspaceRefreshKeyRef.current = null;
1208+ },
1209+ );
1210+ }, [environmentId, gitCwd, refreshProviders, selectedProviderEntry]);
11481211 const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>(
11491212 () => selectedProviderEntry?.models ?? [],
11501213 [selectedProviderEntry],
@@ -1369,11 +1432,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 218 unmodified lines
13691432 },
13701433 ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;
13711434 const slashMenuSkills = getProviderSkillsForSlashMenu(
1372− selectedProviderStatus?.skills ?? [],
1435+ selectedProviderSkills,
13731436 settings.showSkillsInSlashMenu,
13741437 );
13751438 const providerSlashCommandItems = getProviderSlashCommandsForSlashMenu(
1376− selectedProviderStatus?.slashCommands ?? [],
1439+ selectedProviderSlashCommands,
13771440 slashMenuSkills,
13781441 ).map((command) => ({
13791442 id: `provider-slash-command:${selectedProvider}:${command.name}`,
@@ -1402,25 +1465,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 22 unmodified lines
14021465 return searchSlashCommandItems(slashCommandItems, query);
14031466 }
14041467 if (composerTrigger.kind === "skill") {
1405− return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map(
1406− (skill) => ({
1407− id: `skill:${selectedProvider}:${skill.name}`,
1408− type: "skill" as const,
1409− provider: selectedProvider,
1410− skill,
1411− label: formatProviderSkillDisplayName(skill),
1412− description:
1413− skill.shortDescription ??
1414− skill.description ??
1415− (skill.scope ? `${skill.scope} skill` : "Run provider skill"),
1416− }),
1417− );
1468+ return searchProviderSkills(selectedProviderSkills, composerTrigger.query).map((skill) => ({
1469+ id: `skill:${selectedProvider}:${skill.name}`,
1470+ type: "skill" as const,
1471+ provider: selectedProvider,
1472+ skill,
1473+ label: formatProviderSkillDisplayName(skill),
1474+ description:
1475+ skill.shortDescription ??
1476+ skill.description ??
1477+ (skill.scope ? `${skill.scope} skill` : "Run provider skill"),
1478+ }));
14181479 }
14191480 return [];
14201481 }, [
14211482 composerTrigger,
14221483 planModeUiEnabled,
14231484 selectedProvider,
1485+ selectedProviderSkills,
1486+ selectedProviderSlashCommands,
14241487 selectedProviderStatus,
14251488 settings.showSkillsInSlashMenu,
14261489 workspaceEntries.entries,
@@ -1962,7 +2025,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 535 unmodified lines
19622025 rangeStart: number,
19632026 rangeEnd: number,
19642027 replacement: string,
1965− options?: { expectedText?: string; focusEditorAfterReplace?: boolean },
2028+ options?: {
2029+ expectedText?: string;
2030+ focusEditorAfterReplace?: boolean;
2031+ citationComment?: { start: number; sourceAnchor: AssistantCitationSourceAnchor };
2032+ },
19662033 ): boolean => {
19672034 const currentText = promptRef.current;
19682035 const safeStart = Math.max(0, Math.min(currentText.length, rangeStart));
@@ -1976,6 +2043,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 7 unmodified lines
19762043 const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
19772044 const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor);
19782045 const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor);
2046+ if (options?.citationComment) {
2047+ composerEditorRef.current?.requestCitationComment({
2048+ previousValue: currentText,
2049+ value: next.text,
2050+ citationStart: options.citationComment.start,
2051+ sourceAnchor: options.citationComment.sourceAnchor,
2052+ });
2053+ }
19792054 promptRef.current = next.text;
19802055 const activePendingQuestion = activePendingProgress?.activeQuestion;
19812056 if (activePendingQuestion && activePendingUserInput) {
@@ -3264,28 +3339,62 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 1282 unmodified lines
32643339 void addComposerAttachments(files);
32653340 };
32663341
3267− const insertComposerTextAtEnd = (
3268− text: string,
3269− options?: { ensureLeadingBoundary?: boolean },
3270− ): boolean => {
3271− if (
3272− text.length === 0 ||
3273− isConnecting ||
3274− isComposerApprovalState ||
3275− pendingUserInputs.length > 0 ||
3276− projectSelectionRequired
3277− ) {
3278− return false;
3279− }
3280− const prompt = promptRef.current;
3281− const needsLeadingSpace =
3282− (options?.ensureLeadingBoundary ?? false) && prompt.length > 0 && !/\s$/.test(prompt);
3283− return applyPromptReplacement(
3284− prompt.length,
3285− prompt.length,
3286− needsLeadingSpace ? ` ${text}` : text,
3287− );
3288− };
3342+ const insertComposerText = useCallback(
3343+ (
3344+ text: string,
3345+ position: "cursor" | "end",
3346+ options?: {
3347+ ensureLeadingBoundary?: boolean;
3348+ citationCommentAnchor?: AssistantCitationSourceAnchor;
3349+ },
3350+ ): boolean => {
3351+ if (
3352+ text.length === 0 ||
3353+ isConnecting ||
3354+ isComposerApprovalState ||
3355+ pendingUserInputs.length > 0 ||
3356+ projectSelectionRequired ||
3357+ (options?.citationCommentAnchor && !composerEditorRef.current)
3358+ ) {
3359+ return false;
3360+ }
3361+ const prompt = promptRef.current;
3362+ const cursor = position === "cursor" ? readComposerSnapshot().expandedCursor : prompt.length;
3363+ const needsLeadingSpace =
3364+ (options?.ensureLeadingBoundary ?? false) &&
3365+ cursor > 0 &&
3366+ !/\s/.test(prompt[cursor - 1] ?? "");
3367+ const rangeEnd = extendReplacementRangeForTrailingSpace(prompt, cursor, text);
3368+ return applyPromptReplacement(
3369+ cursor,
3370+ rangeEnd,
3371+ needsLeadingSpace ? ` ${text}` : text,
3372+ options?.citationCommentAnchor
3373+ ? {
3374+ citationComment: {
3375+ start: cursor + (needsLeadingSpace ? 1 : 0),
3376+ sourceAnchor: options.citationCommentAnchor,
3377+ },
3378+ focusEditorAfterReplace: false,
3379+ }
3380+ : undefined,
3381+ );
3382+ },
3383+ [
3384+ applyPromptReplacement,
3385+ isComposerApprovalState,
3386+ isConnecting,
3387+ pendingUserInputs.length,
3388+ projectSelectionRequired,
3389+ promptRef,
3390+ readComposerSnapshot,
3391+ ],
3392+ );
3393+
3394+ const insertComposerTextAtEnd = useCallback<ChatComposerHandle["insertTextAtEnd"]>(
3395+ (text, options) => insertComposerText(text, "end", options),
3396+ [insertComposerText],
3397+ );
32893398
32903399 // File-tree drags land as mentions. Handled in the capture phase so the
32913400 // editor never sees the drop; the load-bearing rules (native stop, "move"
@@ -3391,6 +3500,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 99 unmodified lines
33913500 focusComposer();
33923501 },
33933502 insertTextAtEnd: insertComposerTextAtEnd,
3503+ citeAssistantText: (citation, sourceAnchor) =>
3504+ insertComposerText(
3505+ formatAssistantCitationForComposer(citation, citation.comment),
3506+ "cursor",
3507+ { ensureLeadingBoundary: true, citationCommentAnchor: sourceAnchor },
3508+ ),
33943509 openModelPicker: () => {
33953510 setIsComposerModelPickerOpen(true);
33963511 },
@@ -3489,6 +3604,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 92 unmodified lines
34893604 composerCursor,
34903605 composerTerminalContexts,
34913606 insertComposerDraftTerminalContext,
3607+ insertComposerText,
3608+ insertComposerTextAtEnd,
34923609 promptRef,
34933610 composerImagesRef,
34943611 composerFilesRef,
@@ -4120,7 +4237,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 625 unmodified lines
41204237 ? composerTerminalContexts
41214238 : []
41224239 }
4123− skills={selectedProviderStatus?.skills ?? []}
4240+ skills={selectedProviderSkills}
41244241 {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})}
41254242 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
41264243 onChange={onPromptChange}
apps/web/src/components/chat/ComposerBannerStack.tsx+1−0
@@ -1,3 +1,4 @@
1+import { InfoIcon } from "lucide-react";
12 import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
23
34 import { cn } from "~/lib/utils";
apps/web/src/components/chat/ComposerPrimaryActions.test.tsx+0−21
@@ -44,27 +44,6 @@ function renderPendingActions(isRunning: boolean) { · 43 unmodified lines
4444 );
4545 }
4646
47−function renderStandaloneStop() {
48− return renderToStaticMarkup(
49− createElement(ComposerPrimaryActions, {
50− compact: true,
51− pendingAction: null,
52− isRunning: true,
53− showPlanFollowUpPrompt: false,
54− promptHasText: false,
55− isSendBusy: false,
56− sendDisabledReason: null,
57− isConnecting: false,
58− isEnvironmentUnavailable: false,
59− isPreparingWorktree: false,
60− hasSendableContent: false,
61− onPreviousPendingQuestion: () => {},
62− onInterrupt: () => {},
63− onImplementPlanInNewThread: () => {},
64− }),
65− );
66−}
67−
6847 function renderSendButton(sendDisabledReason: string | null = null) {
6948 return renderToStaticMarkup(
7049 createElement(ComposerPrimaryActions, {
apps/web/src/components/chat/ComposerStashBadge.tsx+6−1
@@ -60,7 +60,12 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: { · 59 unmodified lines
6060 }
6161
6262 return (
63− <ComposerBanner.Root width="content" data-composer-shoulder-tab className="ml-auto">
63+ <ComposerBanner.Root
64+ density="comfortable"
65+ width="content"
66+ data-composer-shoulder-tab
67+ className="ml-auto"
68+ >
6469 <ComposerBanner.Row
6570 render={<button type="button" />}
6671 data-prompt-stash-badge="true"
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+315−57
@@ -11,8 +11,11 @@ import { · 10 unmodified lines
1111 normalizeCompactToolLabel,
1212 resolveAssistantMessageCopyState,
1313 resolveTimelineToolPresentation,
14+ resolveWorkGroupScrollIndex,
15+ shouldFollowWorkGroupAppend,
1416 shouldPreserveAssistantLineBreaks,
1517 summarizeToolGroup,
18+ workEntryDisplayLabel,
1619 } from "./MessagesTimeline.logic";
1720 import {
1821 createMessageAttachmentPreviewProjector,
@@ -355,6 +358,171 @@ describe("streaming row projection", () => { · 336 unmodified lines
355358 });
356359 });
357360
361+describe("expanded tool group scrolling", () => {
362+ const entries = [{ id: "first" }, { id: "second" }];
363+
364+ it("follows appended calls only at the hard end", () => {
365+ const appended = [...entries, { id: "third" }];
366+ expect(shouldFollowWorkGroupAppend(entries, appended, 0)).toBe(true);
367+ expect(shouldFollowWorkGroupAppend(entries, appended, 0.5)).toBe(true);
368+ expect(shouldFollowWorkGroupAppend(entries, appended, 1)).toBe(true);
369+ expect(shouldFollowWorkGroupAppend(entries, appended, 1.01)).toBe(false);
370+ expect(shouldFollowWorkGroupAppend(entries, appended, 10)).toBe(false);
371+ expect(shouldFollowWorkGroupAppend(entries, appended, Infinity)).toBe(false);
372+ });
373+
374+ it("does not follow output updates, prepends, or replacements", () => {
375+ expect(
376+ shouldFollowWorkGroupAppend(
377+ entries,
378+ entries.map((entry) => ({ ...entry })),
379+ 0,
380+ ),
381+ ).toBe(false);
382+ expect(shouldFollowWorkGroupAppend(entries, [{ id: "older" }, ...entries], 0)).toBe(false);
383+ expect(
384+ shouldFollowWorkGroupAppend(
385+ entries,
386+ [{ id: "replacement" }, entries[1]!, { id: "third" }],
387+ 0,
388+ ),
389+ ).toBe(false);
390+ expect(shouldFollowWorkGroupAppend([], entries, 0)).toBe(false);
391+ });
392+
393+ it("restores the visible tool and its offset inside expanded output", () => {
394+ const anchor = { entryId: "second", offset: 120 };
395+ expect(resolveWorkGroupScrollIndex(entries, anchor)).toEqual({ index: 1, viewOffset: -120 });
396+ expect(resolveWorkGroupScrollIndex([{ id: "older" }, ...entries], anchor)).toEqual({
397+ index: 2,
398+ viewOffset: -120,
399+ });
400+ });
401+
402+ it("starts normally when the saved tool no longer exists", () => {
403+ expect(resolveWorkGroupScrollIndex(entries, undefined)).toBeUndefined();
404+ expect(
405+ resolveWorkGroupScrollIndex(entries, { entryId: "removed", offset: 120 }),
406+ ).toBeUndefined();
407+ });
408+});
409+
410+describe("work entry labels", () => {
411+ const entry = {
412+ id: "tool-1",
413+ createdAt: "2026-09-01T12:00:00Z",
414+ label: "Tool call",
415+ tone: "tool" as const,
416+ };
417+
418+ it.each([
419+ ["inProgress", "Clicking in the preview browser"],
420+ ["completed", "Clicked in the preview browser"],
421+ ["failed", "Failed to click in the preview browser"],
422+ ["declined", "Declined to click in the preview browser"],
423+ ["stopped", "Stopped clicking in the preview browser"],
424+ ] as const)("uses the same friendly %s label in both views", (toolLifecycleStatus, label) => {
425+ const browserEntry = {
426+ ...entry,
427+ toolTitle: "T3-code.preview_click",
428+ detail: '{"ok":true}',
429+ toolLifecycleStatus,
430+ };
431+ expect(liveWorkEntryLabel(browserEntry, undefined, toolLifecycleStatus === "inProgress")).toBe(
432+ label,
433+ );
434+ expect(workEntryDisplayLabel(browserEntry, undefined)).toBe(label);
435+ });
436+
437+ it("uses the active summary state for legacy tools without a lifecycle status", () => {
438+ const browserEntry = { ...entry, toolTitle: "T3-code.preview_click" };
439+ expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe(
440+ "Clicking in the preview browser",
441+ );
442+ expect(liveWorkEntryLabel(browserEntry, undefined, false)).toBe(
443+ "Clicked in the preview browser",
444+ );
445+ });
446+
447+ it("does not describe a finished call as still running while the turn continues", () => {
448+ const browserEntry = {
449+ ...entry,
450+ toolTitle: "T3-code.preview_click",
451+ toolLifecycleStatus: "completed" as const,
452+ };
453+ expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe(
454+ "Clicked in the preview browser",
455+ );
456+ });
457+
458+ it("keeps custom titles and output for unrecognized tools", () => {
459+ const unknownEntry = { ...entry, toolTitle: "mcp__github__search_issues" };
460+ expect(liveWorkEntryLabel(unknownEntry, undefined, true)).toBe("Mcp__github__search_issues");
461+ expect(workEntryDisplayLabel({ ...unknownEntry, detail: "Found 3 issues" }, undefined)).toBe(
462+ "Found 3 issues",
463+ );
464+ });
465+
466+ it("keeps command summaries compact without replacing the full command in expanded rows", () => {
467+ const commandEntry = { ...entry, command: "vp test run", detail: "All tests passed" };
468+ expect(liveWorkEntryLabel(commandEntry, undefined, true)).toBe("Running vp");
469+ expect(liveWorkEntryLabel(commandEntry, undefined, false)).toBe("Ran vp");
470+ expect(workEntryDisplayLabel(commandEntry, undefined)).toBe("vp test run");
471+ });
472+
473+ it("summarizes the program inside a shell wrapper while preserving the expanded command", () => {
474+ const command = "/bin/zsh -lc 'vp test run apps/web/src/session-logic.test.ts'";
475+ const commandEntry = { ...entry, command };
476+ expect(liveWorkEntryLabel(commandEntry, undefined, true)).toBe("Running vp");
477+ expect(liveWorkEntryLabel(commandEntry, undefined, false)).toBe("Ran vp");
478+ expect(workEntryDisplayLabel(commandEntry, undefined)).toBe(command);
479+ });
480+
481+ it.each([
482+ ["inProgress", "Running vp"],
483+ ["completed", "Ran vp"],
484+ ["failed", "Failed vp"],
485+ ["declined", "Declined vp"],
486+ ["stopped", "Stopped vp"],
487+ ] as const)(
488+ "uses the command's %s outcome even while the turn continues",
489+ (toolLifecycleStatus, label) => {
490+ const commandEntry = {
491+ ...entry,
492+ command: "/bin/bash -lc 'vp test run'",
493+ toolLifecycleStatus,
494+ };
495+ expect(liveWorkEntryLabel(commandEntry, undefined, true)).toBe(label);
496+ expect(liveWorkEntryLabel(commandEntry, undefined, false)).toBe(label);
497+ },
498+ );
499+
500+ it("gives a completed browser group its own count and summary icon category", () => {
501+ const rows = deriveMessagesTimelineRows({
502+ timelineEntries: [
503+ {
504+ id: "browser-entry",
505+ kind: "work",
506+ createdAt: entry.createdAt,
507+ entry: {
508+ ...entry,
509+ itemType: "dynamic_tool",
510+ toolLifecycleStatus: "completed",
511+ toolData: { server: "t3-code", tool: "preview_click" },
512+ },
513+ },
514+ ],
515+ isWorking: false,
516+ activeTurnStartedAt: null,
517+ turnDiffSummaryByAssistantMessageId: new Map(),
518+ revertTurnCountByUserMessageId: new Map(),
519+ });
520+ expect(rows).toMatchObject([
521+ { kind: "work-toggle", summary: "Used browser 1 time", summaryKind: "browser" },
522+ ]);
523+ });
524+});
525+
358526 describe("summarizeToolGroup", () => {
359527 const now = DateTime.makeUnsafe("2026-08-29T00:00:00Z");
360528 function work(overrides: Partial<WorkLogEntry> = {}): WorkLogEntry {
@@ -1682,6 +1850,67 @@ describe("deriveMessagesTimelineRows", () => { · 1321 unmodified lines
16821850 expect(liveRow?.kind === "work-live" ? liveRow.entry.id : null).toBe("work-1");
16831851 });
16841852
1853+ it("reuses one activity row for initial thinking and the latest tool", () => {
1854+ const deriveRows = (
1855+ toolLifecycleStatus: "inProgress" | "completed" | "failed" | "declined" | null,
1856+ ) =>
1857+ deriveMessagesTimelineRows({
1858+ timelineEntries:
1859+ toolLifecycleStatus === null
1860+ ? []
1861+ : [
1862+ {
1863+ id: "latest-command-entry",
1864+ kind: "work",
1865+ createdAt: "2026-01-01T00:00:05Z",
1866+ entry: {
1867+ id: "latest-command",
1868+ createdAt: "2026-01-01T00:00:05Z",
1869+ runId: "run-1" as never,
1870+ label: toolLifecycleStatus === "inProgress" ? "Running rg" : "Ran rg",
1871+ command: "rg toolCall",
1872+ requestKind: "command",
1873+ tone: "tool" as const,
1874+ toolLifecycleStatus,
1875+ ...(toolLifecycleStatus === "inProgress" ? { detail: "exit code 1" } : {}),
1876+ },
1877+ },
1878+ ],
1879+ latestRun: {
1880+ runId: "run-1" as never,
1881+ status: "running" as const,
1882+ startedAt: "2026-01-01T00:00:00Z",
1883+ completedAt: null,
1884+ },
1885+ isWorking: true,
1886+ activeTurnStartedAt: "2026-01-01T00:00:00Z",
1887+ turnDiffSummaryByAssistantMessageId: new Map(),
1888+ revertTurnCountByUserMessageId: new Map(),
1889+ });
1890+
1891+ const initialRows = deriveRows(null);
1892+ const runningRows = deriveRows("inProgress");
1893+ const completedRows = deriveRows("completed");
1894+ const failedRows = deriveRows("failed");
1895+ const declinedRows = deriveRows("declined");
1896+ const initialActivityRow = initialRows.find((row) => row.id === "live-activity-row");
1897+ const runningActivityRow = runningRows.find((row) => row.id === "live-activity-row");
1898+ const completedActivityRow = completedRows.find((row) => row.id === "live-activity-row");
1899+
1900+ expect(initialActivityRow).toMatchObject({ kind: "thinking" });
1901+ expect(runningActivityRow).toMatchObject({ kind: "work-live", active: true });
1902+ expect(completedActivityRow).toMatchObject({ kind: "work-live", active: true });
1903+ expect(failedRows.some((row) => row.kind === "work-live")).toBe(false);
1904+ expect(failedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" });
1905+ expect(declinedRows.find((row) => row.kind === "work-live")).toMatchObject({ active: false });
1906+ expect(declinedRows.at(-1)).toMatchObject({ kind: "thinking", id: "live-activity-row" });
1907+ expect(initialRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1);
1908+ expect(runningRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1);
1909+ expect(completedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1);
1910+ expect(failedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1);
1911+ expect(declinedRows.filter((row) => row.id === "live-activity-row")).toHaveLength(1);
1912+ });
1913+
16851914 it("only shows assistant metadata on the terminal assistant message", () => {
16861915 const rows = deriveMessagesTimelineRows({
16871916 timelineEntries: [
@@ -1766,52 +1995,6 @@ describe("deriveMessagesTimelineRows", () => { · 78 unmodified lines
17661995 expect(assistantRow?.showAssistantCopyButton).toBe(false);
17671996 });
17681997
1769− it("retains a failed command in the summary after a later command succeeds", () => {
1770− const rows = deriveMessagesTimelineRows({
1771− timelineEntries: [
1772− {
1773− id: "work-entry-failed",
1774− kind: "work" as const,
1775− createdAt: "2026-01-01T00:00:01Z",
1776− entry: {
1777− id: "work-failed",
1778− createdAt: "2026-01-01T00:00:01Z",
1779− label: "Ran command",
1780− command: "ssh host true",
1781− detail: "connection refused",
1782− tone: "tool" as const,
1783− itemType: "command_execution" as const,
1784− toolLifecycleStatus: "failed" as const,
1785− },
1786− },
1787− {
1788− id: "work-entry-ok",
1789− kind: "work" as const,
1790− createdAt: "2026-01-01T00:00:02Z",
1791− entry: {
1792− id: "work-ok",
1793− createdAt: "2026-01-01T00:00:02Z",
1794− label: "Ran command",
1795− command: "true",
1796− tone: "tool" as const,
1797− itemType: "command_execution" as const,
1798− toolLifecycleStatus: "completed" as const,
1799− },
1800− },
1801− ],
1802− isWorking: false,
1803− turnDiffSummaryByAssistantMessageId: new Map(),
1804− revertTurnCountByUserMessageId: new Map(),
1805− });
1806−
1807− expect(rows).toHaveLength(1);
1808− expect(rows[0]).toMatchObject({
1809− kind: "work-toggle",
1810− summary: "Ran 2 commands",
1811− hasFailure: true,
1812− });
1813− });
1814−
18151998 it("collapses contiguous settled tool entries behind a summary toggle", () => {
18161999 const timelineEntries = [
18172000 {
@@ -1885,19 +2068,94 @@ describe("deriveMessagesTimelineRows", () => { · 67 unmodified lines
18852068 });
18862069 expect(expandedRows.map((row) => row.id)).toEqual([
18872070 "work-toggle:work-entry-1",
1888− "work-1",
1889− "work-2",
1890− "work-3",
2071+ "work-group:work-entry-1:details",
18912072 ]);
1892− expect(expandedRows[1]).toMatchObject({
1893− kind: "work",
1894− isExpandedToolGroupEntry: true,
1895− isLastExpandedToolGroupEntry: false,
2073+ expect(expandedRows.find((row) => row.kind === "work")).toMatchObject({
2074+ isExpandedToolGroup: true,
2075+ groupedEntries: timelineEntries.map(({ entry }) => entry),
2076+ });
2077+ expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({
2078+ expanded: true,
2079+ });
2080+ });
2081+
2082+ it.each([true, false])(
2083+ "keeps a large expanded tool run inside one timeline item, live=%s",
2084+ (isWorking) => {
2085+ const runId = RunId.make("run-many-tools");
2086+ const createdAt = "2026-09-01T12:00:00Z";
2087+ const timelineEntries = Array.from({ length: 1_000 }, (_, index) => ({
2088+ id: `tool-entry-${index}`,
2089+ kind: "work" as const,
2090+ createdAt,
2091+ entry: {
2092+ id: `tool-${index}`,
2093+ createdAt,
2094+ runId,
2095+ label: "t3-code.preview_snapshot",
2096+ tone: "tool" as const,
2097+ toolLifecycleStatus:
2098+ isWorking && index === 999 ? ("inProgress" as const) : ("completed" as const),
2099+ },
2100+ }));
2101+ const groupId = "work-group:tool-entry-0";
2102+ const input = {
2103+ timelineEntries,
2104+ isWorking,
2105+ expandedRunIds: new Set([runId]),
2106+ latestRun: {
2107+ runId,
2108+ status: isWorking ? ("running" as const) : ("completed" as const),
2109+ startedAt: createdAt,
2110+ completedAt: isWorking ? null : createdAt,
2111+ },
2112+ activeTurnStartedAt: isWorking ? createdAt : null,
2113+ turnDiffSummaryByAssistantMessageId: new Map(),
2114+ revertTurnCountByUserMessageId: new Map(),
2115+ };
2116+ const expandedRows = deriveMessagesTimelineRows({
2117+ ...input,
2118+ expandedWorkGroupIds: new Set([groupId]),
2119+ });
2120+ const groupRows = expandedRows.filter((row) => row.kind === "work");
2121+ expect(groupRows).toHaveLength(1);
2122+ expect(groupRows[0]?.groupedEntries.map(({ id }) => id)).toEqual(
2123+ timelineEntries.map(({ entry }) => entry.id),
2124+ );
2125+ expect(groupRows[0]?.id).toBe(`${groupId}:details`);
2126+ expect(deriveMessagesTimelineRows(input).some((row) => row.kind === "work")).toBe(false);
2127+ },
2128+ );
2129+
2130+ it.each([
2131+ ["recovered", ["failed", "completed"], false],
2132+ ["ending in failure", ["completed", "failed"], true],
2133+ ["failed", ["failed", "failed"], true],
2134+ ] as const)("uses the final call for %s tool groups", (_, statuses, hasFailure) => {
2135+ const timelineEntries = statuses.map((status, index) => ({
2136+ id: `work-entry-${index}`,
2137+ kind: "work" as const,
2138+ createdAt: `2026-01-01T00:00:0${index}Z`,
2139+ entry: {
2140+ id: `work-${index}`,
2141+ createdAt: `2026-01-01T00:00:0${index}Z`,
2142+ label: "Ran command",
2143+ tone: "tool" as const,
2144+ itemType: "command_execution" as const,
2145+ toolLifecycleStatus: status,
2146+ },
2147+ }));
2148+
2149+ const rows = deriveMessagesTimelineRows({
2150+ timelineEntries,
2151+ isWorking: false,
2152+ turnDiffSummaryByAssistantMessageId: new Map(),
2153+ revertTurnCountByUserMessageId: new Map(),
18962154 });
1897− expect(expandedRows[3]).toMatchObject({
1898− kind: "work",
1899− isExpandedToolGroupEntry: true,
1900− isLastExpandedToolGroupEntry: true,
2155+
2156+ expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
2157+ hiddenCount: 2,
2158+ hasFailure,
19012159 });
19022160 });
19032161 });
apps/web/src/components/chat/MessagesTimeline.logic.ts+119−40
@@ -1,6 +1,9 @@
11 import * as Equal from "effect/Equal";
22 import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
3+import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
34 import {
5+ normalizeCompactToolLabel,
6+ resolveWorkEntryToolPresentation,
47 summarizeToolGroup,
58 toolGroupSummaryKind,
69 type ToolGroupSummaryKind,
@@ -40,6 +43,7 @@ import { · 33 unmodified lines
4043 type T3McpToolPresentation,
4144 type T3McpToolSummaryAction,
4245 } from "@t3tools/shared/t3McpToolPresentation";
46+import { formatWorkspaceRelativePath } from "../../filePathDisplay";
4347
4448 function timelineEntryRunId(entry: TimelineEntry): RunId | null {
4549 if (entry.kind === "message") {
@@ -59,6 +63,50 @@ function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { · 13 unmodified lines
5963 );
6064 }
6165
66+export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string | undefined) {
67+ const toolPresentation = resolveWorkEntryToolPresentation(entry);
68+ if (toolPresentation) return toolPresentation.displayName;
69+ if (entry.command) return entry.command;
70+ if (entry.detail) return entry.detail;
71+ const [firstPath] = entry.changedFiles ?? [];
72+ if (firstPath) {
73+ const path = formatWorkspaceRelativePath(firstPath, workspaceRoot);
74+ return entry.changedFiles!.length === 1
75+ ? path
76+ : `${path} +${entry.changedFiles!.length - 1} more`;
77+ }
78+ const heading = normalizeCompactToolLabel(entry.toolTitle || entry.label);
79+ return `${heading.charAt(0).toUpperCase()}${heading.slice(1)}`;
80+}
81+
82+export function liveWorkEntryLabel(
83+ entry: WorkLogEntry,
84+ workspaceRoot: string | undefined,
85+ active: boolean,
86+) {
87+ const toolPresentation = resolveWorkEntryToolPresentation(
88+ entry,
89+ active ? "inProgress" : "completed",
90+ );
91+ if (toolPresentation) return toolPresentation.displayName;
92+ const command = entry.command?.trim();
93+ if (command) {
94+ const status = entry.toolLifecycleStatus ?? (active ? "inProgress" : "completed");
95+ const verb =
96+ status === "inProgress"
97+ ? "Running"
98+ : status === "failed"
99+ ? "Failed"
100+ : status === "declined"
101+ ? "Declined"
102+ : status === "stopped"
103+ ? "Stopped"
104+ : "Ran";
105+ return `${verb} ${commandProgramName(command) ?? "command"}`;
106+ }
107+ return workEntryDisplayLabel(entry, workspaceRoot);
108+}
109+
62110 export function workEntryIsVisibleInGroup(
63111 entry: WorkLogEntry,
64112 expandedToolGroupEntry = false,
@@ -74,6 +122,35 @@ export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; · 9 unmodified lines
74122 export const TIMELINE_CONTENT_MAX_WIDTH = 768;
75123 export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;
76124
125+export interface WorkGroupScrollAnchor {
126+ readonly entryId: string;
127+ readonly offset: number;
128+}
129+
130+/** Restore a visible tool, including a position partway through its expanded output. */
131+export function resolveWorkGroupScrollIndex(
132+ entries: ReadonlyArray<{ readonly id: string }>,
133+ anchor: WorkGroupScrollAnchor | undefined,
134+): { index: number; viewOffset: number } | undefined {
135+ if (!anchor) return undefined;
136+ const index = entries.findIndex((entry) => entry.id === anchor.entryId);
137+ return index < 0 ? undefined : { index, viewOffset: -anchor.offset };
138+}
139+
140+/** Only newly appended calls may follow the end, never status or output updates. */
141+export function shouldFollowWorkGroupAppend(
142+ previous: ReadonlyArray<{ readonly id: string }>,
143+ entries: ReadonlyArray<{ readonly id: string }>,
144+ distanceFromEnd: number,
145+): boolean {
146+ return (
147+ previous.length > 0 &&
148+ entries.length > previous.length &&
149+ distanceFromEnd <= 1 &&
150+ previous.every((entry, index) => entry.id === entries[index]?.id)
151+ );
152+}
153+
77154 export interface TimelineEndState {
78155 readonly isAtEnd?: boolean;
79156 readonly isNearEnd?: boolean;
@@ -228,8 +305,7 @@ export type MessagesTimelineRow = · 148 unmodified lines
228305 id: string;
229306 createdAt: string;
230307 groupedEntries: WorkLogEntry[];
231− isExpandedToolGroupEntry: boolean;
232− isLastExpandedToolGroupEntry: boolean;
308+ isExpandedToolGroup: boolean;
233309 }
234310 | {
235311 kind: "work-live";
@@ -342,6 +418,20 @@ function workGroupId(timelineEntryId: string): string { · 106 unmodified lines
342418 export type TimelineToolPresentation = T3McpToolPresentation;
343419 export const resolveTimelineToolPresentation = resolveT3McpToolPresentation;
344420
421+function expandedWorkGroupRow(
422+ groupId: string,
423+ createdAt: string,
424+ groupedEntries: WorkLogEntry[],
425+): Extract<MessagesTimelineRow, { kind: "work" }> {
426+ return {
427+ kind: "work",
428+ id: `${groupId}:details`,
429+ createdAt,
430+ groupedEntries,
431+ isExpandedToolGroup: true,
432+ };
433+}
434+
345435 export function resolveAssistantMessageCopyState({
346436 text,
347437 showCopyButton,
@@ -719,9 +809,14 @@ export function deriveMessagesTimelineRows(input: { · 371 unmodified lines
719809 latestRunningToolEntry !== undefined ||
720810 (latestVisibleToolEntry !== undefined &&
721811 workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry));
812+ const latestToolFailed =
813+ latestRunningToolEntry === undefined &&
814+ latestVisibleToolEntry !== undefined &&
815+ latestVisibleToolEntry.entry.toolLifecycleStatus !== "declined" &&
816+ workEntryDisplayIndicatesToolFailure(latestVisibleToolEntry.entry);
722817 const activeWorkPlacementEntryId = latestVisibleToolEntry?.id;
723818 const activeWorkRow =
724− activeWorkAnchor && latestVisibleToolEntry
819+ activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed
725820 ? (() => {
726821 const groupId = workGroupId(activeWorkAnchor.id);
727822 return {
@@ -739,7 +834,7 @@ export function deriveMessagesTimelineRows(input: { · 11 unmodified lines
739834 })()
740835 : null;
741836 const activeWorkEntryIds = new Set(
742− activeWorkRow === null ? [] : activeToolEntries.map((entry) => entry.id),
837+ activeWorkRow !== null || latestToolFailed ? activeToolEntries.map((entry) => entry.id) : [],
743838 );
744839 const appendWorkingRow = () => {
745840 nextRows.push({
@@ -754,16 +849,13 @@ export function deriveMessagesTimelineRows(input: { · 8 unmodified lines
754849 nextRows.push(activeWorkRow);
755850 hasActivityRow ||= activeWorkRow.active;
756851 if (!activeWorkRow.expanded) return;
757− for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) {
758− nextRows.push({
759− kind: "work",
760− id: workEntry.id,
761− createdAt: workEntry.createdAt,
762− groupedEntries: [workEntry],
763− isExpandedToolGroupEntry: true,
764− isLastExpandedToolGroupEntry: entryIndex === activeWorkRow.groupedEntries.length - 1,
765− });
766− }
852+ nextRows.push(
853+ expandedWorkGroupRow(
854+ activeWorkRow.groupId,
855+ activeWorkRow.createdAt,
856+ activeWorkRow.groupedEntries,
857+ ),
858+ );
767859 };
768860
769861 for (let index = 0; index < input.timelineEntries.length; index += 1) {
@@ -833,8 +925,7 @@ export function deriveMessagesTimelineRows(input: { · 63 unmodified lines
833925 id: timelineEntry.id,
834926 createdAt: timelineEntry.createdAt,
835927 groupedEntries: [timelineEntry.entry],
836− isExpandedToolGroupEntry: false,
837− isLastExpandedToolGroupEntry: false,
928+ isExpandedToolGroup: false,
838929 });
839930 continue;
840931 }
@@ -880,22 +971,16 @@ export function deriveMessagesTimelineRows(input: { · 39 unmodified lines
880971 });
881972 hasActivityRow = true;
882973 if (expanded) {
883− for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
884− nextRows.push({
885− kind: "work",
886− id: workEntry.id,
887− createdAt: workEntry.createdAt,
888− groupedEntries: [workEntry],
889− isExpandedToolGroupEntry: true,
890− isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1,
891− });
892− }
974+ nextRows.push(
975+ expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries),
976+ );
893977 }
894978 } else {
895979 const groupId = workGroupId(timelineEntry.id);
896980 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
897981 const summaryKind = toolGroupSummaryKind(visibleGroupedEntries);
898982 const groupSummary = summarizeToolGroup(visibleGroupedEntries);
983+ const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike);
899984 nextRows.push({
900985 kind: "work-toggle",
901986 id: `work-toggle:${timelineEntry.id}`,
@@ -909,19 +994,14 @@ export function deriveMessagesTimelineRows(input: { · 7 unmodified lines
909994 ? visibleGroupedEntries[0]!.label
910995 : groupSummary.summary,
911996 summaryKind,
912− hasFailure: groupSummary.hasFailure,
997+ hasFailure:
998+ latestToolEntry !== undefined &&
999+ workEntryDisplayIndicatesToolFailure(latestToolEntry),
9131000 });
9141001 if (expanded) {
915− for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
916− nextRows.push({
917− kind: "work",
918− id: workEntry.id,
919− createdAt: workEntry.createdAt,
920− groupedEntries: [workEntry],
921− isExpandedToolGroupEntry: true,
922− isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1,
923− });
924− }
1002+ nextRows.push(
1003+ expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries),
1004+ );
9251005 }
9261006 }
9271007 }
@@ -1001,7 +1081,7 @@ export function deriveMessagesTimelineRows(input: { · 73 unmodified lines
10011081 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
10021082 appendWorkingRow();
10031083 }
1004− if (input.isWorking && !hasActivityRow) {
1084+ if (input.isWorking && (!hasActivityRow || latestToolFailed)) {
10051085 nextRows.push({
10061086 kind: "thinking",
10071087 id: LIVE_ACTIVITY_ROW_ID,
@@ -1129,8 +1209,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean · 121 unmodified lines
11291209 case "work": {
11301210 const bw = b as typeof a;
11311211 return (
1132− a.isExpandedToolGroupEntry === bw.isExpandedToolGroupEntry &&
1133− a.isLastExpandedToolGroupEntry === bw.isLastExpandedToolGroupEntry &&
1212+ a.isExpandedToolGroup === bw.isExpandedToolGroup &&
11341213 Equal.equals(a.groupedEntries, bw.groupedEntries)
11351214 );
11361215 }
apps/web/src/components/chat/MessagesTimeline.test.tsx+35−21
@@ -55,6 +55,15 @@ vi.mock("@legendapp/list/react", async () => { · 54 unmodified lines
5555 layout?: boolean;
5656 };
5757 };
58+ maintainVisibleContentPosition?:
59+ | boolean
60+ | {
61+ data?: boolean;
62+ size?: boolean;
63+ shouldRestorePosition?: boolean;
64+ };
65+ className?: string;
66+ contentInsetEndAdjustment?: number;
5867 ref?: Ref<LegendListRef>;
5968 }) => {
6069 if (props.anchoredEndSpace) {
@@ -64,7 +73,32 @@ vi.mock("@legendapp/list/react", async () => { · 3 unmodified lines
6473 return (
6574 <div
6675 data-testid={legendListTestId}
76+ data-class-name={props.className}
6777 data-anchor-index={props.anchoredEndSpace?.anchorIndex}
78+ data-anchor-max-size={props.anchoredEndSpace?.anchorMaxSize}
79+ data-anchor-offset={props.anchoredEndSpace?.anchorOffset}
80+ data-anchor-on-ready={Boolean(props.anchoredEndSpace?.onReady)}
81+ data-content-inset-end={props.contentInsetEndAdjustment}
82+ data-maintain-visible-content-position={
83+ typeof props.maintainVisibleContentPosition === "object"
84+ ? "object"
85+ : props.maintainVisibleContentPosition
86+ }
87+ data-maintain-visible-content-position-data={
88+ typeof props.maintainVisibleContentPosition === "object"
89+ ? props.maintainVisibleContentPosition.data
90+ : undefined
91+ }
92+ data-maintain-visible-content-position-size={
93+ typeof props.maintainVisibleContentPosition === "object"
94+ ? props.maintainVisibleContentPosition.size
95+ : undefined
96+ }
97+ data-maintain-visible-content-position-restore={
98+ typeof props.maintainVisibleContentPosition === "object"
99+ ? Boolean(props.maintainVisibleContentPosition.shouldRestorePosition)
100+ : undefined
101+ }
68102 data-maintain-scroll-at-end={props.maintainScrollAtEnd ? "enabled" : undefined}
69103 data-maintain-scroll-at-end-animated={
70104 typeof props.maintainScrollAtEnd === "object"
@@ -86,26 +120,6 @@ vi.mock("@legendapp/list/react", async () => { · 15 unmodified lines
86120 ? props.maintainScrollAtEnd.on?.layout
87121 : undefined
88122 }
89− data-maintain-visible-content-position={
90− typeof props.maintainVisibleContentPosition === "object"
91− ? "object"
92− : props.maintainVisibleContentPosition
93− }
94− data-maintain-visible-content-position-data={
95− typeof props.maintainVisibleContentPosition === "object"
96− ? props.maintainVisibleContentPosition.data
97− : undefined
98− }
99− data-maintain-visible-content-position-size={
100− typeof props.maintainVisibleContentPosition === "object"
101− ? props.maintainVisibleContentPosition.size
102− : undefined
103− }
104− data-maintain-visible-content-position-restore={
105− typeof props.maintainVisibleContentPosition === "object"
106− ? Boolean(props.maintainVisibleContentPosition.shouldRestorePosition)
107− : undefined
108− }
109123 >
110124 {props.ListHeaderComponent}
111125 {props.data.map((item) => (
@@ -625,7 +639,7 @@ describe("MessagesTimeline", () => { · 513 unmodified lines
625639 );
626640
627641 expect(markup).toContain('data-anchor-index="1"');
628− expect(markup).toContain('data-anchor-offset="16"');
642+ expect(markup).toContain('data-anchor-offset="24"');
629643 expect(markup).toContain('data-anchor-on-ready="true"');
630644 expect(markup).not.toContain("data-anchor-max-size=");
631645 expect(markup).toContain('data-content-inset-end="144"');
apps/web/src/components/chat/MessagesTimeline.tsx+381−109
@@ -1,4 +1,5 @@
11 import {
2+ type AssistantCitation,
23 type ChatFileAttachment,
34 type EnvironmentId,
45 type MessageId,
@@ -15,9 +16,15 @@ import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thr · 10 unmodified lines
1516 import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
1617 import { formatWorkingTimerNow } from "./ComposerActivityStatus";
1718 import {
19+ CHAT_TIMELINE_ANCHOR_OFFSET,
20+ keepTimelineEndVisibleAfterOverlayGrowth,
21+} from "./timelineScrollAnchoring";
22+import {
23+ resolveWorkEntryToolPresentation,
1824 resolveViewedImageAsset,
1925 workEntryViewedImagePath,
2026 } from "@t3tools/client-runtime/work-log/presentation";
27+import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
2128 const NOOP_USE_ARTIFACT_TEMPLATE = () => {};
2229 const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
2330
@@ -29,6 +36,7 @@ import { · 5 unmodified lines
2936 use,
3037 useCallback,
3138 useEffect,
39+ useLayoutEffect,
3240 useMemo,
3341 useRef,
3442 useState,
@@ -61,7 +69,9 @@ import { · 26 unmodified lines
6169 resolveDiffThemeName,
6270 resolveFileDiffPath,
6371 } from "../../lib/diffRendering";
72+import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting";
6473 import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";
74+import { T3Wordmark } from "../T3Wordmark";
6575 import {
6676 BotIcon,
6777 BrainIcon,
@@ -92,14 +102,24 @@ import { · 24 unmodified lines
92102 ZapIcon,
93103 } from "lucide-react";
94104 import { Button } from "../ui/button";
105+import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
95106 import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
96107 import { ProposedPlanCard } from "./ProposedPlanCard";
97108 import { ChangedFilesCard } from "./ChangedFilesTree";
98109 import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation";
99110 import { MessageCopyButton } from "./MessageCopyButton";
111+import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar";
112+import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
113+import {
114+ AssistantCitationSource,
115+ type AssistantCitationRequest,
116+ type AssistantCitationTarget,
117+} from "./AssistantCitationSource";
118+import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssistantCitationTarget";
100119 import {
101120 computeStableMessagesTimelineRows,
102121 deriveMessagesTimelineRows,
122+ liveWorkEntryLabel,
103123 normalizeCompactToolLabel,
104124 resolveTimelineToolPresentation,
105125 resolveAssistantMessageCopyState,
@@ -110,12 +130,17 @@ import { · 4 unmodified lines
110130 resolveTimelineMinimapIndexFromPointer,
111131 resolveTimelineMinimapInteractiveWidth,
112132 resolveTimelineMinimapTopPercent,
133+ resolveWorkGroupScrollIndex,
134+ shouldFollowWorkGroupAppend,
113135 shouldPreserveAssistantLineBreaks,
136+ toolGroupAction,
137+ workEntryDisplayLabel,
114138 workEntryIsVisibleInGroup,
115139 type StableMessagesTimelineRowsState,
116140 type MessagesTimelineRow,
117141 TIMELINE_MINIMAP_MIN_ITEMS,
118142 type TimelineLatestRun,
143+ type WorkGroupScrollAnchor,
119144 } from "./MessagesTimeline.logic";
120145 import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
121146 import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
@@ -163,6 +188,8 @@ import { · 41 unmodified lines
163188 // ---------------------------------------------------------------------------
164189
165190 interface TimelineRowSharedState {
191+ citationRequest: AssistantCitationTarget | null;
192+ listRef: React.RefObject<LegendListRef | null>;
166193 timestampFormat: TimestampFormat;
167194 routeThreadKey: string;
168195 threadRef: ScopedThreadRef | null;
@@ -193,6 +220,8 @@ interface TimelineRowSharedState { · 24 unmodified lines
193220 openingVideoAttachmentId: string | null;
194221 onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;
195222 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
223+ onToggleWorkEntry: (anchorKey: string) => void;
224+ workGroupViewState: WorkGroupViewState;
196225 }
197226
198227 interface TimelineRowActivityState {
@@ -205,6 +234,16 @@ interface TimelineRowActivityState { · 6 unmodified lines
205234
206235 const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
207236 const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!);
237+
238+interface WorkGroupViewState {
239+ scrollPositions: Map<string, WorkGroupScrollAnchor>;
240+ expandedEntries: Set<string>;
241+}
242+
243+const WorkGroupViewCtx = createContext<{
244+ state: WorkGroupViewState;
245+ onToggleEntry: () => void;
246+} | null>(null);
208247 const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;
209248 const TIMELINE_LIST_FADE_HEADER = (
210249 <div className="h-[var(--workspace-titlebar-scroll-fade-height)]" />
@@ -234,6 +273,12 @@ export interface MessagesTimelineHistoryControls { · 23 unmodified lines
234273 }
235274
236275 interface MessagesTimelineProps {
276+ citationRequest?: AssistantCitationRequest | null;
277+ citationHistoryLoading?: boolean;
278+ onCiteAssistantText?: (
279+ citation: AssistantCitation,
280+ sourceAnchor: AssistantCitationSourceAnchor,
281+ ) => boolean;
237282 isWorking: boolean;
238283 activeTurnInProgress: boolean;
239284 activeTurnStartedAt?: string | null;
@@ -288,6 +333,8 @@ interface MessagesTimelineProps { · 48 unmodified lines
288333 hideEmptyPlaceholder?: boolean;
289334 topFadeEnabled?: boolean;
290335 historyControls?: MessagesTimelineHistoryControls;
336+ /** Non-null when older turns exist beyond the loaded window. */
337+ loadEarlier?: CitationHistoryPage | null;
291338 }
292339
293340 // ---------------------------------------------------------------------------
@@ -295,6 +342,9 @@ interface MessagesTimelineProps { · 1 unmodified line
295342 // ---------------------------------------------------------------------------
296343
297344 export const MessagesTimeline = memo(function MessagesTimeline({
345+ citationRequest = null,
346+ citationHistoryLoading = false,
347+ onCiteAssistantText,
298348 isWorking,
299349 activeTurnInProgress,
300350 activeTurnStartedAt = null,
@@ -334,17 +384,28 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 33 unmodified lines
334384 hideEmptyPlaceholder = false,
335385 topFadeEnabled = false,
336386 historyControls,
387+ loadEarlier = null,
337388 }: MessagesTimelineProps) {
338389 const [expandedRunIds, setExpandedRunIds] = useState<ReadonlySet<RunId>>(new Set());
339390 const [expandedAttemptIds, setExpandedAttemptIds] = useState<ReadonlySet<RunAttemptId>>(
340391 new Set(),
341392 );
393+ const citationThreadRef = useMemo(() => parseScopedThreadKey(routeThreadKey), [routeThreadKey]);
394+ const expandCitedRun = useCallback((runId: RunId) => {
395+ setExpandedRunIds((current) => (current.has(runId) ? current : new Set([...current, runId])));
396+ }, []);
342397 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
398+ // Scroll/disclosure state outlives virtualized rows, but never the current thread.
399+ const workGroupViewState = useMemo<WorkGroupViewState>(
400+ () => ({ scrollPositions: new Map(), expandedEntries: new Set() }),
401+ [routeThreadKey],
402+ );
343403 const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());
344404 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
345405 const disclosureAnchorKeyRef = useRef<string | null>(null);
346406 const disclosureSettleFrameRef = useRef<number | null>(null);
347407 const disclosureSettleSecondFrameRef = useRef<number | null>(null);
408+ const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustment);
348409
349410 useEffect(() => {
350411 return () => {
@@ -499,6 +560,31 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 148 unmodified lines
499560 const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
500561 null,
501562 );
563+ const {
564+ target: readyCitationRequest,
565+ positioning: citationPositioning,
566+ onListLoad: onCitationListLoad,
567+ alwaysRender: citationAlwaysRender,
568+ } = useAssistantCitationTarget({
569+ request: citationRequest,
570+ entries: timelineEntries,
571+ rows,
572+ listRef,
573+ viewport: timelineViewportElement,
574+ historyLoading: citationHistoryLoading,
575+ loadEarlier,
576+ onExpandTurn: expandCitedRun,
577+ onManualNavigation,
578+ });
579+ useLayoutEffect(() => {
580+ keepTimelineEndVisibleAfterOverlayGrowth({
581+ timeline: listRef.current,
582+ previousOverlayHeight: previousContentInsetEndAdjustmentRef.current,
583+ overlayHeight: contentInsetEndAdjustment,
584+ followingEnd: liveFollowEnabled && anchorMessageId === null && !citationPositioning,
585+ });
586+ previousContentInsetEndAdjustmentRef.current = contentInsetEndAdjustment;
587+ }, [anchorMessageId, citationPositioning, contentInsetEndAdjustment, listRef, liveFollowEnabled]);
502588 const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);
503589 const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0);
504590 const handleAnchorReady = useCallback(
@@ -518,8 +604,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 13 unmodified lines
518604 [anchorMessageId, onAnchorSizeChanged],
519605 );
520606 const anchoredEndSpace = useMemo(() => {
521− const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) =>
522− row.kind === "message" && row.message.role === "user" ? row.message.id : null,
607+ const config = resolveChatListAnchoredEndSpace(
608+ rows,
609+ anchorMessageId,
610+ (row) => (row.kind === "message" && row.message.role === "user" ? row.message.id : null),
611+ { anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET },
523612 );
524613 return config
525614 ? { ...config, onReady: handleAnchorReady, onSizeChanged: handleAnchorSizeChanged }
@@ -537,7 +626,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 11 unmodified lines
537626 const handleScroll = useCallback(() => {
538627 const state = listRef.current?.getState?.();
539628 const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment);
540− if (isAtEnd !== undefined) {
629+ if (isAtEnd !== undefined && !citationPositioning) {
541630 onIsAtEndChange(isAtEnd);
542631 }
543632 if (!state || minimapItems.length === 0) {
@@ -567,7 +656,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 23 unmodified lines
567656 strip.dataset.inView = next;
568657 }
569658 }
570− }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange, contentInsetEndAdjustment]);
659+ }, [
660+ citationPositioning,
661+ contentInsetEndAdjustment,
662+ listRef,
663+ minimapItems,
664+ minimapStripMap,
665+ onIsAtEndChange,
666+ ]);
571667
572668 useEffect(() => {
573669 const frame = requestAnimationFrame(handleScroll);
@@ -601,6 +697,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 27 unmodified lines
601697
602698 const sharedState = useMemo<TimelineRowSharedState>(
603699 () => ({
700+ citationRequest: readyCitationRequest,
701+ listRef,
604702 timestampFormat,
605703 routeThreadKey,
606704 threadRef: parseScopedThreadKey(routeThreadKey),
@@ -623,8 +721,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 16 unmodified lines
623721 onToggleTurnFold,
624722 onToggleAttemptFold,
625723 onToggleWorkGroup,
724+ onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure,
725+ workGroupViewState,
626726 }),
627727 [
728+ readyCitationRequest,
729+ listRef,
628730 timestampFormat,
629731 routeThreadKey,
630732 markdownCwd,
@@ -646,6 +748,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 15 unmodified lines
646748 onToggleTurnFold,
647749 onToggleAttemptFold,
648750 onToggleWorkGroup,
751+ suspendEndScrollMaintenanceForDisclosure,
752+ workGroupViewState,
649753 ],
650754 );
651755 const activityState = useMemo<TimelineRowActivityState>(
@@ -722,7 +826,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 70 unmodified lines
722826 return (
723827 <TimelineRowCtx value={sharedState}>
724828 <TimelineRowActivityCtx value={activityState}>
725− <div ref={setTimelineViewportElement} className="relative h-full min-h-0">
829+ <div
830+ ref={setTimelineViewportElement}
831+ className="relative h-full min-h-0"
832+ data-assistant-citation-viewport="true"
833+ >
834+ {onCiteAssistantText && citationThreadRef ? (
835+ <AssistantSelectionToolbar
836+ viewport={timelineViewportElement}
837+ threadRef={citationThreadRef}
838+ onCite={onCiteAssistantText}
839+ />
840+ ) : null}
726841 <LegendList<MessagesTimelineRow>
727842 ref={listRef}
728843 data={rows}
@@ -730,7 +845,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 1 unmodified line
730845 getItemType={getItemType}
731846 renderItem={renderItem}
732847 estimatedItemSize={90}
733− initialScrollAtEnd
848+ initialScrollAtEnd={citationRequest === null}
849+ // Legend needs a data refresh to mount new pins without a scroll event.
850+ {...(readyCitationRequest ? { dataVersion: readyCitationRequest.key } : {})}
851+ {...(citationAlwaysRender ? { alwaysRender: citationAlwaysRender } : {})}
852+ onLoad={onCitationListLoad}
734853 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
735854 contentInsetEndAdjustment={contentInsetEndAdjustment}
736855 // LegendList owns ordinary end-follow (#5449): the app only turns
@@ -738,11 +857,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 1 unmodified line
738857 // sent turn anchors near the top (anchoredEndSpace), or for the
739858 // two-frame settle window of a fold toggle.
740859 maintainScrollAtEnd={
741− anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling
860+ citationPositioning ||
861+ anchoredEndSpace ||
862+ !liveFollowEnabled ||
863+ disclosureToggleSettling
742864 ? false
743865 : TIMELINE_MAINTAIN_SCROLL_AT_END
744866 }
745− maintainVisibleContentPosition={maintainVisibleContentPosition}
867+ maintainVisibleContentPosition={
868+ citationPositioning ? false : maintainVisibleContentPosition
869+ }
746870 onScroll={handleScroll}
747871 className={cn(
748872 "messages-timeline-scroll scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5",
@@ -1101,8 +1225,7 @@ type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["grouped · 352 unmodified lines
11011225 type TimelineRow = MessagesTimelineRow;
11021226
11031227 const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) {
1104− const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry;
1105− const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry;
1228+ const isExpandedToolGroup = row.kind === "work" && row.isExpandedToolGroup;
11061229 const isExpandedToolGroupHeader =
11071230 (row.kind === "work-toggle" && row.expanded) || (row.kind === "work-live" && row.expanded);
11081231
@@ -1111,10 +1234,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 2 unmodified lines
11111234 className={cn(
11121235 // Commentary (non-terminal assistant) rows carry no metadata row, so
11131236 // they sit closer to the work that follows them.
1114− isExpandedToolGroupEntry
1115− ? isLastExpandedToolGroupEntry
1116− ? "pb-1"
1117− : "pb-0"
1237+ isExpandedToolGroup
1238+ ? "pb-1"
11181239 : isExpandedToolGroupHeader
11191240 ? "pb-0"
11201241 : row.kind === "turn-fold"
@@ -1141,8 +1262,9 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 20 unmodified lines
11411262 >
11421263 {row.kind === "work" ? (
11431264 <WorkGroupSection
1265+ anchorKey={row.id}
11441266 groupedEntries={row.groupedEntries}
1145− isExpandedToolGroupEntry={row.isExpandedToolGroupEntry}
1267+ isExpandedToolGroup={row.isExpandedToolGroup}
11461268 />
11471269 ) : null}
11481270 {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null}
@@ -1486,16 +1608,24 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess · 337 unmodified lines
14861608 return (
14871609 <>
14881610 <div className="relative min-w-0 px-1 py-0.5">
1489− <ChatMarkdown
1490− text={messageText}
1491− cwd={ctx.markdownCwd}
1492− threadRef={ctx.threadRef ?? undefined}
1493− isStreaming={Boolean(row.message.streaming)}
1494− lineBreaks={shouldPreserveAssistantLineBreaks(messageText)}
1495− skills={ctx.skills}
1496− onUseArtifactTemplate={ctx.onUseArtifactTemplate}
1497− onImageExpand={ctx.onImageExpand}
1498− />
1611+ <AssistantCitationSource
1612+ messageId={row.message.id}
1613+ {...(ctx.threadRef ? { threadRef: ctx.threadRef } : {})}
1614+ itemKey={row.id}
1615+ request={ctx.citationRequest}
1616+ listRef={ctx.listRef}
1617+ >
1618+ <ChatMarkdown
1619+ text={messageText}
1620+ cwd={ctx.markdownCwd}
1621+ threadRef={ctx.threadRef ?? undefined}
1622+ isStreaming={Boolean(row.message.streaming)}
1623+ lineBreaks={shouldPreserveAssistantLineBreaks(messageText)}
1624+ skills={ctx.skills}
1625+ onUseArtifactTemplate={ctx.onUseArtifactTemplate}
1626+ onImageExpand={ctx.onImageExpand}
1627+ />
1628+ </AssistantCitationSource>
14991629 <AssistantChangedFilesSection
15001630 turnSummary={row.assistantTurnDiffSummary}
15011631 routeThreadKey={ctx.routeThreadKey}
@@ -1989,40 +2119,47 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ · 487 unmodified lines
19892119 // re-render only the affected row, not the entire list.
19902120 // ---------------------------------------------------------------------------
19912121
1992−/** Renders one or more already-derived work log rows. Overflow expansion is modeled as list data. */
2122+/** Renders standalone activity or one bounded, virtualized expanded tool group. */
19932123 const WorkGroupSection = memo(function WorkGroupSection({
2124+ anchorKey,
19942125 groupedEntries,
1995− isExpandedToolGroupEntry,
2126+ isExpandedToolGroup,
19962127 }: {
2128+ anchorKey: string;
19972129 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];
1998− isExpandedToolGroupEntry: boolean;
2130+ isExpandedToolGroup: boolean;
19992131 }) {
2000− const { workspaceRoot } = use(TimelineRowCtx);
2132+ const { workspaceRoot, routeThreadKey } = use(TimelineRowCtx);
20012133 const nonEmptyEntries = useMemo(
2002− () =>
2003− groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)),
2004− [groupedEntries, isExpandedToolGroupEntry],
2134+ () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)),
2135+ [groupedEntries, isExpandedToolGroup],
20052136 );
2006− const GroupContainer = isExpandedToolGroupEntry ? "div" : "section";
20072137
20082138 if (nonEmptyEntries.length === 0) return null;
2139+ if (isExpandedToolGroup) {
2140+ return (
2141+ <ExpandedWorkGroupEntries
2142+ key={`${routeThreadKey}:${anchorKey}`}
2143+ anchorKey={anchorKey}
2144+ entries={nonEmptyEntries}
2145+ workspaceRoot={workspaceRoot}
2146+ />
2147+ );
2148+ }
20092149
20102150 return (
2011− <GroupContainer
2012− className={cn("-mx-1 px-1", isExpandedToolGroupEntry ? "py-0" : "space-y-0.5 py-0.5")}
2013− aria-label={isExpandedToolGroupEntry ? undefined : "Activity"}
2014− >
2151+ <section className="-mx-1 space-y-0.5 px-1 py-0.5" aria-label="Activity">
20152152 <div className="space-y-px">
20162153 {nonEmptyEntries.map((workEntry) => (
20172154 <SimpleWorkEntryRow
20182155 key={workEntry.id}
20192156 workEntry={workEntry}
20202157 workspaceRoot={workspaceRoot}
2021− isExpandedToolGroupEntry={isExpandedToolGroupEntry}
2158+ isExpandedToolGroupEntry={false}
20222159 />
20232160 ))}
20242161 </div>
2025− </GroupContainer>
2162+ </section>
20262163 );
20272164 });
20282165
@@ -2048,6 +2185,145 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { · 19 unmodified lines
20482185 );
20492186 }
20502187
2188+function ExpandedWorkGroupEntries({
2189+ anchorKey,
2190+ entries,
2191+ workspaceRoot,
2192+}: {
2193+ anchorKey: string;
2194+ entries: TimelineWorkEntry[];
2195+ workspaceRoot: string | undefined;
2196+}) {
2197+ const { workGroupViewState: viewState, onToggleWorkEntry } = use(TimelineRowCtx);
2198+ const [initialScrollIndex] = useState(() =>
2199+ resolveWorkGroupScrollIndex(entries, viewState.scrollPositions.get(anchorKey)),
2200+ );
2201+ const [restoringPosition, setRestoringPosition] = useState(initialScrollIndex !== undefined);
2202+ const listRef = useRef<LegendListRef>(null);
2203+ const [fades, setFades] = useState({ top: false, bottom: false, viewportHeight: 0 });
2204+ const [appendState, setAppendState] = useState({ entries, follow: false });
2205+ // Capture the pre-change edge once per incoming array, before new layout
2206+ // metrics arrive. Edge/viewport changes never turn a status update into a follow.
2207+ if (appendState.entries !== entries) {
2208+ setAppendState({
2209+ entries,
2210+ follow:
2211+ fades.viewportHeight > 0 &&
2212+ shouldFollowWorkGroupAppend(appendState.entries, entries, fades.bottom ? Infinity : 0),
2213+ });
2214+ }
2215+
2216+ const groupView = useMemo(
2217+ () => ({ state: viewState, onToggleEntry: () => onToggleWorkEntry(anchorKey) }),
2218+ [anchorKey, onToggleWorkEntry, viewState],
2219+ );
2220+ const updateScrollFades = useCallback(() => {
2221+ const element = listRef.current?.getScrollableNode();
2222+ if (!element) return;
2223+ const distanceFromEnd = element.scrollHeight - element.clientHeight - element.scrollTop;
2224+ const viewportHeight = element.clientHeight;
2225+ const top = element.scrollTop > 1;
2226+ const bottom = distanceFromEnd > 1;
2227+ setFades((previous) =>
2228+ previous.top === top &&
2229+ previous.bottom === bottom &&
2230+ previous.viewportHeight === viewportHeight
2231+ ? previous
2232+ : { top, bottom, viewportHeight },
2233+ );
2234+ }, []);
2235+
2236+ const handleScroll = useCallback(() => {
2237+ const state = listRef.current?.getState();
2238+ const position = state && resolveWorkGroupScrollAnchor(state);
2239+ if (position) {
2240+ viewState.scrollPositions.set(anchorKey, {
2241+ entryId: position.rowId,
2242+ offset: position.offsetWithinRow,
2243+ });
2244+ }
2245+ updateScrollFades();
2246+ }, [anchorKey, updateScrollFades, viewState]);
2247+
2248+ const handleLoad = useCallback(() => {
2249+ const list = listRef.current;
2250+ const element = list?.getScrollableNode();
2251+ if (initialScrollIndex && list && element) {
2252+ // Bootstrap can report the restored target before the DOM has applied it.
2253+ // Reconcile once at load, before releasing the measured anchor row.
2254+ const offset = Math.max(
2255+ 0,
2256+ Math.min(list.getState().scroll, element.scrollHeight - element.clientHeight),
2257+ );
2258+ if (Math.abs(element.scrollTop - offset) > 1) {
2259+ void list.scrollToOffset({ offset, animated: false });
2260+ }
2261+ }
2262+ setRestoringPosition(false);
2263+ }, [initialScrollIndex]);
2264+
2265+ useLayoutEffect(() => {
2266+ const element = listRef.current?.getScrollableNode();
2267+ if (!element) return;
2268+ updateScrollFades();
2269+ const observer = new ResizeObserver(updateScrollFades);
2270+ observer.observe(element);
2271+ if (element.firstElementChild) observer.observe(element.firstElementChild);
2272+ return () => observer.disconnect();
2273+ }, [updateScrollFades]);
2274+
2275+ const renderEntry = useCallback(
2276+ ({ item }: { item: TimelineWorkEntry }) => (
2277+ <SimpleWorkEntryRow
2278+ key={item.id}
2279+ workEntry={item}
2280+ workspaceRoot={workspaceRoot}
2281+ isExpandedToolGroupEntry
2282+ />
2283+ ),
2284+ [workspaceRoot],
2285+ );
2286+
2287+ return (
2288+ <WorkGroupViewCtx value={groupView}>
2289+ <LegendList
2290+ ref={listRef}
2291+ data={entries}
2292+ extraData={workspaceRoot}
2293+ keyExtractor={workEntryKey}
2294+ renderItem={renderEntry}
2295+ estimatedItemSize={24}
2296+ drawDistance={240}
2297+ recycleItems
2298+ {...(initialScrollIndex ? { initialScrollIndex } : {})}
2299+ maintainScrollAtEnd={
2300+ appendState.follow ? { animated: false, on: { dataChange: true } } : false
2301+ }
2302+ maintainScrollAtEndThreshold={1 / Math.max(1, fades.viewportHeight)}
2303+ // Measure the restored row even when an intra-row offset puts its
2304+ // estimated bounds outside the list's small bootstrap render window.
2305+ {...(restoringPosition && initialScrollIndex
2306+ ? { alwaysRender: { indices: [initialScrollIndex.index] } }
2307+ : {})}
2308+ maintainVisibleContentPosition
2309+ onLoad={handleLoad}
2310+ onScroll={handleScroll}
2311+ onLayout={updateScrollFades}
2312+ tabIndex={0}
2313+ role="region"
2314+ aria-label="Tool calls"
2315+ data-tool-group-scroll
2316+ className={cn(
2317+ "scrollbar-gutter-stable max-h-[min(18rem,50dvh)] scroll-py-6 overflow-x-hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",
2318+ getVirtualizedScrollFadeClassName(fades),
2319+ )}
2320+ />
2321+ </WorkGroupViewCtx>
2322+ );
2323+}
2324+
2325+const workEntryKey = (entry: TimelineWorkEntry) => entry.id;
2326+
20512327 function ActivityShimmerOverlay({ children }: { children: ReactNode }) {
20522328 return (
20532329 <span
@@ -2142,10 +2418,11 @@ function LiveActivityContent({ · 88 unmodified lines
21422418 announceFailure?: boolean;
21432419 highlighted?: boolean;
21442420 }) {
2145− const resolvedIconName = failed ? "circle-alert" : iconName;
2421+ const isSpecialToolIcon = iconName === "browser" || iconName === "t3-code";
2422+ const resolvedIconName = failed && !isSpecialToolIcon ? "circle-alert" : iconName;
21462423
21472424 return (
2148− <div
2425+ <span
21492426 className={cn(
21502427 "flex min-h-6 min-w-0 items-center gap-1.5 py-0.5",
21512428 resolvedIconName ? "px-0.5" : "px-1",
@@ -2161,14 +2438,15 @@ function LiveActivityContent({ · 9 unmodified lines
21612438 role={announceFailure ? "img" : undefined}
21622439 aria-label={announceFailure ? "Tool call failed" : undefined}
21632440 >
2164− <WorkEntryIconSvg
2441+ <WorkEntryIcon
21652442 name={resolvedIconName}
21662443 className={cn("block size-4 shrink-0 stroke-[1.8]", !highlighted && "opacity-70")}
21672444 />
21682445 </span>
21692446 ) : null}
21702447 <span className="min-w-0 flex-1 truncate">{label}</span>
2171− </div>
2448+ {failed && isSpecialToolIcon ? <XIcon aria-hidden className="size-3 shrink-0" /> : null}
2449+ </span>
21722450 );
21732451 }
21742452
@@ -2211,6 +2489,8 @@ function toolGroupSummaryIconName( · 36 unmodified lines
22112489 return "square-pen";
22122490 case "command":
22132491 return "terminal";
2492+ case "browser":
2493+ return "browser";
22142494 case "search":
22152495 return "globe";
22162496 case "code-search":
@@ -2244,7 +2524,7 @@ function WorkGroupToggleTimelineRow({ · 27 unmodified lines
22442524 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
22452525 >
22462526 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
2247− <WorkEntryIconSvg
2527+ <WorkEntryIcon
22482528 name={toolGroupSummaryIconName(row.summaryKind)}
22492529 className="size-4 shrink-0 stroke-[1.8] opacity-70"
22502530 />
@@ -2709,6 +2989,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte · 458 unmodified lines
27092989 collapsed: false,
27102990 diffStyle: "unified",
27112991 theme: resolveDiffThemeName(ctx.resolvedTheme),
2992+ preferredHighlighter: PREFERRED_HIGHLIGHTER,
27122993 }}
27132994 />
27142995 ))}
@@ -2780,6 +3061,7 @@ function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { · 65 unmodified lines
27803061 type WorkEntryIconName =
27813062 | "bot"
27823063 | "brain"
3064+ | "browser"
27833065 | "check"
27843066 | "circle-alert"
27853067 | "eye"
@@ -2789,16 +3071,21 @@ type WorkEntryIconName = · 3 unmodified lines
27893071 | "search"
27903072 | "square-pen"
27913073 | "terminal"
3074+ | "t3-code"
27923075 | "wrench"
27933076 | "x"
27943077 | "zap";
27953078
2796−function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; className: string }) {
3079+function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) {
27973080 switch (name) {
27983081 case "bot":
27993082 return <BotIcon className={className} aria-hidden />;
28003083 case "brain":
28013084 return <BrainIcon className={className} aria-hidden />;
3085+ case "browser":
3086+ return <GlobeIcon className={className} aria-hidden />;
3087+ case "t3-code":
3088+ return <T3Wordmark className={className} aria-hidden />;
28023089 case "check":
28033090 return <CheckIcon className={className} aria-hidden />;
28043091 case "circle-alert":
@@ -2869,33 +3156,6 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { · 64 unmodified lines
28693156 };
28703157 }
28713158
2872−function workEntryPreview(
2873− workEntry: Pick<TimelineWorkEntry, "detail" | "command" | "changedFiles">,
2874− workspaceRoot: string | undefined,
2875−) {
2876− // Collapsed rows show the tool's INPUT (command, touched files, search
2877− // pattern); outputs like stdout and diffs stay behind the expander.
2878− if (workEntry.command) return workEntry.command;
2879− const [firstPath] = workEntry.changedFiles ?? [];
2880− if (firstPath) {
2881− const displayPath = formatWorkspaceRelativePath(firstPath, workspaceRoot);
2882− return workEntry.changedFiles!.length === 1
2883− ? displayPath
2884− : `${displayPath} +${workEntry.changedFiles!.length - 1} more`;
2885− }
2886− if (workEntry.detail?.trim()) {
2887− const lines = workEntry.detail
2888− .trim()
2889− .split(/\r?\n/)
2890− .map((line) => line.trim())
2891− .filter((line) => line.length > 0);
2892− if (lines.length > 0) {
2893− return lines.slice(0, 3).join(" · ");
2894− }
2895− }
2896− return null;
2897−}
2898−
28993159 function workEntryRawCommand(
29003160 workEntry: Pick<TimelineWorkEntry, "command" | "rawCommand">,
29013161 ): string | null {
@@ -2951,42 +3211,14 @@ function buildToolCallExpandedBody( · 49 unmodified lines
29513211 const toolCallExpandedBodyClassName =
29523212 "max-h-64 cursor-text overflow-auto whitespace-pre-wrap break-words font-mono text-secondary-label text-[length:var(--font-size-code,0.6875rem)] leading-relaxed select-text";
29533213
2954−function liveWorkEntryLabel(
2955− workEntry: TimelineWorkEntry,
2956− workspaceRoot: string | undefined,
2957− active: boolean,
2958−): string {
2959− const command = workEntry.command?.trim();
2960− if (command) {
2961− const program = commandProgramName(command);
2962− const verb = active
2963− ? "Running"
2964− : workEntry.toolLifecycleStatus === "declined"
2965− ? "Declined"
2966− : "Ran";
2967− if (program) return `${verb} ${program}`;
2968− return `${verb} command`;
2969− }
2970−
2971− return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry);
2972−}
2973−
29743214 function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
29753215 if (workEntry.itemType === "user_input_request") {
29763216 return "message-circle";
29773217 }
2978− if (workEntry.requestKind === "command") return "terminal";
2979− if (workEntry.requestKind === "file-read") return "eye";
2980− if (workEntry.requestKind === "file-change") return "square-pen";
2981−
2982− if (workEntry.itemType === "command_execution" || workEntry.command) {
2983− return "terminal";
2984− }
2985− if (workEntry.itemType === "file_change" || (workEntry.changedFiles?.length ?? 0) > 0) {
2986− return "square-pen";
2987− }
2988− if (workEntry.itemType === "web_search") return "globe";
2989− if (workEntry.itemType === "file_search") return "eye";
3218+ const toolPresentation = resolveWorkEntryToolPresentation(workEntry);
3219+ if (toolPresentation) return toolPresentation.icon;
3220+ const action = toolGroupAction(workEntry);
3221+ if (action !== "other") return toolGroupSummaryIconName(action);
29903222
29913223 switch (workEntry.itemType) {
29923224 case "dynamic_tool":
@@ -3013,6 +3245,33 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { · 20 unmodified lines
30133245 return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle));
30143246 }
30153247
3248+function workEntryPreview(
3249+ workEntry: Pick<TimelineWorkEntry, "detail" | "command" | "changedFiles">,
3250+ workspaceRoot: string | undefined,
3251+) {
3252+ // Collapsed rows show the tool's INPUT (command, touched files, search
3253+ // pattern); outputs like stdout and diffs stay behind the expander.
3254+ if (workEntry.command) return workEntry.command;
3255+ const [firstPath] = workEntry.changedFiles ?? [];
3256+ if (firstPath) {
3257+ const displayPath = formatWorkspaceRelativePath(firstPath, workspaceRoot);
3258+ return workEntry.changedFiles!.length === 1
3259+ ? displayPath
3260+ : `${displayPath} +${workEntry.changedFiles!.length - 1} more`;
3261+ }
3262+ if (workEntry.detail?.trim()) {
3263+ const lines = workEntry.detail
3264+ .trim()
3265+ .split(/\r?\n/)
3266+ .map((line) => line.trim())
3267+ .filter((line) => line.length > 0);
3268+ if (lines.length > 0) {
3269+ return lines.slice(0, 3).join(" · ");
3270+ }
3271+ }
3272+ return null;
3273+}
3274+
30163275 const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation();
30173276
30183277 const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
@@ -3023,7 +3282,19 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 4 unmodified lines
30233282 const { workEntry, workspaceRoot, isExpandedToolGroupEntry = false } = props;
30243283 const activity = use(TimelineRowActivityCtx);
30253284 const ctx = use(TimelineRowCtx);
3026− const [expanded, setExpanded] = useState(false);
3285+ const groupView = use(WorkGroupViewCtx);
3286+ const [expanded, setExpanded] = useState(
3287+ () => groupView?.state.expandedEntries.has(workEntry.id) ?? false,
3288+ );
3289+ const toggleExpanded = () => {
3290+ const next = !expanded;
3291+ if (groupView) {
3292+ groupView.onToggleEntry();
3293+ if (next) groupView.state.expandedEntries.add(workEntry.id);
3294+ else groupView.state.expandedEntries.delete(workEntry.id);
3295+ }
3296+ setExpanded(next);
3297+ };
30273298 const iconConfig = workToneIcon(workEntry.tone);
30283299 const showWarningIndicator = false;
30293300 const entryIconName = showWarningIndicator ? "circle-alert" : workEntryIconName(workEntry);
@@ -3083,12 +3354,13 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 53 unmodified lines
30833354 ? {
30843355 role: "button" as const,
30853356 tabIndex: 0 as const,
3086− "aria-label": displayText,
3087− onClick: () => setExpanded((v) => !v),
3357+ "aria-label": showFailedIndicator ? `${displayText}, tool call failed` : displayText,
3358+ "aria-expanded": expanded,
3359+ onClick: toggleExpanded,
30883360 onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
30893361 if (e.key === "Enter" || e.key === " ") {
30903362 e.preventDefault();
3091− setExpanded((v) => !v);
3363+ toggleExpanded();
30923364 }
30933365 },
30943366 }
@@ -3115,7 +3387,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 20 unmodified lines
31153387 {toolPresentation?.logo === "t3-code" ? (
31163388 <T3CodeToolLogo />
31173389 ) : (
3118− <WorkEntryIconSvg
3390+ <WorkEntryIcon
31193391 name={entryIconName}
31203392 className="block size-3.5 shrink-0 stroke-[1.8] opacity-80"
31213393 />
apps/web/src/components/chat/useAssistantCitationTarget.ts+3−3
@@ -1,5 +1,5 @@
11 import type { LegendListRef } from "@legendapp/list/react";
2−import type { TurnId } from "@t3tools/contracts";
2+import type { RunId } from "@t3tools/contracts";
33 import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
44 import type { TimelineEntry } from "../../session-logic";
55 import type { MessagesTimelineRow } from "./MessagesTimeline.logic";
@@ -31,7 +31,7 @@ export function useAssistantCitationTarget({ · 25 unmodified lines
3131 viewport: HTMLElement | null;
3232 historyLoading: boolean;
3333 loadEarlier: CitationHistoryPage | null;
34− onExpandTurn: (turnId: TurnId) => void;
34+ onExpandTurn: (runId: RunId) => void;
3535 onManualNavigation: () => void;
3636 }) {
3737 const [ready, setReady] = useState<AssistantCitationTarget | null>(null);
@@ -119,7 +119,7 @@ export function useAssistantCitationTarget({ · 81 unmodified lines
119119 (row) => row.kind === "message" && row.message.id === navigation.target.citation.messageId,
120120 );
121121 if (index < 0) {
122− if (source.message.turnId) onExpandTurn(source.message.turnId);
122+ if (source.message.runId) onExpandTurn(source.message.runId);
123123 return;
124124 }
125125 if (listLoaded && listRef.current) setReady(navigation.target);
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+1−0
@@ -13,6 +13,7 @@ import type { · 12 unmodified lines
1313 PullRequestReviewThread,
1414 PullRequestState,
1515 PullRequestUpdateMethod,
16+ SourceControlProviderKind,
1617 VcsRef,
1718 } from "@t3tools/contracts";
1819
apps/web/src/index.css+8−2
@@ -502,6 +502,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil · 501 unmodified lines
502502 margin-left: var(--live-activity-focus-width);
503503 }
504504
505+@property --assistant-citation-highlight-opacity {
506+ syntax: "<number>";
507+ inherits: true;
508+ initial-value: 0;
509+}
510+
505511 @layer base {
506512 ::highlight(t3-assistant-citation) {
507513 background-color: color-mix(
@@ -1169,14 +1175,14 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil · 661 unmodified lines
11691175 /* Keep controls and floating surfaces close to the neutral-black canvas.
11701176 Borders and hover states provide separation without milky gray fills. */
11711177 --background: var(--color-neutral-950);
1172− --surface-raised: var(--secondary);
1178+ --surface-raised: color-mix(in srgb, var(--background) 97%, var(--color-white));
11731179 --foreground: var(--color-neutral-100);
11741180 --card: color-mix(in srgb, var(--background) 97%, var(--color-white));
11751181 --card-foreground: var(--color-neutral-100);
11761182 --popover: color-mix(in srgb, var(--background) 97%, var(--color-white));
11771183 --popover-foreground: var(--color-neutral-100);
11781184 --primary: oklch(0.571 0.21 264);
1179− --secondary: --alpha(var(--color-white) / 4%);
1185+ --secondary: --alpha(var(--color-white) / 3%);
11801186 --secondary-foreground: var(--color-neutral-100);
11811187 --muted: --alpha(var(--color-white) / 3%);
11821188 --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white));
apps/web/src/lib/contextWindow.ts+0−22
@@ -22,28 +22,6 @@ export type ContextWindowSnapshot = NullableContextWindowUsage & { · 21 unmodified lines
2222 readonly updatedAt: string;
2323 };
2424
25−/** Map a provider driver kind to a user-facing display name. */
26−export function formatProviderDisplayName(provider: string | null | undefined): string {
27− if (!provider) return "This agent";
28− switch (provider) {
29− case "claudeAgent":
30− case "claude":
31− return "Claude";
32− case "codex":
33− return "Codex";
34− case "cursor":
35− return "Cursor";
36− case "opencode":
37− return "OpenCode";
38− default: {
39− // Title-case unknown driver kinds so they read reasonably.
40− const trimmed = provider.replace(/Agent$/i, "").trim();
41− if (trimmed.length === 0) return provider;
42− return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
43− }
44− }
45−}
46−
4725 /** Prefers the provider's live usage report (#8144); falls back to the last compaction item. */
4826 export function deriveLatestContextWindowSnapshot(
4927 entries: ReadonlyArray<{
apps/web/src/state/entities.ts+0−34
@@ -22,16 +22,12 @@ import { · 21 unmodified lines
2222 import { environmentThreadDetails, environmentThreadShells } from "./threads";
2323 import { waitForAtomValue } from "./waitForAtomValue";
2424
25−const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]);
2625 const EMPTY_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]);
2726 const EMPTY_VISIBLE_TURN_ITEMS: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = Object.freeze([]);
2827
2928 const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe(
3029 Atom.withLabel("web-project:empty"),
3130 );
32−const EMPTY_PROJECT_REFS_ATOM = Atom.make(EMPTY_PROJECT_REFS).pipe(
33− Atom.withLabel("web-project-refs:empty"),
34−);
3531 const EMPTY_THREAD_REFS_ATOM = Atom.make(EMPTY_THREAD_REFS).pipe(
3632 Atom.withLabel("web-thread-refs:empty"),
3733 );
@@ -60,32 +56,14 @@ export function useActiveEnvironmentId(): EnvironmentId | null { · 22 unmodified lines
6056 return useAtomValue(activeEnvironmentIdAtom);
6157 }
6258
63−export function readActiveEnvironmentId(): EnvironmentId | null {
64− return appAtomRegistry.get(activeEnvironmentIdAtom);
65−}
66−
6759 export function setActiveEnvironmentId(environmentId: EnvironmentId | null): void {
6860 appAtomRegistry.set(activeEnvironmentIdAtom, environmentId);
6961 }
7062
71−export function useProjectRefs(): ReadonlyArray<ScopedProjectRef> {
72− return useAtomValue(environmentProjects.projectRefsAtom);
73−}
74−
7563 export function useThreadRefs(): ReadonlyArray<ScopedThreadRef> {
7664 return useAtomValue(environmentThreadShells.threadRefsAtom);
7765 }
7866
79−export function useEnvironmentProjectRefs(
80− environmentId: EnvironmentId | null,
81−): ReadonlyArray<ScopedProjectRef> {
82− return useAtomValue(
83− environmentId === null
84− ? EMPTY_PROJECT_REFS_ATOM
85− : environmentProjects.environmentProjectRefsAtom(environmentId),
86− );
87−}
88−
8967 export function useEnvironmentThreadRefs(
9068 environmentId: EnvironmentId | null,
9169 ): ReadonlyArray<ScopedThreadRef> {
@@ -283,18 +261,6 @@ export function readEnvironmentThreadRefs( · 191 unmodified lines
283261 return appAtomRegistry.get(environmentThreadShells.environmentThreadRefsAtom(environmentId));
284262 }
285263
286−export function readThreadRefs(): ReadonlyArray<ScopedThreadRef> {
287− return appAtomRegistry.get(environmentThreadShells.threadRefsAtom);
288−}
289−
290264 export function readThreadShells(): ReadonlyArray<EnvironmentThreadShell> {
291265 return appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
292266 }
293−
294−export function findThreadRef(threadId: ThreadId): ScopedThreadRef | null {
295− return (
296− appAtomRegistry
297− .get(environmentThreadShells.threadRefsAtom)
298− .find((ref) => ref.threadId === threadId) ?? null
299− );
300−}
packages/client-runtime/package.json+4−0
@@ -230,6 +230,10 @@ · 229 unmodified lines
230230 "./work-log/command-label": {
231231 "types": "./src/work-log/commandLabel.ts",
232232 "default": "./src/work-log/commandLabel.ts"
233+ },
234+ "./work-log/scroll-anchor": {
235+ "types": "./src/work-log/scrollAnchor.ts",
236+ "default": "./src/work-log/scrollAnchor.ts"
233237 }
234238 },
235239 "scripts": {
packages/client-runtime/src/connection/registry.test.ts+1−0
@@ -50,6 +50,7 @@ import * as EnvironmentRegistry from "./registry.ts"; · 49 unmodified lines
5050 import * as RpcSession from "../rpc/session.ts";
5151 import * as EnvironmentSupervisor from "./supervisor.ts";
5252 import * as ConnectionWakeups from "./wakeups.ts";
53+import { runDesktopCommitWithReconnectObserver } from "../state/server.ts";
5354 import { v2ShellSnapshot } from "../state/orchestrationV2TestFixtures.ts";
5455
5556 const TARGET = new PrimaryConnectionTarget({
packages/client-runtime/src/operations/threadTitle.ts+2−1
@@ -1,3 +1,4 @@
1+import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
12 import { truncate } from "@t3tools/shared/String";
23
34 export interface ThreadTitleSeedInput {
@@ -7,7 +8,7 @@ export interface ThreadTitleSeedInput { · 3 unmodified lines
78 }
89
910 function normalizeTitleSeed(value: string): string {
10− return value.trim().replace(/\s+/gu, " ");
11+ return assistantCitationsToPlainText(value).trim().replace(/\s+/gu, " ");
1112 }
1213
1314 export function deriveThreadTitleSeed(input: ThreadTitleSeedInput): string {
packages/client-runtime/src/state/models.ts+0−10
@@ -4,7 +4,6 @@ import type { · 3 unmodified lines
44 MessageId,
55 OrchestrationProjectShell,
66 OrchestrationV2RunStatus,
7− OrchestrationV2ShellSnapshot,
87 OrchestrationV2ThreadProjection,
98 OrchestrationV2ThreadShell,
109 PlanId,
@@ -237,12 +236,3 @@ export function presentThreadShell( · 226 unmodified lines
237236 }
238237
239238 export const scopeThreadShell = presentThreadShell;
240−
241−export function selectEnvironmentThreadShell(
242− snapshot: OrchestrationV2ShellSnapshot | null,
243− environmentId: EnvironmentId,
244− threadId: ThreadId,
245−): EnvironmentThreadShell | null {
246− const thread = snapshot?.threads.find((candidate) => candidate.id === threadId) ?? null;
247− return thread ? presentThreadShell(environmentId, thread) : null;
248−}
packages/client-runtime/src/work-log/commandLabel.ts+24−1
@@ -1,5 +1,21 @@
11 type CommandWrapper = "env" | "sudo";
22
3+const SHELL_PROGRAMS = new Set(["sh", "bash", "zsh", "dash", "ash", "ksh", "fish"]);
4+const SHELL_OPTIONS_WITH_VALUE = new Set(["-o", "-O", "--rcfile", "--init-file"]);
5+
6+function shellCommandArgumentIndex(tokens: ReadonlyArray<string>, start: number): number | null {
7+ for (let index = start; index < tokens.length; index += 1) {
8+ const option = tokens[index]!;
9+ if (option === "--" || !option.startsWith("-")) return null;
10+ if (SHELL_OPTIONS_WITH_VALUE.has(option)) {
11+ index += 1;
12+ continue;
13+ }
14+ if (option === "--command" || /^-[a-zA-Z]*c[a-zA-Z]*$/.test(option)) return index + 1;
15+ }
16+ return null;
17+}
18+
319 const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = {
420 env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]),
521 sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]),
@@ -162,7 +178,14 @@ export function commandProgramName(command: string, depth = 0): string | null { · 156 unmodified lines
162178 }
163179 return null;
164180 }
165− return token.split(/[\\/]/).at(-1) || null;
181+ if (tokenProgram && SHELL_PROGRAMS.has(tokenProgram.replace(/\.exe$/i, "").toLowerCase())) {
182+ const scriptIndex = shellCommandArgumentIndex(tokens, index + 1);
183+ if (scriptIndex !== null) {
184+ const script = tokens[scriptIndex];
185+ return script ? commandProgramName(script, depth + 1) : null;
186+ }
187+ }
188+ return tokenProgram || null;
166189 }
167190
168191 return null;
packages/client-runtime/src/work-log/presentation.test.ts+44−92
@@ -11,78 +11,10 @@ import { · 10 unmodified lines
1111 toolGroupAction,
1212 toolGroupSummaryKind,
1313 type WorkLogPresentationEntry,
14+ type WorkLogToolLifecycleStatus,
1415 workEntryViewedImagePath,
1516 } from "./presentation.js";
1617
17−describe("summarizeToolGroup", () => {
18− it.each(["command", "file-read", "file-change"])(
19− "keeps %s approvals out of tool execution counts",
20− (requestKind) => {
21− const approvals = [
22− {
23− label: "Approval requested",
24− sourceActivityKind: "approval.requested",
25− tone: "info",
26− requestKind,
27− },
28− {
29− label: "Approval resolved",
30− sourceActivityKind: "approval.resolved",
31− tone: "info",
32− requestKind,
33− },
34− {
35− label: "Provider approval response failed",
36− sourceActivityKind: "provider.approval.respond.failed",
37− tone: "error",
38− },
39− ] satisfies WorkLogPresentationEntry[];
40−
41− expect(
42− summarizeToolGroup([
43− ...approvals,
44− { label: "Read", tone: "tool", itemType: "dynamic_tool_call" },
45− ]),
46− ).toBe("Received 3 updates and used 1 tool");
47− expect(summarizeToolGroup(approvals)).toBe("Received 3 updates");
48− expect(toolGroupSummaryKind(approvals)).toBe("update");
49− },
50− );
51−
52− it("deduplicates named sources ahead of ordinary actions", () => {
53− const source = { key: "browser-use:chrome", name: "Chrome", kind: "integration" as const };
54− expect(
55− summarizeToolGroup([
56− { label: "Open page", tone: "tool", toolSource: source },
57− { label: "Inspect page", tone: "tool", toolSource: source },
58− {
59− label: "Ran command",
60− tone: "tool",
61− itemType: "command_execution",
62− command: "git status",
63− },
64− ]),
65− ).toBe("Used Chrome integration and ran 1 command");
66− });
67−
68− it("omits the integration suffix for special browser and computer sources", () => {
69− expect(
70− summarizeToolGroup([
71− {
72− label: "Inspect page",
73− tone: "tool",
74− toolSource: { key: "browser-use", name: "Browser", kind: "browser" },
75− },
76− {
77− label: "Click",
78− tone: "tool",
79− toolSource: { key: "computer-use", name: "Computer Use", kind: "computer" },
80− },
81− ]),
82− ).toBe("Used Browser and Computer Use");
83− });
84−});
85−
8618 describe("resolveWorkEntryToolPresentation", () => {
8719 it.each([
8820 "mcp__t3-code__preview_click",
@@ -94,7 +26,7 @@ describe("resolveWorkEntryToolPresentation", () => { · 5 unmodified lines
9426 "preview_click",
9527 ])("recognizes browser tool names across providers: %s", (label) => {
9628 expect(resolveWorkEntryToolPresentation({ label })).toEqual({
97− displayName: "Clicking in the preview browser",
29+ displayName: "Click in the preview browser",
9830 icon: "browser",
9931 });
10032 });
@@ -106,7 +38,7 @@ describe("resolveWorkEntryToolPresentation", () => { · 5 unmodified lines
10638 toolTitle: "Inspect the current page",
10739 toolData: { server: "t3-code", tool: "preview_snapshot", result: { title: "Example" } },
10840 }),
109− ).toEqual({ displayName: "Taking a snapshot of the preview page", icon: "browser" });
41+ ).toEqual({ displayName: "Take a snapshot of the preview page", icon: "browser" });
11042 });
11143
11244 it.each([
@@ -115,12 +47,12 @@ describe("resolveWorkEntryToolPresentation", () => { · 2 unmodified lines
11547 ["failed", "Failed to click in the preview browser"],
11648 ["declined", "Declined to click in the preview browser"],
11749 ["stopped", "Stopped clicking in the preview browser"],
118− ["unknown", "Clicking in the preview browser"],
119− ])("describes the tool's own %s state", (toolLifecycleStatus, displayName) => {
50+ ["unknown", "Click in the preview browser"],
51+ ] as const)("describes the tool's own %s state", (toolLifecycleStatus, displayName) => {
12052 expect(
12153 resolveWorkEntryToolPresentation({
12254 label: "T3-code.preview_click",
123− toolLifecycleStatus,
55+ toolLifecycleStatus: toolLifecycleStatus as WorkLogToolLifecycleStatus,
12456 }),
12557 ).toEqual({ displayName, icon: "browser" });
12658 });
@@ -184,7 +116,7 @@ describe("resolveWorkEntryToolPresentation", () => { · 57 unmodified lines
184116 label: "mcp__t3_code__task_status",
185117 toolTitle: "Check the child task",
186118 }),
187− ).toEqual({ displayName: "Getting delegated task status", icon: "t3-code" });
119+ ).toEqual({ displayName: "Get delegated task status", icon: "t3-code" });
188120 });
189121
190122 it("does not brand unknown tools or another server's matching tool name", () => {
@@ -206,14 +138,20 @@ describe("resolveWorkEntryToolPresentation", () => { · 15 unmodified lines
206138 });
207139
208140 describe("browser group summaries", () => {
141+ const summarizeGroupLabel = (entries: ReadonlyArray<WorkLogPresentationEntry>) =>
142+ summarizeToolGroup(entries).summary;
209143 const browserEntry: WorkLogPresentationEntry = {
144+ id: "browser",
145+ createdAt: "2026-09-01T00:00:00Z",
210146 label: "MCP tool call",
211147 toolData: { server: "t3-code", tool: "preview_click" },
212− itemType: "mcp_tool_call",
148+ itemType: "dynamic_tool",
213149 toolLifecycleStatus: "completed",
214150 tone: "tool",
215151 };
216152 const commandEntry: WorkLogPresentationEntry = {
153+ id: "command",
154+ createdAt: "2026-09-01T00:00:00Z",
217155 label: "Ran command",
218156 command: "/bin/bash -lc 'vp test run'",
219157 itemType: "command_execution",
@@ -226,7 +164,7 @@ describe("browser group summaries", () => { · 6 unmodified lines
226164 ...browserEntry,
227165 toolCallId: `browser-${index}`,
228166 }));
229− expect(summarizeToolGroup(entries)).toBe(
167+ expect(summarizeGroupLabel(entries)).toBe(
230168 `Used browser ${count} ${count === 1 ? "time" : "times"}`,
231169 );
232170 expect(toolGroupSummaryKind(entries)).toBe("browser");
@@ -237,13 +175,13 @@ describe("browser group summaries", () => { · 4 unmodified lines
237175 ...Array.from({ length: 4 }, () => commandEntry),
238176 ...Array.from({ length: 15 }, () => browserEntry),
239177 ];
240− expect(summarizeToolGroup(entries)).toBe("Ran 4 commands and used browser 15 times");
178+ expect(summarizeGroupLabel(entries)).toBe("Ran 4 commands and used browser 15 times");
241179 expect(toolGroupSummaryKind(entries)).toBe("mixed");
242180 });
243181
244182 it("preserves first-seen action ordering alongside non-browser tools", () => {
245183 expect(
246− summarizeToolGroup([
184+ summarizeGroupLabel([
247185 browserEntry,
248186 commandEntry,
249187 {
@@ -251,12 +189,12 @@ describe("browser group summaries", () => { · 1 unmodified line
251189 toolData: { server: "t3-code", tool: "task_status" },
252190 },
253191 ]),
254− ).toBe("Used browser 1 time, ran 1 command, and used 1 tool");
192+ ).toBe("Used browser 1 time, ran 1 command, and performed 1 other action");
255193 });
256194
257195 it("recognizes Claude browser identity without treating script metadata as a shell command", () => {
258196 expect(
259− summarizeToolGroup([
197+ summarizeGroupLabel([
260198 {
261199 ...browserEntry,
262200 command: "node inspect-page.js",
@@ -268,21 +206,27 @@ describe("browser group summaries", () => { · 5 unmodified lines
268206
269207 it("keeps foreign tools and web searches out of the browser count", () => {
270208 expect(
271− summarizeToolGroup([
209+ summarizeGroupLabel([
272210 browserEntry,
273211 {
274212 ...browserEntry,
275213 label: "preview_click",
276214 toolData: { server: "another-server", tool: "preview_click" },
277215 },
278− { label: "Search", tone: "tool", itemType: "web_search" },
216+ {
217+ id: "search",
218+ createdAt: "2026-09-01T00:00:00Z",
219+ label: "Search",
220+ tone: "tool",
221+ itemType: "web_search",
222+ },
279223 ]),
280− ).toBe("Used browser 1 time, used 1 tool, and searched the web 1 time");
224+ ).toBe("Used browser 1 time, searched the web 1 time, and performed 1 other action");
281225 });
282226
283227 it("keeps browser screenshots in the browser count while preserving their image path", () => {
284228 const entry = { ...browserEntry, viewedImagePath: "/workspace/page.png" };
285− expect(summarizeToolGroup([entry])).toBe("Used browser 1 time");
229+ expect(summarizeGroupLabel([entry])).toBe("Used browser 1 time");
286230 expect(workEntryViewedImagePath(entry)).toBe("/workspace/page.png");
287231 });
288232 });
@@ -393,7 +337,7 @@ describe("workEntryViewedImagePath", () => { · 104 unmodified lines
393337 expect(
394338 workEntryViewedImagePath({
395339 ...entry,
396− itemType: "dynamic_tool_call",
340+ itemType: "dynamic_tool",
397341 detail: 'Read: {"file_path":"truncated..."}',
398342 viewedImagePath: " /workspace/reference image.webp ",
399343 }),
@@ -415,9 +359,11 @@ describe("toolGroupAction", () => { · 15 unmodified lines
415359 it("groups legacy Claude image reads with other reads", () => {
416360 expect(
417361 toolGroupAction({
362+ id: "legacy-read",
363+ createdAt: "2026-09-01T00:00:00Z",
418364 label: "Tool call",
419365 tone: "tool",
420− itemType: "dynamic_tool_call",
366+ itemType: "dynamic_tool",
421367 viewedImagePath: "/workspace/reference.png",
422368 }),
423369 ).toBe("read");
@@ -427,11 +373,17 @@ describe("toolGroupAction", () => { · 3 unmodified lines
427373 describe("resolveViewedImageAsset", () => {
428374 const threadId = ThreadId.make("thread-1");
429375
430− it("serves t3 attachment paths in place like any other host path", () => {
431− const path = "/Users/demo/.t3/dev/attachments/11111111-1111-4111-8111-111111111111.png";
432− expect(resolveViewedImageAsset(path, { threadId, workspaceRoot: "/workspace" })).toEqual({
433− resource: { _tag: "media-file", threadId, path },
434− alt: "11111111-1111-4111-8111-111111111111.png",
376+ it("loads t3 attachment paths as attachments", () => {
377+ const attachmentId =
378+ "11111111-1111-4111-8111-111111111111-22222222-2222-4222-8222-222222222222";
379+ expect(
380+ resolveViewedImageAsset(`/Users/demo/.t3/dev/attachments/${attachmentId}.png`, {
381+ threadId,
382+ workspaceRoot: "/workspace",
383+ }),
384+ ).toEqual({
385+ resource: { _tag: "attachment", attachmentId },
386+ alt: `${attachmentId}.png`,
435387 srcFragment: "",
436388 });
437389 });
packages/client-runtime/src/work-log/presentation.ts+261−8
@@ -1,4 +1,9 @@
1−import type { AssetResource, OrchestrationV2TurnItem, ThreadId } from "@t3tools/contracts";
1+import {
2+ isToolLifecycleItemType,
3+ type AssetResource,
4+ type OrchestrationV2TurnItem,
5+ type ThreadId,
6+} from "@t3tools/contracts";
27 import {
38 resolveT3McpToolSummaryAction,
49 type T3McpToolSummaryAction,
@@ -27,6 +32,7 @@ export interface WorkLogPresentationEntry { · 22 unmodified lines
2732 readonly command?: string;
2833 readonly rawCommand?: string;
2934 readonly detail?: string;
35+ readonly viewedImagePath?: string;
3036 readonly changedFiles?: ReadonlyArray<string>;
3137 readonly toolTitle?: string;
3238 readonly toolData?: unknown;
@@ -40,6 +46,7 @@ export type ToolGroupAction = · 7 unmodified lines
4046 | "read"
4147 | "edit"
4248 | "command"
49+ | "browser"
4350 | "code-search"
4451 | "search"
4552 | "other"
@@ -56,16 +63,251 @@ export function normalizeCompactToolLabel(value: string): string { · 10 unmodified lines
5663 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
5764 }
5865
59−export function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean {
66+const T3_MCP_TOOL_LABELS: Record<
67+ string,
68+ readonly [action: string, running: string, completed: string, detail: string]
69+> = {
70+ orchestrator_capabilities: ["Get", "Getting", "Got", "orchestration capabilities"],
71+ delegate_task: ["Delegate", "Delegating", "Delegated", "a child task"],
72+ task_status: ["Get", "Getting", "Got", "delegated task status"],
73+ task_cancel: ["Cancel", "Canceling", "Canceled", "delegated task"],
74+ schedule_task: ["Schedule", "Scheduling", "Scheduled", "a recurring task"],
75+ list_scheduled_tasks: ["List", "Listing", "Listed", "scheduled tasks"],
76+ update_scheduled_task: ["Update", "Updating", "Updated", "a scheduled task"],
77+ delete_scheduled_task: ["Delete", "Deleting", "Deleted", "a scheduled task"],
78+ create_threads: ["Create", "Creating", "Created", "T3 threads"],
79+ t3_thread_start: ["Start", "Starting", "Started", "a T3 thread"],
80+ t3_thread_list: ["List", "Listing", "Listed", "T3 threads"],
81+ t3_thread_read: ["Read", "Reading", "Read", "a T3 thread"],
82+ t3_thread_send: ["Send", "Sending", "Sent", "to a T3 thread"],
83+ t3_thread_wait: ["Wait", "Waiting", "Waited", "for a T3 thread"],
84+ t3_thread_interrupt: ["Interrupt", "Interrupting", "Interrupted", "a T3 thread"],
85+ t3_worktree_handoff: ["Hand off", "Handing off", "Handed off", "thread to a git worktree"],
86+ t3_worktree_status: ["Get", "Getting", "Got", "thread worktree status"],
87+ preview_status: ["Get", "Getting", "Got", "preview browser status"],
88+ preview_open: ["Open", "Opening", "Opened", "a page in the preview browser"],
89+ preview_navigate: ["Navigate", "Navigating", "Navigated", "the preview browser"],
90+ preview_snapshot: [
91+ "Take a snapshot of",
92+ "Taking a snapshot of",
93+ "Took a snapshot of",
94+ "the preview page",
95+ ],
96+ preview_click: ["Click", "Clicking", "Clicked", "in the preview browser"],
97+ preview_press: ["Press", "Pressing", "Pressed", "a key in the preview browser"],
98+ preview_type: ["Type", "Typing", "Typed", "in the preview browser"],
99+ preview_scroll: ["Scroll", "Scrolling", "Scrolled", "the preview browser"],
100+ preview_resize: ["Resize", "Resizing", "Resized", "the preview browser"],
101+ preview_evaluate: ["Evaluate", "Evaluating", "Evaluated", "script in the preview browser"],
102+ preview_wait_for: ["Wait", "Waiting", "Waited", "for the preview page"],
103+ preview_set_appearance: ["Set", "Setting", "Set", "preview browser appearance"],
104+ preview_recording_start: ["Start", "Starting", "Started", "recording the preview browser"],
105+ preview_recording_stop: ["Stop", "Stopping", "Stopped", "recording the preview browser"],
106+};
107+
108+function resolveT3McpToolPresentation(value: string | undefined, status: string | undefined) {
109+ if (!value) return null;
110+ const name = normalizeCompactToolLabel(value).replace(
111+ /^(?:mcp__(?:t3-code|t3_code|t3code)__|(?:t3-code|t3_code|t3code)(?:[.:/]|\s*·\s*))/i,
112+ "",
113+ );
114+ if (!Object.hasOwn(T3_MCP_TOOL_LABELS, name)) return null;
115+
116+ const [action, running, completed, detail] = T3_MCP_TOOL_LABELS[name]!;
117+ const verb =
118+ status === "inProgress"
119+ ? running
120+ : status === "completed"
121+ ? completed
122+ : status === "failed"
123+ ? `Failed to ${action.toLowerCase()}`
124+ : status === "declined"
125+ ? `Declined to ${action.toLowerCase()}`
126+ : status === "stopped"
127+ ? `Stopped ${running.toLowerCase()}`
128+ : action;
129+
130+ return {
131+ displayName: `${verb} ${detail}`,
132+ icon: name.startsWith("preview_") ? ("browser" as const) : ("t3-code" as const),
133+ };
134+}
135+
136+/** Resolves tool identity before choosing labels or icons in either client. */
137+export function resolveWorkEntryToolPresentation(
138+ entry: Pick<WorkLogPresentationEntry, "label" | "toolTitle" | "toolData" | "toolLifecycleStatus">,
139+ fallbackStatus?: "inProgress" | "completed",
140+) {
141+ const status = entry.toolLifecycleStatus ?? fallbackStatus;
142+ const data = entry.toolData;
143+ if (data !== null && typeof data === "object") {
144+ if (
145+ "server" in data &&
146+ typeof data.server === "string" &&
147+ "tool" in data &&
148+ typeof data.tool === "string"
149+ ) {
150+ return resolveT3McpToolPresentation(`${data.server}.${data.tool}`, status);
151+ }
152+ if ("toolName" in data && typeof data.toolName === "string") {
153+ return resolveT3McpToolPresentation(data.toolName, status);
154+ }
155+ }
156+
60157 return (
61− entry.tone === "tool" ||
62− entry.tone === "thinking" ||
63− entry.tone === "error" ||
64− entry.command !== undefined ||
65− entry.requestKind !== undefined
158+ resolveT3McpToolPresentation(entry.toolTitle, status) ??
159+ resolveT3McpToolPresentation(entry.label, status)
66160 );
67161 }
68162
163+function asRecord(value: unknown): Record<string, unknown> | null {
164+ return value !== null && typeof value === "object" && !Array.isArray(value)
165+ ? (value as Record<string, unknown>)
166+ : null;
167+}
168+
169+function nonEmptyString(value: unknown): string | null {
170+ return typeof value === "string" && value.trim().length > 0 ? value : null;
171+}
172+
173+function commandResultContent(value: unknown): string | null {
174+ const direct = nonEmptyString(value);
175+ if (direct) return direct;
176+
177+ const directContent = Array.isArray(value) ? value : null;
178+ const record = asRecord(value);
179+ const content = record?.content;
180+ const contentText = nonEmptyString(content);
181+ if (contentText) return contentText;
182+ const blocks = directContent ?? (Array.isArray(content) ? content : null);
183+ if (!blocks) return null;
184+
185+ const chunks = blocks.flatMap((entry) => {
186+ const text = nonEmptyString(entry) ?? nonEmptyString(asRecord(entry)?.text);
187+ return text ? [text] : [];
188+ });
189+ return chunks.length > 0 ? chunks.join("\n") : null;
190+}
191+
192+/** Returns provider command output before it is formatted for a work-log row. */
193+export function extractCommandOutputText(dataValue: unknown): string | null {
194+ const data = asRecord(dataValue);
195+ const item = asRecord(data?.item);
196+ const itemResult = asRecord(item?.result);
197+ const rawOutput = asRecord(data?.rawOutput);
198+ const outputStreams = [
199+ nonEmptyString(rawOutput?.stdout),
200+ nonEmptyString(rawOutput?.stderr),
201+ ].filter((value): value is string => value !== null);
202+ const acpContent = Array.isArray(data?.content)
203+ ? data.content
204+ .flatMap((entryValue) => {
205+ const entry = asRecord(entryValue);
206+ const content = asRecord(entry?.content);
207+ const text = entry?.type === "content" ? nonEmptyString(content?.text) : null;
208+ return text ? [text] : [];
209+ })
210+ .join("\n")
211+ : null;
212+
213+ const candidates = [
214+ item?.aggregatedOutput,
215+ itemResult?.content,
216+ data?.rawOutput,
217+ rawOutput?.content,
218+ outputStreams.length > 0 ? outputStreams.join("\n") : null,
219+ rawOutput?.output,
220+ acpContent,
221+ data?.result,
222+ ];
223+ for (const candidate of candidates) {
224+ const text = commandResultContent(candidate);
225+ if (text) return text;
226+ }
227+ return null;
228+}
229+
230+/**
231+ * Ingestion caps tool details at 180 chars and appends "...", so a long command
232+ * echo no longer equals the command it repeats. Treat a truncated prefix of the
233+ * command as the same echo.
234+ */
235+function textRepeatsCommand(text: string, commands: ReadonlyArray<string | null>): boolean {
236+ const truncated = text.endsWith("...")
237+ ? text.slice(0, -3)
238+ : text.endsWith("\u2026")
239+ ? text.slice(0, -1)
240+ : null;
241+ return commands.some((candidate) => {
242+ const command = candidate?.trim();
243+ if (!command) return false;
244+ if (command === text) return true;
245+ return (
246+ truncated !== null &&
247+ truncated.length > 0 &&
248+ command.length > truncated.length &&
249+ command.startsWith(truncated)
250+ );
251+ });
252+}
253+
254+/**
255+ * Decides whether a command row's `detail` is a synthetic echo of the command
256+ * rather than real output. OpenCode stores completed output in `detail` with no
257+ * other output channel, so plain equality is only treated as synthetic when the
258+ * payload shape shows the detail came from the command: Codex item metadata,
259+ * an ACP tool call (`data.toolCallId`, `kind: "execute"`), a Claude tool-name
260+ * prefix, or no structured command at all.
261+ */
262+export function commandDetailRepeatsCommand(input: {
263+ readonly detail: string;
264+ readonly command: string | null;
265+ readonly rawCommand: string | null;
266+ readonly toolName: unknown;
267+ readonly data: unknown;
268+}): boolean {
269+ const toolName = nonEmptyString(input.toolName)?.trim();
270+ const detail = input.detail.trim();
271+ const commands = [input.command, input.rawCommand];
272+ if (toolName) {
273+ const prefix = `${toolName}:`;
274+ if (detail.toLowerCase().startsWith(prefix.toLowerCase())) {
275+ const unprefixed = detail.slice(prefix.length).trim();
276+ if (textRepeatsCommand(unprefixed, commands)) return true;
277+ }
278+ }
279+
280+ if (!textRepeatsCommand(detail, commands)) return false;
281+
282+ const data = asRecord(input.data);
283+ const item = asRecord(data?.item);
284+ const itemInput = asRecord(item?.input);
285+ const itemResult = asRecord(item?.result);
286+ const hasStructuredCommand = [
287+ item?.command,
288+ itemInput?.command,
289+ itemResult?.command,
290+ data?.command,
291+ ].some((value) =>
292+ Array.isArray(value)
293+ ? value.some((part) => nonEmptyString(part) !== null)
294+ : nonEmptyString(value) !== null,
295+ );
296+ return (
297+ !hasStructuredCommand ||
298+ item !== null ||
299+ data?.toolCallId !== undefined ||
300+ nonEmptyString(data?.kind)?.toLowerCase() === "execute"
301+ );
302+}
303+
304+export function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean {
305+ if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") return true;
306+ if (entry.command !== undefined && entry.command.trim().length > 0) return true;
307+ if (entry.requestKind !== undefined) return true;
308+ return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType);
309+}
310+
69311 function toolDetailTextLooksLikeFailure(text: string): boolean {
70312 const normalized = text.toLowerCase();
71313 return (
@@ -110,7 +352,8 @@ export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): · 38 unmodified lines
110352 }
111353
112354 export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction {
113− if (entry.requestKind === "file-read") return "read";
355+ if (resolveWorkEntryToolPresentation(entry)?.icon === "browser") return "browser";
356+ if (entry.requestKind === "file-read" || entry.viewedImagePath !== undefined) return "read";
114357 if (
115358 entry.itemType === "dynamic_tool" &&
116359 /^read(?:\s+file)?$/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label))
@@ -125,6 +368,14 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio · 8 unmodified lines
125368 }
126369
127370 export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): string | null {
371+ const viewedImagePath = entry.viewedImagePath?.trim();
372+ if (
373+ viewedImagePath !== undefined &&
374+ !/[\r\n]/.test(viewedImagePath) &&
375+ isWorkspaceImagePreviewPath(viewedImagePath)
376+ ) {
377+ return viewedImagePath;
378+ }
128379 const detail = entry.detail?.trim();
129380 return toolGroupAction(entry) === "read" &&
130381 detail !== undefined &&
@@ -196,6 +447,8 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string { · 65 unmodified lines
196447 return `Changed ${count} ${count === 1 ? "file" : "files"}`;
197448 case "command":
198449 return `Ran ${count} ${count === 1 ? "command" : "commands"}`;
450+ case "browser":
451+ return `Used browser ${count} ${count === 1 ? "time" : "times"}`;
199452 case "search":
200453 return `Searched the web ${count} ${count === 1 ? "time" : "times"}`;
201454 case "code-search":
packages/effect-acp/src/protocol.ts+19−1
@@ -779,7 +779,25 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 778 unmodified lines
779779 }
780780 const encoded = `${exit.value}\n`;
781781 yield* logProtocol({ direction: "outgoing", stage: "raw", payload: encoded });
782− yield* Queue.offer(outgoing, encoded);
782+ // FIFO queue admission preserves wire ordering against later requests
783+ // (a session/prompt sent after session/cancel cannot overtake it).
784+ yield* Queue.offer(outgoing, { payload: encoded }).pipe(
785+ Effect.mapError(
786+ (cause) =>
787+ new AcpError.AcpTransportError({
788+ detail: "Failed to queue an outgoing ACP notification",
789+ cause,
790+ }),
791+ ),
792+ Effect.filterOrFail(
793+ (accepted) => accepted,
794+ () =>
795+ new AcpError.AcpTransportError({
796+ detail: "The ACP output queue was closed before accepting a notification",
797+ cause: "Output queue closed",
798+ }),
799+ ),
800+ );
783801 });
784802
785803 const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) {