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
85import { useUniwindTheme } from "../../lib/useUniwindTheme";85import { useUniwindTheme } from "../../lib/useUniwindTheme";
86import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";86import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
87import { useFontFamily } from "../../lib/useFontFamily";87import { useFontFamily } from "../../lib/useFontFamily";
88import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations";
88import { scopedThreadKey } from "../../lib/scopedEntities";89import { scopedThreadKey } from "../../lib/scopedEntities";
89import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";90import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
90import { tryOpenExternalUrl } from "../../lib/openExternalUrl";91import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
@@ -123,7 +124,11 @@ import { · 32 unmodified lines
123} from "../review/nativeReviewDiffAdapter";124} from "../review/nativeReviewDiffAdapter";
124import { buildReviewParsedDiff } from "../review/reviewModel";125import { buildReviewParsedDiff } from "../review/reviewModel";
125import { cn } from "../../lib/cn";126import { cn } from "../../lib/cn";
126import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../../lib/layout";127import {
128 deriveCenteredContentHorizontalPadding,
129 deriveThreadWorkLogSizing,
130 type LayoutVariant,
131} from "../../lib/layout";
127import { uuidv4 } from "../../lib/uuid";132import { uuidv4 } from "../../lib/uuid";
128import {133import {
129 resolveMarkdownFontSizes,134 resolveMarkdownFontSizes,
@@ -147,6 +152,7 @@ import type { ThreadContentPresentation } from "./threadContentPresentation"; · 17 unmodified lines
147import {152import {
148 resolveThreadFeedLiveFollow,153 resolveThreadFeedLiveFollow,
149 type ThreadFeedLiveFollowEvent,154 type ThreadFeedLiveFollowEvent,
155 type ThreadWorkGroupScrollPosition,
150} from "./thread-feed-live-follow";156} from "./thread-feed-live-follow";
151import {157import {
152 ThreadDisclosureChevron,158 ThreadDisclosureChevron,
@@ -171,7 +177,12 @@ import { appAtomRegistry } from "../../state/atom-registry"; · 18 unmodified lines
171import { environmentThreadShells, threadEnvironment } from "../../state/threads";177import { environmentThreadShells, threadEnvironment } from "../../state/threads";
172import { useAtomCommand } from "../../state/use-atom-command";178import { useAtomCommand } from "../../state/use-atom-command";
173import { useV2ItemSupport } from "../../state/v2-item-support";179import { useV2ItemSupport } from "../../state/v2-item-support";
174import { resolveWorkspaceRelativeFilePath } from "../files/filePath";180import {
181 basename,
182 fileRoutePathSegments,
183 isAbsolutePath,
184 resolveWorkspaceRelativeFilePath,
185} from "../files/filePath";
175import { waitForThreadShellReady } from "./threadForkNavigation";186import { waitForThreadShellReady } from "./threadForkNavigation";
176import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge";187import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge";
177 188
@@ -1818,15 +1829,12 @@ function renderFeedEntry( · 1640 unmodified lines
1818 activities={entry.activities}1829 activities={entry.activities}
1819 anchorKey={entry.id}1830 anchorKey={entry.id}
1820 copiedRowId={props.copiedRowId}1831 copiedRowId={props.copiedRowId}
1821 currentThreadId={props.threadId}
1822 environmentId={props.environmentId}
1823 expandedRows={props.expandedWorkRows}1832 expandedRows={props.expandedWorkRows}
1824 rowSizing={props.workRowSizing}1833 rowSizing={props.workRowSizing}
1825 scrollPositions={props.workGroupScrollPositions}1834 scrollPositions={props.workGroupScrollPositions}
1826 iconSubtleColor={iconSubtleColor}1835 iconSubtleColor={iconSubtleColor}
1827 onCopyRow={props.onCopyWorkRow}1836 onCopyRow={props.onCopyWorkRow}
1828 onToggleRow={(rowId) => props.onToggleWorkRow(rowId, entry.id)}1837 onToggleRow={props.onToggleWorkRow}
1829 workspaceRoot={props.workspaceRoot}
1830 renderImage={props.renderViewedImage}1838 renderImage={props.renderViewedImage}
1831 />1839 />
1832 );1840 );
@@ -2145,7 +2153,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 312 unmodified lines
2145 const previousLatestTurnRef = useRef(props.latestRun);2153 const previousLatestTurnRef = useRef(props.latestRun);
2146 const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);2154 const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
2147 const headerMaterialVisibleRef = useRef(false);2155 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]);
2149 const [viewportWidth, setViewportWidth] = useState(() =>2172 const [viewportWidth, setViewportWidth] = useState(() =>
2150 props.layoutVariant === "split" ? 0 : windowWidth,2173 props.layoutVariant === "split" ? 0 : windowWidth,
2151 );2174 );
@@ -2516,6 +2539,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 364 unmodified lines
2516 // ThreadId, and keying resets (or the list mount) on the bare id would2539 // ThreadId, and keying resets (or the list mount) on the bare id would
2517 // carry stale scroll/follow state across an environment switch.2540 // carry stale scroll/follow state across an environment switch.
2518 const feedThreadKey = scopedThreadKey(props.environmentId, props.threadId);2541 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 );
2519 // A thread switch opens pinned to the end; a send explicitly returns to the2548 // A thread switch opens pinned to the end; a send explicitly returns to the
2520 // live edge (ThreadDetailScreen scrolls the new message into place). Both2549 // live edge (ThreadDetailScreen scrolls the new message into place). Both
2521 // re-arm follow regardless of where the user had scrolled before.2550 // 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
2829 threadId: props.threadId,2858 threadId: props.threadId,
2830 copiedRowId,2859 copiedRowId,
2831 expandedWorkRows,2860 expandedWorkRows,
2861 workRowSizing,
2862 workGroupScrollPositions,
2832 terminalAssistantMessageIds,2863 terminalAssistantMessageIds,
2833 unsettledTurnId,2864 unsettledTurnId,
2834 onCopyWorkRow,2865 onCopyWorkRow,
apps/mobile/src/features/threads/threadPresentation.ts+0−11
@@ -4,11 +4,6 @@ import { · 3 unmodified lines
4 type EnvironmentThreadShell,4 type EnvironmentThreadShell,
5} from "@t3tools/client-runtime/state/shell";5} from "@t3tools/client-runtime/state/shell";
6 6
7export function threadSortValue(thread: EnvironmentThreadShell): number {
8 const candidate = Date.parse(thread.updatedAt ?? thread.createdAt);
9 return Number.isNaN(candidate) ? 0 : candidate;
10}
11
12export type ThreadStatusKind =7export type ThreadStatusKind =
13 | "pending-approval"8 | "pending-approval"
14 | "awaiting-input"9 | "awaiting-input"
@@ -27,12 +22,6 @@ export interface ThreadStatusPresentation extends StatusTone { · 12 unmodified lines
27 readonly pulse: boolean;22 readonly pulse: boolean;
28}23}
29 24
30/** Neutral icon colors for threads with no actionable status. */
31export const THREAD_STATUS_NEUTRAL_ICON = {
32 iconColor: "#8e8e93",
33 iconBackground: "rgba(142,142,147,0.22)",
34} as const;
35
36function isLatestRunSettled(thread: EnvironmentThreadShell): boolean {25function isLatestRunSettled(thread: EnvironmentThreadShell): boolean {
37 if (!thread.latestRun?.startedAt) return false;26 if (!thread.latestRun?.startedAt) return false;
38 if (!thread.latestRun.completedAt) return false;27 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
26 },26 },
27 {27 {
28 comment: 'Why "shared"?',28 comment: 'Why "shared"?',
29 title: 'Keep `cache[key]` & <parser> shared. Retry! Comment: Why "shared"?',29 title: "Keep `cache[key]` & <parser> shared. Retry! Commen...",
30 },30 },
31 ])("uses readable titles and intact links with comment $comment", ({ comment, title }) => {31 ])("uses readable titles and intact links with comment $comment", ({ comment, title }) => {
32 const quoteText = "Keep `cache[key]` & <parser> shared.\n Retry!";32 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
8 type RuntimeMode,8 type RuntimeMode,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";10import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations";
11import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
11 12
12import type { UploadedMobileAttachment } from "./attachmentUpload";13import type { UploadedMobileAttachment } from "./attachmentUpload";
13 14
@@ -82,7 +83,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe · 68 unmodified lines
82}83}
83 84
84export function deriveThreadTitleFromPrompt(value: string): string {85export function deriveThreadTitleFromPrompt(value: string): string {
85 const trimmed = value.trim();86 const trimmed = assistantCitationsToPlainText(value).trim();
86 if (trimmed.length === 0) {87 if (trimmed.length === 0) {
87 return "New thread";88 return "New thread";
88 }89 }
apps/mobile/src/lib/threadActivity.test.ts+1−3
@@ -679,9 +679,7 @@ describe("buildThreadFeed", () => { · 678 unmodified lines
679 );679 );
680 expect(expanded.map((entry) => entry.id)).toEqual([680 expect(expanded.map((entry) => entry.id)).toEqual([
681 "work-toggle:work-group:activity-neutral",681 "work-toggle:work-group:activity-neutral",
682 "activity-1",682 "work-details:work-group:activity-neutral",
683 "activity-2",
684 "activity-3",
685 ]);683 ]);
686 expect(expanded[0]).toMatchObject({684 expect(expanded[0]).toMatchObject({
687 type: "work-toggle",685 type: "work-toggle",
apps/mobile/src/lib/threadActivity.ts+42−23
@@ -6,6 +6,8 @@ import type { · 5 unmodified lines
6import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation";6import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation";
7import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";7import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
8import {8import {
9 workEntryDisplayIndicatesToolFailure,
10 resolveWorkEntryToolPresentation,
9 summarizeToolGroup,11 summarizeToolGroup,
10 toolGroupSummaryKind,12 toolGroupSummaryKind,
11 type ToolGroupSummaryKind,13 type ToolGroupSummaryKind,
@@ -868,7 +870,7 @@ function appendToolGroupRows( · 856 unmodified lines
868 : (latestInProgressActivity ?? activities.at(-1)!);870 : (latestInProgressActivity ?? activities.at(-1)!);
869 const groupSummary = summarizeToolGroup(activities.map((activity) => activity.workEntry));871 const groupSummary = summarizeToolGroup(activities.map((activity) => activity.workEntry));
870 const summary = live872 const summary = live
871 ? liveToolActivitySummary(latestActivity)873 ? liveToolActivitySummary(latestActivity, latestInProgressActivity !== undefined)
872 : activities.length === 1 && !activities[0]!.toolLike874 : activities.length === 1 && !activities[0]!.toolLike
873 ? activities[0]!.workEntry.label875 ? activities[0]!.workEntry.label
874 : groupSummary.summary;876 : groupSummary.summary;
@@ -884,7 +886,12 @@ function appendToolGroupRows( · 9 unmodified lines
884 summaryKind: toolGroupSummaryKind(886 summaryKind: toolGroupSummaryKind(
885 (live ? [latestActivity] : activities).map((activity) => activity.workEntry),887 (live ? [latestActivity] : activities).map((activity) => activity.workEntry),
886 ),888 ),
887 hasFailure: groupSummary.hasFailure,889 hasFailure: (() => {
890 const lastToolLike = activities.findLast((activity) => activity.toolLike);
891 return (
892 lastToolLike !== undefined && workEntryDisplayIndicatesToolFailure(lastToolLike.workEntry)
893 );
894 })(),
888 live,895 live,
889 shimmer:896 shimmer:
890 isWorking &&897 isWorking &&
@@ -892,32 +899,44 @@ function appendToolGroupRows( · 1 unmodified line
892 latestActivity.runId === activeRunId,899 latestActivity.runId === activeRunId,
893 });900 });
894 if (!expanded) return;901 if (!expanded) return;
895 for (const activity of activities) {902 result.push({
896 result.push({903 type: "activity-group",
897 type: "activity-group",904 id: `work-details:${groupId}`,
898 id: activity.id,905 createdAt: activities[0]!.createdAt,
899 createdAt: activity.createdAt,906 runId: activities[0]!.runId,
900 runId: activity.runId,907 activities: activities.map((activity) => ({
901 activities: [908 ...activity,
902 {909 groupedToolDetail: true,
903 ...activity,910 live:
904 groupedToolDetail: true,911 isWorking &&
905 live:912 activity.id === latestActivity.id &&
906 isWorking &&913 activity.lifecycleStatus === "inProgress" &&
907 activity.id === latestActivity.id &&914 activity.runId === activeRunId,
908 activity.lifecycleStatus === "inProgress" &&915 })),
909 activity.runId === activeRunId,916 });
910 },
911 ],
912 });
913 }
914}917}
915 918
916function liveToolActivitySummary(activity: ThreadFeedActivity): string {919function liveToolActivitySummary(activity: ThreadFeedActivity, active: boolean): string {
920 const presentation = resolveWorkEntryToolPresentation(
921 activity.workEntry,
922 active ? "inProgress" : "completed",
923 );
924 if (presentation) return presentation.displayName;
917 const command = activity.workEntry.command?.trim();925 const command = activity.workEntry.command?.trim();
918 if (command) {926 if (command) {
919 const program = commandProgramName(command);927 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"}`;
921 }940 }
922 return activity.detail ?? activity.summary;941 return activity.detail ?? activity.summary;
923}942}
apps/server/scripts/acp-mock-agent.ts+15−5
@@ -303,11 +303,18 @@ function modeState(): AcpSchema.SessionModeState { · 302 unmodified lines
303// "grok-build" product name, and it rejects unknown ids in session/set_model.303// "grok-build" product name, and it rejects unknown ids in session/set_model.
304const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [304const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [
305 {305 {
306 modelId: "grok-build",306 modelId: "grok-4.6",
307 name: "Grok Build",307 name: "Grok 4.6",
308 ...(initialGrokReasoningEffort308 _meta: {
309 ? { _meta: { reasoningEffort: initialGrokReasoningEffort } }309 totalContextTokens: 500_000,
310 : {}),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 },
311 },318 },
312 { modelId: "grok-mock-alt", name: "Grok Mock Alt" },319 { modelId: "grok-mock-alt", name: "Grok Mock Alt" },
313];320];
@@ -331,6 +338,9 @@ const program = Effect.gen(function* () { · 17 unmodified lines
331 request.clientCapabilities?._meta?.parameterizedModelPicker === true;338 request.clientCapabilities?._meta?.parameterizedModelPicker === true;
332 return {339 return {
333 protocolVersion: 1,340 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() },
334 agentCapabilities: {344 agentCapabilities: {
335 loadSession: true,345 loadSession: true,
336 ...(supportsSessionLifecycle346 ...(supportsSessionLifecycle
apps/server/src/httpCors.ts+2−7
@@ -1,3 +1,5 @@
1import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts";
2
1export const browserApiCorsAllowedMethods = ["GET", "POST", "OPTIONS"] as const;3export const browserApiCorsAllowedMethods = ["GET", "POST", "OPTIONS"] as const;
2export const browserApiCorsAllowedHeaders = [4export const browserApiCorsAllowedHeaders = [
3 "authorization",5 "authorization",
@@ -7,10 +9,3 @@ export const browserApiCorsAllowedHeaders = [ · 3 unmodified lines
7 "dpop",9 "dpop",
8 ORCHESTRATION_PROTOCOL_HEADER,10 ORCHESTRATION_PROTOCOL_HEADER,
9] as const;11] as const;
10
11export const browserApiCorsHeaders = {
12 "access-control-allow-origin": "*",
13 "access-control-allow-methods": browserApiCorsAllowedMethods.join(", "),
14 "access-control-allow-headers": browserApiCorsAllowedHeaders.join(", "),
15} as const;
16import { 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
1406 instanceId,1406 instanceId,
1407 model: "grok-mock-alt",1407 model: "grok-mock-alt",
1408 } satisfies ModelSelection;1408 } satisfies ModelSelection;
1409 const originalSelection = { instanceId, model: "grok-build" } satisfies ModelSelection;1409 const originalSelection = { instanceId, model: "grok-4.6" } satisfies ModelSelection;
1410 const runtime = yield* adapter.openSession({1410 const runtime = yield* adapter.openSession({
1411 threadId: firstThreadId,1411 threadId: firstThreadId,
1412 providerSessionId: ProviderSessionId.make("provider-session-acp-active-setup"),1412 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
884 environment: {},884 environment: {},
885 attachmentsDir,885 attachmentsDir,
886 fileSystem,886 fileSystem,
887 path: yield* Path.Path,
887 idAllocator,888 idAllocator,
888 queryRunner: {889 queryRunner: {
889 allocateSessionId: Effect.succeed("native-thread-claude-resume"),890 allocateSessionId: Effect.succeed("native-thread-claude-resume"),
@@ -1100,6 +1101,7 @@ describe("ClaudeAdapterV2 attachments", () => { · 210 unmodified lines
1100 environment: {},1101 environment: {},
1101 attachmentsDir,1102 attachmentsDir,
1102 fileSystem,1103 fileSystem,
1104 path: yield* Path.Path,
1103 idAllocator,1105 idAllocator,
1104 queryRunner: {1106 queryRunner: {
1105 allocateSessionId: Effect.succeed("native-thread-claude-attachments"),1107 allocateSessionId: Effect.succeed("native-thread-claude-attachments"),
@@ -1237,6 +1239,7 @@ describe("ClaudeAdapterV2 attachments", () => { · 131 unmodified lines
1237 environment: {},1239 environment: {},
1238 attachmentsDir,1240 attachmentsDir,
1239 fileSystem,1241 fileSystem,
1242 path: yield* Path.Path,
1240 idAllocator,1243 idAllocator,
1241 queryRunner: {1244 queryRunner: {
1242 allocateSessionId: Effect.succeed("native-thread-claude-unsupported-attachment"),1245 allocateSessionId: Effect.succeed("native-thread-claude-unsupported-attachment"),
@@ -1328,6 +1331,7 @@ describe("ClaudeAdapterV2 native fork", () => { · 85 unmodified lines
1328 environment: {},1331 environment: {},
1329 attachmentsDir,1332 attachmentsDir,
1330 fileSystem,1333 fileSystem,
1334 path: yield* Path.Path,
1331 idAllocator,1335 idAllocator,
1332 queryRunner: {1336 queryRunner: {
1333 allocateSessionId: Effect.succeed("source-native-session"),1337 allocateSessionId: Effect.succeed("source-native-session"),
@@ -1497,6 +1501,7 @@ describe("ClaudeAdapterV2 native session identity", () => { · 163 unmodified lines
1497 environment: {},1501 environment: {},
1498 attachmentsDir,1502 attachmentsDir,
1499 fileSystem,1503 fileSystem,
1504 path: yield* Path.Path,
1500 idAllocator,1505 idAllocator,
1501 queryRunner: {1506 queryRunner: {
1502 allocateSessionId: Effect.succeed("native-session-identity"),1507 allocateSessionId: Effect.succeed("native-session-identity"),
@@ -1719,6 +1724,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 216 unmodified lines
1719 environment: {},1724 environment: {},
1720 attachmentsDir,1725 attachmentsDir,
1721 fileSystem,1726 fileSystem,
1727 path: yield* Path.Path,
1722 idAllocator,1728 idAllocator,
1723 continuationRequests: {1729 continuationRequests: {
1724 offer: (request) =>1730 offer: (request) =>
@@ -2301,6 +2307,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 576 unmodified lines
2301 environment: {},2307 environment: {},
2302 attachmentsDir,2308 attachmentsDir,
2303 fileSystem,2309 fileSystem,
2310 path: yield* Path.Path,
2304 idAllocator,2311 idAllocator,
2305 continuationRequests: { offer: () => Effect.void },2312 continuationRequests: { offer: () => Effect.void },
2306 queryRunner: {2313 queryRunner: {
@@ -2412,6 +2419,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 105 unmodified lines
2412 environment: {},2419 environment: {},
2413 attachmentsDir,2420 attachmentsDir,
2414 fileSystem,2421 fileSystem,
2422 path: yield* Path.Path,
2415 idAllocator,2423 idAllocator,
2416 continuationRequests: {2424 continuationRequests: {
2417 offer: () => Effect.void,2425 offer: () => Effect.void,
@@ -4481,6 +4489,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 2063 unmodified lines
4481 environment: {},4489 environment: {},
4482 attachmentsDir,4490 attachmentsDir,
4483 fileSystem,4491 fileSystem,
4492 path: yield* Path.Path,
4484 idAllocator,4493 idAllocator,
4485 continuationRequests: {4494 continuationRequests: {
4486 offer: (request) =>4495 offer: (request) =>
@@ -4747,6 +4756,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 260 unmodified lines
4747 environment: {},4756 environment: {},
4748 attachmentsDir,4757 attachmentsDir,
4749 fileSystem,4758 fileSystem,
4759 path: yield* Path.Path,
4750 idAllocator,4760 idAllocator,
4751 continuationRequests: {4761 continuationRequests: {
4752 offer: (request) =>4762 offer: (request) =>
@@ -4979,6 +4989,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 226 unmodified lines
4979 environment: {},4989 environment: {},
4980 attachmentsDir,4990 attachmentsDir,
4981 fileSystem,4991 fileSystem,
4992 path: yield* Path.Path,
4982 idAllocator,4993 idAllocator,
4983 continuationRequests: {4994 continuationRequests: {
4984 offer: (request) =>4995 offer: (request) =>
@@ -5169,6 +5180,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 184 unmodified lines
5169 environment: {},5180 environment: {},
5170 attachmentsDir,5181 attachmentsDir,
5171 fileSystem,5182 fileSystem,
5183 path: yield* Path.Path,
5172 idAllocator,5184 idAllocator,
5173 continuationRequests: {5185 continuationRequests: {
5174 offer: () => Effect.void,5186 offer: () => Effect.void,
@@ -5302,6 +5314,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 127 unmodified lines
5302 environment: {},5314 environment: {},
5303 attachmentsDir,5315 attachmentsDir,
5304 fileSystem,5316 fileSystem,
5317 path: yield* Path.Path,
5305 idAllocator,5318 idAllocator,
5306 continuationRequests: {5319 continuationRequests: {
5307 offer: (request) =>5320 offer: (request) =>
@@ -5496,6 +5509,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 188 unmodified lines
5496 environment: {},5509 environment: {},
5497 attachmentsDir,5510 attachmentsDir,
5498 fileSystem,5511 fileSystem,
5512 path: yield* Path.Path,
5499 idAllocator,5513 idAllocator,
5500 continuationRequests: {5514 continuationRequests: {
5501 offer: () => Effect.void,5515 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
74import * as Stream from "effect/Stream";74import * as Stream from "effect/Stream";
75 75
76import { resolveAttachmentPath } from "../../attachmentStore.ts";76import { resolveAttachmentPath } from "../../attachmentStore.ts";
77import { planClaudeSkillDispatch } from "../../provider/Drivers/ClaudeSkillDispatch.ts";
78import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts";
77import { compileClaudeModelSelection } from "../../claudeModelOptions.ts";79import { compileClaudeModelSelection } from "../../claudeModelOptions.ts";
78import { ServerConfig } from "../../config.ts";80import { ServerConfig } from "../../config.ts";
79import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts";81import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts";
@@ -1064,12 +1066,28 @@ function isSupportedClaudeImageMimeType( · 984 unmodified lines
1064export function makeClaudeUserMessage(input: {1066export function makeClaudeUserMessage(input: {
1065 readonly text: string;1067 readonly text: string;
1066 readonly priority?: SDKUserMessage["priority"];1068 readonly priority?: SDKUserMessage["priority"];
1069 readonly skillNames?: ReadonlySet<string>;
1067}): SDKUserMessage {1070}): 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);
1068 return {1079 return {
1069 type: "user",1080 type: "user",
1070 message: {1081 message: {
1071 role: "user",1082 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,
1073 },1091 },
1074 parent_tool_use_id: null,1092 parent_tool_use_id: null,
1075 ...(input.priority === undefined ? {} : { priority: input.priority }),1093 ...(input.priority === undefined ? {} : { priority: input.priority }),
@@ -1082,10 +1100,12 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: · 6 unmodified lines
1082 readonly priority?: SDKUserMessage["priority"];1100 readonly priority?: SDKUserMessage["priority"];
1083 readonly attachmentsDir: string;1101 readonly attachmentsDir: string;
1084 readonly fileSystem: FileSystem.FileSystem;1102 readonly fileSystem: FileSystem.FileSystem;
1103 readonly skillNames?: ReadonlySet<string>;
1085}) {1104}) {
1086 if (input.attachments.length === 0) {1105 if (input.attachments.length === 0) {
1087 return makeClaudeUserMessage({1106 return makeClaudeUserMessage({
1088 text: input.text,1107 text: input.text,
1108 ...(input.skillNames === undefined ? {} : { skillNames: input.skillNames }),
1089 ...(input.priority === undefined ? {} : { priority: input.priority }),1109 ...(input.priority === undefined ? {} : { priority: input.priority }),
1090 });1110 });
1091 }1111 }
@@ -1099,8 +1119,16 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: · 7 unmodified lines
1099 attachmentsDir: input.attachmentsDir,1119 attachmentsDir: input.attachmentsDir,
1100 });1120 });
1101 1121
1122 const dispatch =
1123 input.skillNames === undefined
1124 ? undefined
1125 : planClaudeSkillDispatch(textWithAttachmentPaths, input.skillNames);
1102 const content: Array<ClaudeUserContentBlock> = [];1126 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) {
1104 content.push({ type: "text", text: textWithAttachmentPaths });1132 content.push({ type: "text", text: textWithAttachmentPaths });
1105 }1133 }
1106 1134
@@ -1146,6 +1174,12 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: · 39 unmodified lines
1146 });1174 });
1147 }1175 }
1148 1176
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
1149 return {1183 return {
1150 type: "user",1184 type: "user",
1151 message: {1185 message: {
@@ -2317,6 +2351,7 @@ export interface ClaudeAdapterV2Options { · 1165 unmodified lines
2317 readonly environment: NodeJS.ProcessEnv;2351 readonly environment: NodeJS.ProcessEnv;
2318 readonly attachmentsDir: string;2352 readonly attachmentsDir: string;
2319 readonly fileSystem: FileSystem.FileSystem;2353 readonly fileSystem: FileSystem.FileSystem;
2354 readonly path: Path.Path;
2320 readonly idAllocator: IdAllocatorV2Shape;2355 readonly idAllocator: IdAllocatorV2Shape;
2321 readonly queryRunner: ClaudeAgentSdkQueryRunnerShape;2356 readonly queryRunner: ClaudeAgentSdkQueryRunnerShape;
2322 /** Sink for wake-turn continuation requests; defaults to dropping them. */2357 /** Sink for wake-turn continuation requests; defaults to dropping them. */
@@ -2328,11 +2363,33 @@ export interface ClaudeAdapterV2Options { · 5 unmodified lines
2328export function makeClaudeAdapterV2(2363export function makeClaudeAdapterV2(
2329 adapterOptions: ClaudeAdapterV2Options,2364 adapterOptions: ClaudeAdapterV2Options,
2330): ProviderAdapterV2Shape {2365): ProviderAdapterV2Shape {
2331 const { attachmentsDir, fileSystem, idAllocator, queryRunner } = adapterOptions;2366 const { attachmentsDir, fileSystem, path, idAllocator, queryRunner } = adapterOptions;
2332 const continuationRequests = adapterOptions.continuationRequests ?? {2367 const continuationRequests = adapterOptions.continuationRequests ?? {
2333 offer: () => Effect.void,2368 offer: () => Effect.void,
2334 };2369 };
2335 2370
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
2336 return ProviderAdapterV2.of({2393 return ProviderAdapterV2.of({
2337 instanceId: adapterOptions.instanceId,2394 instanceId: adapterOptions.instanceId,
2338 driver: CLAUDE_PROVIDER,2395 driver: CLAUDE_PROVIDER,
@@ -5101,6 +5158,7 @@ export function makeClaudeAdapterV2( · 2762 unmodified lines
5101 attachments: turnInput.message.attachments,5158 attachments: turnInput.message.attachments,
5102 attachmentsDir,5159 attachmentsDir,
5103 fileSystem,5160 fileSystem,
5161 skillNames: yield* userInvocableSkillNames(turnInput.runtimePolicy.cwd),
5104 });5162 });
5105 const querySession = yield* openQuery(turnInput, nativeThreadId);5163 const querySession = yield* openQuery(turnInput, nativeThreadId);
5106 yield* Ref.set(activeTurn, context);5164 yield* Ref.set(activeTurn, context);
@@ -5274,6 +5332,7 @@ export function makeClaudeAdapterV2( · 167 unmodified lines
5274 priority: "now",5332 priority: "now",
5275 attachmentsDir,5333 attachmentsDir,
5276 fileSystem,5334 fileSystem,
5335 skillNames: yield* userInvocableSkillNames(currentTurn.input.runtimePolicy.cwd),
5277 });5336 });
5278 yield* Ref.update(steeredTurns, (current) => {5337 yield* Ref.update(steeredTurns, (current) => {
5279 const next = new Set(current);5338 const next = new Set(current);
@@ -5664,12 +5723,14 @@ export const ClaudeAdapterV2Driver: ProviderAdapterDriver< · 384 unmodified lines
5664 const continuationRequests = yield* ProviderContinuationRequests;5723 const continuationRequests = yield* ProviderContinuationRequests;
5665 const baseEnvironment = mergeProviderInstanceEnvironment(environment, hostEnvironment);5724 const baseEnvironment = mergeProviderInstanceEnvironment(environment, hostEnvironment);
5666 const claudeEnvironment = yield* makeClaudeEnvironment(config, baseEnvironment);5725 const claudeEnvironment = yield* makeClaudeEnvironment(config, baseEnvironment);
5726 const path = yield* Path.Path;
5667 return makeClaudeAdapterV2({5727 return makeClaudeAdapterV2({
5668 instanceId,5728 instanceId,
5669 settings: { ...config, enabled },5729 settings: { ...config, enabled },
5670 environment: claudeEnvironment,5730 environment: claudeEnvironment,
5671 attachmentsDir: serverConfig.attachmentsDir,5731 attachmentsDir: serverConfig.attachmentsDir,
5672 fileSystem,5732 fileSystem,
5733 path,
5673 idAllocator,5734 idAllocator,
5674 queryRunner,5735 queryRunner,
5675 continuationRequests,5736 continuationRequests,
@@ -5692,6 +5753,7 @@ export const ClaudeAdapterV2Driver: ProviderAdapterDriver< · 16 unmodified lines
5692 5753
5693const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function* () {5754const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function* () {
5694 const fileSystem = yield* FileSystem.FileSystem;5755 const fileSystem = yield* FileSystem.FileSystem;
5756 const path = yield* Path.Path;
5695 const hostEnvironment = yield* HostProcessEnvironment;5757 const hostEnvironment = yield* HostProcessEnvironment;
5696 const idAllocator = yield* IdAllocatorV2;5758 const idAllocator = yield* IdAllocatorV2;
5697 const queryRunner = yield* ClaudeAgentSdkQueryRunner;5759 const queryRunner = yield* ClaudeAgentSdkQueryRunner;
@@ -5704,6 +5766,7 @@ const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function* · 6 unmodified lines
5704 environment: hostEnvironment,5766 environment: hostEnvironment,
5705 attachmentsDir: serverConfig.attachmentsDir,5767 attachmentsDir: serverConfig.attachmentsDir,
5706 fileSystem,5768 fileSystem,
5769 path,
5707 idAllocator,5770 idAllocator,
5708 queryRunner,5771 queryRunner,
5709 continuationRequests,5772 continuationRequests,
@@ -5713,5 +5776,5 @@ const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function* · 3 unmodified lines
5713export const layer: Layer.Layer<5776export const layer: Layer.Layer<
5714 ProviderAdapterV2,5777 ProviderAdapterV2,
5715 never,5778 never,
5716 ClaudeAgentSdkQueryRunner | FileSystem.FileSystem | IdAllocatorV2 | ServerConfig5779 ClaudeAgentSdkQueryRunner | FileSystem.FileSystem | IdAllocatorV2 | Path.Path | ServerConfig
5717> = Layer.effect(ProviderAdapterV2, makeDefaultClaudeAdapterV2());5780> = 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
342 detail: "OpenCode replay does not load inventory.",342 detail: "OpenCode replay does not load inventory.",
343 }),343 }),
344 ),344 ),
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 ),
345 } satisfies OpenCodeRuntimeShape);359 } satisfies OpenCodeRuntimeShape);
346 }),360 }),
347 );361 );
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+3−0
@@ -242,6 +242,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 241 unmodified lines
242 );242 );
243 243
244 commandReadModel = committedCommand.nextCommandReadModel;244 commandReadModel = committedCommand.nextCommandReadModel;
245 for (const cleanup of committedCommand.attachmentCleanups) {
246 yield* cleanup;
247 }
245 yield* eventStore.publishCommitted(248 yield* eventStore.publishCommitted(
246 committedCommand.committedEvents.filter(249 committedCommand.committedEvents.filter(
247 (event) =>250 (event) =>
apps/server/src/provider/Drivers/AcpRegistryDriver.ts+2−2
@@ -13,7 +13,7 @@ import { · 12 unmodified lines
13 AcpRegistryAdapterV2Driver,13 AcpRegistryAdapterV2Driver,
14 type AcpRegistryAdapterV2DriverEnv,14 type AcpRegistryAdapterV2DriverEnv,
15} from "../../orchestration-v2/Adapters/AcpRegistryAdapterV2.ts";15} from "../../orchestration-v2/Adapters/AcpRegistryAdapterV2.ts";
16import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts";16import type { TextGeneration } from "../../textGeneration/TextGeneration.ts";
17import { ProviderDriverError } from "../Errors.ts";17import { ProviderDriverError } from "../Errors.ts";
18import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";18import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
19import {19import {
@@ -25,7 +25,7 @@ import { · 5 unmodified lines
25const DRIVER_KIND = ProviderDriverKind.make("acpRegistry");25const DRIVER_KIND = ProviderDriverKind.make("acpRegistry");
26const decodeSettings = Schema.decodeSync(AcpRegistrySettings);26const decodeSettings = Schema.decodeSync(AcpRegistrySettings);
27 27
28const makeUnsupportedTextGeneration = (): TextGenerationShape => {28const makeUnsupportedTextGeneration = (): TextGeneration["Service"] => {
29 const unsupported = (operation: string) =>29 const unsupported = (operation: string) =>
30 Effect.fail(30 Effect.fail(
31 new TextGenerationError({31 new TextGenerationError({
apps/server/src/provider/Drivers/CodexDriver.ts+35−1
@@ -40,7 +40,12 @@ import { · 39 unmodified lines
40} from "../../orchestration-v2/Adapters/CodexAdapterV2.ts";40} from "../../orchestration-v2/Adapters/CodexAdapterV2.ts";
41import { ServerSettingsService } from "../../serverSettings.ts";41import { ServerSettingsService } from "../../serverSettings.ts";
42import { ProviderDriverError } from "../Errors.ts";42import { ProviderDriverError } from "../Errors.ts";
43import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts";43import {
44 checkCodexProviderStatus,
45 makePendingCodexProvider,
46 probeCodexSkillsForCwd,
47} from "../Layers/CodexProvider.ts";
48import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts";
44import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";49import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
45import * as ModelManifest from "../ModelManifest.ts";50import * as ModelManifest from "../ModelManifest.ts";
46import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts";51import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts";
@@ -237,6 +242,34 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { · 190 unmodified lines
237 }),242 }),
238 ),243 ),
239 );244 );
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 );
240 273
241 return {274 return {
242 instanceId,275 instanceId,
@@ -246,6 +279,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { · 3 unmodified lines
246 accentColor,279 accentColor,
247 enabled,280 enabled,
248 snapshot,281 snapshot,
282 snapshotForCwd,
249 orchestrationAdapter,283 orchestrationAdapter,
250 textGeneration,284 textGeneration,
251 } satisfies ProviderInstance;285 } satisfies ProviderInstance;
apps/server/src/provider/Drivers/CursorDriver.ts+1−1
@@ -7,7 +7,7 @@ · 6 unmodified lines
7 *7 *
8 * @module provider/Drivers/CursorDriver8 * @module provider/Drivers/CursorDriver
9 */9 */
10import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";10import { CursorSettings, ProviderDriverKind } from "@t3tools/contracts";
11import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
12import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
13 13
apps/server/src/provider/Drivers/OpenCodeDriver.ts+50−0
@@ -33,6 +33,7 @@ import { ProviderDriverError } from "../Errors.ts"; · 32 unmodified lines
33import {33import {
34 checkOpenCodeProviderStatus,34 checkOpenCodeProviderStatus,
35 makePendingOpenCodeProvider,35 makePendingOpenCodeProvider,
36 openCodeSkillsToServerProviderSkills,
36} from "../Layers/OpenCodeProvider.ts";37} from "../Layers/OpenCodeProvider.ts";
37import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";38import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
38import { OpenCodeRuntime } from "../opencodeRuntime.ts";39import { OpenCodeRuntime } from "../opencodeRuntime.ts";
@@ -169,6 +170,34 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> · 130 unmodified lines
169 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),170 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
170 Effect.provideService(OpenCodeRuntime, openCodeRuntime),171 Effect.provideService(OpenCodeRuntime, openCodeRuntime),
171 );172 );
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 });
172 201
173 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);202 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
174 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>(203 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>(
@@ -213,6 +242,27 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> · 38 unmodified lines
213 accentColor,242 accentColor,
214 enabled,243 enabled,
215 snapshot,244 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 ),
216 orchestrationAdapter,266 orchestrationAdapter,
217 textGeneration,267 textGeneration,
218 } satisfies ProviderInstance;268 } satisfies ProviderInstance;
apps/server/src/provider/Layers/ProviderRegistry.test.ts+195−0
@@ -1,6 +1,7 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, it, assert } from "@effect/vitest";2import { describe, it, assert } from "@effect/vitest";
3import * as DateTime from "effect/DateTime";3import * as DateTime from "effect/DateTime";
4import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
5import * as Path from "effect/Path";6import * as Path from "effect/Path";
6import * as Exit from "effect/Exit";7import * as Exit from "effect/Exit";
@@ -43,6 +44,7 @@ import { · 36 unmodified lines
43 haveProvidersChanged,44 haveProvidersChanged,
44 mergeProviderSnapshot,45 mergeProviderSnapshot,
45 mergeProviderSnapshots,46 mergeProviderSnapshots,
47 upsertProviderWorkspaceSnapshot,
46 ProviderRegistryLive,48 ProviderRegistryLive,
47 selectProvidersByKind,49 selectProvidersByKind,
48} from "./ProviderRegistry.ts";50} from "./ProviderRegistry.ts";
@@ -581,6 +583,41 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 532 unmodified lines
581 assert.strictEqual(haveProvidersChanged(providers, [...providers]), false);583 assert.strictEqual(haveProvidersChanged(providers, [...providers]), false);
582 });584 });
583 585
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
584 it("preserves previously discovered provider models when a refresh returns none", () => {621 it("preserves previously discovered provider models when a refresh returns none", () => {
585 const previousProvider = {622 const previousProvider = {
586 instanceId: ProviderInstanceId.make("cursor"),623 instanceId: ProviderInstanceId.make("cursor"),
@@ -1004,6 +1041,164 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 417 unmodified lines
1004 }),1041 }),
1005 );1042 );
1006 1043
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
1007 it.effect("refreshes OpenCode catalogs and preserves other providers", () =>1202 it.effect("refreshes OpenCode catalogs and preserves other providers", () =>
1008 Effect.gen(function* () {1203 Effect.gen(function* () {
1009 const codexDriver = ProviderDriverKind.make("codex");1204 const codexDriver = ProviderDriverKind.make("codex");
apps/server/src/provider/Layers/ProviderRegistry.ts+24−0
@@ -188,6 +188,30 @@ export const mergeProviderSnapshot = ( · 187 unmodified lines
188 : {}),188 : {}),
189 };189 };
190 190
191export 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
209export const selectProvidersByKind = (
210 providers: ReadonlyArray<ServerProvider>,
211 providerKinds: ReadonlySet<ProviderDriverKind>,
212): ReadonlyArray<ServerProvider> =>
213 providers.filter((provider) => providerKinds.has(provider.driver));
214
191export const haveProvidersChanged = (215export const haveProvidersChanged = (
192 previousProviders: ReadonlyArray<ServerProvider>,216 previousProviders: ReadonlyArray<ServerProvider>,
193 nextProviders: ReadonlyArray<ServerProvider>,217 nextProviders: ReadonlyArray<ServerProvider>,
apps/server/src/provider/ProviderDriver.ts+4−2
@@ -25,12 +25,13 @@ import type { · 24 unmodified lines
25 ProviderDriverKind,25 ProviderDriverKind,
26 ProviderInstanceEnvironment,26 ProviderInstanceEnvironment,
27 ProviderInstanceId,27 ProviderInstanceId,
28 ServerProvider,
28} from "@t3tools/contracts";29} from "@t3tools/contracts";
29import type * as Effect from "effect/Effect";30import type * as Effect from "effect/Effect";
30import type * as Schema from "effect/Schema";31import type * as Schema from "effect/Schema";
31import type * as Scope from "effect/Scope";32import type * as Scope from "effect/Scope";
32 33
33import type { TextGenerationShape } from "../textGeneration/TextGeneration.ts";34import type { TextGeneration } from "../textGeneration/TextGeneration.ts";
34import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts";35import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts";
35import type { ProviderDriverError } from "./Errors.ts";36import type { ProviderDriverError } from "./Errors.ts";
36import type { ServerProviderShape } from "./Services/ServerProvider.ts";37import type { ServerProviderShape } from "./Services/ServerProvider.ts";
@@ -69,8 +70,9 @@ export interface ProviderInstance { · 32 unmodified lines
69 readonly accentColor?: string | undefined;70 readonly accentColor?: string | undefined;
70 readonly enabled: boolean;71 readonly enabled: boolean;
71 readonly snapshot: ServerProviderShape;72 readonly snapshot: ServerProviderShape;
73 readonly snapshotForCwd?: (cwd: string) => Effect.Effect<ServerProvider, ProviderDriverError>;
72 readonly orchestrationAdapter: ProviderAdapterV2Shape;74 readonly orchestrationAdapter: ProviderAdapterV2Shape;
73 readonly textGeneration: TextGenerationShape;75 readonly textGeneration: TextGeneration["Service"];
74}76}
75 77
76export interface ProviderContinuationIdentity {78export interface ProviderContinuationIdentity {
apps/server/src/provider/acp/AcpSessionRuntime.ts+18−3
@@ -1155,6 +1155,16 @@ export class AcpSessionRuntime extends Context.Service< · 1154 unmodified lines
1155 * @see https://agentclientprotocol.com/protocol/extensibility1155 * @see https://agentclientprotocol.com/protocol/extensibility
1156 */1156 */
1157 readonly handleExtNotification: EffectAcpClient.AcpClient["Service"]["handleExtNotification"];1157 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 >;
1158 /**1168 /**
1159 * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session.1169 * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session.
1160 * Concurrent calls share the same in-flight startup and a failed startup may be retried.1170 * 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
1187 sessionId?: string,1197 sessionId?: string,
1188 ) => Effect.Effect<EffectAcpSchema.CloseSessionResponse, EffectAcpErrors.AcpError>;1198 ) => Effect.Effect<EffectAcpSchema.CloseSessionResponse, EffectAcpErrors.AcpError>;
1189 /**1199 /**
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.
1191 * @see https://agentclientprotocol.com/protocol/schema#session/prompt1203 * @see https://agentclientprotocol.com/protocol/schema#session/prompt
1192 */1204 */
1193 readonly prompt: (1205 readonly prompt: (
1194 payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">,1206 payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">,
1207 options?: { readonly dispatched?: Deferred.Deferred<void> },
1195 ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>;1208 ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>;
1196 /**1209 /**
1197 * Sends a real ACP `session/cancel` notification for the active session.1210 * Sends a real ACP `session/cancel` notification for the active session.
@@ -2146,7 +2159,7 @@ export const make = ( · 948 unmodified lines
2146 );2159 );
2147 }),2160 }),
2148 ),2161 ),
2149 prompt: (payload) =>2162 prompt: (payload, promptOptions?) =>
2150 promptSerializationSemaphore.withPermit(2163 promptSerializationSemaphore.withPermit(
2151 Effect.gen(function* () {2164 Effect.gen(function* () {
2152 const started = yield* getStartedState;2165 const started = yield* getStartedState;
@@ -2203,12 +2216,14 @@ export const make = ( · 50 unmodified lines
2203 if (Option.isSome(activePromptFiber)) {2216 if (Option.isSome(activePromptFiber)) {
2204 yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore);2217 yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore);
2205 }2218 }
2219 // Await the notification write so a replacement session/prompt
2220 // cannot race ahead of session/cancel on the wire.
2206 yield* acp.agent2221 yield* acp.agent
2207 .cancel({2222 .cancel({
2208 sessionId: started.sessionId,2223 sessionId: started.sessionId,
2209 ...(options.cancelMeta === undefined ? {} : { _meta: options.cancelMeta }),2224 ...(options.cancelMeta === undefined ? {} : { _meta: options.cancelMeta }),
2210 })2225 })
2211 .pipe(Effect.ignore, Effect.forkIn(runtimeScope));2226 .pipe(Effect.ignore);
2212 }),2227 }),
2213 ),2228 ),
2214 ),2229 ),
apps/server/src/provider/acp/GrokAcpSupport.ts+5−3
@@ -185,15 +185,17 @@ export function applyGrokAcpModelSelection<E>(input: { · 184 unmodified lines
185 readonly requestedReasoningEffort?: string | undefined;185 readonly requestedReasoningEffort?: string | undefined;
186 readonly mapError: (cause: EffectAcpErrors.AcpError) => E;186 readonly mapError: (cause: EffectAcpErrors.AcpError) => E;
187}): Effect.Effect<string | undefined, E> {187}): Effect.Effect<string | undefined, E> {
188 const modelChanged =188 // The product slug is never sent over the wire; it keeps the session's current model.
189 input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId;189 const requestedModelId =
190 input.requestedModelId === GROK_DEFAULT_MODEL_SLUG ? undefined : input.requestedModelId;
191 const modelChanged = requestedModelId !== undefined && requestedModelId !== input.currentModelId;
190 const reasoningProvided = input.requestedReasoningEffort !== undefined;192 const reasoningProvided = input.requestedReasoningEffort !== undefined;
191 const reasoningEffort = reasoningProvided193 const reasoningEffort = reasoningProvided
192 ? normalizeGrokReasoningEffort(input.requestedReasoningEffort)194 ? normalizeGrokReasoningEffort(input.requestedReasoningEffort)
193 : undefined;195 : undefined;
194 const reasoningEffortChanged =196 const reasoningEffortChanged =
195 reasoningProvided && reasoningEffort !== input.currentReasoningEffort;197 reasoningProvided && reasoningEffort !== input.currentReasoningEffort;
196 const targetModelId = input.requestedModelId ?? input.currentModelId;198 const targetModelId = requestedModelId ?? input.currentModelId;
197 if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) {199 if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) {
198 return Effect.succeed(input.currentModelId);200 return Effect.succeed(input.currentModelId);
199 }201 }
apps/server/src/provider/acp/XAiAcpExtension.ts+11−8
@@ -1428,7 +1428,7 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion · 1427 unmodified lines
1428 1428
1429 return {1429 return {
1430 ...runtime,1430 ...runtime,
1431 prompt: (payload) =>1431 prompt: (payload, promptOptions?) =>
1432 Effect.gen(function* () {1432 Effect.gen(function* () {
1433 const started = yield* runtime.start();1433 const started = yield* runtime.start();
1434 const promptId = yield* allocatePromptFallbackId;1434 const promptId = yield* allocatePromptFallbackId;
@@ -1444,14 +1444,17 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion · 9 unmodified lines
1444 agentResult: null,1444 agentResult: null,
1445 });1445 });
1446 const promptRpcFiber = yield* runtime1446 const promptRpcFiber = yield* runtime
1447 .prompt({1447 .prompt(
1448 ...payload,1448 {
1449 _meta: {1449 ...payload,
1450 ...payload._meta,1450 _meta: {
1451 promptId: fallback.promptId,1451 ...payload._meta,
1452 requestId: fallback.promptId,1452 promptId: fallback.promptId,
1453 requestId: fallback.promptId,
1454 },
1453 },1455 },
1454 })1456 promptOptions,
1457 )
1455 .pipe(Effect.forkChild);1458 .pipe(Effect.forkChild);
1456 return yield* Effect.raceFirst(1459 return yield* Effect.raceFirst(
1457 Fiber.join(promptRpcFiber).pipe(1460 Fiber.join(promptRpcFiber).pipe(
apps/server/src/ws.ts+37−2
@@ -387,6 +387,10 @@ const ServerWsRpcGroup = WsRpcGroup; · 386 unmodified lines
387// past this gap a single O(active-threads) snapshot is cheaper and bounded.387// past this gap a single O(active-threads) snapshot is cheaper and bounded.
388// Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT).388// Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT).
389const SHELL_RESUME_MAX_GAP = 1_000;389const 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.
393const ORCHESTRATION_REPLAY_PAYLOAD_BUDGET_BYTES = 8 * 1024 * 1024;
390 394
391function toAuthAccessStreamEvent(395function toAuthAccessStreamEvent(
392 change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange,396 change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange,
@@ -523,6 +527,32 @@ const makeWsRpcLayer = ( · 130 unmodified lines
523 const threadManagement = yield* ThreadManagementService.ThreadManagementService;527 const threadManagement = yield* ThreadManagementService.ThreadManagementService;
524 const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore;528 const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore;
525 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;529 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 });
526 const providerSessionsV2 = yield* ProviderSessionManagerV2;556 const providerSessionsV2 = yield* ProviderSessionManagerV2;
527 const analytics = yield* AnalyticsService.AnalyticsService;557 const analytics = yield* AnalyticsService.AnalyticsService;
528 // Client-origin attribution (#7774): every thread/turn the connecting558 // Client-origin attribution (#7774): every thread/turn the connecting
@@ -1065,8 +1095,13 @@ const makeWsRpcLayer = ( · 536 unmodified lines
1065 }1095 }
1066 1096
1067 const highWater = yield* applicationEvents.latestApplicationSequence;1097 const highWater = yield* applicationEvents.latestApplicationSequence;
1068 const replayGap = highWater - input.afterSequence;1098 if (
1069 if (replayGap < 0 || replayGap > SHELL_RESUME_MAX_GAP) {1099 !(yield* canReplayPersistedRange(
1100 input.afterSequence,
1101 highWater,
1102 SHELL_RESUME_MAX_GAP,
1103 ))
1104 ) {
1070 const loaded = yield* loadSnapshot();1105 const loaded = yield* loadSnapshot();
1071 return composeShellStreamWithEnrichment({1106 return composeShellStreamWithEnrichment({
1072 initial: initialSnapshotItems(loaded),1107 initial: initialSnapshotItems(loaded),
apps/web/src/components/ChatView.logic.test.ts+1−1
@@ -10,7 +10,7 @@ import { · 9 unmodified lines
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";11import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
12import * as DateTime from "effect/DateTime";12import * as DateTime from "effect/DateTime";
13import { describe, expect, it } from "vite-plus/test";13import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
14 14
15import type { Thread } from "../types";15import type { Thread } from "../types";
16import { makeThreadFixture } from "../test-fixtures";16import { makeThreadFixture } from "../test-fixtures";
apps/web/src/components/ChatView.logic.ts+16−0
@@ -87,6 +87,22 @@ export function shouldDockDraftHeroForSubmission(input: { · 86 unmodified lines
87 );87 );
88}88}
89 89
90export 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
90export function resolveDraftHeroState(input: {106export function resolveDraftHeroState(input: {
91 isLocalDraftThread: boolean;107 isLocalDraftThread: boolean;
92 hasTimelineEntries: boolean;108 hasTimelineEntries: boolean;
apps/web/src/components/ChatView.tsx+67−16
@@ -1,4 +1,5 @@
1import {1import {
2 type AssistantCitation,
2 type ApprovalRequestId,3 type ApprovalRequestId,
3 type ChatFileAttachment,4 type ChatFileAttachment,
4 DEFAULT_MODEL,5 DEFAULT_MODEL,
@@ -268,6 +269,7 @@ import { · 263 unmodified lines
268} from "../state/server";269} from "../state/server";
269import { terminalEnvironment } from "../state/terminal";270import { terminalEnvironment } from "../state/terminal";
270import { threadEnvironment } from "../state/threads";271import { threadEnvironment } from "../state/threads";
272import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
271import { vcsEnvironment } from "../state/vcs";273import { vcsEnvironment } from "../state/vcs";
272import { useEnvironments, usePrimaryEnvironment } from "../state/environments";274import { useEnvironments, usePrimaryEnvironment } from "../state/environments";
273import {275import {
@@ -287,6 +289,7 @@ import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; · 13 unmodified lines
287import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";289import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
288import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";290import { ExpandedImageDialog } from "./chat/ExpandedImageDialog";
289import { PullRequestThreadDialog } from "./PullRequestThreadDialog";291import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
292import type { AssistantCitationRequest } from "./chat/AssistantCitationSource";
290import { MessagesTimeline, type MessagesTimelineHistoryControls } from "./chat/MessagesTimeline";293import { MessagesTimeline, type MessagesTimelineHistoryControls } from "./chat/MessagesTimeline";
291import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";294import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic";
292import { ChatHeader } from "./chat/ChatHeader";295import { ChatHeader } from "./chat/ChatHeader";
@@ -372,6 +375,7 @@ import { · 79 unmodified lines
372 shouldShowComposerContextStrip,375 shouldShowComposerContextStrip,
373 startNewThreadForProject,376 startNewThreadForProject,
374 codexArtifactTemplatePromptToAppend,377 codexArtifactTemplatePromptToAppend,
378 toolGroupConsumesUpwardNavigation,
375 waitForStartedServerThread,379 waitForStartedServerThread,
376} from "./ChatView.logic";380} from "./ChatView.logic";
377import { useLocalStorage } from "~/hooks/useLocalStorage";381import { useLocalStorage } from "~/hooks/useLocalStorage";
@@ -1410,6 +1414,7 @@ function ChatViewContent(props: ChatViewProps) { · 1032 unmodified lines
1410 shellExists: serverThread !== null,1414 shellExists: serverThread !== null,
1411 status: threadStatus,1415 status: threadStatus,
1412 });1416 });
1417 const threadDetailLoading = threadSyncPhase === "loading";
1413 // Latest provider-reported context usage (#8144): the newest turn that has1418 // Latest provider-reported context usage (#8144): the newest turn that has
1414 // a report wins; stale turns keep the meter alive between turns.1419 // a report wins; stale turns keep the meter alive between turns.
1415 const activeThreadLiveTokenUsage = useMemo(() => {1420 const activeThreadLiveTokenUsage = useMemo(() => {
@@ -1547,6 +1552,21 @@ function ChatViewContent(props: ChatViewProps) { · 131 unmodified lines
1547 const composerElementContextsRef = useRef<ElementContextDraft[]>([]);1552 const composerElementContextsRef = useRef<ElementContextDraft[]>([]);
1548 const localComposerRef = useRef<ChatComposerHandle | null>(null);1553 const localComposerRef = useRef<ChatComposerHandle | null>(null);
1549 const composerRef = useComposerHandleContext() ?? localComposerRef;1554 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 );
1550 const [showScrollToBottom, setShowScrollToBottom] = useState(false);1570 const [showScrollToBottom, setShowScrollToBottom] = useState(false);
1551 const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false);1571 const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false);
1552 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);1572 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
@@ -2394,6 +2414,7 @@ function ChatViewContent(props: ChatViewProps) { · 841 unmodified lines
2394 }, [setDismissedVersionMismatchKey, versionMismatchDismissKey]);2414 }, [setDismissedVersionMismatchKey, versionMismatchDismissKey]);
2395 const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;2415 const serverUpdateEnvironmentId = activeThread?.environmentId ?? null;
2396 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);2416 const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig);
2417 const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig);
2397 const serverUpdateState = useAtomValue(2418 const serverUpdateState = useAtomValue(
2398 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),2419 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),
2399 );2420 );
@@ -2437,21 +2458,20 @@ function ChatViewContent(props: ChatViewProps) { · 37 unmodified lines
2437 />2458 />
2438 ),2459 ),
2439 title: `${unavailableConnection.phase === "connecting" ? "Connecting" : "Reconnecting"} to ${activeEnvironmentUnavailableState.label}`,2460 title: `${unavailableConnection.phase === "connecting" ? "Connecting" : "Reconnecting"} to ${activeEnvironmentUnavailableState.label}`,
2440 description: "It may be finishing an update. One moment.",2461 description: "Finishing an update",
2441 });2462 });
2442 } else {2463 } else {
2443 items.push({2464 items.push({
2444 id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,2465 id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
2445 variant: unavailableConnection.phase === "error" ? "error" : "warning",2466 variant: unavailableConnection.phase === "error" ? "error" : "warning",
2446 icon: <WifiOffIcon />,2467 icon: <WifiOffIcon />,
2447 title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusTitle(unavailableConnection)}`,2468 title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`,
2448 description:2469 description: environmentReconnecting ? "Trying again" : "Reconnect to continue",
2449 unavailableConnection.error ??
2450 "Reconnect this environment before sending messages or running actions.",
2451 actions: (2470 actions: (
2452 <>2471 <>
2453 <Button2472 <Button
2454 size="xs"2473 size="xs"
2474 variant="ghost"
2455 disabled={environmentReconnecting}2475 disabled={environmentReconnecting}
2456 onClick={() =>2476 onClick={() =>
2457 void handleReconnectActiveEnvironment(2477 void handleReconnectActiveEnvironment(
@@ -2463,7 +2483,7 @@ function ChatViewContent(props: ChatViewProps) { · 5 unmodified lines
2463 </Button>2483 </Button>
2464 <Button2484 <Button
2465 size="xs"2485 size="xs"
2466 variant="outline"2486 variant="ghost"
2467 onClick={() => void navigate({ to: "/settings/connections" })}2487 onClick={() => void navigate({ to: "/settings/connections" })}
2468 >2488 >
2469 Connections2489 Connections
@@ -2512,21 +2532,25 @@ function ChatViewContent(props: ChatViewProps) { · 42 unmodified lines
2512 "Server update available"2532 "Server update available"
2513 ),2533 ),
2514 description:2534 description:
2515 !updateInProgress && !updateFailed && versionMismatchSelfUpdate === "desktop-managed"2535 !updateInProgress &&
2516 ? serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)2536 !updateFailed &&
2537 versionMismatchSelfUpdate !== null &&
2538 (versionMismatchSelfUpdate !== "desktop-managed" || !versionMismatchDesktopAppUpdate)
2539 ? serverUpdateGuidance(versionMismatchSelfUpdate)
2517 : undefined,2540 : undefined,
2518 // The desktop-managed guidance is already the description; the action
2519 // slot would only repeat it.
2520 actions:2541 actions:
2521 updateInProgress ||2542 updateInProgress ||
2522 !versionMismatch ||2543 !versionMismatch ||
2523 versionMismatchSelfUpdate === "desktop-managed" ? undefined : (2544 (versionMismatchSelfUpdate === "desktop-managed" &&
2545 !versionMismatchDesktopAppUpdate) ? undefined : (
2524 <ServerUpdateAction2546 <ServerUpdateAction
2525 environmentId={serverUpdateEnvironmentId}2547 environmentId={serverUpdateEnvironmentId}
2526 serverLabel={versionMismatchServerLabel}2548 serverLabel={versionMismatchServerLabel}
2527 selfUpdate={versionMismatchSelfUpdate}2549 selfUpdate={versionMismatchSelfUpdate}
2550 desktopAppUpdate={versionMismatchDesktopAppUpdate}
2528 targetVersion={versionMismatch.clientVersion}2551 targetVersion={versionMismatch.clientVersion}
2529 label={updateFailed ? "Retry" : "Update"}2552 label={updateFailed ? "Retry" : "Update"}
2553 variant="ghost"
2530 />2554 />
2531 ),2555 ),
2532 ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey)2556 ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey)
@@ -2557,6 +2581,7 @@ function ChatViewContent(props: ChatViewProps) { · 24 unmodified lines
2557 versionMismatchDismissKey,2581 versionMismatchDismissKey,
2558 serverUpdateEnvironmentId,2582 serverUpdateEnvironmentId,
2559 versionMismatchSelfUpdate,2583 versionMismatchSelfUpdate,
2584 versionMismatchDesktopAppUpdate,
2560 versionMismatchServerLabel,2585 versionMismatchServerLabel,
2561 ]);2586 ]);
2562 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;2587 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
@@ -4469,7 +4494,11 @@ function ChatViewContent(props: ChatViewProps) { · 1906 unmodified lines
4469 // Only an upward wheel is a navigation intent; wheeling down while4494 // Only an upward wheel is a navigation intent; wheeling down while
4470 // following either does nothing (at the end) or moves toward it.4495 // following either does nothing (at the end) or moves toward it.
4471 const handleWheel = (event: WheelEvent) => {4496 const handleWheel = (event: WheelEvent) => {
4472 if (event.deltaY < 0 && contentScrollsUp()) {4497 if (
4498 event.deltaY < 0 &&
4499 contentScrollsUp() &&
4500 !toolGroupConsumesUpwardNavigation(event.target)
4501 ) {
4473 handleManualNavigation();4502 handleManualNavigation();
4474 }4503 }
4475 };4504 };
@@ -4506,7 +4535,7 @@ function ChatViewContent(props: ChatViewProps) { · 30 unmodified lines
4506 case "PageUp":4535 case "PageUp":
4507 case "Home":4536 case "Home":
4508 case "ArrowUp":4537 case "ArrowUp":
4509 if (contentScrollsUp()) {4538 if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) {
4510 handleManualNavigation();4539 handleManualNavigation();
4511 }4540 }
4512 break;4541 break;
@@ -5188,6 +5217,23 @@ function ChatViewContent(props: ChatViewProps) { · 675 unmodified lines
5188 // The stack renders items[0] front-most and tucks the rest behind hover, so5217 // The stack renders items[0] front-most and tucks the rest behind hover, so
5189 // ordering is priority: system banners, then the branch-mismatch notice,5218 // ordering is priority: system banners, then the branch-mismatch notice,
5190 // and the informational parked-thread banner last — it must never cover another.5219 // 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]);
5191 const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {5237 const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
5192 if (!activeThreadSnoozed && !activeThreadSettled) {5238 if (!activeThreadSnoozed && !activeThreadSettled) {
5193 return null;5239 return null;
@@ -7440,6 +7486,9 @@ function ChatViewContent(props: ChatViewProps) { · 2246 unmodified lines
7440 <div className="relative flex min-h-0 flex-1 flex-col">7486 <div className="relative flex min-h-0 flex-1 flex-col">
7441 {/* Messages — LegendList handles virtualization and scrolling internally */}7487 {/* Messages — LegendList handles virtualization and scrolling internally */}
7442 <MessagesTimeline7488 <MessagesTimeline
7489 citationRequest={citationRequest}
7490 citationHistoryLoading={threadDetailLoading}
7491 onCiteAssistantText={citeAssistantText}
7443 key={activeThread.id}7492 key={activeThread.id}
7444 isWorking={isWorking}7493 isWorking={isWorking}
7445 activeTurnInProgress={isWorking || !latestRunSettled}7494 activeTurnInProgress={isWorking || !latestRunSettled}
@@ -7467,9 +7516,11 @@ function ChatViewContent(props: ChatViewProps) { · 21 unmodified lines
7467 resolvedTheme={resolvedTheme}7516 resolvedTheme={resolvedTheme}
7468 timestampFormat={timestampFormat}7517 timestampFormat={timestampFormat}
7469 workspaceRoot={activeWorkspaceRoot}7518 workspaceRoot={activeWorkspaceRoot}
7470 skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}7519 skills={
7471 providerStatuses={providerStatuses}7520 activeProviderStatus
7472 runs={serverProjection?.runs ?? EMPTY_PROJECTION_RUNS}7521 ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd)
7522 : EMPTY_PROVIDER_SKILLS
7523 }
7473 anchorMessageId={timelineAnchorMessageId}7524 anchorMessageId={timelineAnchorMessageId}
7474 onAnchorReady={onTimelineAnchorReady}7525 onAnchorReady={onTimelineAnchorReady}
7475 onAnchorSizeChanged={onTimelineAnchorSizeChanged}7526 onAnchorSizeChanged={onTimelineAnchorSizeChanged}
apps/web/src/components/DiffPanel.tsx+7−0
@@ -330,6 +330,13 @@ export default function DiffPanel({ · 329 unmodified lines
330 cacheKey: selectedGitSource.diffHash,330 cacheKey: selectedGitSource.diffHash,
331 });331 });
332 }, [activeThread, branchDiffPreview.data, getDiffFileContents, selectedGitSource, selectedRunId]);332 }, [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 }, []);
333 const localBranchRefs = useEnvironmentQuery(340 const localBranchRefs = useEnvironmentQuery(
334 selectedRunId === null &&341 selectedRunId === null &&
335 selectedGitScope === "branch" &&342 selectedGitScope === "branch" &&
apps/web/src/components/GitActionsControl.tsx+14−7
@@ -16,7 +16,15 @@ import type { · 15 unmodified lines
16} from "@t3tools/contracts";16} from "@t3tools/contracts";
17import { useNavigate } from "@tanstack/react-router";17import { useNavigate } from "@tanstack/react-router";
18import * as Option from "effect/Option";18import * as Option from "effect/Option";
19import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";19import {
20 type MouseEvent,
21 useCallback,
22 useEffect,
23 useEffectEvent,
24 useMemo,
25 useRef,
26 useState,
27} from "react";
20import { flushSync } from "react-dom";28import { flushSync } from "react-dom";
21import {29import {
22 CheckIcon,30 CheckIcon,
@@ -37,7 +45,7 @@ import { RadioGroup } from "~/components/ui/radio-group"; · 14 unmodified lines
37import { Spinner } from "~/components/ui/spinner";45import { Spinner } from "~/components/ui/spinner";
38import { toggleVariants } from "~/components/ui/toggle";46import { toggleVariants } from "~/components/ui/toggle";
39import { cn } from "~/lib/utils";47import { cn } from "~/lib/utils";
40import { openPullRequestLink } from "~/lib/openPullRequestLink";48import { openPullRequestLink, useOpenPrLink } from "~/lib/openPullRequestLink";
41import {49import {
42 buildMenuItems,50 buildMenuItems,
43 formatGitActionElapsed,51 formatGitActionElapsed,
@@ -1117,6 +1125,7 @@ export default function GitActionsControl({ · 1073 unmodified lines
1117 [activeThreadRef],1125 [activeThreadRef],
1118 );1126 );
1119 const activeServerThread = useThreadShell(activeThreadRef);1127 const activeServerThread = useThreadShell(activeThreadRef);
1128 const openPrLink = useOpenPrLink(activeThreadRef ?? undefined);
1120 const activeDraftThread = useComposerDraftStore((store) =>1129 const activeDraftThread = useComposerDraftStore((store) =>
1121 draftId1130 draftId
1122 ? store.getDraftSession(draftId)1131 ? store.getDraftSession(draftId)
@@ -1457,7 +1466,7 @@ export default function GitActionsControl({ · 334 unmodified lines
1457 const toastCta = actionResult.toast.cta;1466 const toastCta = actionResult.toast.cta;
1458 let toastActionProps: {1467 let toastActionProps: {
1459 children: string;1468 children: string;
1460 onClick: () => void;1469 onClick: (event: MouseEvent<HTMLButtonElement>) => void;
1461 } | null = null;1470 } | null = null;
1462 if (toastCta.kind === "run_action") {1471 if (toastCta.kind === "run_action") {
1463 toastActionProps = {1472 toastActionProps = {
@@ -1472,11 +1481,9 @@ export default function GitActionsControl({ · 8 unmodified lines
1472 } else if (toastCta.kind === "open_pr") {1481 } else if (toastCta.kind === "open_pr") {
1473 toastActionProps = {1482 toastActionProps = {
1474 children: toastCta.label,1483 children: toastCta.label,
1475 onClick: () => {1484 onClick: (event) => {
1476 const api = readLocalApi();
1477 if (!api) return;
1478 closeResultToast();1485 closeResultToast();
1479 void api.shell.openExternal(toastCta.url);1486 openPrLink(event, toastCta.url);
1480 },1487 },
1481 };1488 };
1482 }1489 }
apps/web/src/components/Sidebar.tsx+11−4
@@ -146,6 +146,8 @@ import { · 145 unmodified lines
146 sortPinnedThreadsForSidebar,146 sortPinnedThreadsForSidebar,
147 sortSettledThreadsForSidebar,147 sortSettledThreadsForSidebar,
148 sortThreadsForSidebar,148 sortThreadsForSidebar,
149 useRetainedValue,
150 useSidebarRowSubscriptionLease,
149} from "./Sidebar.logic";151} from "./Sidebar.logic";
150import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";152import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
151import {153import {
@@ -791,6 +793,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 639 unmodified lines
791 [thread.environmentId, thread.id],793 [thread.environmentId, thread.id],
792 );794 );
793 const threadKey = scopedThreadKey(threadRef);795 const threadKey = scopedThreadKey(threadRef);
796 const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive);
794 const isRegeneratingTitle = thread.titleRegeneration != null;797 const isRegeneratingTitle = thread.titleRegeneration != null;
795 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);798 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
796 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));799 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
@@ -824,10 +827,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 27 unmodified lines
824 })827 })
825 : null,828 : null,
826 );829 );
830 const visibleGitStatus = useRetainedValue(
831 JSON.stringify([thread.environmentId, gitCwd]),
832 gitStatus.data,
833 );
827 const retainTerminalOnBranchMismatch = thread.worktreePath === null;834 const retainTerminalOnBranchMismatch = thread.worktreePath === null;
828 const pr = resolveDisplayedThreadPr({835 const pr = resolveDisplayedThreadPr({
829 threadBranch: thread.branch,836 threadBranch: thread.branch,
830 gitStatus: gitStatus.data,837 gitStatus: visibleGitStatus,
831 snapshot: changeRequestSnapshot,838 snapshot: changeRequestSnapshot,
832 retainTerminalOnBranchMismatch,839 retainTerminalOnBranchMismatch,
833 });840 });
@@ -926,7 +933,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 92 unmodified lines
926 });933 });
927 const prProvider = resolveDisplayedThreadPrProvider({934 const prProvider = resolveDisplayedThreadPrProvider({
928 threadBranch: thread.branch,935 threadBranch: thread.branch,
929 gitStatus: gitStatus.data,936 gitStatus: visibleGitStatus,
930 snapshot: changeRequestSnapshot,937 snapshot: changeRequestSnapshot,
931 retainTerminalOnBranchMismatch,938 retainTerminalOnBranchMismatch,
932 });939 });
@@ -935,7 +942,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 2 unmodified lines
935 useEffect(() => {942 useEffect(() => {
936 const nextSnapshot = nextThreadChangeRequestSnapshot({943 const nextSnapshot = nextThreadChangeRequestSnapshot({
937 threadBranch: thread.branch,944 threadBranch: thread.branch,
938 gitStatus: gitStatus.data,945 gitStatus: visibleGitStatus,
939 snapshot: changeRequestSnapshot,946 snapshot: changeRequestSnapshot,
940 retainTerminalOnBranchMismatch,947 retainTerminalOnBranchMismatch,
941 });948 });
@@ -943,7 +950,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 1 unmodified line
943 onChangeRequestSnapshot(threadKey, nextSnapshot);950 onChangeRequestSnapshot(threadKey, nextSnapshot);
944 }, [951 }, [
945 changeRequestSnapshot,952 changeRequestSnapshot,
946 gitStatus.data,953 visibleGitStatus,
947 onChangeRequestSnapshot,954 onChangeRequestSnapshot,
948 retainTerminalOnBranchMismatch,955 retainTerminalOnBranchMismatch,
949 thread.branch,956 thread.branch,
apps/web/src/components/chat/ChatComposer.tsx+156−39
@@ -1,4 +1,5 @@
1import type {1import type {
2 AssistantCitation,
2 ChatAttachment as ContractChatAttachment,3 ChatAttachment as ContractChatAttachment,
3 EnvironmentId,4 EnvironmentId,
4 ModelSelection,5 ModelSelection,
@@ -40,6 +41,7 @@ import { · 35 unmodified lines
40 composerSubmissionIntentForEnter,41 composerSubmissionIntentForEnter,
41 detectComposerTrigger,42 detectComposerTrigger,
42 expandCollapsedComposerCursor,43 expandCollapsedComposerCursor,
44 formatAssistantCitationForComposer,
43 replaceTextRange,45 replaceTextRange,
44} from "../../composer-logic";46} from "../../composer-logic";
45import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";47import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";
@@ -118,6 +120,7 @@ import { · 72 unmodified lines
118} from "../../lib/attachmentUploadState";120} from "../../lib/attachmentUploadState";
119import { isCommandPaletteOpen } from "../../commandPaletteBus";121import { isCommandPaletteOpen } from "../../commandPaletteBus";
120import { getTerminalFocusOwner } from "../../lib/terminalFocus";122import { getTerminalFocusOwner } from "../../lib/terminalFocus";
123import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
121import { resolveShortcutCommand, shortcutLabelForCommand } from "../../keybindings";124import { resolveShortcutCommand, shortcutLabelForCommand } from "../../keybindings";
122import {125import {
123 type TerminalContextDraft,126 type TerminalContextDraft,
@@ -336,9 +339,13 @@ import { · 212 unmodified lines
336 formatProviderSkillDisplayName,339 formatProviderSkillDisplayName,
337 getProviderSlashCommandsForSlashMenu,340 getProviderSlashCommandsForSlashMenu,
338 getProviderSkillsForSlashMenu,341 getProviderSkillsForSlashMenu,
342 resolveProviderSkillsForCwd,
343 resolveProviderSlashCommandsForCwd,
339} from "@t3tools/client-runtime/providerSkills";344} from "@t3tools/client-runtime/providerSkills";
340import { searchProviderSkills } from "../../providerSkillSearch";345import { searchProviderSkills } from "../../providerSkillSearch";
341import { useMediaQuery } from "../../hooks/useMediaQuery";346import { useMediaQuery } from "../../hooks/useMediaQuery";
347import { useAtomCommand } from "../../state/use-atom-command";
348import { serverEnvironment } from "../../state/server";
342import type { ReviewCommentContext } from "../../reviewCommentContext";349import type { ReviewCommentContext } from "../../reviewCommentContext";
343 350
344const runtimeModeConfig: Record<351const runtimeModeConfig: Record<
@@ -574,6 +581,10 @@ export interface ChatComposerHandle { · 229 unmodified lines
574 focusAt: (cursor: number) => void;581 focusAt: (cursor: number) => void;
575 addDroppedFiles: (files: File[]) => void;582 addDroppedFiles: (files: File[]) => void;
576 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;583 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;
584 citeAssistantText: (
585 citation: AssistantCitation,
586 sourceAnchor: AssistantCitationSourceAnchor,
587 ) => boolean;
577 openModelPicker: () => void;588 openModelPicker: () => void;
578 toggleModelPicker: () => void;589 toggleModelPicker: () => void;
579 isModelPickerOpen: () => boolean;590 isModelPickerOpen: () => boolean;
@@ -1145,6 +1156,58 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 565 unmodified lines
1145 () => selectedProviderEntry?.snapshot ?? null,1156 () => selectedProviderEntry?.snapshot ?? null,
1146 [selectedProviderEntry],1157 [selectedProviderEntry],
1147 );1158 );
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]);
1148 const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>(1211 const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>(
1149 () => selectedProviderEntry?.models ?? [],1212 () => selectedProviderEntry?.models ?? [],
1150 [selectedProviderEntry],1213 [selectedProviderEntry],
@@ -1369,11 +1432,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 218 unmodified lines
1369 },1432 },
1370 ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;1433 ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>;
1371 const slashMenuSkills = getProviderSkillsForSlashMenu(1434 const slashMenuSkills = getProviderSkillsForSlashMenu(
1372 selectedProviderStatus?.skills ?? [],1435 selectedProviderSkills,
1373 settings.showSkillsInSlashMenu,1436 settings.showSkillsInSlashMenu,
1374 );1437 );
1375 const providerSlashCommandItems = getProviderSlashCommandsForSlashMenu(1438 const providerSlashCommandItems = getProviderSlashCommandsForSlashMenu(
1376 selectedProviderStatus?.slashCommands ?? [],1439 selectedProviderSlashCommands,
1377 slashMenuSkills,1440 slashMenuSkills,
1378 ).map((command) => ({1441 ).map((command) => ({
1379 id: `provider-slash-command:${selectedProvider}:${command.name}`,1442 id: `provider-slash-command:${selectedProvider}:${command.name}`,
@@ -1402,25 +1465,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 22 unmodified lines
1402 return searchSlashCommandItems(slashCommandItems, query);1465 return searchSlashCommandItems(slashCommandItems, query);
1403 }1466 }
1404 if (composerTrigger.kind === "skill") {1467 if (composerTrigger.kind === "skill") {
1405 return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map(1468 return searchProviderSkills(selectedProviderSkills, composerTrigger.query).map((skill) => ({
1406 (skill) => ({1469 id: `skill:${selectedProvider}:${skill.name}`,
1407 id: `skill:${selectedProvider}:${skill.name}`,1470 type: "skill" as const,
1408 type: "skill" as const,1471 provider: selectedProvider,
1409 provider: selectedProvider,1472 skill,
1410 skill,1473 label: formatProviderSkillDisplayName(skill),
1411 label: formatProviderSkillDisplayName(skill),1474 description:
1412 description:1475 skill.shortDescription ??
1413 skill.shortDescription ??1476 skill.description ??
1414 skill.description ??1477 (skill.scope ? `${skill.scope} skill` : "Run provider skill"),
1415 (skill.scope ? `${skill.scope} skill` : "Run provider skill"),1478 }));
1416 }),
1417 );
1418 }1479 }
1419 return [];1480 return [];
1420 }, [1481 }, [
1421 composerTrigger,1482 composerTrigger,
1422 planModeUiEnabled,1483 planModeUiEnabled,
1423 selectedProvider,1484 selectedProvider,
1485 selectedProviderSkills,
1486 selectedProviderSlashCommands,
1424 selectedProviderStatus,1487 selectedProviderStatus,
1425 settings.showSkillsInSlashMenu,1488 settings.showSkillsInSlashMenu,
1426 workspaceEntries.entries,1489 workspaceEntries.entries,
@@ -1962,7 +2025,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 535 unmodified lines
1962 rangeStart: number,2025 rangeStart: number,
1963 rangeEnd: number,2026 rangeEnd: number,
1964 replacement: string,2027 replacement: string,
1965 options?: { expectedText?: string; focusEditorAfterReplace?: boolean },2028 options?: {
2029 expectedText?: string;
2030 focusEditorAfterReplace?: boolean;
2031 citationComment?: { start: number; sourceAnchor: AssistantCitationSourceAnchor };
2032 },
1966 ): boolean => {2033 ): boolean => {
1967 const currentText = promptRef.current;2034 const currentText = promptRef.current;
1968 const safeStart = Math.max(0, Math.min(currentText.length, rangeStart));2035 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
1976 const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);2043 const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement);
1977 const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor);2044 const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor);
1978 const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor);2045 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 }
1979 promptRef.current = next.text;2054 promptRef.current = next.text;
1980 const activePendingQuestion = activePendingProgress?.activeQuestion;2055 const activePendingQuestion = activePendingProgress?.activeQuestion;
1981 if (activePendingQuestion && activePendingUserInput) {2056 if (activePendingQuestion && activePendingUserInput) {
@@ -3264,28 +3339,62 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 1282 unmodified lines
3264 void addComposerAttachments(files);3339 void addComposerAttachments(files);
3265 };3340 };
3266 3341
3267 const insertComposerTextAtEnd = (3342 const insertComposerText = useCallback(
3268 text: string,3343 (
3269 options?: { ensureLeadingBoundary?: boolean },3344 text: string,
3270 ): boolean => {3345 position: "cursor" | "end",
3271 if (3346 options?: {
3272 text.length === 0 ||3347 ensureLeadingBoundary?: boolean;
3273 isConnecting ||3348 citationCommentAnchor?: AssistantCitationSourceAnchor;
3274 isComposerApprovalState ||3349 },
3275 pendingUserInputs.length > 0 ||3350 ): boolean => {
3276 projectSelectionRequired3351 if (
3277 ) {3352 text.length === 0 ||
3278 return false;3353 isConnecting ||
3279 }3354 isComposerApprovalState ||
3280 const prompt = promptRef.current;3355 pendingUserInputs.length > 0 ||
3281 const needsLeadingSpace =3356 projectSelectionRequired ||
3282 (options?.ensureLeadingBoundary ?? false) && prompt.length > 0 && !/\s$/.test(prompt);3357 (options?.citationCommentAnchor && !composerEditorRef.current)
3283 return applyPromptReplacement(3358 ) {
3284 prompt.length,3359 return false;
3285 prompt.length,3360 }
3286 needsLeadingSpace ? ` ${text}` : text,3361 const prompt = promptRef.current;
3287 );3362 const cursor = position === "cursor" ? readComposerSnapshot().expandedCursor : prompt.length;
3288 };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 );
3289 3398
3290 // File-tree drags land as mentions. Handled in the capture phase so the3399 // File-tree drags land as mentions. Handled in the capture phase so the
3291 // editor never sees the drop; the load-bearing rules (native stop, "move"3400 // 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
3391 focusComposer();3500 focusComposer();
3392 },3501 },
3393 insertTextAtEnd: insertComposerTextAtEnd,3502 insertTextAtEnd: insertComposerTextAtEnd,
3503 citeAssistantText: (citation, sourceAnchor) =>
3504 insertComposerText(
3505 formatAssistantCitationForComposer(citation, citation.comment),
3506 "cursor",
3507 { ensureLeadingBoundary: true, citationCommentAnchor: sourceAnchor },
3508 ),
3394 openModelPicker: () => {3509 openModelPicker: () => {
3395 setIsComposerModelPickerOpen(true);3510 setIsComposerModelPickerOpen(true);
3396 },3511 },
@@ -3489,6 +3604,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 92 unmodified lines
3489 composerCursor,3604 composerCursor,
3490 composerTerminalContexts,3605 composerTerminalContexts,
3491 insertComposerDraftTerminalContext,3606 insertComposerDraftTerminalContext,
3607 insertComposerText,
3608 insertComposerTextAtEnd,
3492 promptRef,3609 promptRef,
3493 composerImagesRef,3610 composerImagesRef,
3494 composerFilesRef,3611 composerFilesRef,
@@ -4120,7 +4237,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 625 unmodified lines
4120 ? composerTerminalContexts4237 ? composerTerminalContexts
4121 : []4238 : []
4122 }4239 }
4123 skills={selectedProviderStatus?.skills ?? []}4240 skills={selectedProviderSkills}
4124 {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})}4241 {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})}
4125 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}4242 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
4126 onChange={onPromptChange}4243 onChange={onPromptChange}
apps/web/src/components/chat/ComposerBannerStack.tsx+1−0
@@ -1,3 +1,4 @@
1import { InfoIcon } from "lucide-react";
1import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";2import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
2 3
3import { cn } from "~/lib/utils";4import { 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
44 );44 );
45}45}
46 46
47function 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
68function renderSendButton(sendDisabledReason: string | null = null) {47function renderSendButton(sendDisabledReason: string | null = null) {
69 return renderToStaticMarkup(48 return renderToStaticMarkup(
70 createElement(ComposerPrimaryActions, {49 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
60 }60 }
61 61
62 return (62 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 >
64 <ComposerBanner.Row69 <ComposerBanner.Row
65 render={<button type="button" />}70 render={<button type="button" />}
66 data-prompt-stash-badge="true"71 data-prompt-stash-badge="true"
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+315−57
@@ -11,8 +11,11 @@ import { · 10 unmodified lines
11 normalizeCompactToolLabel,11 normalizeCompactToolLabel,
12 resolveAssistantMessageCopyState,12 resolveAssistantMessageCopyState,
13 resolveTimelineToolPresentation,13 resolveTimelineToolPresentation,
14 resolveWorkGroupScrollIndex,
15 shouldFollowWorkGroupAppend,
14 shouldPreserveAssistantLineBreaks,16 shouldPreserveAssistantLineBreaks,
15 summarizeToolGroup,17 summarizeToolGroup,
18 workEntryDisplayLabel,
16} from "./MessagesTimeline.logic";19} from "./MessagesTimeline.logic";
17import {20import {
18 createMessageAttachmentPreviewProjector,21 createMessageAttachmentPreviewProjector,
@@ -355,6 +358,171 @@ describe("streaming row projection", () => { · 336 unmodified lines
355 });358 });
356});359});
357 360
361describe("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
410describe("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
358describe("summarizeToolGroup", () => {526describe("summarizeToolGroup", () => {
359 const now = DateTime.makeUnsafe("2026-08-29T00:00:00Z");527 const now = DateTime.makeUnsafe("2026-08-29T00:00:00Z");
360 function work(overrides: Partial<WorkLogEntry> = {}): WorkLogEntry {528 function work(overrides: Partial<WorkLogEntry> = {}): WorkLogEntry {
@@ -1682,6 +1850,67 @@ describe("deriveMessagesTimelineRows", () => { · 1321 unmodified lines
1682 expect(liveRow?.kind === "work-live" ? liveRow.entry.id : null).toBe("work-1");1850 expect(liveRow?.kind === "work-live" ? liveRow.entry.id : null).toBe("work-1");
1683 });1851 });
1684 1852
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
1685 it("only shows assistant metadata on the terminal assistant message", () => {1914 it("only shows assistant metadata on the terminal assistant message", () => {
1686 const rows = deriveMessagesTimelineRows({1915 const rows = deriveMessagesTimelineRows({
1687 timelineEntries: [1916 timelineEntries: [
@@ -1766,52 +1995,6 @@ describe("deriveMessagesTimelineRows", () => { · 78 unmodified lines
1766 expect(assistantRow?.showAssistantCopyButton).toBe(false);1995 expect(assistantRow?.showAssistantCopyButton).toBe(false);
1767 });1996 });
1768 1997
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
1815 it("collapses contiguous settled tool entries behind a summary toggle", () => {1998 it("collapses contiguous settled tool entries behind a summary toggle", () => {
1816 const timelineEntries = [1999 const timelineEntries = [
1817 {2000 {
@@ -1885,19 +2068,94 @@ describe("deriveMessagesTimelineRows", () => { · 67 unmodified lines
1885 });2068 });
1886 expect(expandedRows.map((row) => row.id)).toEqual([2069 expect(expandedRows.map((row) => row.id)).toEqual([
1887 "work-toggle:work-entry-1",2070 "work-toggle:work-entry-1",
1888 "work-1",2071 "work-group:work-entry-1:details",
1889 "work-2",
1890 "work-3",
1891 ]);2072 ]);
1892 expect(expandedRows[1]).toMatchObject({2073 expect(expandedRows.find((row) => row.kind === "work")).toMatchObject({
1893 kind: "work",2074 isExpandedToolGroup: true,
1894 isExpandedToolGroupEntry: true,2075 groupedEntries: timelineEntries.map(({ entry }) => entry),
1895 isLastExpandedToolGroupEntry: false,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(),
1896 });2154 });
1897 expect(expandedRows[3]).toMatchObject({2155
1898 kind: "work",2156 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
1899 isExpandedToolGroupEntry: true,2157 hiddenCount: 2,
1900 isLastExpandedToolGroupEntry: true,2158 hasFailure,
1901 });2159 });
1902 });2160 });
1903});2161});
apps/web/src/components/chat/MessagesTimeline.logic.ts+119−40
@@ -1,6 +1,9 @@
1import * as Equal from "effect/Equal";1import * as Equal from "effect/Equal";
2import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";2import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
3import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
3import {4import {
5 normalizeCompactToolLabel,
6 resolveWorkEntryToolPresentation,
4 summarizeToolGroup,7 summarizeToolGroup,
5 toolGroupSummaryKind,8 toolGroupSummaryKind,
6 type ToolGroupSummaryKind,9 type ToolGroupSummaryKind,
@@ -40,6 +43,7 @@ import { · 33 unmodified lines
40 type T3McpToolPresentation,43 type T3McpToolPresentation,
41 type T3McpToolSummaryAction,44 type T3McpToolSummaryAction,
42} from "@t3tools/shared/t3McpToolPresentation";45} from "@t3tools/shared/t3McpToolPresentation";
46import { formatWorkspaceRelativePath } from "../../filePathDisplay";
43 47
44function timelineEntryRunId(entry: TimelineEntry): RunId | null {48function timelineEntryRunId(entry: TimelineEntry): RunId | null {
45 if (entry.kind === "message") {49 if (entry.kind === "message") {
@@ -59,6 +63,50 @@ function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { · 13 unmodified lines
59 );63 );
60}64}
61 65
66export 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
82export 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
62export function workEntryIsVisibleInGroup(110export function workEntryIsVisibleInGroup(
63 entry: WorkLogEntry,111 entry: WorkLogEntry,
64 expandedToolGroupEntry = false,112 expandedToolGroupEntry = false,
@@ -74,6 +122,35 @@ export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; · 9 unmodified lines
74export const TIMELINE_CONTENT_MAX_WIDTH = 768;122export const TIMELINE_CONTENT_MAX_WIDTH = 768;
75export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;123export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;
76 124
125export 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. */
131export 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. */
141export 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
77export interface TimelineEndState {154export interface TimelineEndState {
78 readonly isAtEnd?: boolean;155 readonly isAtEnd?: boolean;
79 readonly isNearEnd?: boolean;156 readonly isNearEnd?: boolean;
@@ -228,8 +305,7 @@ export type MessagesTimelineRow = · 148 unmodified lines
228 id: string;305 id: string;
229 createdAt: string;306 createdAt: string;
230 groupedEntries: WorkLogEntry[];307 groupedEntries: WorkLogEntry[];
231 isExpandedToolGroupEntry: boolean;308 isExpandedToolGroup: boolean;
232 isLastExpandedToolGroupEntry: boolean;
233 }309 }
234 | {310 | {
235 kind: "work-live";311 kind: "work-live";
@@ -342,6 +418,20 @@ function workGroupId(timelineEntryId: string): string { · 106 unmodified lines
342export type TimelineToolPresentation = T3McpToolPresentation;418export type TimelineToolPresentation = T3McpToolPresentation;
343export const resolveTimelineToolPresentation = resolveT3McpToolPresentation;419export const resolveTimelineToolPresentation = resolveT3McpToolPresentation;
344 420
421function 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
345export function resolveAssistantMessageCopyState({435export function resolveAssistantMessageCopyState({
346 text,436 text,
347 showCopyButton,437 showCopyButton,
@@ -719,9 +809,14 @@ export function deriveMessagesTimelineRows(input: { · 371 unmodified lines
719 latestRunningToolEntry !== undefined ||809 latestRunningToolEntry !== undefined ||
720 (latestVisibleToolEntry !== undefined &&810 (latestVisibleToolEntry !== undefined &&
721 workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry));811 workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry));
812 const latestToolFailed =
813 latestRunningToolEntry === undefined &&
814 latestVisibleToolEntry !== undefined &&
815 latestVisibleToolEntry.entry.toolLifecycleStatus !== "declined" &&
816 workEntryDisplayIndicatesToolFailure(latestVisibleToolEntry.entry);
722 const activeWorkPlacementEntryId = latestVisibleToolEntry?.id;817 const activeWorkPlacementEntryId = latestVisibleToolEntry?.id;
723 const activeWorkRow =818 const activeWorkRow =
724 activeWorkAnchor && latestVisibleToolEntry819 activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed
725 ? (() => {820 ? (() => {
726 const groupId = workGroupId(activeWorkAnchor.id);821 const groupId = workGroupId(activeWorkAnchor.id);
727 return {822 return {
@@ -739,7 +834,7 @@ export function deriveMessagesTimelineRows(input: { · 11 unmodified lines
739 })()834 })()
740 : null;835 : null;
741 const activeWorkEntryIds = new Set(836 const activeWorkEntryIds = new Set(
742 activeWorkRow === null ? [] : activeToolEntries.map((entry) => entry.id),837 activeWorkRow !== null || latestToolFailed ? activeToolEntries.map((entry) => entry.id) : [],
743 );838 );
744 const appendWorkingRow = () => {839 const appendWorkingRow = () => {
745 nextRows.push({840 nextRows.push({
@@ -754,16 +849,13 @@ export function deriveMessagesTimelineRows(input: { · 8 unmodified lines
754 nextRows.push(activeWorkRow);849 nextRows.push(activeWorkRow);
755 hasActivityRow ||= activeWorkRow.active;850 hasActivityRow ||= activeWorkRow.active;
756 if (!activeWorkRow.expanded) return;851 if (!activeWorkRow.expanded) return;
757 for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) {852 nextRows.push(
758 nextRows.push({853 expandedWorkGroupRow(
759 kind: "work",854 activeWorkRow.groupId,
760 id: workEntry.id,855 activeWorkRow.createdAt,
761 createdAt: workEntry.createdAt,856 activeWorkRow.groupedEntries,
762 groupedEntries: [workEntry],857 ),
763 isExpandedToolGroupEntry: true,858 );
764 isLastExpandedToolGroupEntry: entryIndex === activeWorkRow.groupedEntries.length - 1,
765 });
766 }
767 };859 };
768 860
769 for (let index = 0; index < input.timelineEntries.length; index += 1) {861 for (let index = 0; index < input.timelineEntries.length; index += 1) {
@@ -833,8 +925,7 @@ export function deriveMessagesTimelineRows(input: { · 63 unmodified lines
833 id: timelineEntry.id,925 id: timelineEntry.id,
834 createdAt: timelineEntry.createdAt,926 createdAt: timelineEntry.createdAt,
835 groupedEntries: [timelineEntry.entry],927 groupedEntries: [timelineEntry.entry],
836 isExpandedToolGroupEntry: false,928 isExpandedToolGroup: false,
837 isLastExpandedToolGroupEntry: false,
838 });929 });
839 continue;930 continue;
840 }931 }
@@ -880,22 +971,16 @@ export function deriveMessagesTimelineRows(input: { · 39 unmodified lines
880 });971 });
881 hasActivityRow = true;972 hasActivityRow = true;
882 if (expanded) {973 if (expanded) {
883 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {974 nextRows.push(
884 nextRows.push({975 expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries),
885 kind: "work",976 );
886 id: workEntry.id,
887 createdAt: workEntry.createdAt,
888 groupedEntries: [workEntry],
889 isExpandedToolGroupEntry: true,
890 isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1,
891 });
892 }
893 }977 }
894 } else {978 } else {
895 const groupId = workGroupId(timelineEntry.id);979 const groupId = workGroupId(timelineEntry.id);
896 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;980 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
897 const summaryKind = toolGroupSummaryKind(visibleGroupedEntries);981 const summaryKind = toolGroupSummaryKind(visibleGroupedEntries);
898 const groupSummary = summarizeToolGroup(visibleGroupedEntries);982 const groupSummary = summarizeToolGroup(visibleGroupedEntries);
983 const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike);
899 nextRows.push({984 nextRows.push({
900 kind: "work-toggle",985 kind: "work-toggle",
901 id: `work-toggle:${timelineEntry.id}`,986 id: `work-toggle:${timelineEntry.id}`,
@@ -909,19 +994,14 @@ export function deriveMessagesTimelineRows(input: { · 7 unmodified lines
909 ? visibleGroupedEntries[0]!.label994 ? visibleGroupedEntries[0]!.label
910 : groupSummary.summary,995 : groupSummary.summary,
911 summaryKind,996 summaryKind,
912 hasFailure: groupSummary.hasFailure,997 hasFailure:
998 latestToolEntry !== undefined &&
999 workEntryDisplayIndicatesToolFailure(latestToolEntry),
913 });1000 });
914 if (expanded) {1001 if (expanded) {
915 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {1002 nextRows.push(
916 nextRows.push({1003 expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries),
917 kind: "work",1004 );
918 id: workEntry.id,
919 createdAt: workEntry.createdAt,
920 groupedEntries: [workEntry],
921 isExpandedToolGroupEntry: true,
922 isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1,
923 });
924 }
925 }1005 }
926 }1006 }
927 }1007 }
@@ -1001,7 +1081,7 @@ export function deriveMessagesTimelineRows(input: { · 73 unmodified lines
1001 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {1081 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
1002 appendWorkingRow();1082 appendWorkingRow();
1003 }1083 }
1004 if (input.isWorking && !hasActivityRow) {1084 if (input.isWorking && (!hasActivityRow || latestToolFailed)) {
1005 nextRows.push({1085 nextRows.push({
1006 kind: "thinking",1086 kind: "thinking",
1007 id: LIVE_ACTIVITY_ROW_ID,1087 id: LIVE_ACTIVITY_ROW_ID,
@@ -1129,8 +1209,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean · 121 unmodified lines
1129 case "work": {1209 case "work": {
1130 const bw = b as typeof a;1210 const bw = b as typeof a;
1131 return (1211 return (
1132 a.isExpandedToolGroupEntry === bw.isExpandedToolGroupEntry &&1212 a.isExpandedToolGroup === bw.isExpandedToolGroup &&
1133 a.isLastExpandedToolGroupEntry === bw.isLastExpandedToolGroupEntry &&
1134 Equal.equals(a.groupedEntries, bw.groupedEntries)1213 Equal.equals(a.groupedEntries, bw.groupedEntries)
1135 );1214 );
1136 }1215 }
apps/web/src/components/chat/MessagesTimeline.test.tsx+35−21
@@ -55,6 +55,15 @@ vi.mock("@legendapp/list/react", async () => { · 54 unmodified lines
55 layout?: boolean;55 layout?: boolean;
56 };56 };
57 };57 };
58 maintainVisibleContentPosition?:
59 | boolean
60 | {
61 data?: boolean;
62 size?: boolean;
63 shouldRestorePosition?: boolean;
64 };
65 className?: string;
66 contentInsetEndAdjustment?: number;
58 ref?: Ref<LegendListRef>;67 ref?: Ref<LegendListRef>;
59 }) => {68 }) => {
60 if (props.anchoredEndSpace) {69 if (props.anchoredEndSpace) {
@@ -64,7 +73,32 @@ vi.mock("@legendapp/list/react", async () => { · 3 unmodified lines
64 return (73 return (
65 <div74 <div
66 data-testid={legendListTestId}75 data-testid={legendListTestId}
76 data-class-name={props.className}
67 data-anchor-index={props.anchoredEndSpace?.anchorIndex}77 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 }
68 data-maintain-scroll-at-end={props.maintainScrollAtEnd ? "enabled" : undefined}102 data-maintain-scroll-at-end={props.maintainScrollAtEnd ? "enabled" : undefined}
69 data-maintain-scroll-at-end-animated={103 data-maintain-scroll-at-end-animated={
70 typeof props.maintainScrollAtEnd === "object"104 typeof props.maintainScrollAtEnd === "object"
@@ -86,26 +120,6 @@ vi.mock("@legendapp/list/react", async () => { · 15 unmodified lines
86 ? props.maintainScrollAtEnd.on?.layout120 ? props.maintainScrollAtEnd.on?.layout
87 : undefined121 : undefined
88 }122 }
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 }
109 >123 >
110 {props.ListHeaderComponent}124 {props.ListHeaderComponent}
111 {props.data.map((item) => (125 {props.data.map((item) => (
@@ -625,7 +639,7 @@ describe("MessagesTimeline", () => { · 513 unmodified lines
625 );639 );
626 640
627 expect(markup).toContain('data-anchor-index="1"');641 expect(markup).toContain('data-anchor-index="1"');
628 expect(markup).toContain('data-anchor-offset="16"');642 expect(markup).toContain('data-anchor-offset="24"');
629 expect(markup).toContain('data-anchor-on-ready="true"');643 expect(markup).toContain('data-anchor-on-ready="true"');
630 expect(markup).not.toContain("data-anchor-max-size=");644 expect(markup).not.toContain("data-anchor-max-size=");
631 expect(markup).toContain('data-content-inset-end="144"');645 expect(markup).toContain('data-content-inset-end="144"');
apps/web/src/components/chat/MessagesTimeline.tsx+381−109
@@ -1,4 +1,5 @@
1import {1import {
2 type AssistantCitation,
2 type ChatFileAttachment,3 type ChatFileAttachment,
3 type EnvironmentId,4 type EnvironmentId,
4 type MessageId,5 type MessageId,
@@ -15,9 +16,15 @@ import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thr · 10 unmodified lines
15import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";16import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
16import { formatWorkingTimerNow } from "./ComposerActivityStatus";17import { formatWorkingTimerNow } from "./ComposerActivityStatus";
17import {18import {
19 CHAT_TIMELINE_ANCHOR_OFFSET,
20 keepTimelineEndVisibleAfterOverlayGrowth,
21} from "./timelineScrollAnchoring";
22import {
23 resolveWorkEntryToolPresentation,
18 resolveViewedImageAsset,24 resolveViewedImageAsset,
19 workEntryViewedImagePath,25 workEntryViewedImagePath,
20} from "@t3tools/client-runtime/work-log/presentation";26} from "@t3tools/client-runtime/work-log/presentation";
27import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
21const NOOP_USE_ARTIFACT_TEMPLATE = () => {};28const NOOP_USE_ARTIFACT_TEMPLATE = () => {};
22const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};29const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
23 30
@@ -29,6 +36,7 @@ import { · 5 unmodified lines
29 use,36 use,
30 useCallback,37 useCallback,
31 useEffect,38 useEffect,
39 useLayoutEffect,
32 useMemo,40 useMemo,
33 useRef,41 useRef,
34 useState,42 useState,
@@ -61,7 +69,9 @@ import { · 26 unmodified lines
61 resolveDiffThemeName,69 resolveDiffThemeName,
62 resolveFileDiffPath,70 resolveFileDiffPath,
63} from "../../lib/diffRendering";71} from "../../lib/diffRendering";
72import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting";
64import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";73import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";
74import { T3Wordmark } from "../T3Wordmark";
65import {75import {
66 BotIcon,76 BotIcon,
67 BrainIcon,77 BrainIcon,
@@ -92,14 +102,24 @@ import { · 24 unmodified lines
92 ZapIcon,102 ZapIcon,
93} from "lucide-react";103} from "lucide-react";
94import { Button } from "../ui/button";104import { Button } from "../ui/button";
105import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
95import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";106import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
96import { ProposedPlanCard } from "./ProposedPlanCard";107import { ProposedPlanCard } from "./ProposedPlanCard";
97import { ChangedFilesCard } from "./ChangedFilesTree";108import { ChangedFilesCard } from "./ChangedFilesTree";
98import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation";109import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation";
99import { MessageCopyButton } from "./MessageCopyButton";110import { MessageCopyButton } from "./MessageCopyButton";
111import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar";
112import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
113import {
114 AssistantCitationSource,
115 type AssistantCitationRequest,
116 type AssistantCitationTarget,
117} from "./AssistantCitationSource";
118import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssistantCitationTarget";
100import {119import {
101 computeStableMessagesTimelineRows,120 computeStableMessagesTimelineRows,
102 deriveMessagesTimelineRows,121 deriveMessagesTimelineRows,
122 liveWorkEntryLabel,
103 normalizeCompactToolLabel,123 normalizeCompactToolLabel,
104 resolveTimelineToolPresentation,124 resolveTimelineToolPresentation,
105 resolveAssistantMessageCopyState,125 resolveAssistantMessageCopyState,
@@ -110,12 +130,17 @@ import { · 4 unmodified lines
110 resolveTimelineMinimapIndexFromPointer,130 resolveTimelineMinimapIndexFromPointer,
111 resolveTimelineMinimapInteractiveWidth,131 resolveTimelineMinimapInteractiveWidth,
112 resolveTimelineMinimapTopPercent,132 resolveTimelineMinimapTopPercent,
133 resolveWorkGroupScrollIndex,
134 shouldFollowWorkGroupAppend,
113 shouldPreserveAssistantLineBreaks,135 shouldPreserveAssistantLineBreaks,
136 toolGroupAction,
137 workEntryDisplayLabel,
114 workEntryIsVisibleInGroup,138 workEntryIsVisibleInGroup,
115 type StableMessagesTimelineRowsState,139 type StableMessagesTimelineRowsState,
116 type MessagesTimelineRow,140 type MessagesTimelineRow,
117 TIMELINE_MINIMAP_MIN_ITEMS,141 TIMELINE_MINIMAP_MIN_ITEMS,
118 type TimelineLatestRun,142 type TimelineLatestRun,
143 type WorkGroupScrollAnchor,
119} from "./MessagesTimeline.logic";144} from "./MessagesTimeline.logic";
120import { TerminalContextInlineChip } from "./TerminalContextInlineChip";145import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
121import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";146import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
@@ -163,6 +188,8 @@ import { · 41 unmodified lines
163// ---------------------------------------------------------------------------188// ---------------------------------------------------------------------------
164 189
165interface TimelineRowSharedState {190interface TimelineRowSharedState {
191 citationRequest: AssistantCitationTarget | null;
192 listRef: React.RefObject<LegendListRef | null>;
166 timestampFormat: TimestampFormat;193 timestampFormat: TimestampFormat;
167 routeThreadKey: string;194 routeThreadKey: string;
168 threadRef: ScopedThreadRef | null;195 threadRef: ScopedThreadRef | null;
@@ -193,6 +220,8 @@ interface TimelineRowSharedState { · 24 unmodified lines
193 openingVideoAttachmentId: string | null;220 openingVideoAttachmentId: string | null;
194 onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;221 onUseArtifactTemplate: (template: CodexArtifactTemplate) => void;
195 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;222 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
223 onToggleWorkEntry: (anchorKey: string) => void;
224 workGroupViewState: WorkGroupViewState;
196}225}
197 226
198interface TimelineRowActivityState {227interface TimelineRowActivityState {
@@ -205,6 +234,16 @@ interface TimelineRowActivityState { · 6 unmodified lines
205 234
206const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);235const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
207const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!);236const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!);
237
238interface WorkGroupViewState {
239 scrollPositions: Map<string, WorkGroupScrollAnchor>;
240 expandedEntries: Set<string>;
241}
242
243const WorkGroupViewCtx = createContext<{
244 state: WorkGroupViewState;
245 onToggleEntry: () => void;
246} | null>(null);
208const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;247const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />;
209const TIMELINE_LIST_FADE_HEADER = (248const TIMELINE_LIST_FADE_HEADER = (
210 <div className="h-[var(--workspace-titlebar-scroll-fade-height)]" />249 <div className="h-[var(--workspace-titlebar-scroll-fade-height)]" />
@@ -234,6 +273,12 @@ export interface MessagesTimelineHistoryControls { · 23 unmodified lines
234}273}
235 274
236interface MessagesTimelineProps {275interface MessagesTimelineProps {
276 citationRequest?: AssistantCitationRequest | null;
277 citationHistoryLoading?: boolean;
278 onCiteAssistantText?: (
279 citation: AssistantCitation,
280 sourceAnchor: AssistantCitationSourceAnchor,
281 ) => boolean;
237 isWorking: boolean;282 isWorking: boolean;
238 activeTurnInProgress: boolean;283 activeTurnInProgress: boolean;
239 activeTurnStartedAt?: string | null;284 activeTurnStartedAt?: string | null;
@@ -288,6 +333,8 @@ interface MessagesTimelineProps { · 48 unmodified lines
288 hideEmptyPlaceholder?: boolean;333 hideEmptyPlaceholder?: boolean;
289 topFadeEnabled?: boolean;334 topFadeEnabled?: boolean;
290 historyControls?: MessagesTimelineHistoryControls;335 historyControls?: MessagesTimelineHistoryControls;
336 /** Non-null when older turns exist beyond the loaded window. */
337 loadEarlier?: CitationHistoryPage | null;
291}338}
292 339
293// ---------------------------------------------------------------------------340// ---------------------------------------------------------------------------
@@ -295,6 +342,9 @@ interface MessagesTimelineProps { · 1 unmodified line
295// ---------------------------------------------------------------------------342// ---------------------------------------------------------------------------
296 343
297export const MessagesTimeline = memo(function MessagesTimeline({344export const MessagesTimeline = memo(function MessagesTimeline({
345 citationRequest = null,
346 citationHistoryLoading = false,
347 onCiteAssistantText,
298 isWorking,348 isWorking,
299 activeTurnInProgress,349 activeTurnInProgress,
300 activeTurnStartedAt = null,350 activeTurnStartedAt = null,
@@ -334,17 +384,28 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 33 unmodified lines
334 hideEmptyPlaceholder = false,384 hideEmptyPlaceholder = false,
335 topFadeEnabled = false,385 topFadeEnabled = false,
336 historyControls,386 historyControls,
387 loadEarlier = null,
337}: MessagesTimelineProps) {388}: MessagesTimelineProps) {
338 const [expandedRunIds, setExpandedRunIds] = useState<ReadonlySet<RunId>>(new Set());389 const [expandedRunIds, setExpandedRunIds] = useState<ReadonlySet<RunId>>(new Set());
339 const [expandedAttemptIds, setExpandedAttemptIds] = useState<ReadonlySet<RunAttemptId>>(390 const [expandedAttemptIds, setExpandedAttemptIds] = useState<ReadonlySet<RunAttemptId>>(
340 new Set(),391 new Set(),
341 );392 );
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 }, []);
342 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());397 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 );
343 const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());403 const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());
344 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);404 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
345 const disclosureAnchorKeyRef = useRef<string | null>(null);405 const disclosureAnchorKeyRef = useRef<string | null>(null);
346 const disclosureSettleFrameRef = useRef<number | null>(null);406 const disclosureSettleFrameRef = useRef<number | null>(null);
347 const disclosureSettleSecondFrameRef = useRef<number | null>(null);407 const disclosureSettleSecondFrameRef = useRef<number | null>(null);
408 const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustment);
348 409
349 useEffect(() => {410 useEffect(() => {
350 return () => {411 return () => {
@@ -499,6 +560,31 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 148 unmodified lines
499 const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(560 const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
500 null,561 null,
501 );562 );
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]);
502 const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);588 const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);
503 const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0);589 const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0);
504 const handleAnchorReady = useCallback(590 const handleAnchorReady = useCallback(
@@ -518,8 +604,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 13 unmodified lines
518 [anchorMessageId, onAnchorSizeChanged],604 [anchorMessageId, onAnchorSizeChanged],
519 );605 );
520 const anchoredEndSpace = useMemo(() => {606 const anchoredEndSpace = useMemo(() => {
521 const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) =>607 const config = resolveChatListAnchoredEndSpace(
522 row.kind === "message" && row.message.role === "user" ? row.message.id : null,608 rows,
609 anchorMessageId,
610 (row) => (row.kind === "message" && row.message.role === "user" ? row.message.id : null),
611 { anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET },
523 );612 );
524 return config613 return config
525 ? { ...config, onReady: handleAnchorReady, onSizeChanged: handleAnchorSizeChanged }614 ? { ...config, onReady: handleAnchorReady, onSizeChanged: handleAnchorSizeChanged }
@@ -537,7 +626,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 11 unmodified lines
537 const handleScroll = useCallback(() => {626 const handleScroll = useCallback(() => {
538 const state = listRef.current?.getState?.();627 const state = listRef.current?.getState?.();
539 const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment);628 const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment);
540 if (isAtEnd !== undefined) {629 if (isAtEnd !== undefined && !citationPositioning) {
541 onIsAtEndChange(isAtEnd);630 onIsAtEndChange(isAtEnd);
542 }631 }
543 if (!state || minimapItems.length === 0) {632 if (!state || minimapItems.length === 0) {
@@ -567,7 +656,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 23 unmodified lines
567 strip.dataset.inView = next;656 strip.dataset.inView = next;
568 }657 }
569 }658 }
570 }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange, contentInsetEndAdjustment]);659 }, [
660 citationPositioning,
661 contentInsetEndAdjustment,
662 listRef,
663 minimapItems,
664 minimapStripMap,
665 onIsAtEndChange,
666 ]);
571 667
572 useEffect(() => {668 useEffect(() => {
573 const frame = requestAnimationFrame(handleScroll);669 const frame = requestAnimationFrame(handleScroll);
@@ -601,6 +697,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 27 unmodified lines
601 697
602 const sharedState = useMemo<TimelineRowSharedState>(698 const sharedState = useMemo<TimelineRowSharedState>(
603 () => ({699 () => ({
700 citationRequest: readyCitationRequest,
701 listRef,
604 timestampFormat,702 timestampFormat,
605 routeThreadKey,703 routeThreadKey,
606 threadRef: parseScopedThreadKey(routeThreadKey),704 threadRef: parseScopedThreadKey(routeThreadKey),
@@ -623,8 +721,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 16 unmodified lines
623 onToggleTurnFold,721 onToggleTurnFold,
624 onToggleAttemptFold,722 onToggleAttemptFold,
625 onToggleWorkGroup,723 onToggleWorkGroup,
724 onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure,
725 workGroupViewState,
626 }),726 }),
627 [727 [
728 readyCitationRequest,
729 listRef,
628 timestampFormat,730 timestampFormat,
629 routeThreadKey,731 routeThreadKey,
630 markdownCwd,732 markdownCwd,
@@ -646,6 +748,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 15 unmodified lines
646 onToggleTurnFold,748 onToggleTurnFold,
647 onToggleAttemptFold,749 onToggleAttemptFold,
648 onToggleWorkGroup,750 onToggleWorkGroup,
751 suspendEndScrollMaintenanceForDisclosure,
752 workGroupViewState,
649 ],753 ],
650 );754 );
651 const activityState = useMemo<TimelineRowActivityState>(755 const activityState = useMemo<TimelineRowActivityState>(
@@ -722,7 +826,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 70 unmodified lines
722 return (826 return (
723 <TimelineRowCtx value={sharedState}>827 <TimelineRowCtx value={sharedState}>
724 <TimelineRowActivityCtx value={activityState}>828 <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}
726 <LegendList<MessagesTimelineRow>841 <LegendList<MessagesTimelineRow>
727 ref={listRef}842 ref={listRef}
728 data={rows}843 data={rows}
@@ -730,7 +845,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 1 unmodified line
730 getItemType={getItemType}845 getItemType={getItemType}
731 renderItem={renderItem}846 renderItem={renderItem}
732 estimatedItemSize={90}847 estimatedItemSize={90}
733 initialScrollAtEnd848 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}
734 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}853 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
735 contentInsetEndAdjustment={contentInsetEndAdjustment}854 contentInsetEndAdjustment={contentInsetEndAdjustment}
736 // LegendList owns ordinary end-follow (#5449): the app only turns855 // LegendList owns ordinary end-follow (#5449): the app only turns
@@ -738,11 +857,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 1 unmodified line
738 // sent turn anchors near the top (anchoredEndSpace), or for the857 // sent turn anchors near the top (anchoredEndSpace), or for the
739 // two-frame settle window of a fold toggle.858 // two-frame settle window of a fold toggle.
740 maintainScrollAtEnd={859 maintainScrollAtEnd={
741 anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling860 citationPositioning ||
861 anchoredEndSpace ||
862 !liveFollowEnabled ||
863 disclosureToggleSettling
742 ? false864 ? false
743 : TIMELINE_MAINTAIN_SCROLL_AT_END865 : TIMELINE_MAINTAIN_SCROLL_AT_END
744 }866 }
745 maintainVisibleContentPosition={maintainVisibleContentPosition}867 maintainVisibleContentPosition={
868 citationPositioning ? false : maintainVisibleContentPosition
869 }
746 onScroll={handleScroll}870 onScroll={handleScroll}
747 className={cn(871 className={cn(
748 "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",872 "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
1101type TimelineRow = MessagesTimelineRow;1225type TimelineRow = MessagesTimelineRow;
1102 1226
1103const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) {1227const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) {
1104 const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry;1228 const isExpandedToolGroup = row.kind === "work" && row.isExpandedToolGroup;
1105 const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry;
1106 const isExpandedToolGroupHeader =1229 const isExpandedToolGroupHeader =
1107 (row.kind === "work-toggle" && row.expanded) || (row.kind === "work-live" && row.expanded);1230 (row.kind === "work-toggle" && row.expanded) || (row.kind === "work-live" && row.expanded);
1108 1231
@@ -1111,10 +1234,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 2 unmodified lines
1111 className={cn(1234 className={cn(
1112 // Commentary (non-terminal assistant) rows carry no metadata row, so1235 // Commentary (non-terminal assistant) rows carry no metadata row, so
1113 // they sit closer to the work that follows them.1236 // they sit closer to the work that follows them.
1114 isExpandedToolGroupEntry1237 isExpandedToolGroup
1115 ? isLastExpandedToolGroupEntry1238 ? "pb-1"
1116 ? "pb-1"
1117 : "pb-0"
1118 : isExpandedToolGroupHeader1239 : isExpandedToolGroupHeader
1119 ? "pb-0"1240 ? "pb-0"
1120 : row.kind === "turn-fold"1241 : row.kind === "turn-fold"
@@ -1141,8 +1262,9 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 20 unmodified lines
1141 >1262 >
1142 {row.kind === "work" ? (1263 {row.kind === "work" ? (
1143 <WorkGroupSection1264 <WorkGroupSection
1265 anchorKey={row.id}
1144 groupedEntries={row.groupedEntries}1266 groupedEntries={row.groupedEntries}
1145 isExpandedToolGroupEntry={row.isExpandedToolGroupEntry}1267 isExpandedToolGroup={row.isExpandedToolGroup}
1146 />1268 />
1147 ) : null}1269 ) : null}
1148 {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null}1270 {row.kind === "work-live" ? <LiveWorkEntryTimelineRow row={row} /> : null}
@@ -1486,16 +1608,24 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess · 337 unmodified lines
1486 return (1608 return (
1487 <>1609 <>
1488 <div className="relative min-w-0 px-1 py-0.5">1610 <div className="relative min-w-0 px-1 py-0.5">
1489 <ChatMarkdown1611 <AssistantCitationSource
1490 text={messageText}1612 messageId={row.message.id}
1491 cwd={ctx.markdownCwd}1613 {...(ctx.threadRef ? { threadRef: ctx.threadRef } : {})}
1492 threadRef={ctx.threadRef ?? undefined}1614 itemKey={row.id}
1493 isStreaming={Boolean(row.message.streaming)}1615 request={ctx.citationRequest}
1494 lineBreaks={shouldPreserveAssistantLineBreaks(messageText)}1616 listRef={ctx.listRef}
1495 skills={ctx.skills}1617 >
1496 onUseArtifactTemplate={ctx.onUseArtifactTemplate}1618 <ChatMarkdown
1497 onImageExpand={ctx.onImageExpand}1619 text={messageText}
1498 />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>
1499 <AssistantChangedFilesSection1629 <AssistantChangedFilesSection
1500 turnSummary={row.assistantTurnDiffSummary}1630 turnSummary={row.assistantTurnDiffSummary}
1501 routeThreadKey={ctx.routeThreadKey}1631 routeThreadKey={ctx.routeThreadKey}
@@ -1989,40 +2119,47 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ · 487 unmodified lines
1989// re-render only the affected row, not the entire list.2119// re-render only the affected row, not the entire list.
1990// ---------------------------------------------------------------------------2120// ---------------------------------------------------------------------------
1991 2121
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. */
1993const WorkGroupSection = memo(function WorkGroupSection({2123const WorkGroupSection = memo(function WorkGroupSection({
2124 anchorKey,
1994 groupedEntries,2125 groupedEntries,
1995 isExpandedToolGroupEntry,2126 isExpandedToolGroup,
1996}: {2127}: {
2128 anchorKey: string;
1997 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];2129 groupedEntries: Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"];
1998 isExpandedToolGroupEntry: boolean;2130 isExpandedToolGroup: boolean;
1999}) {2131}) {
2000 const { workspaceRoot } = use(TimelineRowCtx);2132 const { workspaceRoot, routeThreadKey } = use(TimelineRowCtx);
2001 const nonEmptyEntries = useMemo(2133 const nonEmptyEntries = useMemo(
2002 () =>2134 () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)),
2003 groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)),2135 [groupedEntries, isExpandedToolGroup],
2004 [groupedEntries, isExpandedToolGroupEntry],
2005 );2136 );
2006 const GroupContainer = isExpandedToolGroupEntry ? "div" : "section";
2007 2137
2008 if (nonEmptyEntries.length === 0) return null;2138 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 }
2009 2149
2010 return (2150 return (
2011 <GroupContainer2151 <section className="-mx-1 space-y-0.5 px-1 py-0.5" aria-label="Activity">
2012 className={cn("-mx-1 px-1", isExpandedToolGroupEntry ? "py-0" : "space-y-0.5 py-0.5")}
2013 aria-label={isExpandedToolGroupEntry ? undefined : "Activity"}
2014 >
2015 <div className="space-y-px">2152 <div className="space-y-px">
2016 {nonEmptyEntries.map((workEntry) => (2153 {nonEmptyEntries.map((workEntry) => (
2017 <SimpleWorkEntryRow2154 <SimpleWorkEntryRow
2018 key={workEntry.id}2155 key={workEntry.id}
2019 workEntry={workEntry}2156 workEntry={workEntry}
2020 workspaceRoot={workspaceRoot}2157 workspaceRoot={workspaceRoot}
2021 isExpandedToolGroupEntry={isExpandedToolGroupEntry}2158 isExpandedToolGroupEntry={false}
2022 />2159 />
2023 ))}2160 ))}
2024 </div>2161 </div>
2025 </GroupContainer>2162 </section>
2026 );2163 );
2027});2164});
2028 2165
@@ -2048,6 +2185,145 @@ function WorkingTimer({ createdAt }: { createdAt: string }) { · 19 unmodified lines
2048 );2185 );
2049}2186}
2050 2187
2188function 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
2325const workEntryKey = (entry: TimelineWorkEntry) => entry.id;
2326
2051function ActivityShimmerOverlay({ children }: { children: ReactNode }) {2327function ActivityShimmerOverlay({ children }: { children: ReactNode }) {
2052 return (2328 return (
2053 <span2329 <span
@@ -2142,10 +2418,11 @@ function LiveActivityContent({ · 88 unmodified lines
2142 announceFailure?: boolean;2418 announceFailure?: boolean;
2143 highlighted?: boolean;2419 highlighted?: boolean;
2144}) {2420}) {
2145 const resolvedIconName = failed ? "circle-alert" : iconName;2421 const isSpecialToolIcon = iconName === "browser" || iconName === "t3-code";
2422 const resolvedIconName = failed && !isSpecialToolIcon ? "circle-alert" : iconName;
2146 2423
2147 return (2424 return (
2148 <div2425 <span
2149 className={cn(2426 className={cn(
2150 "flex min-h-6 min-w-0 items-center gap-1.5 py-0.5",2427 "flex min-h-6 min-w-0 items-center gap-1.5 py-0.5",
2151 resolvedIconName ? "px-0.5" : "px-1",2428 resolvedIconName ? "px-0.5" : "px-1",
@@ -2161,14 +2438,15 @@ function LiveActivityContent({ · 9 unmodified lines
2161 role={announceFailure ? "img" : undefined}2438 role={announceFailure ? "img" : undefined}
2162 aria-label={announceFailure ? "Tool call failed" : undefined}2439 aria-label={announceFailure ? "Tool call failed" : undefined}
2163 >2440 >
2164 <WorkEntryIconSvg2441 <WorkEntryIcon
2165 name={resolvedIconName}2442 name={resolvedIconName}
2166 className={cn("block size-4 shrink-0 stroke-[1.8]", !highlighted && "opacity-70")}2443 className={cn("block size-4 shrink-0 stroke-[1.8]", !highlighted && "opacity-70")}
2167 />2444 />
2168 </span>2445 </span>
2169 ) : null}2446 ) : null}
2170 <span className="min-w-0 flex-1 truncate">{label}</span>2447 <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>
2172 );2450 );
2173}2451}
2174 2452
@@ -2211,6 +2489,8 @@ function toolGroupSummaryIconName( · 36 unmodified lines
2211 return "square-pen";2489 return "square-pen";
2212 case "command":2490 case "command":
2213 return "terminal";2491 return "terminal";
2492 case "browser":
2493 return "browser";
2214 case "search":2494 case "search":
2215 return "globe";2495 return "globe";
2216 case "code-search":2496 case "code-search":
@@ -2244,7 +2524,7 @@ function WorkGroupToggleTimelineRow({ · 27 unmodified lines
2244 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}2524 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
2245 >2525 >
2246 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">2526 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
2247 <WorkEntryIconSvg2527 <WorkEntryIcon
2248 name={toolGroupSummaryIconName(row.summaryKind)}2528 name={toolGroupSummaryIconName(row.summaryKind)}
2249 className="size-4 shrink-0 stroke-[1.8] opacity-70"2529 className="size-4 shrink-0 stroke-[1.8] opacity-70"
2250 />2530 />
@@ -2709,6 +2989,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte · 458 unmodified lines
2709 collapsed: false,2989 collapsed: false,
2710 diffStyle: "unified",2990 diffStyle: "unified",
2711 theme: resolveDiffThemeName(ctx.resolvedTheme),2991 theme: resolveDiffThemeName(ctx.resolvedTheme),
2992 preferredHighlighter: PREFERRED_HIGHLIGHTER,
2712 }}2993 }}
2713 />2994 />
2714 ))}2995 ))}
@@ -2780,6 +3061,7 @@ function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { · 65 unmodified lines
2780type WorkEntryIconName =3061type WorkEntryIconName =
2781 | "bot"3062 | "bot"
2782 | "brain"3063 | "brain"
3064 | "browser"
2783 | "check"3065 | "check"
2784 | "circle-alert"3066 | "circle-alert"
2785 | "eye"3067 | "eye"
@@ -2789,16 +3071,21 @@ type WorkEntryIconName = · 3 unmodified lines
2789 | "search"3071 | "search"
2790 | "square-pen"3072 | "square-pen"
2791 | "terminal"3073 | "terminal"
3074 | "t3-code"
2792 | "wrench"3075 | "wrench"
2793 | "x"3076 | "x"
2794 | "zap";3077 | "zap";
2795 3078
2796function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; className: string }) {3079function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) {
2797 switch (name) {3080 switch (name) {
2798 case "bot":3081 case "bot":
2799 return <BotIcon className={className} aria-hidden />;3082 return <BotIcon className={className} aria-hidden />;
2800 case "brain":3083 case "brain":
2801 return <BrainIcon className={className} aria-hidden />;3084 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 />;
2802 case "check":3089 case "check":
2803 return <CheckIcon className={className} aria-hidden />;3090 return <CheckIcon className={className} aria-hidden />;
2804 case "circle-alert":3091 case "circle-alert":
@@ -2869,33 +3156,6 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { · 64 unmodified lines
2869 };3156 };
2870}3157}
2871 3158
2872function 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
2899function workEntryRawCommand(3159function workEntryRawCommand(
2900 workEntry: Pick<TimelineWorkEntry, "command" | "rawCommand">,3160 workEntry: Pick<TimelineWorkEntry, "command" | "rawCommand">,
2901): string | null {3161): string | null {
@@ -2951,42 +3211,14 @@ function buildToolCallExpandedBody( · 49 unmodified lines
2951const toolCallExpandedBodyClassName =3211const toolCallExpandedBodyClassName =
2952 "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";3212 "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";
2953 3213
2954function 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
2974function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {3214function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
2975 if (workEntry.itemType === "user_input_request") {3215 if (workEntry.itemType === "user_input_request") {
2976 return "message-circle";3216 return "message-circle";
2977 }3217 }
2978 if (workEntry.requestKind === "command") return "terminal";3218 const toolPresentation = resolveWorkEntryToolPresentation(workEntry);
2979 if (workEntry.requestKind === "file-read") return "eye";3219 if (toolPresentation) return toolPresentation.icon;
2980 if (workEntry.requestKind === "file-change") return "square-pen";3220 const action = toolGroupAction(workEntry);
2981 3221 if (action !== "other") return toolGroupSummaryIconName(action);
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";
2990 3222
2991 switch (workEntry.itemType) {3223 switch (workEntry.itemType) {
2992 case "dynamic_tool":3224 case "dynamic_tool":
@@ -3013,6 +3245,33 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { · 20 unmodified lines
3013 return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle));3245 return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle));
3014}3246}
3015 3247
3248function 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
3016const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation();3275const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation();
3017 3276
3018const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {3277const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
@@ -3023,7 +3282,19 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 4 unmodified lines
3023 const { workEntry, workspaceRoot, isExpandedToolGroupEntry = false } = props;3282 const { workEntry, workspaceRoot, isExpandedToolGroupEntry = false } = props;
3024 const activity = use(TimelineRowActivityCtx);3283 const activity = use(TimelineRowActivityCtx);
3025 const ctx = use(TimelineRowCtx);3284 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 };
3027 const iconConfig = workToneIcon(workEntry.tone);3298 const iconConfig = workToneIcon(workEntry.tone);
3028 const showWarningIndicator = false;3299 const showWarningIndicator = false;
3029 const entryIconName = showWarningIndicator ? "circle-alert" : workEntryIconName(workEntry);3300 const entryIconName = showWarningIndicator ? "circle-alert" : workEntryIconName(workEntry);
@@ -3083,12 +3354,13 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 53 unmodified lines
3083 ? {3354 ? {
3084 role: "button" as const,3355 role: "button" as const,
3085 tabIndex: 0 as const,3356 tabIndex: 0 as const,
3086 "aria-label": displayText,3357 "aria-label": showFailedIndicator ? `${displayText}, tool call failed` : displayText,
3087 onClick: () => setExpanded((v) => !v),3358 "aria-expanded": expanded,
3359 onClick: toggleExpanded,
3088 onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {3360 onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
3089 if (e.key === "Enter" || e.key === " ") {3361 if (e.key === "Enter" || e.key === " ") {
3090 e.preventDefault();3362 e.preventDefault();
3091 setExpanded((v) => !v);3363 toggleExpanded();
3092 }3364 }
3093 },3365 },
3094 }3366 }
@@ -3115,7 +3387,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 20 unmodified lines
3115 {toolPresentation?.logo === "t3-code" ? (3387 {toolPresentation?.logo === "t3-code" ? (
3116 <T3CodeToolLogo />3388 <T3CodeToolLogo />
3117 ) : (3389 ) : (
3118 <WorkEntryIconSvg3390 <WorkEntryIcon
3119 name={entryIconName}3391 name={entryIconName}
3120 className="block size-3.5 shrink-0 stroke-[1.8] opacity-80"3392 className="block size-3.5 shrink-0 stroke-[1.8] opacity-80"
3121 />3393 />
apps/web/src/components/chat/useAssistantCitationTarget.ts+3−3
@@ -1,5 +1,5 @@
1import type { LegendListRef } from "@legendapp/list/react";1import type { LegendListRef } from "@legendapp/list/react";
2import type { TurnId } from "@t3tools/contracts";2import type { RunId } from "@t3tools/contracts";
3import { useCallback, useEffect, useRef, useState, type RefObject } from "react";3import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
4import type { TimelineEntry } from "../../session-logic";4import type { TimelineEntry } from "../../session-logic";
5import type { MessagesTimelineRow } from "./MessagesTimeline.logic";5import type { MessagesTimelineRow } from "./MessagesTimeline.logic";
@@ -31,7 +31,7 @@ export function useAssistantCitationTarget({ · 25 unmodified lines
31 viewport: HTMLElement | null;31 viewport: HTMLElement | null;
32 historyLoading: boolean;32 historyLoading: boolean;
33 loadEarlier: CitationHistoryPage | null;33 loadEarlier: CitationHistoryPage | null;
34 onExpandTurn: (turnId: TurnId) => void;34 onExpandTurn: (runId: RunId) => void;
35 onManualNavigation: () => void;35 onManualNavigation: () => void;
36}) {36}) {
37 const [ready, setReady] = useState<AssistantCitationTarget | null>(null);37 const [ready, setReady] = useState<AssistantCitationTarget | null>(null);
@@ -119,7 +119,7 @@ export function useAssistantCitationTarget({ · 81 unmodified lines
119 (row) => row.kind === "message" && row.message.id === navigation.target.citation.messageId,119 (row) => row.kind === "message" && row.message.id === navigation.target.citation.messageId,
120 );120 );
121 if (index < 0) {121 if (index < 0) {
122 if (source.message.turnId) onExpandTurn(source.message.turnId);122 if (source.message.runId) onExpandTurn(source.message.runId);
123 return;123 return;
124 }124 }
125 if (listLoaded && listRef.current) setReady(navigation.target);125 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
13 PullRequestReviewThread,13 PullRequestReviewThread,
14 PullRequestState,14 PullRequestState,
15 PullRequestUpdateMethod,15 PullRequestUpdateMethod,
16 SourceControlProviderKind,
16 VcsRef,17 VcsRef,
17} from "@t3tools/contracts";18} from "@t3tools/contracts";
18 19
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
502 margin-left: var(--live-activity-focus-width);502 margin-left: var(--live-activity-focus-width);
503}503}
504 504
505@property --assistant-citation-highlight-opacity {
506 syntax: "<number>";
507 inherits: true;
508 initial-value: 0;
509}
510
505@layer base {511@layer base {
506 ::highlight(t3-assistant-citation) {512 ::highlight(t3-assistant-citation) {
507 background-color: color-mix(513 background-color: color-mix(
@@ -1169,14 +1175,14 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil · 661 unmodified lines
1169 /* Keep controls and floating surfaces close to the neutral-black canvas.1175 /* Keep controls and floating surfaces close to the neutral-black canvas.
1170 Borders and hover states provide separation without milky gray fills. */1176 Borders and hover states provide separation without milky gray fills. */
1171 --background: var(--color-neutral-950);1177 --background: var(--color-neutral-950);
1172 --surface-raised: var(--secondary);1178 --surface-raised: color-mix(in srgb, var(--background) 97%, var(--color-white));
1173 --foreground: var(--color-neutral-100);1179 --foreground: var(--color-neutral-100);
1174 --card: color-mix(in srgb, var(--background) 97%, var(--color-white));1180 --card: color-mix(in srgb, var(--background) 97%, var(--color-white));
1175 --card-foreground: var(--color-neutral-100);1181 --card-foreground: var(--color-neutral-100);
1176 --popover: color-mix(in srgb, var(--background) 97%, var(--color-white));1182 --popover: color-mix(in srgb, var(--background) 97%, var(--color-white));
1177 --popover-foreground: var(--color-neutral-100);1183 --popover-foreground: var(--color-neutral-100);
1178 --primary: oklch(0.571 0.21 264);1184 --primary: oklch(0.571 0.21 264);
1179 --secondary: --alpha(var(--color-white) / 4%);1185 --secondary: --alpha(var(--color-white) / 3%);
1180 --secondary-foreground: var(--color-neutral-100);1186 --secondary-foreground: var(--color-neutral-100);
1181 --muted: --alpha(var(--color-white) / 3%);1187 --muted: --alpha(var(--color-white) / 3%);
1182 --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white));1188 --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
22 readonly updatedAt: string;22 readonly updatedAt: string;
23};23};
24 24
25/** Map a provider driver kind to a user-facing display name. */
26export 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
47/** Prefers the provider's live usage report (#8144); falls back to the last compaction item. */25/** Prefers the provider's live usage report (#8144); falls back to the last compaction item. */
48export function deriveLatestContextWindowSnapshot(26export function deriveLatestContextWindowSnapshot(
49 entries: ReadonlyArray<{27 entries: ReadonlyArray<{
apps/web/src/state/entities.ts+0−34
@@ -22,16 +22,12 @@ import { · 21 unmodified lines
22import { environmentThreadDetails, environmentThreadShells } from "./threads";22import { environmentThreadDetails, environmentThreadShells } from "./threads";
23import { waitForAtomValue } from "./waitForAtomValue";23import { waitForAtomValue } from "./waitForAtomValue";
24 24
25const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]);
26const EMPTY_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]);25const EMPTY_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]);
27const EMPTY_VISIBLE_TURN_ITEMS: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = Object.freeze([]);26const EMPTY_VISIBLE_TURN_ITEMS: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = Object.freeze([]);
28 27
29const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe(28const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe(
30 Atom.withLabel("web-project:empty"),29 Atom.withLabel("web-project:empty"),
31);30);
32const EMPTY_PROJECT_REFS_ATOM = Atom.make(EMPTY_PROJECT_REFS).pipe(
33 Atom.withLabel("web-project-refs:empty"),
34);
35const EMPTY_THREAD_REFS_ATOM = Atom.make(EMPTY_THREAD_REFS).pipe(31const EMPTY_THREAD_REFS_ATOM = Atom.make(EMPTY_THREAD_REFS).pipe(
36 Atom.withLabel("web-thread-refs:empty"),32 Atom.withLabel("web-thread-refs:empty"),
37);33);
@@ -60,32 +56,14 @@ export function useActiveEnvironmentId(): EnvironmentId | null { · 22 unmodified lines
60 return useAtomValue(activeEnvironmentIdAtom);56 return useAtomValue(activeEnvironmentIdAtom);
61}57}
62 58
63export function readActiveEnvironmentId(): EnvironmentId | null {
64 return appAtomRegistry.get(activeEnvironmentIdAtom);
65}
66
67export function setActiveEnvironmentId(environmentId: EnvironmentId | null): void {59export function setActiveEnvironmentId(environmentId: EnvironmentId | null): void {
68 appAtomRegistry.set(activeEnvironmentIdAtom, environmentId);60 appAtomRegistry.set(activeEnvironmentIdAtom, environmentId);
69}61}
70 62
71export function useProjectRefs(): ReadonlyArray<ScopedProjectRef> {
72 return useAtomValue(environmentProjects.projectRefsAtom);
73}
74
75export function useThreadRefs(): ReadonlyArray<ScopedThreadRef> {63export function useThreadRefs(): ReadonlyArray<ScopedThreadRef> {
76 return useAtomValue(environmentThreadShells.threadRefsAtom);64 return useAtomValue(environmentThreadShells.threadRefsAtom);
77}65}
78 66
79export 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
89export function useEnvironmentThreadRefs(67export function useEnvironmentThreadRefs(
90 environmentId: EnvironmentId | null,68 environmentId: EnvironmentId | null,
91): ReadonlyArray<ScopedThreadRef> {69): ReadonlyArray<ScopedThreadRef> {
@@ -283,18 +261,6 @@ export function readEnvironmentThreadRefs( · 191 unmodified lines
283 return appAtomRegistry.get(environmentThreadShells.environmentThreadRefsAtom(environmentId));261 return appAtomRegistry.get(environmentThreadShells.environmentThreadRefsAtom(environmentId));
284}262}
285 263
286export function readThreadRefs(): ReadonlyArray<ScopedThreadRef> {
287 return appAtomRegistry.get(environmentThreadShells.threadRefsAtom);
288}
289
290export function readThreadShells(): ReadonlyArray<EnvironmentThreadShell> {264export function readThreadShells(): ReadonlyArray<EnvironmentThreadShell> {
291 return appAtomRegistry.get(environmentThreadShells.threadShellsAtom);265 return appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
292}266}
293
294export 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
230 "./work-log/command-label": {230 "./work-log/command-label": {
231 "types": "./src/work-log/commandLabel.ts",231 "types": "./src/work-log/commandLabel.ts",
232 "default": "./src/work-log/commandLabel.ts"232 "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"
233 }237 }
234 },238 },
235 "scripts": {239 "scripts": {
packages/client-runtime/src/connection/registry.test.ts+1−0
@@ -50,6 +50,7 @@ import * as EnvironmentRegistry from "./registry.ts"; · 49 unmodified lines
50import * as RpcSession from "../rpc/session.ts";50import * as RpcSession from "../rpc/session.ts";
51import * as EnvironmentSupervisor from "./supervisor.ts";51import * as EnvironmentSupervisor from "./supervisor.ts";
52import * as ConnectionWakeups from "./wakeups.ts";52import * as ConnectionWakeups from "./wakeups.ts";
53import { runDesktopCommitWithReconnectObserver } from "../state/server.ts";
53import { v2ShellSnapshot } from "../state/orchestrationV2TestFixtures.ts";54import { v2ShellSnapshot } from "../state/orchestrationV2TestFixtures.ts";
54 55
55const TARGET = new PrimaryConnectionTarget({56const TARGET = new PrimaryConnectionTarget({
packages/client-runtime/src/operations/threadTitle.ts+2−1
@@ -1,3 +1,4 @@
1import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
1import { truncate } from "@t3tools/shared/String";2import { truncate } from "@t3tools/shared/String";
2 3
3export interface ThreadTitleSeedInput {4export interface ThreadTitleSeedInput {
@@ -7,7 +8,7 @@ export interface ThreadTitleSeedInput { · 3 unmodified lines
7}8}
8 9
9function normalizeTitleSeed(value: string): string {10function normalizeTitleSeed(value: string): string {
10 return value.trim().replace(/\s+/gu, " ");11 return assistantCitationsToPlainText(value).trim().replace(/\s+/gu, " ");
11}12}
12 13
13export function deriveThreadTitleSeed(input: ThreadTitleSeedInput): string {14export function deriveThreadTitleSeed(input: ThreadTitleSeedInput): string {
packages/client-runtime/src/state/models.ts+0−10
@@ -4,7 +4,6 @@ import type { · 3 unmodified lines
4 MessageId,4 MessageId,
5 OrchestrationProjectShell,5 OrchestrationProjectShell,
6 OrchestrationV2RunStatus,6 OrchestrationV2RunStatus,
7 OrchestrationV2ShellSnapshot,
8 OrchestrationV2ThreadProjection,7 OrchestrationV2ThreadProjection,
9 OrchestrationV2ThreadShell,8 OrchestrationV2ThreadShell,
10 PlanId,9 PlanId,
@@ -237,12 +236,3 @@ export function presentThreadShell( · 226 unmodified lines
237}236}
238 237
239export const scopeThreadShell = presentThreadShell;238export const scopeThreadShell = presentThreadShell;
240
241export 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 @@
1type CommandWrapper = "env" | "sudo";1type CommandWrapper = "env" | "sudo";
2 2
3const SHELL_PROGRAMS = new Set(["sh", "bash", "zsh", "dash", "ash", "ksh", "fish"]);
4const SHELL_OPTIONS_WITH_VALUE = new Set(["-o", "-O", "--rcfile", "--init-file"]);
5
6function 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
3const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = {19const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = {
4 env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]),20 env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]),
5 sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]),21 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
162 }178 }
163 return null;179 return null;
164 }180 }
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;
166 }189 }
167 190
168 return null;191 return null;
packages/client-runtime/src/work-log/presentation.test.ts+44−92
@@ -11,78 +11,10 @@ import { · 10 unmodified lines
11 toolGroupAction,11 toolGroupAction,
12 toolGroupSummaryKind,12 toolGroupSummaryKind,
13 type WorkLogPresentationEntry,13 type WorkLogPresentationEntry,
14 type WorkLogToolLifecycleStatus,
14 workEntryViewedImagePath,15 workEntryViewedImagePath,
15} from "./presentation.js";16} from "./presentation.js";
16 17
17describe("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
86describe("resolveWorkEntryToolPresentation", () => {18describe("resolveWorkEntryToolPresentation", () => {
87 it.each([19 it.each([
88 "mcp__t3-code__preview_click",20 "mcp__t3-code__preview_click",
@@ -94,7 +26,7 @@ describe("resolveWorkEntryToolPresentation", () => { · 5 unmodified lines
94 "preview_click",26 "preview_click",
95 ])("recognizes browser tool names across providers: %s", (label) => {27 ])("recognizes browser tool names across providers: %s", (label) => {
96 expect(resolveWorkEntryToolPresentation({ label })).toEqual({28 expect(resolveWorkEntryToolPresentation({ label })).toEqual({
97 displayName: "Clicking in the preview browser",29 displayName: "Click in the preview browser",
98 icon: "browser",30 icon: "browser",
99 });31 });
100 });32 });
@@ -106,7 +38,7 @@ describe("resolveWorkEntryToolPresentation", () => { · 5 unmodified lines
106 toolTitle: "Inspect the current page",38 toolTitle: "Inspect the current page",
107 toolData: { server: "t3-code", tool: "preview_snapshot", result: { title: "Example" } },39 toolData: { server: "t3-code", tool: "preview_snapshot", result: { title: "Example" } },
108 }),40 }),
109 ).toEqual({ displayName: "Taking a snapshot of the preview page", icon: "browser" });41 ).toEqual({ displayName: "Take a snapshot of the preview page", icon: "browser" });
110 });42 });
111 43
112 it.each([44 it.each([
@@ -115,12 +47,12 @@ describe("resolveWorkEntryToolPresentation", () => { · 2 unmodified lines
115 ["failed", "Failed to click in the preview browser"],47 ["failed", "Failed to click in the preview browser"],
116 ["declined", "Declined to click in the preview browser"],48 ["declined", "Declined to click in the preview browser"],
117 ["stopped", "Stopped clicking in the preview browser"],49 ["stopped", "Stopped clicking in the preview browser"],
118 ["unknown", "Clicking in the preview browser"],50 ["unknown", "Click in the preview browser"],
119 ])("describes the tool's own %s state", (toolLifecycleStatus, displayName) => {51 ] as const)("describes the tool's own %s state", (toolLifecycleStatus, displayName) => {
120 expect(52 expect(
121 resolveWorkEntryToolPresentation({53 resolveWorkEntryToolPresentation({
122 label: "T3-code.preview_click",54 label: "T3-code.preview_click",
123 toolLifecycleStatus,55 toolLifecycleStatus: toolLifecycleStatus as WorkLogToolLifecycleStatus,
124 }),56 }),
125 ).toEqual({ displayName, icon: "browser" });57 ).toEqual({ displayName, icon: "browser" });
126 });58 });
@@ -184,7 +116,7 @@ describe("resolveWorkEntryToolPresentation", () => { · 57 unmodified lines
184 label: "mcp__t3_code__task_status",116 label: "mcp__t3_code__task_status",
185 toolTitle: "Check the child task",117 toolTitle: "Check the child task",
186 }),118 }),
187 ).toEqual({ displayName: "Getting delegated task status", icon: "t3-code" });119 ).toEqual({ displayName: "Get delegated task status", icon: "t3-code" });
188 });120 });
189 121
190 it("does not brand unknown tools or another server's matching tool name", () => {122 it("does not brand unknown tools or another server's matching tool name", () => {
@@ -206,14 +138,20 @@ describe("resolveWorkEntryToolPresentation", () => { · 15 unmodified lines
206});138});
207 139
208describe("browser group summaries", () => {140describe("browser group summaries", () => {
141 const summarizeGroupLabel = (entries: ReadonlyArray<WorkLogPresentationEntry>) =>
142 summarizeToolGroup(entries).summary;
209 const browserEntry: WorkLogPresentationEntry = {143 const browserEntry: WorkLogPresentationEntry = {
144 id: "browser",
145 createdAt: "2026-09-01T00:00:00Z",
210 label: "MCP tool call",146 label: "MCP tool call",
211 toolData: { server: "t3-code", tool: "preview_click" },147 toolData: { server: "t3-code", tool: "preview_click" },
212 itemType: "mcp_tool_call",148 itemType: "dynamic_tool",
213 toolLifecycleStatus: "completed",149 toolLifecycleStatus: "completed",
214 tone: "tool",150 tone: "tool",
215 };151 };
216 const commandEntry: WorkLogPresentationEntry = {152 const commandEntry: WorkLogPresentationEntry = {
153 id: "command",
154 createdAt: "2026-09-01T00:00:00Z",
217 label: "Ran command",155 label: "Ran command",
218 command: "/bin/bash -lc 'vp test run'",156 command: "/bin/bash -lc 'vp test run'",
219 itemType: "command_execution",157 itemType: "command_execution",
@@ -226,7 +164,7 @@ describe("browser group summaries", () => { · 6 unmodified lines
226 ...browserEntry,164 ...browserEntry,
227 toolCallId: `browser-${index}`,165 toolCallId: `browser-${index}`,
228 }));166 }));
229 expect(summarizeToolGroup(entries)).toBe(167 expect(summarizeGroupLabel(entries)).toBe(
230 `Used browser ${count} ${count === 1 ? "time" : "times"}`,168 `Used browser ${count} ${count === 1 ? "time" : "times"}`,
231 );169 );
232 expect(toolGroupSummaryKind(entries)).toBe("browser");170 expect(toolGroupSummaryKind(entries)).toBe("browser");
@@ -237,13 +175,13 @@ describe("browser group summaries", () => { · 4 unmodified lines
237 ...Array.from({ length: 4 }, () => commandEntry),175 ...Array.from({ length: 4 }, () => commandEntry),
238 ...Array.from({ length: 15 }, () => browserEntry),176 ...Array.from({ length: 15 }, () => browserEntry),
239 ];177 ];
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");
241 expect(toolGroupSummaryKind(entries)).toBe("mixed");179 expect(toolGroupSummaryKind(entries)).toBe("mixed");
242 });180 });
243 181
244 it("preserves first-seen action ordering alongside non-browser tools", () => {182 it("preserves first-seen action ordering alongside non-browser tools", () => {
245 expect(183 expect(
246 summarizeToolGroup([184 summarizeGroupLabel([
247 browserEntry,185 browserEntry,
248 commandEntry,186 commandEntry,
249 {187 {
@@ -251,12 +189,12 @@ describe("browser group summaries", () => { · 1 unmodified line
251 toolData: { server: "t3-code", tool: "task_status" },189 toolData: { server: "t3-code", tool: "task_status" },
252 },190 },
253 ]),191 ]),
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");
255 });193 });
256 194
257 it("recognizes Claude browser identity without treating script metadata as a shell command", () => {195 it("recognizes Claude browser identity without treating script metadata as a shell command", () => {
258 expect(196 expect(
259 summarizeToolGroup([197 summarizeGroupLabel([
260 {198 {
261 ...browserEntry,199 ...browserEntry,
262 command: "node inspect-page.js",200 command: "node inspect-page.js",
@@ -268,21 +206,27 @@ describe("browser group summaries", () => { · 5 unmodified lines
268 206
269 it("keeps foreign tools and web searches out of the browser count", () => {207 it("keeps foreign tools and web searches out of the browser count", () => {
270 expect(208 expect(
271 summarizeToolGroup([209 summarizeGroupLabel([
272 browserEntry,210 browserEntry,
273 {211 {
274 ...browserEntry,212 ...browserEntry,
275 label: "preview_click",213 label: "preview_click",
276 toolData: { server: "another-server", tool: "preview_click" },214 toolData: { server: "another-server", tool: "preview_click" },
277 },215 },
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 },
279 ]),223 ]),
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");
281 });225 });
282 226
283 it("keeps browser screenshots in the browser count while preserving their image path", () => {227 it("keeps browser screenshots in the browser count while preserving their image path", () => {
284 const entry = { ...browserEntry, viewedImagePath: "/workspace/page.png" };228 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");
286 expect(workEntryViewedImagePath(entry)).toBe("/workspace/page.png");230 expect(workEntryViewedImagePath(entry)).toBe("/workspace/page.png");
287 });231 });
288});232});
@@ -393,7 +337,7 @@ describe("workEntryViewedImagePath", () => { · 104 unmodified lines
393 expect(337 expect(
394 workEntryViewedImagePath({338 workEntryViewedImagePath({
395 ...entry,339 ...entry,
396 itemType: "dynamic_tool_call",340 itemType: "dynamic_tool",
397 detail: 'Read: {"file_path":"truncated..."}',341 detail: 'Read: {"file_path":"truncated..."}',
398 viewedImagePath: " /workspace/reference image.webp ",342 viewedImagePath: " /workspace/reference image.webp ",
399 }),343 }),
@@ -415,9 +359,11 @@ describe("toolGroupAction", () => { · 15 unmodified lines
415 it("groups legacy Claude image reads with other reads", () => {359 it("groups legacy Claude image reads with other reads", () => {
416 expect(360 expect(
417 toolGroupAction({361 toolGroupAction({
362 id: "legacy-read",
363 createdAt: "2026-09-01T00:00:00Z",
418 label: "Tool call",364 label: "Tool call",
419 tone: "tool",365 tone: "tool",
420 itemType: "dynamic_tool_call",366 itemType: "dynamic_tool",
421 viewedImagePath: "/workspace/reference.png",367 viewedImagePath: "/workspace/reference.png",
422 }),368 }),
423 ).toBe("read");369 ).toBe("read");
@@ -427,11 +373,17 @@ describe("toolGroupAction", () => { · 3 unmodified lines
427describe("resolveViewedImageAsset", () => {373describe("resolveViewedImageAsset", () => {
428 const threadId = ThreadId.make("thread-1");374 const threadId = ThreadId.make("thread-1");
429 375
430 it("serves t3 attachment paths in place like any other host path", () => {376 it("loads t3 attachment paths as attachments", () => {
431 const path = "/Users/demo/.t3/dev/attachments/11111111-1111-4111-8111-111111111111.png";377 const attachmentId =
432 expect(resolveViewedImageAsset(path, { threadId, workspaceRoot: "/workspace" })).toEqual({378 "11111111-1111-4111-8111-111111111111-22222222-2222-4222-8222-222222222222";
433 resource: { _tag: "media-file", threadId, path },379 expect(
434 alt: "11111111-1111-4111-8111-111111111111.png",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`,
435 srcFragment: "",387 srcFragment: "",
436 });388 });
437 });389 });
packages/client-runtime/src/work-log/presentation.ts+261−8
@@ -1,4 +1,9 @@
1import type { AssetResource, OrchestrationV2TurnItem, ThreadId } from "@t3tools/contracts";1import {
2 isToolLifecycleItemType,
3 type AssetResource,
4 type OrchestrationV2TurnItem,
5 type ThreadId,
6} from "@t3tools/contracts";
2import {7import {
3 resolveT3McpToolSummaryAction,8 resolveT3McpToolSummaryAction,
4 type T3McpToolSummaryAction,9 type T3McpToolSummaryAction,
@@ -27,6 +32,7 @@ export interface WorkLogPresentationEntry { · 22 unmodified lines
27 readonly command?: string;32 readonly command?: string;
28 readonly rawCommand?: string;33 readonly rawCommand?: string;
29 readonly detail?: string;34 readonly detail?: string;
35 readonly viewedImagePath?: string;
30 readonly changedFiles?: ReadonlyArray<string>;36 readonly changedFiles?: ReadonlyArray<string>;
31 readonly toolTitle?: string;37 readonly toolTitle?: string;
32 readonly toolData?: unknown;38 readonly toolData?: unknown;
@@ -40,6 +46,7 @@ export type ToolGroupAction = · 7 unmodified lines
40 | "read"46 | "read"
41 | "edit"47 | "edit"
42 | "command"48 | "command"
49 | "browser"
43 | "code-search"50 | "code-search"
44 | "search"51 | "search"
45 | "other"52 | "other"
@@ -56,16 +63,251 @@ export function normalizeCompactToolLabel(value: string): string { · 10 unmodified lines
56 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();63 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
57}64}
58 65
59export function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean {66const 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
108function 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. */
137export 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
60 return (157 return (
61 entry.tone === "tool" ||158 resolveT3McpToolPresentation(entry.toolTitle, status) ??
62 entry.tone === "thinking" ||159 resolveT3McpToolPresentation(entry.label, status)
63 entry.tone === "error" ||
64 entry.command !== undefined ||
65 entry.requestKind !== undefined
66 );160 );
67}161}
68 162
163function 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
169function nonEmptyString(value: unknown): string | null {
170 return typeof value === "string" && value.trim().length > 0 ? value : null;
171}
172
173function 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. */
193export 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 */
235function 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 */
262export 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
304export 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
69function toolDetailTextLooksLikeFailure(text: string): boolean {311function toolDetailTextLooksLikeFailure(text: string): boolean {
70 const normalized = text.toLowerCase();312 const normalized = text.toLowerCase();
71 return (313 return (
@@ -110,7 +352,8 @@ export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): · 38 unmodified lines
110}352}
111 353
112export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction {354export 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";
114 if (357 if (
115 entry.itemType === "dynamic_tool" &&358 entry.itemType === "dynamic_tool" &&
116 /^read(?:\s+file)?$/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label))359 /^read(?:\s+file)?$/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label))
@@ -125,6 +368,14 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio · 8 unmodified lines
125}368}
126 369
127export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): string | null {370export 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 }
128 const detail = entry.detail?.trim();379 const detail = entry.detail?.trim();
129 return toolGroupAction(entry) === "read" &&380 return toolGroupAction(entry) === "read" &&
130 detail !== undefined &&381 detail !== undefined &&
@@ -196,6 +447,8 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string { · 65 unmodified lines
196 return `Changed ${count} ${count === 1 ? "file" : "files"}`;447 return `Changed ${count} ${count === 1 ? "file" : "files"}`;
197 case "command":448 case "command":
198 return `Ran ${count} ${count === 1 ? "command" : "commands"}`;449 return `Ran ${count} ${count === 1 ? "command" : "commands"}`;
450 case "browser":
451 return `Used browser ${count} ${count === 1 ? "time" : "times"}`;
199 case "search":452 case "search":
200 return `Searched the web ${count} ${count === 1 ? "time" : "times"}`;453 return `Searched the web ${count} ${count === 1 ? "time" : "times"}`;
201 case "code-search":454 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
779 }779 }
780 const encoded = `${exit.value}\n`;780 const encoded = `${exit.value}\n`;
781 yield* logProtocol({ direction: "outgoing", stage: "raw", payload: encoded });781 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 );
783 });801 });
784 802
785 const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) {803 const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) {