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).
Unified · Split
| @@ -85,6 +85,7 @@ import Animated, { · 84 unmodified lines | ||
| 85 | 85 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 86 | 86 | import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; |
| 87 | 87 | import { useFontFamily } from "../../lib/useFontFamily"; |
| 88 | +import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; | |
| 88 | 89 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 89 | 90 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 90 | 91 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; |
| @@ -123,7 +124,11 @@ import { · 32 unmodified lines | ||
| 123 | 124 | } from "../review/nativeReviewDiffAdapter"; |
| 124 | 125 | import { buildReviewParsedDiff } from "../review/reviewModel"; |
| 125 | 126 | import { cn } from "../../lib/cn"; |
| 126 | −import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../../lib/layout"; | |
| 127 | +import { | |
| 128 | + deriveCenteredContentHorizontalPadding, | |
| 129 | + deriveThreadWorkLogSizing, | |
| 130 | + type LayoutVariant, | |
| 131 | +} from "../../lib/layout"; | |
| 127 | 132 | import { uuidv4 } from "../../lib/uuid"; |
| 128 | 133 | import { |
| 129 | 134 | resolveMarkdownFontSizes, |
| @@ -147,6 +152,7 @@ import type { ThreadContentPresentation } from "./threadContentPresentation"; · 17 unmodified lines | ||
| 147 | 152 | import { |
| 148 | 153 | resolveThreadFeedLiveFollow, |
| 149 | 154 | type ThreadFeedLiveFollowEvent, |
| 155 | + type ThreadWorkGroupScrollPosition, | |
| 150 | 156 | } from "./thread-feed-live-follow"; |
| 151 | 157 | import { |
| 152 | 158 | ThreadDisclosureChevron, |
| @@ -171,7 +177,12 @@ import { appAtomRegistry } from "../../state/atom-registry"; · 18 unmodified lines | ||
| 171 | 177 | import { environmentThreadShells, threadEnvironment } from "../../state/threads"; |
| 172 | 178 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 173 | 179 | import { useV2ItemSupport } from "../../state/v2-item-support"; |
| 174 | −import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; | |
| 180 | +import { | |
| 181 | + basename, | |
| 182 | + fileRoutePathSegments, | |
| 183 | + isAbsolutePath, | |
| 184 | + resolveWorkspaceRelativeFilePath, | |
| 185 | +} from "../files/filePath"; | |
| 175 | 186 | import { waitForThreadShellReady } from "./threadForkNavigation"; |
| 176 | 187 | import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge"; |
| 177 | 188 | |
| @@ -1818,15 +1829,12 @@ function renderFeedEntry( · 1640 unmodified lines | ||
| 1818 | 1829 | activities={entry.activities} |
| 1819 | 1830 | anchorKey={entry.id} |
| 1820 | 1831 | copiedRowId={props.copiedRowId} |
| 1821 | − currentThreadId={props.threadId} | |
| 1822 | − environmentId={props.environmentId} | |
| 1823 | 1832 | expandedRows={props.expandedWorkRows} |
| 1824 | 1833 | rowSizing={props.workRowSizing} |
| 1825 | 1834 | scrollPositions={props.workGroupScrollPositions} |
| 1826 | 1835 | iconSubtleColor={iconSubtleColor} |
| 1827 | 1836 | onCopyRow={props.onCopyWorkRow} |
| 1828 | − onToggleRow={(rowId) => props.onToggleWorkRow(rowId, entry.id)} | |
| 1829 | − workspaceRoot={props.workspaceRoot} | |
| 1837 | + onToggleRow={props.onToggleWorkRow} | |
| 1830 | 1838 | renderImage={props.renderViewedImage} |
| 1831 | 1839 | /> |
| 1832 | 1840 | ); |
| @@ -2145,7 +2153,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 312 unmodified lines | ||
| 2145 | 2153 | const previousLatestTurnRef = useRef(props.latestRun); |
| 2146 | 2154 | const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 2147 | 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 | 2172 | const [viewportWidth, setViewportWidth] = useState(() => |
| 2150 | 2173 | props.layoutVariant === "split" ? 0 : windowWidth, |
| 2151 | 2174 | ); |
| @@ -2516,6 +2539,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 364 unmodified lines | ||
| 2516 | 2539 | // ThreadId, and keying resets (or the list mount) on the bare id would |
| 2517 | 2540 | // carry stale scroll/follow state across an environment switch. |
| 2518 | 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 | 2548 | // A thread switch opens pinned to the end; a send explicitly returns to the |
| 2520 | 2549 | // live edge (ThreadDetailScreen scrolls the new message into place). Both |
| 2521 | 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 | 2858 | threadId: props.threadId, |
| 2830 | 2859 | copiedRowId, |
| 2831 | 2860 | expandedWorkRows, |
| 2861 | + workRowSizing, | |
| 2862 | + workGroupScrollPositions, | |
| 2832 | 2863 | terminalAssistantMessageIds, |
| 2833 | 2864 | unsettledTurnId, |
| 2834 | 2865 | onCopyWorkRow, |
| @@ -4,11 +4,6 @@ import { · 3 unmodified lines | ||
| 4 | 4 | type EnvironmentThreadShell, |
| 5 | 5 | } from "@t3tools/client-runtime/state/shell"; |
| 6 | 6 | |
| 7 | −export function threadSortValue(thread: EnvironmentThreadShell): number { | |
| 8 | − const candidate = Date.parse(thread.updatedAt ?? thread.createdAt); | |
| 9 | − return Number.isNaN(candidate) ? 0 : candidate; | |
| 10 | −} | |
| 11 | − | |
| 12 | 7 | export type ThreadStatusKind = |
| 13 | 8 | | "pending-approval" |
| 14 | 9 | | "awaiting-input" |
| @@ -27,12 +22,6 @@ export interface ThreadStatusPresentation extends StatusTone { · 12 unmodified lines | ||
| 27 | 22 | readonly pulse: boolean; |
| 28 | 23 | } |
| 29 | 24 | |
| 30 | −/** Neutral icon colors for threads with no actionable status. */ | |
| 31 | −export const THREAD_STATUS_NEUTRAL_ICON = { | |
| 32 | − iconColor: "#8e8e93", | |
| 33 | − iconBackground: "rgba(142,142,147,0.22)", | |
| 34 | −} as const; | |
| 35 | − | |
| 36 | 25 | function isLatestRunSettled(thread: EnvironmentThreadShell): boolean { |
| 37 | 26 | if (!thread.latestRun?.startedAt) return false; |
| 38 | 27 | if (!thread.latestRun.completedAt) return false; |
| @@ -26,7 +26,7 @@ describe("project thread title", () => { · 25 unmodified lines | ||
| 26 | 26 | }, |
| 27 | 27 | { |
| 28 | 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 | 31 | ])("uses readable titles and intact links with comment $comment", ({ comment, title }) => { |
| 32 | 32 | const quoteText = "Keep `cache[key]` & <parser> shared.\n Retry!"; |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | type RuntimeMode, |
| 9 | 9 | } from "@t3tools/contracts"; |
| 10 | 10 | import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; |
| 11 | +import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; | |
| 11 | 12 | |
| 12 | 13 | import type { UploadedMobileAttachment } from "./attachmentUpload"; |
| 13 | 14 | |
| @@ -82,7 +83,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe · 68 unmodified lines | ||
| 82 | 83 | } |
| 83 | 84 | |
| 84 | 85 | export function deriveThreadTitleFromPrompt(value: string): string { |
| 85 | − const trimmed = value.trim(); | |
| 86 | + const trimmed = assistantCitationsToPlainText(value).trim(); | |
| 86 | 87 | if (trimmed.length === 0) { |
| 87 | 88 | return "New thread"; |
| 88 | 89 | } |
| @@ -679,9 +679,7 @@ describe("buildThreadFeed", () => { · 678 unmodified lines | ||
| 679 | 679 | ); |
| 680 | 680 | expect(expanded.map((entry) => entry.id)).toEqual([ |
| 681 | 681 | "work-toggle:work-group:activity-neutral", |
| 682 | − "activity-1", | |
| 683 | − "activity-2", | |
| 684 | − "activity-3", | |
| 682 | + "work-details:work-group:activity-neutral", | |
| 685 | 683 | ]); |
| 686 | 684 | expect(expanded[0]).toMatchObject({ |
| 687 | 685 | type: "work-toggle", |
| @@ -6,6 +6,8 @@ import type { · 5 unmodified lines | ||
| 6 | 6 | import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; |
| 7 | 7 | import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; |
| 8 | 8 | import { |
| 9 | + workEntryDisplayIndicatesToolFailure, | |
| 10 | + resolveWorkEntryToolPresentation, | |
| 9 | 11 | summarizeToolGroup, |
| 10 | 12 | toolGroupSummaryKind, |
| 11 | 13 | type ToolGroupSummaryKind, |
| @@ -868,7 +870,7 @@ function appendToolGroupRows( · 856 unmodified lines | ||
| 868 | 870 | : (latestInProgressActivity ?? activities.at(-1)!); |
| 869 | 871 | const groupSummary = summarizeToolGroup(activities.map((activity) => activity.workEntry)); |
| 870 | 872 | const summary = live |
| 871 | − ? liveToolActivitySummary(latestActivity) | |
| 873 | + ? liveToolActivitySummary(latestActivity, latestInProgressActivity !== undefined) | |
| 872 | 874 | : activities.length === 1 && !activities[0]!.toolLike |
| 873 | 875 | ? activities[0]!.workEntry.label |
| 874 | 876 | : groupSummary.summary; |
| @@ -884,7 +886,12 @@ function appendToolGroupRows( · 9 unmodified lines | ||
| 884 | 886 | summaryKind: toolGroupSummaryKind( |
| 885 | 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 | 895 | live, |
| 889 | 896 | shimmer: |
| 890 | 897 | isWorking && |
| @@ -892,32 +899,44 @@ function appendToolGroupRows( · 1 unmodified line | ||
| 892 | 899 | latestActivity.runId === activeRunId, |
| 893 | 900 | }); |
| 894 | 901 | if (!expanded) return; |
| 895 | − for (const activity of activities) { | |
| 896 | − result.push({ | |
| 897 | − type: "activity-group", | |
| 898 | − id: activity.id, | |
| 899 | − createdAt: activity.createdAt, | |
| 900 | − runId: activity.runId, | |
| 901 | − activities: [ | |
| 902 | − { | |
| 903 | − ...activity, | |
| 904 | − groupedToolDetail: true, | |
| 905 | − live: | |
| 906 | − isWorking && | |
| 907 | − activity.id === latestActivity.id && | |
| 908 | − activity.lifecycleStatus === "inProgress" && | |
| 909 | − activity.runId === activeRunId, | |
| 910 | − }, | |
| 911 | − ], | |
| 912 | − }); | |
| 913 | − } | |
| 902 | + result.push({ | |
| 903 | + type: "activity-group", | |
| 904 | + id: `work-details:${groupId}`, | |
| 905 | + createdAt: activities[0]!.createdAt, | |
| 906 | + runId: activities[0]!.runId, | |
| 907 | + activities: activities.map((activity) => ({ | |
| 908 | + ...activity, | |
| 909 | + groupedToolDetail: true, | |
| 910 | + live: | |
| 911 | + isWorking && | |
| 912 | + activity.id === latestActivity.id && | |
| 913 | + activity.lifecycleStatus === "inProgress" && | |
| 914 | + activity.runId === activeRunId, | |
| 915 | + })), | |
| 916 | + }); | |
| 914 | 917 | } |
| 915 | 918 | |
| 916 | −function liveToolActivitySummary(activity: ThreadFeedActivity): string { | |
| 919 | +function liveToolActivitySummary(activity: ThreadFeedActivity, active: boolean): string { | |
| 920 | + const presentation = resolveWorkEntryToolPresentation( | |
| 921 | + activity.workEntry, | |
| 922 | + active ? "inProgress" : "completed", | |
| 923 | + ); | |
| 924 | + if (presentation) return presentation.displayName; | |
| 917 | 925 | const command = activity.workEntry.command?.trim(); |
| 918 | 926 | if (command) { |
| 919 | 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 | 941 | return activity.detail ?? activity.summary; |
| 923 | 942 | } |
| @@ -303,11 +303,18 @@ function modeState(): AcpSchema.SessionModeState { · 302 unmodified lines | ||
| 303 | 303 | // "grok-build" product name, and it rejects unknown ids in session/set_model. |
| 304 | 304 | const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [ |
| 305 | 305 | { |
| 306 | − modelId: "grok-build", | |
| 307 | − name: "Grok Build", | |
| 308 | − ...(initialGrokReasoningEffort | |
| 309 | − ? { _meta: { reasoningEffort: initialGrokReasoningEffort } } | |
| 310 | − : {}), | |
| 306 | + modelId: "grok-4.6", | |
| 307 | + name: "Grok 4.6", | |
| 308 | + _meta: { | |
| 309 | + totalContextTokens: 500_000, | |
| 310 | + supportsReasoningEffort: true, | |
| 311 | + reasoningEffort: initialGrokReasoningEffort ?? "high", | |
| 312 | + reasoningEfforts: [ | |
| 313 | + { id: "xhigh", value: "xhigh", label: "Extra High Effort", default: false }, | |
| 314 | + { id: "high", value: "high", label: "High Effort", default: true }, | |
| 315 | + { id: "low", value: "low", label: "Low Effort", default: false }, | |
| 316 | + ], | |
| 317 | + }, | |
| 311 | 318 | }, |
| 312 | 319 | { modelId: "grok-mock-alt", name: "Grok Mock Alt" }, |
| 313 | 320 | ]; |
| @@ -331,6 +338,9 @@ const program = Effect.gen(function* () { · 17 unmodified lines | ||
| 331 | 338 | request.clientCapabilities?._meta?.parameterizedModelPicker === true; |
| 332 | 339 | return { |
| 333 | 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 | 344 | agentCapabilities: { |
| 335 | 345 | loadSession: true, |
| 336 | 346 | ...(supportsSessionLifecycle |
| @@ -1,3 +1,5 @@ | ||
| 1 | +import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts"; | |
| 2 | + | |
| 1 | 3 | export const browserApiCorsAllowedMethods = ["GET", "POST", "OPTIONS"] as const; |
| 2 | 4 | export const browserApiCorsAllowedHeaders = [ |
| 3 | 5 | "authorization", |
| @@ -7,10 +9,3 @@ export const browserApiCorsAllowedHeaders = [ · 3 unmodified lines | ||
| 7 | 9 | "dpop", |
| 8 | 10 | ORCHESTRATION_PROTOCOL_HEADER, |
| 9 | 11 | ] as const; |
| 10 | − | |
| 11 | −export const browserApiCorsHeaders = { | |
| 12 | − "access-control-allow-origin": "*", | |
| 13 | − "access-control-allow-methods": browserApiCorsAllowedMethods.join(", "), | |
| 14 | − "access-control-allow-headers": browserApiCorsAllowedHeaders.join(", "), | |
| 15 | −} as const; | |
| 16 | −import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts"; | |
| @@ -1406,7 +1406,7 @@ describe("AcpAdapterV2", () => { · 1405 unmodified lines | ||
| 1406 | 1406 | instanceId, |
| 1407 | 1407 | model: "grok-mock-alt", |
| 1408 | 1408 | } satisfies ModelSelection; |
| 1409 | − const originalSelection = { instanceId, model: "grok-build" } satisfies ModelSelection; | |
| 1409 | + const originalSelection = { instanceId, model: "grok-4.6" } satisfies ModelSelection; | |
| 1410 | 1410 | const runtime = yield* adapter.openSession({ |
| 1411 | 1411 | threadId: firstThreadId, |
| 1412 | 1412 | providerSessionId: ProviderSessionId.make("provider-session-acp-active-setup"), |
| @@ -884,6 +884,7 @@ describe("ClaudeAdapterV2 resume compaction", () => { · 883 unmodified lines | ||
| 884 | 884 | environment: {}, |
| 885 | 885 | attachmentsDir, |
| 886 | 886 | fileSystem, |
| 887 | + path: yield* Path.Path, | |
| 887 | 888 | idAllocator, |
| 888 | 889 | queryRunner: { |
| 889 | 890 | allocateSessionId: Effect.succeed("native-thread-claude-resume"), |
| @@ -1100,6 +1101,7 @@ describe("ClaudeAdapterV2 attachments", () => { · 210 unmodified lines | ||
| 1100 | 1101 | environment: {}, |
| 1101 | 1102 | attachmentsDir, |
| 1102 | 1103 | fileSystem, |
| 1104 | + path: yield* Path.Path, | |
| 1103 | 1105 | idAllocator, |
| 1104 | 1106 | queryRunner: { |
| 1105 | 1107 | allocateSessionId: Effect.succeed("native-thread-claude-attachments"), |
| @@ -1237,6 +1239,7 @@ describe("ClaudeAdapterV2 attachments", () => { · 131 unmodified lines | ||
| 1237 | 1239 | environment: {}, |
| 1238 | 1240 | attachmentsDir, |
| 1239 | 1241 | fileSystem, |
| 1242 | + path: yield* Path.Path, | |
| 1240 | 1243 | idAllocator, |
| 1241 | 1244 | queryRunner: { |
| 1242 | 1245 | allocateSessionId: Effect.succeed("native-thread-claude-unsupported-attachment"), |
| @@ -1328,6 +1331,7 @@ describe("ClaudeAdapterV2 native fork", () => { · 85 unmodified lines | ||
| 1328 | 1331 | environment: {}, |
| 1329 | 1332 | attachmentsDir, |
| 1330 | 1333 | fileSystem, |
| 1334 | + path: yield* Path.Path, | |
| 1331 | 1335 | idAllocator, |
| 1332 | 1336 | queryRunner: { |
| 1333 | 1337 | allocateSessionId: Effect.succeed("source-native-session"), |
| @@ -1497,6 +1501,7 @@ describe("ClaudeAdapterV2 native session identity", () => { · 163 unmodified lines | ||
| 1497 | 1501 | environment: {}, |
| 1498 | 1502 | attachmentsDir, |
| 1499 | 1503 | fileSystem, |
| 1504 | + path: yield* Path.Path, | |
| 1500 | 1505 | idAllocator, |
| 1501 | 1506 | queryRunner: { |
| 1502 | 1507 | allocateSessionId: Effect.succeed("native-session-identity"), |
| @@ -1719,6 +1724,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 216 unmodified lines | ||
| 1719 | 1724 | environment: {}, |
| 1720 | 1725 | attachmentsDir, |
| 1721 | 1726 | fileSystem, |
| 1727 | + path: yield* Path.Path, | |
| 1722 | 1728 | idAllocator, |
| 1723 | 1729 | continuationRequests: { |
| 1724 | 1730 | offer: (request) => |
| @@ -2301,6 +2307,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 576 unmodified lines | ||
| 2301 | 2307 | environment: {}, |
| 2302 | 2308 | attachmentsDir, |
| 2303 | 2309 | fileSystem, |
| 2310 | + path: yield* Path.Path, | |
| 2304 | 2311 | idAllocator, |
| 2305 | 2312 | continuationRequests: { offer: () => Effect.void }, |
| 2306 | 2313 | queryRunner: { |
| @@ -2412,6 +2419,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 105 unmodified lines | ||
| 2412 | 2419 | environment: {}, |
| 2413 | 2420 | attachmentsDir, |
| 2414 | 2421 | fileSystem, |
| 2422 | + path: yield* Path.Path, | |
| 2415 | 2423 | idAllocator, |
| 2416 | 2424 | continuationRequests: { |
| 2417 | 2425 | offer: () => Effect.void, |
| @@ -4481,6 +4489,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 2063 unmodified lines | ||
| 4481 | 4489 | environment: {}, |
| 4482 | 4490 | attachmentsDir, |
| 4483 | 4491 | fileSystem, |
| 4492 | + path: yield* Path.Path, | |
| 4484 | 4493 | idAllocator, |
| 4485 | 4494 | continuationRequests: { |
| 4486 | 4495 | offer: (request) => |
| @@ -4747,6 +4756,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 260 unmodified lines | ||
| 4747 | 4756 | environment: {}, |
| 4748 | 4757 | attachmentsDir, |
| 4749 | 4758 | fileSystem, |
| 4759 | + path: yield* Path.Path, | |
| 4750 | 4760 | idAllocator, |
| 4751 | 4761 | continuationRequests: { |
| 4752 | 4762 | offer: (request) => |
| @@ -4979,6 +4989,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 226 unmodified lines | ||
| 4979 | 4989 | environment: {}, |
| 4980 | 4990 | attachmentsDir, |
| 4981 | 4991 | fileSystem, |
| 4992 | + path: yield* Path.Path, | |
| 4982 | 4993 | idAllocator, |
| 4983 | 4994 | continuationRequests: { |
| 4984 | 4995 | offer: (request) => |
| @@ -5169,6 +5180,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 184 unmodified lines | ||
| 5169 | 5180 | environment: {}, |
| 5170 | 5181 | attachmentsDir, |
| 5171 | 5182 | fileSystem, |
| 5183 | + path: yield* Path.Path, | |
| 5172 | 5184 | idAllocator, |
| 5173 | 5185 | continuationRequests: { |
| 5174 | 5186 | offer: () => Effect.void, |
| @@ -5302,6 +5314,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 127 unmodified lines | ||
| 5302 | 5314 | environment: {}, |
| 5303 | 5315 | attachmentsDir, |
| 5304 | 5316 | fileSystem, |
| 5317 | + path: yield* Path.Path, | |
| 5305 | 5318 | idAllocator, |
| 5306 | 5319 | continuationRequests: { |
| 5307 | 5320 | offer: (request) => |
| @@ -5496,6 +5509,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { · 188 unmodified lines | ||
| 5496 | 5509 | environment: {}, |
| 5497 | 5510 | attachmentsDir, |
| 5498 | 5511 | fileSystem, |
| 5512 | + path: yield* Path.Path, | |
| 5499 | 5513 | idAllocator, |
| 5500 | 5514 | continuationRequests: { |
| 5501 | 5515 | offer: () => Effect.void, |
| @@ -74,6 +74,8 @@ import * as Schema from "effect/Schema"; · 73 unmodified lines | ||
| 74 | 74 | import * as Stream from "effect/Stream"; |
| 75 | 75 | |
| 76 | 76 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 77 | +import { planClaudeSkillDispatch } from "../../provider/Drivers/ClaudeSkillDispatch.ts"; | |
| 78 | +import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts"; | |
| 77 | 79 | import { compileClaudeModelSelection } from "../../claudeModelOptions.ts"; |
| 78 | 80 | import { ServerConfig } from "../../config.ts"; |
| 79 | 81 | import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts"; |
| @@ -1064,12 +1066,28 @@ function isSupportedClaudeImageMimeType( · 984 unmodified lines | ||
| 1064 | 1066 | export function makeClaudeUserMessage(input: { |
| 1065 | 1067 | readonly text: string; |
| 1066 | 1068 | readonly priority?: SDKUserMessage["priority"]; |
| 1069 | + readonly skillNames?: ReadonlySet<string>; | |
| 1067 | 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 | 1079 | return { |
| 1069 | 1080 | type: "user", |
| 1070 | 1081 | message: { |
| 1071 | 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 | 1092 | parent_tool_use_id: null, |
| 1075 | 1093 | ...(input.priority === undefined ? {} : { priority: input.priority }), |
| @@ -1082,10 +1100,12 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: · 6 unmodified lines | ||
| 1082 | 1100 | readonly priority?: SDKUserMessage["priority"]; |
| 1083 | 1101 | readonly attachmentsDir: string; |
| 1084 | 1102 | readonly fileSystem: FileSystem.FileSystem; |
| 1103 | + readonly skillNames?: ReadonlySet<string>; | |
| 1085 | 1104 | }) { |
| 1086 | 1105 | if (input.attachments.length === 0) { |
| 1087 | 1106 | return makeClaudeUserMessage({ |
| 1088 | 1107 | text: input.text, |
| 1108 | + ...(input.skillNames === undefined ? {} : { skillNames: input.skillNames }), | |
| 1089 | 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 | 1119 | attachmentsDir: input.attachmentsDir, |
| 1100 | 1120 | }); |
| 1101 | 1121 | |
| 1122 | + const dispatch = | |
| 1123 | + input.skillNames === undefined | |
| 1124 | + ? undefined | |
| 1125 | + : planClaudeSkillDispatch(textWithAttachmentPaths, input.skillNames); | |
| 1102 | 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 | 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 | 1183 | return { |
| 1150 | 1184 | type: "user", |
| 1151 | 1185 | message: { |
| @@ -2317,6 +2351,7 @@ export interface ClaudeAdapterV2Options { · 1165 unmodified lines | ||
| 2317 | 2351 | readonly environment: NodeJS.ProcessEnv; |
| 2318 | 2352 | readonly attachmentsDir: string; |
| 2319 | 2353 | readonly fileSystem: FileSystem.FileSystem; |
| 2354 | + readonly path: Path.Path; | |
| 2320 | 2355 | readonly idAllocator: IdAllocatorV2Shape; |
| 2321 | 2356 | readonly queryRunner: ClaudeAgentSdkQueryRunnerShape; |
| 2322 | 2357 | /** Sink for wake-turn continuation requests; defaults to dropping them. */ |
| @@ -2328,11 +2363,33 @@ export interface ClaudeAdapterV2Options { · 5 unmodified lines | ||
| 2328 | 2363 | export function makeClaudeAdapterV2( |
| 2329 | 2364 | adapterOptions: ClaudeAdapterV2Options, |
| 2330 | 2365 | ): ProviderAdapterV2Shape { |
| 2331 | − const { attachmentsDir, fileSystem, idAllocator, queryRunner } = adapterOptions; | |
| 2366 | + const { attachmentsDir, fileSystem, path, idAllocator, queryRunner } = adapterOptions; | |
| 2332 | 2367 | const continuationRequests = adapterOptions.continuationRequests ?? { |
| 2333 | 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 | 2393 | return ProviderAdapterV2.of({ |
| 2337 | 2394 | instanceId: adapterOptions.instanceId, |
| 2338 | 2395 | driver: CLAUDE_PROVIDER, |
| @@ -5101,6 +5158,7 @@ export function makeClaudeAdapterV2( · 2762 unmodified lines | ||
| 5101 | 5158 | attachments: turnInput.message.attachments, |
| 5102 | 5159 | attachmentsDir, |
| 5103 | 5160 | fileSystem, |
| 5161 | + skillNames: yield* userInvocableSkillNames(turnInput.runtimePolicy.cwd), | |
| 5104 | 5162 | }); |
| 5105 | 5163 | const querySession = yield* openQuery(turnInput, nativeThreadId); |
| 5106 | 5164 | yield* Ref.set(activeTurn, context); |
| @@ -5274,6 +5332,7 @@ export function makeClaudeAdapterV2( · 167 unmodified lines | ||
| 5274 | 5332 | priority: "now", |
| 5275 | 5333 | attachmentsDir, |
| 5276 | 5334 | fileSystem, |
| 5335 | + skillNames: yield* userInvocableSkillNames(currentTurn.input.runtimePolicy.cwd), | |
| 5277 | 5336 | }); |
| 5278 | 5337 | yield* Ref.update(steeredTurns, (current) => { |
| 5279 | 5338 | const next = new Set(current); |
| @@ -5664,12 +5723,14 @@ export const ClaudeAdapterV2Driver: ProviderAdapterDriver< · 384 unmodified lines | ||
| 5664 | 5723 | const continuationRequests = yield* ProviderContinuationRequests; |
| 5665 | 5724 | const baseEnvironment = mergeProviderInstanceEnvironment(environment, hostEnvironment); |
| 5666 | 5725 | const claudeEnvironment = yield* makeClaudeEnvironment(config, baseEnvironment); |
| 5726 | + const path = yield* Path.Path; | |
| 5667 | 5727 | return makeClaudeAdapterV2({ |
| 5668 | 5728 | instanceId, |
| 5669 | 5729 | settings: { ...config, enabled }, |
| 5670 | 5730 | environment: claudeEnvironment, |
| 5671 | 5731 | attachmentsDir: serverConfig.attachmentsDir, |
| 5672 | 5732 | fileSystem, |
| 5733 | + path, | |
| 5673 | 5734 | idAllocator, |
| 5674 | 5735 | queryRunner, |
| 5675 | 5736 | continuationRequests, |
| @@ -5692,6 +5753,7 @@ export const ClaudeAdapterV2Driver: ProviderAdapterDriver< · 16 unmodified lines | ||
| 5692 | 5753 | |
| 5693 | 5754 | const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function* () { |
| 5694 | 5755 | const fileSystem = yield* FileSystem.FileSystem; |
| 5756 | + const path = yield* Path.Path; | |
| 5695 | 5757 | const hostEnvironment = yield* HostProcessEnvironment; |
| 5696 | 5758 | const idAllocator = yield* IdAllocatorV2; |
| 5697 | 5759 | const queryRunner = yield* ClaudeAgentSdkQueryRunner; |
| @@ -5704,6 +5766,7 @@ const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function* · 6 unmodified lines | ||
| 5704 | 5766 | environment: hostEnvironment, |
| 5705 | 5767 | attachmentsDir: serverConfig.attachmentsDir, |
| 5706 | 5768 | fileSystem, |
| 5769 | + path, | |
| 5707 | 5770 | idAllocator, |
| 5708 | 5771 | queryRunner, |
| 5709 | 5772 | continuationRequests, |
| @@ -5713,5 +5776,5 @@ const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function* · 3 unmodified lines | ||
| 5713 | 5776 | export const layer: Layer.Layer< |
| 5714 | 5777 | ProviderAdapterV2, |
| 5715 | 5778 | never, |
| 5716 | − ClaudeAgentSdkQueryRunner | FileSystem.FileSystem | IdAllocatorV2 | ServerConfig | |
| 5779 | + ClaudeAgentSdkQueryRunner | FileSystem.FileSystem | IdAllocatorV2 | Path.Path | ServerConfig | |
| 5717 | 5780 | > = Layer.effect(ProviderAdapterV2, makeDefaultClaudeAdapterV2()); |
| @@ -342,6 +342,20 @@ function makeOpenCodeReplayRuntimeLayer(transcript: OpenCodeSdkReplayTranscript) · 341 unmodified lines | ||
| 342 | 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 | 359 | } satisfies OpenCodeRuntimeShape); |
| 346 | 360 | }), |
| 347 | 361 | ); |
| @@ -242,6 +242,9 @@ const makeOrchestrationEngine = Effect.gen(function* () { · 241 unmodified lines | ||
| 242 | 242 | ); |
| 243 | 243 | |
| 244 | 244 | commandReadModel = committedCommand.nextCommandReadModel; |
| 245 | + for (const cleanup of committedCommand.attachmentCleanups) { | |
| 246 | + yield* cleanup; | |
| 247 | + } | |
| 245 | 248 | yield* eventStore.publishCommitted( |
| 246 | 249 | committedCommand.committedEvents.filter( |
| 247 | 250 | (event) => |
| @@ -13,7 +13,7 @@ import { · 12 unmodified lines | ||
| 13 | 13 | AcpRegistryAdapterV2Driver, |
| 14 | 14 | type AcpRegistryAdapterV2DriverEnv, |
| 15 | 15 | } from "../../orchestration-v2/Adapters/AcpRegistryAdapterV2.ts"; |
| 16 | −import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; | |
| 16 | +import type { TextGeneration } from "../../textGeneration/TextGeneration.ts"; | |
| 17 | 17 | import { ProviderDriverError } from "../Errors.ts"; |
| 18 | 18 | import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; |
| 19 | 19 | import { |
| @@ -25,7 +25,7 @@ import { · 5 unmodified lines | ||
| 25 | 25 | const DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); |
| 26 | 26 | const decodeSettings = Schema.decodeSync(AcpRegistrySettings); |
| 27 | 27 | |
| 28 | −const makeUnsupportedTextGeneration = (): TextGenerationShape => { | |
| 28 | +const makeUnsupportedTextGeneration = (): TextGeneration["Service"] => { | |
| 29 | 29 | const unsupported = (operation: string) => |
| 30 | 30 | Effect.fail( |
| 31 | 31 | new TextGenerationError({ |
| @@ -40,7 +40,12 @@ import { · 39 unmodified lines | ||
| 40 | 40 | } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; |
| 41 | 41 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 42 | 42 | import { ProviderDriverError } from "../Errors.ts"; |
| 43 | −import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; | |
| 43 | +import { | |
| 44 | + checkCodexProviderStatus, | |
| 45 | + makePendingCodexProvider, | |
| 46 | + probeCodexSkillsForCwd, | |
| 47 | +} from "../Layers/CodexProvider.ts"; | |
| 48 | +import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts"; | |
| 44 | 49 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 45 | 50 | import * as ModelManifest from "../ModelManifest.ts"; |
| 46 | 51 | import 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 | 274 | return { |
| 242 | 275 | instanceId, |
| @@ -246,6 +279,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = { · 3 unmodified lines | ||
| 246 | 279 | accentColor, |
| 247 | 280 | enabled, |
| 248 | 281 | snapshot, |
| 282 | + snapshotForCwd, | |
| 249 | 283 | orchestrationAdapter, |
| 250 | 284 | textGeneration, |
| 251 | 285 | } satisfies ProviderInstance; |
| @@ -7,7 +7,7 @@ · 6 unmodified lines | ||
| 7 | 7 | * |
| 8 | 8 | * @module provider/Drivers/CursorDriver |
| 9 | 9 | */ |
| 10 | −import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; | |
| 10 | +import { CursorSettings, ProviderDriverKind } from "@t3tools/contracts"; | |
| 11 | 11 | import * as Effect from "effect/Effect"; |
| 12 | 12 | import * as Schema from "effect/Schema"; |
| 13 | 13 | |
| @@ -33,6 +33,7 @@ import { ProviderDriverError } from "../Errors.ts"; · 32 unmodified lines | ||
| 33 | 33 | import { |
| 34 | 34 | checkOpenCodeProviderStatus, |
| 35 | 35 | makePendingOpenCodeProvider, |
| 36 | + openCodeSkillsToServerProviderSkills, | |
| 36 | 37 | } from "../Layers/OpenCodeProvider.ts"; |
| 37 | 38 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 38 | 39 | import { OpenCodeRuntime } from "../opencodeRuntime.ts"; |
| @@ -169,6 +170,34 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> · 130 unmodified lines | ||
| 169 | 170 | Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), |
| 170 | 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 | 202 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); |
| 174 | 203 | const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<OpenCodeSettings>>( |
| @@ -213,6 +242,27 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> · 38 unmodified lines | ||
| 213 | 242 | accentColor, |
| 214 | 243 | enabled, |
| 215 | 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 | 266 | orchestrationAdapter, |
| 217 | 267 | textGeneration, |
| 218 | 268 | } satisfies ProviderInstance; |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 2 | import { describe, it, assert } from "@effect/vitest"; |
| 3 | 3 | import * as DateTime from "effect/DateTime"; |
| 4 | +import * as Deferred from "effect/Deferred"; | |
| 4 | 5 | import * as Effect from "effect/Effect"; |
| 5 | 6 | import * as Path from "effect/Path"; |
| 6 | 7 | import * as Exit from "effect/Exit"; |
| @@ -43,6 +44,7 @@ import { · 36 unmodified lines | ||
| 43 | 44 | haveProvidersChanged, |
| 44 | 45 | mergeProviderSnapshot, |
| 45 | 46 | mergeProviderSnapshots, |
| 47 | + upsertProviderWorkspaceSnapshot, | |
| 46 | 48 | ProviderRegistryLive, |
| 47 | 49 | selectProvidersByKind, |
| 48 | 50 | } from "./ProviderRegistry.ts"; |
| @@ -581,6 +583,41 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 532 unmodified lines | ||
| 581 | 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 | 621 | it("preserves previously discovered provider models when a refresh returns none", () => { |
| 585 | 622 | const previousProvider = { |
| 586 | 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 | 1202 | it.effect("refreshes OpenCode catalogs and preserves other providers", () => |
| 1008 | 1203 | Effect.gen(function* () { |
| 1009 | 1204 | const codexDriver = ProviderDriverKind.make("codex"); |
| @@ -188,6 +188,30 @@ export const mergeProviderSnapshot = ( · 187 unmodified lines | ||
| 188 | 188 | : {}), |
| 189 | 189 | }; |
| 190 | 190 | |
| 191 | +export const mergeProviderSnapshots = ( | |
| 192 | + previousProviders: ReadonlyArray<ServerProvider>, | |
| 193 | + nextProviders: ReadonlyArray<ServerProvider>, | |
| 194 | +): ReadonlyArray<ServerProvider> => { | |
| 195 | + const mergedProviders = new Map( | |
| 196 | + previousProviders.map((provider) => [snapshotInstanceKey(provider), provider] as const), | |
| 197 | + ); | |
| 198 | + | |
| 199 | + for (const provider of nextProviders) { | |
| 200 | + mergedProviders.set( | |
| 201 | + snapshotInstanceKey(provider), | |
| 202 | + mergeProviderSnapshot(mergedProviders.get(snapshotInstanceKey(provider)), provider), | |
| 203 | + ); | |
| 204 | + } | |
| 205 | + | |
| 206 | + return orderProviderSnapshots([...mergedProviders.values()]); | |
| 207 | +}; | |
| 208 | + | |
| 209 | +export const selectProvidersByKind = ( | |
| 210 | + providers: ReadonlyArray<ServerProvider>, | |
| 211 | + providerKinds: ReadonlySet<ProviderDriverKind>, | |
| 212 | +): ReadonlyArray<ServerProvider> => | |
| 213 | + providers.filter((provider) => providerKinds.has(provider.driver)); | |
| 214 | + | |
| 191 | 215 | export const haveProvidersChanged = ( |
| 192 | 216 | previousProviders: ReadonlyArray<ServerProvider>, |
| 193 | 217 | nextProviders: ReadonlyArray<ServerProvider>, |
| @@ -25,12 +25,13 @@ import type { · 24 unmodified lines | ||
| 25 | 25 | ProviderDriverKind, |
| 26 | 26 | ProviderInstanceEnvironment, |
| 27 | 27 | ProviderInstanceId, |
| 28 | + ServerProvider, | |
| 28 | 29 | } from "@t3tools/contracts"; |
| 29 | 30 | import type * as Effect from "effect/Effect"; |
| 30 | 31 | import type * as Schema from "effect/Schema"; |
| 31 | 32 | import type * as Scope from "effect/Scope"; |
| 32 | 33 | |
| 33 | −import type { TextGenerationShape } from "../textGeneration/TextGeneration.ts"; | |
| 34 | +import type { TextGeneration } from "../textGeneration/TextGeneration.ts"; | |
| 34 | 35 | import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; |
| 35 | 36 | import type { ProviderDriverError } from "./Errors.ts"; |
| 36 | 37 | import type { ServerProviderShape } from "./Services/ServerProvider.ts"; |
| @@ -69,8 +70,9 @@ export interface ProviderInstance { · 32 unmodified lines | ||
| 69 | 70 | readonly accentColor?: string | undefined; |
| 70 | 71 | readonly enabled: boolean; |
| 71 | 72 | readonly snapshot: ServerProviderShape; |
| 73 | + readonly snapshotForCwd?: (cwd: string) => Effect.Effect<ServerProvider, ProviderDriverError>; | |
| 72 | 74 | readonly orchestrationAdapter: ProviderAdapterV2Shape; |
| 73 | − readonly textGeneration: TextGenerationShape; | |
| 75 | + readonly textGeneration: TextGeneration["Service"]; | |
| 74 | 76 | } |
| 75 | 77 | |
| 76 | 78 | export interface ProviderContinuationIdentity { |
| @@ -1155,6 +1155,16 @@ export class AcpSessionRuntime extends Context.Service< · 1154 unmodified lines | ||
| 1155 | 1155 | * @see https://agentclientprotocol.com/protocol/extensibility |
| 1156 | 1156 | */ |
| 1157 | 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 | 1169 | * Initializes the ACP connection, authenticates, and loads, resumes, or creates the session. |
| 1160 | 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 | 1197 | sessionId?: string, |
| 1188 | 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 | 1203 | * @see https://agentclientprotocol.com/protocol/schema#session/prompt |
| 1192 | 1204 | */ |
| 1193 | 1205 | readonly prompt: ( |
| 1194 | 1206 | payload: Omit<EffectAcpSchema.PromptRequest, "sessionId">, |
| 1207 | + options?: { readonly dispatched?: Deferred.Deferred<void> }, | |
| 1195 | 1208 | ) => Effect.Effect<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>; |
| 1196 | 1209 | /** |
| 1197 | 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 | 2163 | promptSerializationSemaphore.withPermit( |
| 2151 | 2164 | Effect.gen(function* () { |
| 2152 | 2165 | const started = yield* getStartedState; |
| @@ -2203,12 +2216,14 @@ export const make = ( · 50 unmodified lines | ||
| 2203 | 2216 | if (Option.isSome(activePromptFiber)) { |
| 2204 | 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 | 2221 | yield* acp.agent |
| 2207 | 2222 | .cancel({ |
| 2208 | 2223 | sessionId: started.sessionId, |
| 2209 | 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 | ), |
| @@ -185,15 +185,17 @@ export function applyGrokAcpModelSelection<E>(input: { · 184 unmodified lines | ||
| 185 | 185 | readonly requestedReasoningEffort?: string | undefined; |
| 186 | 186 | readonly mapError: (cause: EffectAcpErrors.AcpError) => E; |
| 187 | 187 | }): Effect.Effect<string | undefined, E> { |
| 188 | − const modelChanged = | |
| 189 | − input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; | |
| 188 | + // The product slug is never sent over the wire; it keeps the session's current model. | |
| 189 | + const requestedModelId = | |
| 190 | + input.requestedModelId === GROK_DEFAULT_MODEL_SLUG ? undefined : input.requestedModelId; | |
| 191 | + const modelChanged = requestedModelId !== undefined && requestedModelId !== input.currentModelId; | |
| 190 | 192 | const reasoningProvided = input.requestedReasoningEffort !== undefined; |
| 191 | 193 | const reasoningEffort = reasoningProvided |
| 192 | 194 | ? normalizeGrokReasoningEffort(input.requestedReasoningEffort) |
| 193 | 195 | : undefined; |
| 194 | 196 | const reasoningEffortChanged = |
| 195 | 197 | reasoningProvided && reasoningEffort !== input.currentReasoningEffort; |
| 196 | − const targetModelId = input.requestedModelId ?? input.currentModelId; | |
| 198 | + const targetModelId = requestedModelId ?? input.currentModelId; | |
| 197 | 199 | if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) { |
| 198 | 200 | return Effect.succeed(input.currentModelId); |
| 199 | 201 | } |
| @@ -1428,7 +1428,7 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion · 1427 unmodified lines | ||
| 1428 | 1428 | |
| 1429 | 1429 | return { |
| 1430 | 1430 | ...runtime, |
| 1431 | − prompt: (payload) => | |
| 1431 | + prompt: (payload, promptOptions?) => | |
| 1432 | 1432 | Effect.gen(function* () { |
| 1433 | 1433 | const started = yield* runtime.start(); |
| 1434 | 1434 | const promptId = yield* allocatePromptFallbackId; |
| @@ -1444,14 +1444,17 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion · 9 unmodified lines | ||
| 1444 | 1444 | agentResult: null, |
| 1445 | 1445 | }); |
| 1446 | 1446 | const promptRpcFiber = yield* runtime |
| 1447 | − .prompt({ | |
| 1448 | − ...payload, | |
| 1449 | − _meta: { | |
| 1450 | − ...payload._meta, | |
| 1451 | − promptId: fallback.promptId, | |
| 1452 | − requestId: fallback.promptId, | |
| 1447 | + .prompt( | |
| 1448 | + { | |
| 1449 | + ...payload, | |
| 1450 | + _meta: { | |
| 1451 | + ...payload._meta, | |
| 1452 | + promptId: fallback.promptId, | |
| 1453 | + requestId: fallback.promptId, | |
| 1454 | + }, | |
| 1453 | 1455 | }, |
| 1454 | − }) | |
| 1456 | + promptOptions, | |
| 1457 | + ) | |
| 1455 | 1458 | .pipe(Effect.forkChild); |
| 1456 | 1459 | return yield* Effect.raceFirst( |
| 1457 | 1460 | Fiber.join(promptRpcFiber).pipe( |
| @@ -387,6 +387,10 @@ const ServerWsRpcGroup = WsRpcGroup; · 386 unmodified lines | ||
| 387 | 387 | // past this gap a single O(active-threads) snapshot is cheaper and bounded. |
| 388 | 388 | // Matches the event store's default page size (DEFAULT_READ_FROM_SEQUENCE_LIMIT). |
| 389 | 389 | const SHELL_RESUME_MAX_GAP = 1_000; |
| 390 | +// Row count alone does not bound replay memory: a few events with large tool | |
| 391 | +// payloads can decode to gigabytes. Before replaying, sum the serialized | |
| 392 | +// payload bytes of the range in SQL and reset with a snapshot past this budget. | |
| 393 | +const ORCHESTRATION_REPLAY_PAYLOAD_BUDGET_BYTES = 8 * 1024 * 1024; | |
| 390 | 394 | |
| 391 | 395 | function toAuthAccessStreamEvent( |
| 392 | 396 | change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange, |
| @@ -523,6 +527,32 @@ const makeWsRpcLayer = ( · 130 unmodified lines | ||
| 523 | 527 | const threadManagement = yield* ThreadManagementService.ThreadManagementService; |
| 524 | 528 | const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; |
| 525 | 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 | 556 | const providerSessionsV2 = yield* ProviderSessionManagerV2; |
| 527 | 557 | const analytics = yield* AnalyticsService.AnalyticsService; |
| 528 | 558 | // Client-origin attribution (#7774): every thread/turn the connecting |
| @@ -1065,8 +1095,13 @@ const makeWsRpcLayer = ( · 536 unmodified lines | ||
| 1065 | 1095 | } |
| 1066 | 1096 | |
| 1067 | 1097 | const highWater = yield* applicationEvents.latestApplicationSequence; |
| 1068 | − const replayGap = highWater - input.afterSequence; | |
| 1069 | − if (replayGap < 0 || replayGap > SHELL_RESUME_MAX_GAP) { | |
| 1098 | + if ( | |
| 1099 | + !(yield* canReplayPersistedRange( | |
| 1100 | + input.afterSequence, | |
| 1101 | + highWater, | |
| 1102 | + SHELL_RESUME_MAX_GAP, | |
| 1103 | + )) | |
| 1104 | + ) { | |
| 1070 | 1105 | const loaded = yield* loadSnapshot(); |
| 1071 | 1106 | return composeShellStreamWithEnrichment({ |
| 1072 | 1107 | initial: initialSnapshotItems(loaded), |
| @@ -10,7 +10,7 @@ import { · 9 unmodified lines | ||
| 10 | 10 | } from "@t3tools/contracts"; |
| 11 | 11 | import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; |
| 12 | 12 | import * as DateTime from "effect/DateTime"; |
| 13 | −import { describe, expect, it } from "vite-plus/test"; | |
| 13 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 14 | 14 | |
| 15 | 15 | import type { Thread } from "../types"; |
| 16 | 16 | import { makeThreadFixture } from "../test-fixtures"; |
| @@ -87,6 +87,22 @@ export function shouldDockDraftHeroForSubmission(input: { · 86 unmodified lines | ||
| 87 | 87 | ); |
| 88 | 88 | } |
| 89 | 89 | |
| 90 | +export function toolGroupConsumesUpwardNavigation(target: EventTarget | null): boolean { | |
| 91 | + const elementTarget = target instanceof Element ? target : null; | |
| 92 | + const group = elementTarget?.closest<HTMLElement>("[data-tool-group-scroll]"); | |
| 93 | + if (!group) return false; | |
| 94 | + | |
| 95 | + // A nested result or the group itself can consume an upward scroll. | |
| 96 | + for (let element = elementTarget; element; element = element.parentElement) { | |
| 97 | + if (element.scrollTop > 0) { | |
| 98 | + const overflowY = getComputedStyle(element).overflowY; | |
| 99 | + if (overflowY === "auto" || overflowY === "scroll") return true; | |
| 100 | + } | |
| 101 | + if (element === group) break; | |
| 102 | + } | |
| 103 | + return false; | |
| 104 | +} | |
| 105 | + | |
| 90 | 106 | export function resolveDraftHeroState(input: { |
| 91 | 107 | isLocalDraftThread: boolean; |
| 92 | 108 | hasTimelineEntries: boolean; |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { |
| 2 | + type AssistantCitation, | |
| 2 | 3 | type ApprovalRequestId, |
| 3 | 4 | type ChatFileAttachment, |
| 4 | 5 | DEFAULT_MODEL, |
| @@ -268,6 +269,7 @@ import { · 263 unmodified lines | ||
| 268 | 269 | } from "../state/server"; |
| 269 | 270 | import { terminalEnvironment } from "../state/terminal"; |
| 270 | 271 | import { threadEnvironment } from "../state/threads"; |
| 272 | +import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; | |
| 271 | 273 | import { vcsEnvironment } from "../state/vcs"; |
| 272 | 274 | import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; |
| 273 | 275 | import { |
| @@ -287,6 +289,7 @@ import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; · 13 unmodified lines | ||
| 287 | 289 | import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; |
| 288 | 290 | import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; |
| 289 | 291 | import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; |
| 292 | +import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; | |
| 290 | 293 | import { MessagesTimeline, type MessagesTimelineHistoryControls } from "./chat/MessagesTimeline"; |
| 291 | 294 | import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; |
| 292 | 295 | import { ChatHeader } from "./chat/ChatHeader"; |
| @@ -372,6 +375,7 @@ import { · 79 unmodified lines | ||
| 372 | 375 | shouldShowComposerContextStrip, |
| 373 | 376 | startNewThreadForProject, |
| 374 | 377 | codexArtifactTemplatePromptToAppend, |
| 378 | + toolGroupConsumesUpwardNavigation, | |
| 375 | 379 | waitForStartedServerThread, |
| 376 | 380 | } from "./ChatView.logic"; |
| 377 | 381 | import { useLocalStorage } from "~/hooks/useLocalStorage"; |
| @@ -1410,6 +1414,7 @@ function ChatViewContent(props: ChatViewProps) { · 1032 unmodified lines | ||
| 1410 | 1414 | shellExists: serverThread !== null, |
| 1411 | 1415 | status: threadStatus, |
| 1412 | 1416 | }); |
| 1417 | + const threadDetailLoading = threadSyncPhase === "loading"; | |
| 1413 | 1418 | // Latest provider-reported context usage (#8144): the newest turn that has |
| 1414 | 1419 | // a report wins; stale turns keep the meter alive between turns. |
| 1415 | 1420 | const activeThreadLiveTokenUsage = useMemo(() => { |
| @@ -1547,6 +1552,21 @@ function ChatViewContent(props: ChatViewProps) { · 131 unmodified lines | ||
| 1547 | 1552 | const composerElementContextsRef = useRef<ElementContextDraft[]>([]); |
| 1548 | 1553 | const localComposerRef = useRef<ChatComposerHandle | null>(null); |
| 1549 | 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 | 1570 | const [showScrollToBottom, setShowScrollToBottom] = useState(false); |
| 1551 | 1571 | const [isWorkspaceFileDragActive, setIsWorkspaceFileDragActive] = useState(false); |
| 1552 | 1572 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| @@ -2394,6 +2414,7 @@ function ChatViewContent(props: ChatViewProps) { · 841 unmodified lines | ||
| 2394 | 2414 | }, [setDismissedVersionMismatchKey, versionMismatchDismissKey]); |
| 2395 | 2415 | const serverUpdateEnvironmentId = activeThread?.environmentId ?? null; |
| 2396 | 2416 | const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); |
| 2417 | + const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig); | |
| 2397 | 2418 | const serverUpdateState = useAtomValue( |
| 2398 | 2419 | serverEnvironment.updateStateAtom(serverUpdateEnvironmentId), |
| 2399 | 2420 | ); |
| @@ -2437,21 +2458,20 @@ function ChatViewContent(props: ChatViewProps) { · 37 unmodified lines | ||
| 2437 | 2458 | /> |
| 2438 | 2459 | ), |
| 2439 | 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 | 2463 | } else { |
| 2443 | 2464 | items.push({ |
| 2444 | 2465 | id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, |
| 2445 | 2466 | variant: unavailableConnection.phase === "error" ? "error" : "warning", |
| 2446 | 2467 | icon: <WifiOffIcon />, |
| 2447 | − title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusTitle(unavailableConnection)}`, | |
| 2448 | − description: | |
| 2449 | − unavailableConnection.error ?? | |
| 2450 | − "Reconnect this environment before sending messages or running actions.", | |
| 2468 | + title: `${activeEnvironmentUnavailableState.label} is ${environmentReconnecting ? "reconnecting" : "offline"}`, | |
| 2469 | + description: environmentReconnecting ? "Trying again" : "Reconnect to continue", | |
| 2451 | 2470 | actions: ( |
| 2452 | 2471 | <> |
| 2453 | 2472 | <Button |
| 2454 | 2473 | size="xs" |
| 2474 | + variant="ghost" | |
| 2455 | 2475 | disabled={environmentReconnecting} |
| 2456 | 2476 | onClick={() => |
| 2457 | 2477 | void handleReconnectActiveEnvironment( |
| @@ -2463,7 +2483,7 @@ function ChatViewContent(props: ChatViewProps) { · 5 unmodified lines | ||
| 2463 | 2483 | </Button> |
| 2464 | 2484 | <Button |
| 2465 | 2485 | size="xs" |
| 2466 | − variant="outline" | |
| 2486 | + variant="ghost" | |
| 2467 | 2487 | onClick={() => void navigate({ to: "/settings/connections" })} |
| 2468 | 2488 | > |
| 2469 | 2489 | Connections |
| @@ -2512,21 +2532,25 @@ function ChatViewContent(props: ChatViewProps) { · 42 unmodified lines | ||
| 2512 | 2532 | "Server update available" |
| 2513 | 2533 | ), |
| 2514 | 2534 | description: |
| 2515 | − !updateInProgress && !updateFailed && versionMismatchSelfUpdate === "desktop-managed" | |
| 2516 | − ? serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel) | |
| 2535 | + !updateInProgress && | |
| 2536 | + !updateFailed && | |
| 2537 | + versionMismatchSelfUpdate !== null && | |
| 2538 | + (versionMismatchSelfUpdate !== "desktop-managed" || !versionMismatchDesktopAppUpdate) | |
| 2539 | + ? serverUpdateGuidance(versionMismatchSelfUpdate) | |
| 2517 | 2540 | : undefined, |
| 2518 | − // The desktop-managed guidance is already the description; the action | |
| 2519 | − // slot would only repeat it. | |
| 2520 | 2541 | actions: |
| 2521 | 2542 | updateInProgress || |
| 2522 | 2543 | !versionMismatch || |
| 2523 | − versionMismatchSelfUpdate === "desktop-managed" ? undefined : ( | |
| 2544 | + (versionMismatchSelfUpdate === "desktop-managed" && | |
| 2545 | + !versionMismatchDesktopAppUpdate) ? undefined : ( | |
| 2524 | 2546 | <ServerUpdateAction |
| 2525 | 2547 | environmentId={serverUpdateEnvironmentId} |
| 2526 | 2548 | serverLabel={versionMismatchServerLabel} |
| 2527 | 2549 | selfUpdate={versionMismatchSelfUpdate} |
| 2550 | + desktopAppUpdate={versionMismatchDesktopAppUpdate} | |
| 2528 | 2551 | targetVersion={versionMismatch.clientVersion} |
| 2529 | 2552 | label={updateFailed ? "Retry" : "Update"} |
| 2553 | + variant="ghost" | |
| 2530 | 2554 | /> |
| 2531 | 2555 | ), |
| 2532 | 2556 | ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey) |
| @@ -2557,6 +2581,7 @@ function ChatViewContent(props: ChatViewProps) { · 24 unmodified lines | ||
| 2557 | 2581 | versionMismatchDismissKey, |
| 2558 | 2582 | serverUpdateEnvironmentId, |
| 2559 | 2583 | versionMismatchSelfUpdate, |
| 2584 | + versionMismatchDesktopAppUpdate, | |
| 2560 | 2585 | versionMismatchServerLabel, |
| 2561 | 2586 | ]); |
| 2562 | 2587 | const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; |
| @@ -4469,7 +4494,11 @@ function ChatViewContent(props: ChatViewProps) { · 1906 unmodified lines | ||
| 4469 | 4494 | // Only an upward wheel is a navigation intent; wheeling down while |
| 4470 | 4495 | // following either does nothing (at the end) or moves toward it. |
| 4471 | 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 | 4502 | handleManualNavigation(); |
| 4474 | 4503 | } |
| 4475 | 4504 | }; |
| @@ -4506,7 +4535,7 @@ function ChatViewContent(props: ChatViewProps) { · 30 unmodified lines | ||
| 4506 | 4535 | case "PageUp": |
| 4507 | 4536 | case "Home": |
| 4508 | 4537 | case "ArrowUp": |
| 4509 | − if (contentScrollsUp()) { | |
| 4538 | + if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) { | |
| 4510 | 4539 | handleManualNavigation(); |
| 4511 | 4540 | } |
| 4512 | 4541 | break; |
| @@ -5188,6 +5217,23 @@ function ChatViewContent(props: ChatViewProps) { · 675 unmodified lines | ||
| 5188 | 5217 | // The stack renders items[0] front-most and tucks the rest behind hover, so |
| 5189 | 5218 | // ordering is priority: system banners, then the branch-mismatch notice, |
| 5190 | 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 | 5237 | const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => { |
| 5192 | 5238 | if (!activeThreadSnoozed && !activeThreadSettled) { |
| 5193 | 5239 | return null; |
| @@ -7440,6 +7486,9 @@ function ChatViewContent(props: ChatViewProps) { · 2246 unmodified lines | ||
| 7440 | 7486 | <div className="relative flex min-h-0 flex-1 flex-col"> |
| 7441 | 7487 | {/* Messages — LegendList handles virtualization and scrolling internally */} |
| 7442 | 7488 | <MessagesTimeline |
| 7489 | + citationRequest={citationRequest} | |
| 7490 | + citationHistoryLoading={threadDetailLoading} | |
| 7491 | + onCiteAssistantText={citeAssistantText} | |
| 7443 | 7492 | key={activeThread.id} |
| 7444 | 7493 | isWorking={isWorking} |
| 7445 | 7494 | activeTurnInProgress={isWorking || !latestRunSettled} |
| @@ -7467,9 +7516,11 @@ function ChatViewContent(props: ChatViewProps) { · 21 unmodified lines | ||
| 7467 | 7516 | resolvedTheme={resolvedTheme} |
| 7468 | 7517 | timestampFormat={timestampFormat} |
| 7469 | 7518 | workspaceRoot={activeWorkspaceRoot} |
| 7470 | − skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} | |
| 7471 | − providerStatuses={providerStatuses} | |
| 7472 | − runs={serverProjection?.runs ?? EMPTY_PROJECTION_RUNS} | |
| 7519 | + skills={ | |
| 7520 | + activeProviderStatus | |
| 7521 | + ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) | |
| 7522 | + : EMPTY_PROVIDER_SKILLS | |
| 7523 | + } | |
| 7473 | 7524 | anchorMessageId={timelineAnchorMessageId} |
| 7474 | 7525 | onAnchorReady={onTimelineAnchorReady} |
| 7475 | 7526 | onAnchorSizeChanged={onTimelineAnchorSizeChanged} |
| @@ -330,6 +330,13 @@ export default function DiffPanel({ · 329 unmodified lines | ||
| 330 | 330 | cacheKey: selectedGitSource.diffHash, |
| 331 | 331 | }); |
| 332 | 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 | 340 | const localBranchRefs = useEnvironmentQuery( |
| 334 | 341 | selectedRunId === null && |
| 335 | 342 | selectedGitScope === "branch" && |
| @@ -16,7 +16,15 @@ import type { · 15 unmodified lines | ||
| 16 | 16 | } from "@t3tools/contracts"; |
| 17 | 17 | import { useNavigate } from "@tanstack/react-router"; |
| 18 | 18 | import * as Option from "effect/Option"; |
| 19 | −import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; | |
| 19 | +import { | |
| 20 | + type MouseEvent, | |
| 21 | + useCallback, | |
| 22 | + useEffect, | |
| 23 | + useEffectEvent, | |
| 24 | + useMemo, | |
| 25 | + useRef, | |
| 26 | + useState, | |
| 27 | +} from "react"; | |
| 20 | 28 | import { flushSync } from "react-dom"; |
| 21 | 29 | import { |
| 22 | 30 | CheckIcon, |
| @@ -37,7 +45,7 @@ import { RadioGroup } from "~/components/ui/radio-group"; · 14 unmodified lines | ||
| 37 | 45 | import { Spinner } from "~/components/ui/spinner"; |
| 38 | 46 | import { toggleVariants } from "~/components/ui/toggle"; |
| 39 | 47 | import { cn } from "~/lib/utils"; |
| 40 | −import { openPullRequestLink } from "~/lib/openPullRequestLink"; | |
| 48 | +import { openPullRequestLink, useOpenPrLink } from "~/lib/openPullRequestLink"; | |
| 41 | 49 | import { |
| 42 | 50 | buildMenuItems, |
| 43 | 51 | formatGitActionElapsed, |
| @@ -1117,6 +1125,7 @@ export default function GitActionsControl({ · 1073 unmodified lines | ||
| 1117 | 1125 | [activeThreadRef], |
| 1118 | 1126 | ); |
| 1119 | 1127 | const activeServerThread = useThreadShell(activeThreadRef); |
| 1128 | + const openPrLink = useOpenPrLink(activeThreadRef ?? undefined); | |
| 1120 | 1129 | const activeDraftThread = useComposerDraftStore((store) => |
| 1121 | 1130 | draftId |
| 1122 | 1131 | ? store.getDraftSession(draftId) |
| @@ -1457,7 +1466,7 @@ export default function GitActionsControl({ · 334 unmodified lines | ||
| 1457 | 1466 | const toastCta = actionResult.toast.cta; |
| 1458 | 1467 | let toastActionProps: { |
| 1459 | 1468 | children: string; |
| 1460 | − onClick: () => void; | |
| 1469 | + onClick: (event: MouseEvent<HTMLButtonElement>) => void; | |
| 1461 | 1470 | } | null = null; |
| 1462 | 1471 | if (toastCta.kind === "run_action") { |
| 1463 | 1472 | toastActionProps = { |
| @@ -1472,11 +1481,9 @@ export default function GitActionsControl({ · 8 unmodified lines | ||
| 1472 | 1481 | } else if (toastCta.kind === "open_pr") { |
| 1473 | 1482 | toastActionProps = { |
| 1474 | 1483 | children: toastCta.label, |
| 1475 | − onClick: () => { | |
| 1476 | − const api = readLocalApi(); | |
| 1477 | − if (!api) return; | |
| 1484 | + onClick: (event) => { | |
| 1478 | 1485 | closeResultToast(); |
| 1479 | − void api.shell.openExternal(toastCta.url); | |
| 1486 | + openPrLink(event, toastCta.url); | |
| 1480 | 1487 | }, |
| 1481 | 1488 | }; |
| 1482 | 1489 | } |
| @@ -146,6 +146,8 @@ import { · 145 unmodified lines | ||
| 146 | 146 | sortPinnedThreadsForSidebar, |
| 147 | 147 | sortSettledThreadsForSidebar, |
| 148 | 148 | sortThreadsForSidebar, |
| 149 | + useRetainedValue, | |
| 150 | + useSidebarRowSubscriptionLease, | |
| 149 | 151 | } from "./Sidebar.logic"; |
| 150 | 152 | import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; |
| 151 | 153 | import { |
| @@ -791,6 +793,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 639 unmodified lines | ||
| 791 | 793 | [thread.environmentId, thread.id], |
| 792 | 794 | ); |
| 793 | 795 | const threadKey = scopedThreadKey(threadRef); |
| 796 | + const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(props.isActive); | |
| 794 | 797 | const isRegeneratingTitle = thread.titleRegeneration != null; |
| 795 | 798 | const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); |
| 796 | 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 | 828 | : null, |
| 826 | 829 | ); |
| 830 | + const visibleGitStatus = useRetainedValue( | |
| 831 | + JSON.stringify([thread.environmentId, gitCwd]), | |
| 832 | + gitStatus.data, | |
| 833 | + ); | |
| 827 | 834 | const retainTerminalOnBranchMismatch = thread.worktreePath === null; |
| 828 | 835 | const pr = resolveDisplayedThreadPr({ |
| 829 | 836 | threadBranch: thread.branch, |
| 830 | − gitStatus: gitStatus.data, | |
| 837 | + gitStatus: visibleGitStatus, | |
| 831 | 838 | snapshot: changeRequestSnapshot, |
| 832 | 839 | retainTerminalOnBranchMismatch, |
| 833 | 840 | }); |
| @@ -926,7 +933,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 92 unmodified lines | ||
| 926 | 933 | }); |
| 927 | 934 | const prProvider = resolveDisplayedThreadPrProvider({ |
| 928 | 935 | threadBranch: thread.branch, |
| 929 | − gitStatus: gitStatus.data, | |
| 936 | + gitStatus: visibleGitStatus, | |
| 930 | 937 | snapshot: changeRequestSnapshot, |
| 931 | 938 | retainTerminalOnBranchMismatch, |
| 932 | 939 | }); |
| @@ -935,7 +942,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 2 unmodified lines | ||
| 935 | 942 | useEffect(() => { |
| 936 | 943 | const nextSnapshot = nextThreadChangeRequestSnapshot({ |
| 937 | 944 | threadBranch: thread.branch, |
| 938 | − gitStatus: gitStatus.data, | |
| 945 | + gitStatus: visibleGitStatus, | |
| 939 | 946 | snapshot: changeRequestSnapshot, |
| 940 | 947 | retainTerminalOnBranchMismatch, |
| 941 | 948 | }); |
| @@ -943,7 +950,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 1 unmodified line | ||
| 943 | 950 | onChangeRequestSnapshot(threadKey, nextSnapshot); |
| 944 | 951 | }, [ |
| 945 | 952 | changeRequestSnapshot, |
| 946 | − gitStatus.data, | |
| 953 | + visibleGitStatus, | |
| 947 | 954 | onChangeRequestSnapshot, |
| 948 | 955 | retainTerminalOnBranchMismatch, |
| 949 | 956 | thread.branch, |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { |
| 2 | + AssistantCitation, | |
| 2 | 3 | ChatAttachment as ContractChatAttachment, |
| 3 | 4 | EnvironmentId, |
| 4 | 5 | ModelSelection, |
| @@ -40,6 +41,7 @@ import { · 35 unmodified lines | ||
| 40 | 41 | composerSubmissionIntentForEnter, |
| 41 | 42 | detectComposerTrigger, |
| 42 | 43 | expandCollapsedComposerCursor, |
| 44 | + formatAssistantCitationForComposer, | |
| 43 | 45 | replaceTextRange, |
| 44 | 46 | } from "../../composer-logic"; |
| 45 | 47 | import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; |
| @@ -118,6 +120,7 @@ import { · 72 unmodified lines | ||
| 118 | 120 | } from "../../lib/attachmentUploadState"; |
| 119 | 121 | import { isCommandPaletteOpen } from "../../commandPaletteBus"; |
| 120 | 122 | import { getTerminalFocusOwner } from "../../lib/terminalFocus"; |
| 123 | +import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; | |
| 121 | 124 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../../keybindings"; |
| 122 | 125 | import { |
| 123 | 126 | type TerminalContextDraft, |
| @@ -336,9 +339,13 @@ import { · 212 unmodified lines | ||
| 336 | 339 | formatProviderSkillDisplayName, |
| 337 | 340 | getProviderSlashCommandsForSlashMenu, |
| 338 | 341 | getProviderSkillsForSlashMenu, |
| 342 | + resolveProviderSkillsForCwd, | |
| 343 | + resolveProviderSlashCommandsForCwd, | |
| 339 | 344 | } from "@t3tools/client-runtime/providerSkills"; |
| 340 | 345 | import { searchProviderSkills } from "../../providerSkillSearch"; |
| 341 | 346 | import { useMediaQuery } from "../../hooks/useMediaQuery"; |
| 347 | +import { useAtomCommand } from "../../state/use-atom-command"; | |
| 348 | +import { serverEnvironment } from "../../state/server"; | |
| 342 | 349 | import type { ReviewCommentContext } from "../../reviewCommentContext"; |
| 343 | 350 | |
| 344 | 351 | const runtimeModeConfig: Record< |
| @@ -574,6 +581,10 @@ export interface ChatComposerHandle { · 229 unmodified lines | ||
| 574 | 581 | focusAt: (cursor: number) => void; |
| 575 | 582 | addDroppedFiles: (files: File[]) => void; |
| 576 | 583 | insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean; |
| 584 | + citeAssistantText: ( | |
| 585 | + citation: AssistantCitation, | |
| 586 | + sourceAnchor: AssistantCitationSourceAnchor, | |
| 587 | + ) => boolean; | |
| 577 | 588 | openModelPicker: () => void; |
| 578 | 589 | toggleModelPicker: () => void; |
| 579 | 590 | isModelPickerOpen: () => boolean; |
| @@ -1145,6 +1156,58 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 565 unmodified lines | ||
| 1145 | 1156 | () => selectedProviderEntry?.snapshot ?? null, |
| 1146 | 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 | 1211 | const selectedProviderModels = useMemo<ReadonlyArray<ServerProvider["models"][number]>>( |
| 1149 | 1212 | () => selectedProviderEntry?.models ?? [], |
| 1150 | 1213 | [selectedProviderEntry], |
| @@ -1369,11 +1432,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 218 unmodified lines | ||
| 1369 | 1432 | }, |
| 1370 | 1433 | ] satisfies ReadonlyArray<Extract<ComposerCommandItem, { type: "slash-command" }>>; |
| 1371 | 1434 | const slashMenuSkills = getProviderSkillsForSlashMenu( |
| 1372 | − selectedProviderStatus?.skills ?? [], | |
| 1435 | + selectedProviderSkills, | |
| 1373 | 1436 | settings.showSkillsInSlashMenu, |
| 1374 | 1437 | ); |
| 1375 | 1438 | const providerSlashCommandItems = getProviderSlashCommandsForSlashMenu( |
| 1376 | − selectedProviderStatus?.slashCommands ?? [], | |
| 1439 | + selectedProviderSlashCommands, | |
| 1377 | 1440 | slashMenuSkills, |
| 1378 | 1441 | ).map((command) => ({ |
| 1379 | 1442 | id: `provider-slash-command:${selectedProvider}:${command.name}`, |
| @@ -1402,25 +1465,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 22 unmodified lines | ||
| 1402 | 1465 | return searchSlashCommandItems(slashCommandItems, query); |
| 1403 | 1466 | } |
| 1404 | 1467 | if (composerTrigger.kind === "skill") { |
| 1405 | − return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map( | |
| 1406 | − (skill) => ({ | |
| 1407 | − id: `skill:${selectedProvider}:${skill.name}`, | |
| 1408 | − type: "skill" as const, | |
| 1409 | − provider: selectedProvider, | |
| 1410 | − skill, | |
| 1411 | − label: formatProviderSkillDisplayName(skill), | |
| 1412 | − description: | |
| 1413 | − skill.shortDescription ?? | |
| 1414 | − skill.description ?? | |
| 1415 | − (skill.scope ? `${skill.scope} skill` : "Run provider skill"), | |
| 1416 | − }), | |
| 1417 | − ); | |
| 1468 | + return searchProviderSkills(selectedProviderSkills, composerTrigger.query).map((skill) => ({ | |
| 1469 | + id: `skill:${selectedProvider}:${skill.name}`, | |
| 1470 | + type: "skill" as const, | |
| 1471 | + provider: selectedProvider, | |
| 1472 | + skill, | |
| 1473 | + label: formatProviderSkillDisplayName(skill), | |
| 1474 | + description: | |
| 1475 | + skill.shortDescription ?? | |
| 1476 | + skill.description ?? | |
| 1477 | + (skill.scope ? `${skill.scope} skill` : "Run provider skill"), | |
| 1478 | + })); | |
| 1418 | 1479 | } |
| 1419 | 1480 | return []; |
| 1420 | 1481 | }, [ |
| 1421 | 1482 | composerTrigger, |
| 1422 | 1483 | planModeUiEnabled, |
| 1423 | 1484 | selectedProvider, |
| 1485 | + selectedProviderSkills, | |
| 1486 | + selectedProviderSlashCommands, | |
| 1424 | 1487 | selectedProviderStatus, |
| 1425 | 1488 | settings.showSkillsInSlashMenu, |
| 1426 | 1489 | workspaceEntries.entries, |
| @@ -1962,7 +2025,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 535 unmodified lines | ||
| 1962 | 2025 | rangeStart: number, |
| 1963 | 2026 | rangeEnd: number, |
| 1964 | 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 | 2033 | ): boolean => { |
| 1967 | 2034 | const currentText = promptRef.current; |
| 1968 | 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 | 2043 | const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); |
| 1977 | 2044 | const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); |
| 1978 | 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 | 2054 | promptRef.current = next.text; |
| 1980 | 2055 | const activePendingQuestion = activePendingProgress?.activeQuestion; |
| 1981 | 2056 | if (activePendingQuestion && activePendingUserInput) { |
| @@ -3264,28 +3339,62 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 1282 unmodified lines | ||
| 3264 | 3339 | void addComposerAttachments(files); |
| 3265 | 3340 | }; |
| 3266 | 3341 | |
| 3267 | − const insertComposerTextAtEnd = ( | |
| 3268 | − text: string, | |
| 3269 | − options?: { ensureLeadingBoundary?: boolean }, | |
| 3270 | − ): boolean => { | |
| 3271 | − if ( | |
| 3272 | − text.length === 0 || | |
| 3273 | − isConnecting || | |
| 3274 | − isComposerApprovalState || | |
| 3275 | − pendingUserInputs.length > 0 || | |
| 3276 | − projectSelectionRequired | |
| 3277 | − ) { | |
| 3278 | − return false; | |
| 3279 | − } | |
| 3280 | − const prompt = promptRef.current; | |
| 3281 | − const needsLeadingSpace = | |
| 3282 | − (options?.ensureLeadingBoundary ?? false) && prompt.length > 0 && !/\s$/.test(prompt); | |
| 3283 | − return applyPromptReplacement( | |
| 3284 | − prompt.length, | |
| 3285 | − prompt.length, | |
| 3286 | − needsLeadingSpace ? ` ${text}` : text, | |
| 3287 | − ); | |
| 3288 | − }; | |
| 3342 | + const insertComposerText = useCallback( | |
| 3343 | + ( | |
| 3344 | + text: string, | |
| 3345 | + position: "cursor" | "end", | |
| 3346 | + options?: { | |
| 3347 | + ensureLeadingBoundary?: boolean; | |
| 3348 | + citationCommentAnchor?: AssistantCitationSourceAnchor; | |
| 3349 | + }, | |
| 3350 | + ): boolean => { | |
| 3351 | + if ( | |
| 3352 | + text.length === 0 || | |
| 3353 | + isConnecting || | |
| 3354 | + isComposerApprovalState || | |
| 3355 | + pendingUserInputs.length > 0 || | |
| 3356 | + projectSelectionRequired || | |
| 3357 | + (options?.citationCommentAnchor && !composerEditorRef.current) | |
| 3358 | + ) { | |
| 3359 | + return false; | |
| 3360 | + } | |
| 3361 | + const prompt = promptRef.current; | |
| 3362 | + const cursor = position === "cursor" ? readComposerSnapshot().expandedCursor : prompt.length; | |
| 3363 | + const needsLeadingSpace = | |
| 3364 | + (options?.ensureLeadingBoundary ?? false) && | |
| 3365 | + cursor > 0 && | |
| 3366 | + !/\s/.test(prompt[cursor - 1] ?? ""); | |
| 3367 | + const rangeEnd = extendReplacementRangeForTrailingSpace(prompt, cursor, text); | |
| 3368 | + return applyPromptReplacement( | |
| 3369 | + cursor, | |
| 3370 | + rangeEnd, | |
| 3371 | + needsLeadingSpace ? ` ${text}` : text, | |
| 3372 | + options?.citationCommentAnchor | |
| 3373 | + ? { | |
| 3374 | + citationComment: { | |
| 3375 | + start: cursor + (needsLeadingSpace ? 1 : 0), | |
| 3376 | + sourceAnchor: options.citationCommentAnchor, | |
| 3377 | + }, | |
| 3378 | + focusEditorAfterReplace: false, | |
| 3379 | + } | |
| 3380 | + : undefined, | |
| 3381 | + ); | |
| 3382 | + }, | |
| 3383 | + [ | |
| 3384 | + applyPromptReplacement, | |
| 3385 | + isComposerApprovalState, | |
| 3386 | + isConnecting, | |
| 3387 | + pendingUserInputs.length, | |
| 3388 | + projectSelectionRequired, | |
| 3389 | + promptRef, | |
| 3390 | + readComposerSnapshot, | |
| 3391 | + ], | |
| 3392 | + ); | |
| 3393 | + | |
| 3394 | + const insertComposerTextAtEnd = useCallback<ChatComposerHandle["insertTextAtEnd"]>( | |
| 3395 | + (text, options) => insertComposerText(text, "end", options), | |
| 3396 | + [insertComposerText], | |
| 3397 | + ); | |
| 3289 | 3398 | |
| 3290 | 3399 | // File-tree drags land as mentions. Handled in the capture phase so the |
| 3291 | 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 | 3500 | focusComposer(); |
| 3392 | 3501 | }, |
| 3393 | 3502 | insertTextAtEnd: insertComposerTextAtEnd, |
| 3503 | + citeAssistantText: (citation, sourceAnchor) => | |
| 3504 | + insertComposerText( | |
| 3505 | + formatAssistantCitationForComposer(citation, citation.comment), | |
| 3506 | + "cursor", | |
| 3507 | + { ensureLeadingBoundary: true, citationCommentAnchor: sourceAnchor }, | |
| 3508 | + ), | |
| 3394 | 3509 | openModelPicker: () => { |
| 3395 | 3510 | setIsComposerModelPickerOpen(true); |
| 3396 | 3511 | }, |
| @@ -3489,6 +3604,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 92 unmodified lines | ||
| 3489 | 3604 | composerCursor, |
| 3490 | 3605 | composerTerminalContexts, |
| 3491 | 3606 | insertComposerDraftTerminalContext, |
| 3607 | + insertComposerText, | |
| 3608 | + insertComposerTextAtEnd, | |
| 3492 | 3609 | promptRef, |
| 3493 | 3610 | composerImagesRef, |
| 3494 | 3611 | composerFilesRef, |
| @@ -4120,7 +4237,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 625 unmodified lines | ||
| 4120 | 4237 | ? composerTerminalContexts |
| 4121 | 4238 | : [] |
| 4122 | 4239 | } |
| 4123 | − skills={selectedProviderStatus?.skills ?? []} | |
| 4240 | + skills={selectedProviderSkills} | |
| 4124 | 4241 | {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} |
| 4125 | 4242 | onRemoveTerminalContext={removeComposerTerminalContextFromDraft} |
| 4126 | 4243 | onChange={onPromptChange} |
| @@ -1,3 +1,4 @@ | ||
| 1 | +import { InfoIcon } from "lucide-react"; | |
| 1 | 2 | import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react"; |
| 2 | 3 | |
| 3 | 4 | import { cn } from "~/lib/utils"; |
| @@ -44,27 +44,6 @@ function renderPendingActions(isRunning: boolean) { · 43 unmodified lines | ||
| 44 | 44 | ); |
| 45 | 45 | } |
| 46 | 46 | |
| 47 | −function renderStandaloneStop() { | |
| 48 | − return renderToStaticMarkup( | |
| 49 | − createElement(ComposerPrimaryActions, { | |
| 50 | − compact: true, | |
| 51 | − pendingAction: null, | |
| 52 | − isRunning: true, | |
| 53 | − showPlanFollowUpPrompt: false, | |
| 54 | − promptHasText: false, | |
| 55 | − isSendBusy: false, | |
| 56 | − sendDisabledReason: null, | |
| 57 | − isConnecting: false, | |
| 58 | − isEnvironmentUnavailable: false, | |
| 59 | − isPreparingWorktree: false, | |
| 60 | − hasSendableContent: false, | |
| 61 | − onPreviousPendingQuestion: () => {}, | |
| 62 | − onInterrupt: () => {}, | |
| 63 | − onImplementPlanInNewThread: () => {}, | |
| 64 | − }), | |
| 65 | − ); | |
| 66 | −} | |
| 67 | − | |
| 68 | 47 | function renderSendButton(sendDisabledReason: string | null = null) { |
| 69 | 48 | return renderToStaticMarkup( |
| 70 | 49 | createElement(ComposerPrimaryActions, { |
| @@ -60,7 +60,12 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: { · 59 unmodified lines | ||
| 60 | 60 | } |
| 61 | 61 | |
| 62 | 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 | 69 | <ComposerBanner.Row |
| 65 | 70 | render={<button type="button" />} |
| 66 | 71 | data-prompt-stash-badge="true" |
| @@ -11,8 +11,11 @@ import { · 10 unmodified lines | ||
| 11 | 11 | normalizeCompactToolLabel, |
| 12 | 12 | resolveAssistantMessageCopyState, |
| 13 | 13 | resolveTimelineToolPresentation, |
| 14 | + resolveWorkGroupScrollIndex, | |
| 15 | + shouldFollowWorkGroupAppend, | |
| 14 | 16 | shouldPreserveAssistantLineBreaks, |
| 15 | 17 | summarizeToolGroup, |
| 18 | + workEntryDisplayLabel, | |
| 16 | 19 | } from "./MessagesTimeline.logic"; |
| 17 | 20 | import { |
| 18 | 21 | createMessageAttachmentPreviewProjector, |
| @@ -355,6 +358,171 @@ describe("streaming row projection", () => { · 336 unmodified lines | ||
| 355 | 358 | }); |
| 356 | 359 | }); |
| 357 | 360 | |
| 361 | +describe("expanded tool group scrolling", () => { | |
| 362 | + const entries = [{ id: "first" }, { id: "second" }]; | |
| 363 | + | |
| 364 | + it("follows appended calls only at the hard end", () => { | |
| 365 | + const appended = [...entries, { id: "third" }]; | |
| 366 | + expect(shouldFollowWorkGroupAppend(entries, appended, 0)).toBe(true); | |
| 367 | + expect(shouldFollowWorkGroupAppend(entries, appended, 0.5)).toBe(true); | |
| 368 | + expect(shouldFollowWorkGroupAppend(entries, appended, 1)).toBe(true); | |
| 369 | + expect(shouldFollowWorkGroupAppend(entries, appended, 1.01)).toBe(false); | |
| 370 | + expect(shouldFollowWorkGroupAppend(entries, appended, 10)).toBe(false); | |
| 371 | + expect(shouldFollowWorkGroupAppend(entries, appended, Infinity)).toBe(false); | |
| 372 | + }); | |
| 373 | + | |
| 374 | + it("does not follow output updates, prepends, or replacements", () => { | |
| 375 | + expect( | |
| 376 | + shouldFollowWorkGroupAppend( | |
| 377 | + entries, | |
| 378 | + entries.map((entry) => ({ ...entry })), | |
| 379 | + 0, | |
| 380 | + ), | |
| 381 | + ).toBe(false); | |
| 382 | + expect(shouldFollowWorkGroupAppend(entries, [{ id: "older" }, ...entries], 0)).toBe(false); | |
| 383 | + expect( | |
| 384 | + shouldFollowWorkGroupAppend( | |
| 385 | + entries, | |
| 386 | + [{ id: "replacement" }, entries[1]!, { id: "third" }], | |
| 387 | + 0, | |
| 388 | + ), | |
| 389 | + ).toBe(false); | |
| 390 | + expect(shouldFollowWorkGroupAppend([], entries, 0)).toBe(false); | |
| 391 | + }); | |
| 392 | + | |
| 393 | + it("restores the visible tool and its offset inside expanded output", () => { | |
| 394 | + const anchor = { entryId: "second", offset: 120 }; | |
| 395 | + expect(resolveWorkGroupScrollIndex(entries, anchor)).toEqual({ index: 1, viewOffset: -120 }); | |
| 396 | + expect(resolveWorkGroupScrollIndex([{ id: "older" }, ...entries], anchor)).toEqual({ | |
| 397 | + index: 2, | |
| 398 | + viewOffset: -120, | |
| 399 | + }); | |
| 400 | + }); | |
| 401 | + | |
| 402 | + it("starts normally when the saved tool no longer exists", () => { | |
| 403 | + expect(resolveWorkGroupScrollIndex(entries, undefined)).toBeUndefined(); | |
| 404 | + expect( | |
| 405 | + resolveWorkGroupScrollIndex(entries, { entryId: "removed", offset: 120 }), | |
| 406 | + ).toBeUndefined(); | |
| 407 | + }); | |
| 408 | +}); | |
| 409 | + | |
| 410 | +describe("work entry labels", () => { | |
| 411 | + const entry = { | |
| 412 | + id: "tool-1", | |
| 413 | + createdAt: "2026-09-01T12:00:00Z", | |
| 414 | + label: "Tool call", | |
| 415 | + tone: "tool" as const, | |
| 416 | + }; | |
| 417 | + | |
| 418 | + it.each([ | |
| 419 | + ["inProgress", "Clicking in the preview browser"], | |
| 420 | + ["completed", "Clicked in the preview browser"], | |
| 421 | + ["failed", "Failed to click in the preview browser"], | |
| 422 | + ["declined", "Declined to click in the preview browser"], | |
| 423 | + ["stopped", "Stopped clicking in the preview browser"], | |
| 424 | + ] as const)("uses the same friendly %s label in both views", (toolLifecycleStatus, label) => { | |
| 425 | + const browserEntry = { | |
| 426 | + ...entry, | |
| 427 | + toolTitle: "T3-code.preview_click", | |
| 428 | + detail: '{"ok":true}', | |
| 429 | + toolLifecycleStatus, | |
| 430 | + }; | |
| 431 | + expect(liveWorkEntryLabel(browserEntry, undefined, toolLifecycleStatus === "inProgress")).toBe( | |
| 432 | + label, | |
| 433 | + ); | |
| 434 | + expect(workEntryDisplayLabel(browserEntry, undefined)).toBe(label); | |
| 435 | + }); | |
| 436 | + | |
| 437 | + it("uses the active summary state for legacy tools without a lifecycle status", () => { | |
| 438 | + const browserEntry = { ...entry, toolTitle: "T3-code.preview_click" }; | |
| 439 | + expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe( | |
| 440 | + "Clicking in the preview browser", | |
| 441 | + ); | |
| 442 | + expect(liveWorkEntryLabel(browserEntry, undefined, false)).toBe( | |
| 443 | + "Clicked in the preview browser", | |
| 444 | + ); | |
| 445 | + }); | |
| 446 | + | |
| 447 | + it("does not describe a finished call as still running while the turn continues", () => { | |
| 448 | + const browserEntry = { | |
| 449 | + ...entry, | |
| 450 | + toolTitle: "T3-code.preview_click", | |
| 451 | + toolLifecycleStatus: "completed" as const, | |
| 452 | + }; | |
| 453 | + expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe( | |
| 454 | + "Clicked in the preview browser", | |
| 455 | + ); | |
| 456 | + }); | |
| 457 | + | |
| 458 | + it("keeps custom titles and output for unrecognized tools", () => { | |
| 459 | + const unknownEntry = { ...entry, toolTitle: "mcp__github__search_issues" }; | |
| 460 | + expect(liveWorkEntryLabel(unknownEntry, undefined, true)).toBe("Mcp__github__search_issues"); | |
| 461 | + expect(workEntryDisplayLabel({ ...unknownEntry, detail: "Found 3 issues" }, undefined)).toBe( | |
| 462 | + "Found 3 issues", | |
| 463 | + ); | |
| 464 | + }); | |
| 465 | + | |
| 466 | + it("keeps command summaries compact without replacing the full command in expanded rows", () => { | |
| 467 | + const commandEntry = { ...entry, command: "vp test run", detail: "All tests passed" }; | |
| 468 | + expect(liveWorkEntryLabel(commandEntry, undefined, true)).toBe("Running vp"); | |
| 469 | + expect(liveWorkEntryLabel(commandEntry, undefined, false)).toBe("Ran vp"); | |
| 470 | + expect(workEntryDisplayLabel(commandEntry, undefined)).toBe("vp test run"); | |
| 471 | + }); | |
| 472 | + | |
| 473 | + it("summarizes the program inside a shell wrapper while preserving the expanded command", () => { | |
| 474 | + const command = "/bin/zsh -lc 'vp test run apps/web/src/session-logic.test.ts'"; | |
| 475 | + const commandEntry = { ...entry, command }; | |
| 476 | + expect(liveWorkEntryLabel(commandEntry, undefined, true)).toBe("Running vp"); | |
| 477 | + expect(liveWorkEntryLabel(commandEntry, undefined, false)).toBe("Ran vp"); | |
| 478 | + expect(workEntryDisplayLabel(commandEntry, undefined)).toBe(command); | |
| 479 | + }); | |
| 480 | + | |
| 481 | + it.each([ | |
| 482 | + ["inProgress", "Running vp"], | |
| 483 | + ["completed", "Ran vp"], | |
| 484 | + ["failed", "Failed vp"], | |
| 485 | + ["declined", "Declined vp"], | |
| 486 | + ["stopped", "Stopped vp"], | |
| 487 | + ] as const)( | |
| 488 | + "uses the command's %s outcome even while the turn continues", | |
| 489 | + (toolLifecycleStatus, label) => { | |
| 490 | + const commandEntry = { | |
| 491 | + ...entry, | |
| 492 | + command: "/bin/bash -lc 'vp test run'", | |
| 493 | + toolLifecycleStatus, | |
| 494 | + }; | |
| 495 | + expect(liveWorkEntryLabel(commandEntry, undefined, true)).toBe(label); | |
| 496 | + expect(liveWorkEntryLabel(commandEntry, undefined, false)).toBe(label); | |
| 497 | + }, | |
| 498 | + ); | |
| 499 | + | |
| 500 | + it("gives a completed browser group its own count and summary icon category", () => { | |
| 501 | + const rows = deriveMessagesTimelineRows({ | |
| 502 | + timelineEntries: [ | |
| 503 | + { | |
| 504 | + id: "browser-entry", | |
| 505 | + kind: "work", | |
| 506 | + createdAt: entry.createdAt, | |
| 507 | + entry: { | |
| 508 | + ...entry, | |
| 509 | + itemType: "dynamic_tool", | |
| 510 | + toolLifecycleStatus: "completed", | |
| 511 | + toolData: { server: "t3-code", tool: "preview_click" }, | |
| 512 | + }, | |
| 513 | + }, | |
| 514 | + ], | |
| 515 | + isWorking: false, | |
| 516 | + activeTurnStartedAt: null, | |
| 517 | + turnDiffSummaryByAssistantMessageId: new Map(), | |
| 518 | + revertTurnCountByUserMessageId: new Map(), | |
| 519 | + }); | |
| 520 | + expect(rows).toMatchObject([ | |
| 521 | + { kind: "work-toggle", summary: "Used browser 1 time", summaryKind: "browser" }, | |
| 522 | + ]); | |
| 523 | + }); | |
| 524 | +}); | |
| 525 | + | |
| 358 | 526 | describe("summarizeToolGroup", () => { |
| 359 | 527 | const now = DateTime.makeUnsafe("2026-08-29T00:00:00Z"); |
| 360 | 528 | function work(overrides: Partial<WorkLogEntry> = {}): WorkLogEntry { |
| @@ -1682,6 +1850,67 @@ describe("deriveMessagesTimelineRows", () => { · 1321 unmodified lines | ||
| 1682 | 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 | 1914 | it("only shows assistant metadata on the terminal assistant message", () => { |
| 1686 | 1915 | const rows = deriveMessagesTimelineRows({ |
| 1687 | 1916 | timelineEntries: [ |
| @@ -1766,52 +1995,6 @@ describe("deriveMessagesTimelineRows", () => { · 78 unmodified lines | ||
| 1766 | 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 | 1998 | it("collapses contiguous settled tool entries behind a summary toggle", () => { |
| 1816 | 1999 | const timelineEntries = [ |
| 1817 | 2000 | { |
| @@ -1885,19 +2068,94 @@ describe("deriveMessagesTimelineRows", () => { · 67 unmodified lines | ||
| 1885 | 2068 | }); |
| 1886 | 2069 | expect(expandedRows.map((row) => row.id)).toEqual([ |
| 1887 | 2070 | "work-toggle:work-entry-1", |
| 1888 | − "work-1", | |
| 1889 | − "work-2", | |
| 1890 | − "work-3", | |
| 2071 | + "work-group:work-entry-1:details", | |
| 1891 | 2072 | ]); |
| 1892 | − expect(expandedRows[1]).toMatchObject({ | |
| 1893 | − kind: "work", | |
| 1894 | − isExpandedToolGroupEntry: true, | |
| 1895 | − isLastExpandedToolGroupEntry: false, | |
| 2073 | + expect(expandedRows.find((row) => row.kind === "work")).toMatchObject({ | |
| 2074 | + isExpandedToolGroup: true, | |
| 2075 | + groupedEntries: timelineEntries.map(({ entry }) => entry), | |
| 2076 | + }); | |
| 2077 | + expect(expandedRows.find((row) => row.kind === "work-toggle")).toMatchObject({ | |
| 2078 | + expanded: true, | |
| 2079 | + }); | |
| 2080 | + }); | |
| 2081 | + | |
| 2082 | + it.each([true, false])( | |
| 2083 | + "keeps a large expanded tool run inside one timeline item, live=%s", | |
| 2084 | + (isWorking) => { | |
| 2085 | + const runId = RunId.make("run-many-tools"); | |
| 2086 | + const createdAt = "2026-09-01T12:00:00Z"; | |
| 2087 | + const timelineEntries = Array.from({ length: 1_000 }, (_, index) => ({ | |
| 2088 | + id: `tool-entry-${index}`, | |
| 2089 | + kind: "work" as const, | |
| 2090 | + createdAt, | |
| 2091 | + entry: { | |
| 2092 | + id: `tool-${index}`, | |
| 2093 | + createdAt, | |
| 2094 | + runId, | |
| 2095 | + label: "t3-code.preview_snapshot", | |
| 2096 | + tone: "tool" as const, | |
| 2097 | + toolLifecycleStatus: | |
| 2098 | + isWorking && index === 999 ? ("inProgress" as const) : ("completed" as const), | |
| 2099 | + }, | |
| 2100 | + })); | |
| 2101 | + const groupId = "work-group:tool-entry-0"; | |
| 2102 | + const input = { | |
| 2103 | + timelineEntries, | |
| 2104 | + isWorking, | |
| 2105 | + expandedRunIds: new Set([runId]), | |
| 2106 | + latestRun: { | |
| 2107 | + runId, | |
| 2108 | + status: isWorking ? ("running" as const) : ("completed" as const), | |
| 2109 | + startedAt: createdAt, | |
| 2110 | + completedAt: isWorking ? null : createdAt, | |
| 2111 | + }, | |
| 2112 | + activeTurnStartedAt: isWorking ? createdAt : null, | |
| 2113 | + turnDiffSummaryByAssistantMessageId: new Map(), | |
| 2114 | + revertTurnCountByUserMessageId: new Map(), | |
| 2115 | + }; | |
| 2116 | + const expandedRows = deriveMessagesTimelineRows({ | |
| 2117 | + ...input, | |
| 2118 | + expandedWorkGroupIds: new Set([groupId]), | |
| 2119 | + }); | |
| 2120 | + const groupRows = expandedRows.filter((row) => row.kind === "work"); | |
| 2121 | + expect(groupRows).toHaveLength(1); | |
| 2122 | + expect(groupRows[0]?.groupedEntries.map(({ id }) => id)).toEqual( | |
| 2123 | + timelineEntries.map(({ entry }) => entry.id), | |
| 2124 | + ); | |
| 2125 | + expect(groupRows[0]?.id).toBe(`${groupId}:details`); | |
| 2126 | + expect(deriveMessagesTimelineRows(input).some((row) => row.kind === "work")).toBe(false); | |
| 2127 | + }, | |
| 2128 | + ); | |
| 2129 | + | |
| 2130 | + it.each([ | |
| 2131 | + ["recovered", ["failed", "completed"], false], | |
| 2132 | + ["ending in failure", ["completed", "failed"], true], | |
| 2133 | + ["failed", ["failed", "failed"], true], | |
| 2134 | + ] as const)("uses the final call for %s tool groups", (_, statuses, hasFailure) => { | |
| 2135 | + const timelineEntries = statuses.map((status, index) => ({ | |
| 2136 | + id: `work-entry-${index}`, | |
| 2137 | + kind: "work" as const, | |
| 2138 | + createdAt: `2026-01-01T00:00:0${index}Z`, | |
| 2139 | + entry: { | |
| 2140 | + id: `work-${index}`, | |
| 2141 | + createdAt: `2026-01-01T00:00:0${index}Z`, | |
| 2142 | + label: "Ran command", | |
| 2143 | + tone: "tool" as const, | |
| 2144 | + itemType: "command_execution" as const, | |
| 2145 | + toolLifecycleStatus: status, | |
| 2146 | + }, | |
| 2147 | + })); | |
| 2148 | + | |
| 2149 | + const rows = deriveMessagesTimelineRows({ | |
| 2150 | + timelineEntries, | |
| 2151 | + isWorking: false, | |
| 2152 | + turnDiffSummaryByAssistantMessageId: new Map(), | |
| 2153 | + revertTurnCountByUserMessageId: new Map(), | |
| 1896 | 2154 | }); |
| 1897 | − expect(expandedRows[3]).toMatchObject({ | |
| 1898 | − kind: "work", | |
| 1899 | − isExpandedToolGroupEntry: true, | |
| 1900 | − isLastExpandedToolGroupEntry: true, | |
| 2155 | + | |
| 2156 | + expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({ | |
| 2157 | + hiddenCount: 2, | |
| 2158 | + hasFailure, | |
| 1901 | 2159 | }); |
| 1902 | 2160 | }); |
| 1903 | 2161 | }); |
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | import * as Equal from "effect/Equal"; |
| 2 | 2 | import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; |
| 3 | +import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; | |
| 3 | 4 | import { |
| 5 | + normalizeCompactToolLabel, | |
| 6 | + resolveWorkEntryToolPresentation, | |
| 4 | 7 | summarizeToolGroup, |
| 5 | 8 | toolGroupSummaryKind, |
| 6 | 9 | type ToolGroupSummaryKind, |
| @@ -40,6 +43,7 @@ import { · 33 unmodified lines | ||
| 40 | 43 | type T3McpToolPresentation, |
| 41 | 44 | type T3McpToolSummaryAction, |
| 42 | 45 | } from "@t3tools/shared/t3McpToolPresentation"; |
| 46 | +import { formatWorkspaceRelativePath } from "../../filePathDisplay"; | |
| 43 | 47 | |
| 44 | 48 | function timelineEntryRunId(entry: TimelineEntry): RunId | null { |
| 45 | 49 | if (entry.kind === "message") { |
| @@ -59,6 +63,50 @@ function workEntryIsActiveTurnActivity(entry: WorkLogEntry): boolean { · 13 unmodified lines | ||
| 59 | 63 | ); |
| 60 | 64 | } |
| 61 | 65 | |
| 66 | +export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string | undefined) { | |
| 67 | + const toolPresentation = resolveWorkEntryToolPresentation(entry); | |
| 68 | + if (toolPresentation) return toolPresentation.displayName; | |
| 69 | + if (entry.command) return entry.command; | |
| 70 | + if (entry.detail) return entry.detail; | |
| 71 | + const [firstPath] = entry.changedFiles ?? []; | |
| 72 | + if (firstPath) { | |
| 73 | + const path = formatWorkspaceRelativePath(firstPath, workspaceRoot); | |
| 74 | + return entry.changedFiles!.length === 1 | |
| 75 | + ? path | |
| 76 | + : `${path} +${entry.changedFiles!.length - 1} more`; | |
| 77 | + } | |
| 78 | + const heading = normalizeCompactToolLabel(entry.toolTitle || entry.label); | |
| 79 | + return `${heading.charAt(0).toUpperCase()}${heading.slice(1)}`; | |
| 80 | +} | |
| 81 | + | |
| 82 | +export function liveWorkEntryLabel( | |
| 83 | + entry: WorkLogEntry, | |
| 84 | + workspaceRoot: string | undefined, | |
| 85 | + active: boolean, | |
| 86 | +) { | |
| 87 | + const toolPresentation = resolveWorkEntryToolPresentation( | |
| 88 | + entry, | |
| 89 | + active ? "inProgress" : "completed", | |
| 90 | + ); | |
| 91 | + if (toolPresentation) return toolPresentation.displayName; | |
| 92 | + const command = entry.command?.trim(); | |
| 93 | + if (command) { | |
| 94 | + const status = entry.toolLifecycleStatus ?? (active ? "inProgress" : "completed"); | |
| 95 | + const verb = | |
| 96 | + status === "inProgress" | |
| 97 | + ? "Running" | |
| 98 | + : status === "failed" | |
| 99 | + ? "Failed" | |
| 100 | + : status === "declined" | |
| 101 | + ? "Declined" | |
| 102 | + : status === "stopped" | |
| 103 | + ? "Stopped" | |
| 104 | + : "Ran"; | |
| 105 | + return `${verb} ${commandProgramName(command) ?? "command"}`; | |
| 106 | + } | |
| 107 | + return workEntryDisplayLabel(entry, workspaceRoot); | |
| 108 | +} | |
| 109 | + | |
| 62 | 110 | export function workEntryIsVisibleInGroup( |
| 63 | 111 | entry: WorkLogEntry, |
| 64 | 112 | expandedToolGroupEntry = false, |
| @@ -74,6 +122,35 @@ export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; · 9 unmodified lines | ||
| 74 | 122 | export const TIMELINE_CONTENT_MAX_WIDTH = 768; |
| 75 | 123 | export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; |
| 76 | 124 | |
| 125 | +export interface WorkGroupScrollAnchor { | |
| 126 | + readonly entryId: string; | |
| 127 | + readonly offset: number; | |
| 128 | +} | |
| 129 | + | |
| 130 | +/** Restore a visible tool, including a position partway through its expanded output. */ | |
| 131 | +export function resolveWorkGroupScrollIndex( | |
| 132 | + entries: ReadonlyArray<{ readonly id: string }>, | |
| 133 | + anchor: WorkGroupScrollAnchor | undefined, | |
| 134 | +): { index: number; viewOffset: number } | undefined { | |
| 135 | + if (!anchor) return undefined; | |
| 136 | + const index = entries.findIndex((entry) => entry.id === anchor.entryId); | |
| 137 | + return index < 0 ? undefined : { index, viewOffset: -anchor.offset }; | |
| 138 | +} | |
| 139 | + | |
| 140 | +/** Only newly appended calls may follow the end, never status or output updates. */ | |
| 141 | +export function shouldFollowWorkGroupAppend( | |
| 142 | + previous: ReadonlyArray<{ readonly id: string }>, | |
| 143 | + entries: ReadonlyArray<{ readonly id: string }>, | |
| 144 | + distanceFromEnd: number, | |
| 145 | +): boolean { | |
| 146 | + return ( | |
| 147 | + previous.length > 0 && | |
| 148 | + entries.length > previous.length && | |
| 149 | + distanceFromEnd <= 1 && | |
| 150 | + previous.every((entry, index) => entry.id === entries[index]?.id) | |
| 151 | + ); | |
| 152 | +} | |
| 153 | + | |
| 77 | 154 | export interface TimelineEndState { |
| 78 | 155 | readonly isAtEnd?: boolean; |
| 79 | 156 | readonly isNearEnd?: boolean; |
| @@ -228,8 +305,7 @@ export type MessagesTimelineRow = · 148 unmodified lines | ||
| 228 | 305 | id: string; |
| 229 | 306 | createdAt: string; |
| 230 | 307 | groupedEntries: WorkLogEntry[]; |
| 231 | − isExpandedToolGroupEntry: boolean; | |
| 232 | − isLastExpandedToolGroupEntry: boolean; | |
| 308 | + isExpandedToolGroup: boolean; | |
| 233 | 309 | } |
| 234 | 310 | | { |
| 235 | 311 | kind: "work-live"; |
| @@ -342,6 +418,20 @@ function workGroupId(timelineEntryId: string): string { · 106 unmodified lines | ||
| 342 | 418 | export type TimelineToolPresentation = T3McpToolPresentation; |
| 343 | 419 | export const resolveTimelineToolPresentation = resolveT3McpToolPresentation; |
| 344 | 420 | |
| 421 | +function expandedWorkGroupRow( | |
| 422 | + groupId: string, | |
| 423 | + createdAt: string, | |
| 424 | + groupedEntries: WorkLogEntry[], | |
| 425 | +): Extract<MessagesTimelineRow, { kind: "work" }> { | |
| 426 | + return { | |
| 427 | + kind: "work", | |
| 428 | + id: `${groupId}:details`, | |
| 429 | + createdAt, | |
| 430 | + groupedEntries, | |
| 431 | + isExpandedToolGroup: true, | |
| 432 | + }; | |
| 433 | +} | |
| 434 | + | |
| 345 | 435 | export function resolveAssistantMessageCopyState({ |
| 346 | 436 | text, |
| 347 | 437 | showCopyButton, |
| @@ -719,9 +809,14 @@ export function deriveMessagesTimelineRows(input: { · 371 unmodified lines | ||
| 719 | 809 | latestRunningToolEntry !== undefined || |
| 720 | 810 | (latestVisibleToolEntry !== undefined && |
| 721 | 811 | workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry)); |
| 812 | + const latestToolFailed = | |
| 813 | + latestRunningToolEntry === undefined && | |
| 814 | + latestVisibleToolEntry !== undefined && | |
| 815 | + latestVisibleToolEntry.entry.toolLifecycleStatus !== "declined" && | |
| 816 | + workEntryDisplayIndicatesToolFailure(latestVisibleToolEntry.entry); | |
| 722 | 817 | const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; |
| 723 | 818 | const activeWorkRow = |
| 724 | − activeWorkAnchor && latestVisibleToolEntry | |
| 819 | + activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed | |
| 725 | 820 | ? (() => { |
| 726 | 821 | const groupId = workGroupId(activeWorkAnchor.id); |
| 727 | 822 | return { |
| @@ -739,7 +834,7 @@ export function deriveMessagesTimelineRows(input: { · 11 unmodified lines | ||
| 739 | 834 | })() |
| 740 | 835 | : null; |
| 741 | 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 | 839 | const appendWorkingRow = () => { |
| 745 | 840 | nextRows.push({ |
| @@ -754,16 +849,13 @@ export function deriveMessagesTimelineRows(input: { · 8 unmodified lines | ||
| 754 | 849 | nextRows.push(activeWorkRow); |
| 755 | 850 | hasActivityRow ||= activeWorkRow.active; |
| 756 | 851 | if (!activeWorkRow.expanded) return; |
| 757 | − for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { | |
| 758 | − nextRows.push({ | |
| 759 | − kind: "work", | |
| 760 | − id: workEntry.id, | |
| 761 | − createdAt: workEntry.createdAt, | |
| 762 | − groupedEntries: [workEntry], | |
| 763 | − isExpandedToolGroupEntry: true, | |
| 764 | − isLastExpandedToolGroupEntry: entryIndex === activeWorkRow.groupedEntries.length - 1, | |
| 765 | − }); | |
| 766 | − } | |
| 852 | + nextRows.push( | |
| 853 | + expandedWorkGroupRow( | |
| 854 | + activeWorkRow.groupId, | |
| 855 | + activeWorkRow.createdAt, | |
| 856 | + activeWorkRow.groupedEntries, | |
| 857 | + ), | |
| 858 | + ); | |
| 767 | 859 | }; |
| 768 | 860 | |
| 769 | 861 | for (let index = 0; index < input.timelineEntries.length; index += 1) { |
| @@ -833,8 +925,7 @@ export function deriveMessagesTimelineRows(input: { · 63 unmodified lines | ||
| 833 | 925 | id: timelineEntry.id, |
| 834 | 926 | createdAt: timelineEntry.createdAt, |
| 835 | 927 | groupedEntries: [timelineEntry.entry], |
| 836 | − isExpandedToolGroupEntry: false, | |
| 837 | − isLastExpandedToolGroupEntry: false, | |
| 928 | + isExpandedToolGroup: false, | |
| 838 | 929 | }); |
| 839 | 930 | continue; |
| 840 | 931 | } |
| @@ -880,22 +971,16 @@ export function deriveMessagesTimelineRows(input: { · 39 unmodified lines | ||
| 880 | 971 | }); |
| 881 | 972 | hasActivityRow = true; |
| 882 | 973 | if (expanded) { |
| 883 | − for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { | |
| 884 | − nextRows.push({ | |
| 885 | − kind: "work", | |
| 886 | − id: workEntry.id, | |
| 887 | − createdAt: workEntry.createdAt, | |
| 888 | − groupedEntries: [workEntry], | |
| 889 | − isExpandedToolGroupEntry: true, | |
| 890 | − isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1, | |
| 891 | − }); | |
| 892 | − } | |
| 974 | + nextRows.push( | |
| 975 | + expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries), | |
| 976 | + ); | |
| 893 | 977 | } |
| 894 | 978 | } else { |
| 895 | 979 | const groupId = workGroupId(timelineEntry.id); |
| 896 | 980 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; |
| 897 | 981 | const summaryKind = toolGroupSummaryKind(visibleGroupedEntries); |
| 898 | 982 | const groupSummary = summarizeToolGroup(visibleGroupedEntries); |
| 983 | + const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike); | |
| 899 | 984 | nextRows.push({ |
| 900 | 985 | kind: "work-toggle", |
| 901 | 986 | id: `work-toggle:${timelineEntry.id}`, |
| @@ -909,19 +994,14 @@ export function deriveMessagesTimelineRows(input: { · 7 unmodified lines | ||
| 909 | 994 | ? visibleGroupedEntries[0]!.label |
| 910 | 995 | : groupSummary.summary, |
| 911 | 996 | summaryKind, |
| 912 | − hasFailure: groupSummary.hasFailure, | |
| 997 | + hasFailure: | |
| 998 | + latestToolEntry !== undefined && | |
| 999 | + workEntryDisplayIndicatesToolFailure(latestToolEntry), | |
| 913 | 1000 | }); |
| 914 | 1001 | if (expanded) { |
| 915 | − for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { | |
| 916 | − nextRows.push({ | |
| 917 | − kind: "work", | |
| 918 | − id: workEntry.id, | |
| 919 | − createdAt: workEntry.createdAt, | |
| 920 | − groupedEntries: [workEntry], | |
| 921 | − isExpandedToolGroupEntry: true, | |
| 922 | − isLastExpandedToolGroupEntry: entryIndex === visibleGroupedEntries.length - 1, | |
| 923 | − }); | |
| 924 | − } | |
| 1002 | + nextRows.push( | |
| 1003 | + expandedWorkGroupRow(groupId, timelineEntry.createdAt, visibleGroupedEntries), | |
| 1004 | + ); | |
| 925 | 1005 | } |
| 926 | 1006 | } |
| 927 | 1007 | } |
| @@ -1001,7 +1081,7 @@ export function deriveMessagesTimelineRows(input: { · 73 unmodified lines | ||
| 1001 | 1081 | if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { |
| 1002 | 1082 | appendWorkingRow(); |
| 1003 | 1083 | } |
| 1004 | − if (input.isWorking && !hasActivityRow) { | |
| 1084 | + if (input.isWorking && (!hasActivityRow || latestToolFailed)) { | |
| 1005 | 1085 | nextRows.push({ |
| 1006 | 1086 | kind: "thinking", |
| 1007 | 1087 | id: LIVE_ACTIVITY_ROW_ID, |
| @@ -1129,8 +1209,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean · 121 unmodified lines | ||
| 1129 | 1209 | case "work": { |
| 1130 | 1210 | const bw = b as typeof a; |
| 1131 | 1211 | return ( |
| 1132 | − a.isExpandedToolGroupEntry === bw.isExpandedToolGroupEntry && | |
| 1133 | − a.isLastExpandedToolGroupEntry === bw.isLastExpandedToolGroupEntry && | |
| 1212 | + a.isExpandedToolGroup === bw.isExpandedToolGroup && | |
| 1134 | 1213 | Equal.equals(a.groupedEntries, bw.groupedEntries) |
| 1135 | 1214 | ); |
| 1136 | 1215 | } |
| @@ -55,6 +55,15 @@ vi.mock("@legendapp/list/react", async () => { · 54 unmodified lines | ||
| 55 | 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 | 67 | ref?: Ref<LegendListRef>; |
| 59 | 68 | }) => { |
| 60 | 69 | if (props.anchoredEndSpace) { |
| @@ -64,7 +73,32 @@ vi.mock("@legendapp/list/react", async () => { · 3 unmodified lines | ||
| 64 | 73 | return ( |
| 65 | 74 | <div |
| 66 | 75 | data-testid={legendListTestId} |
| 76 | + data-class-name={props.className} | |
| 67 | 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 | 102 | data-maintain-scroll-at-end={props.maintainScrollAtEnd ? "enabled" : undefined} |
| 69 | 103 | data-maintain-scroll-at-end-animated={ |
| 70 | 104 | typeof props.maintainScrollAtEnd === "object" |
| @@ -86,26 +120,6 @@ vi.mock("@legendapp/list/react", async () => { · 15 unmodified lines | ||
| 86 | 120 | ? props.maintainScrollAtEnd.on?.layout |
| 87 | 121 | : 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 | 124 | {props.ListHeaderComponent} |
| 111 | 125 | {props.data.map((item) => ( |
| @@ -625,7 +639,7 @@ describe("MessagesTimeline", () => { · 513 unmodified lines | ||
| 625 | 639 | ); |
| 626 | 640 | |
| 627 | 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 | 643 | expect(markup).toContain('data-anchor-on-ready="true"'); |
| 630 | 644 | expect(markup).not.toContain("data-anchor-max-size="); |
| 631 | 645 | expect(markup).toContain('data-content-inset-end="144"'); |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { |
| 2 | + type AssistantCitation, | |
| 2 | 3 | type ChatFileAttachment, |
| 3 | 4 | type EnvironmentId, |
| 4 | 5 | type MessageId, |
| @@ -15,9 +16,15 @@ import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thr · 10 unmodified lines | ||
| 15 | 16 | import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; |
| 16 | 17 | import { formatWorkingTimerNow } from "./ComposerActivityStatus"; |
| 17 | 18 | import { |
| 19 | + CHAT_TIMELINE_ANCHOR_OFFSET, | |
| 20 | + keepTimelineEndVisibleAfterOverlayGrowth, | |
| 21 | +} from "./timelineScrollAnchoring"; | |
| 22 | +import { | |
| 23 | + resolveWorkEntryToolPresentation, | |
| 18 | 24 | resolveViewedImageAsset, |
| 19 | 25 | workEntryViewedImagePath, |
| 20 | 26 | } from "@t3tools/client-runtime/work-log/presentation"; |
| 27 | +import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; | |
| 21 | 28 | const NOOP_USE_ARTIFACT_TEMPLATE = () => {}; |
| 22 | 29 | const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; |
| 23 | 30 | |
| @@ -29,6 +36,7 @@ import { · 5 unmodified lines | ||
| 29 | 36 | use, |
| 30 | 37 | useCallback, |
| 31 | 38 | useEffect, |
| 39 | + useLayoutEffect, | |
| 32 | 40 | useMemo, |
| 33 | 41 | useRef, |
| 34 | 42 | useState, |
| @@ -61,7 +69,9 @@ import { · 26 unmodified lines | ||
| 61 | 69 | resolveDiffThemeName, |
| 62 | 70 | resolveFileDiffPath, |
| 63 | 71 | } from "../../lib/diffRendering"; |
| 72 | +import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; | |
| 64 | 73 | import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; |
| 74 | +import { T3Wordmark } from "../T3Wordmark"; | |
| 65 | 75 | import { |
| 66 | 76 | BotIcon, |
| 67 | 77 | BrainIcon, |
| @@ -92,14 +102,24 @@ import { · 24 unmodified lines | ||
| 92 | 102 | ZapIcon, |
| 93 | 103 | } from "lucide-react"; |
| 94 | 104 | import { Button } from "../ui/button"; |
| 105 | +import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; | |
| 95 | 106 | import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; |
| 96 | 107 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 97 | 108 | import { ChangedFilesCard } from "./ChangedFilesTree"; |
| 98 | 109 | import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; |
| 99 | 110 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 111 | +import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar"; | |
| 112 | +import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; | |
| 113 | +import { | |
| 114 | + AssistantCitationSource, | |
| 115 | + type AssistantCitationRequest, | |
| 116 | + type AssistantCitationTarget, | |
| 117 | +} from "./AssistantCitationSource"; | |
| 118 | +import { useAssistantCitationTarget, type CitationHistoryPage } from "./useAssistantCitationTarget"; | |
| 100 | 119 | import { |
| 101 | 120 | computeStableMessagesTimelineRows, |
| 102 | 121 | deriveMessagesTimelineRows, |
| 122 | + liveWorkEntryLabel, | |
| 103 | 123 | normalizeCompactToolLabel, |
| 104 | 124 | resolveTimelineToolPresentation, |
| 105 | 125 | resolveAssistantMessageCopyState, |
| @@ -110,12 +130,17 @@ import { · 4 unmodified lines | ||
| 110 | 130 | resolveTimelineMinimapIndexFromPointer, |
| 111 | 131 | resolveTimelineMinimapInteractiveWidth, |
| 112 | 132 | resolveTimelineMinimapTopPercent, |
| 133 | + resolveWorkGroupScrollIndex, | |
| 134 | + shouldFollowWorkGroupAppend, | |
| 113 | 135 | shouldPreserveAssistantLineBreaks, |
| 136 | + toolGroupAction, | |
| 137 | + workEntryDisplayLabel, | |
| 114 | 138 | workEntryIsVisibleInGroup, |
| 115 | 139 | type StableMessagesTimelineRowsState, |
| 116 | 140 | type MessagesTimelineRow, |
| 117 | 141 | TIMELINE_MINIMAP_MIN_ITEMS, |
| 118 | 142 | type TimelineLatestRun, |
| 143 | + type WorkGroupScrollAnchor, | |
| 119 | 144 | } from "./MessagesTimeline.logic"; |
| 120 | 145 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; |
| 121 | 146 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| @@ -163,6 +188,8 @@ import { · 41 unmodified lines | ||
| 163 | 188 | // --------------------------------------------------------------------------- |
| 164 | 189 | |
| 165 | 190 | interface TimelineRowSharedState { |
| 191 | + citationRequest: AssistantCitationTarget | null; | |
| 192 | + listRef: React.RefObject<LegendListRef | null>; | |
| 166 | 193 | timestampFormat: TimestampFormat; |
| 167 | 194 | routeThreadKey: string; |
| 168 | 195 | threadRef: ScopedThreadRef | null; |
| @@ -193,6 +220,8 @@ interface TimelineRowSharedState { · 24 unmodified lines | ||
| 193 | 220 | openingVideoAttachmentId: string | null; |
| 194 | 221 | onUseArtifactTemplate: (template: CodexArtifactTemplate) => void; |
| 195 | 222 | onToggleWorkGroup: (groupId: string, anchorKey: string) => void; |
| 223 | + onToggleWorkEntry: (anchorKey: string) => void; | |
| 224 | + workGroupViewState: WorkGroupViewState; | |
| 196 | 225 | } |
| 197 | 226 | |
| 198 | 227 | interface TimelineRowActivityState { |
| @@ -205,6 +234,16 @@ interface TimelineRowActivityState { · 6 unmodified lines | ||
| 205 | 234 | |
| 206 | 235 | const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); |
| 207 | 236 | const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!); |
| 237 | + | |
| 238 | +interface WorkGroupViewState { | |
| 239 | + scrollPositions: Map<string, WorkGroupScrollAnchor>; | |
| 240 | + expandedEntries: Set<string>; | |
| 241 | +} | |
| 242 | + | |
| 243 | +const WorkGroupViewCtx = createContext<{ | |
| 244 | + state: WorkGroupViewState; | |
| 245 | + onToggleEntry: () => void; | |
| 246 | +} | null>(null); | |
| 208 | 247 | const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />; |
| 209 | 248 | const TIMELINE_LIST_FADE_HEADER = ( |
| 210 | 249 | <div className="h-[var(--workspace-titlebar-scroll-fade-height)]" /> |
| @@ -234,6 +273,12 @@ export interface MessagesTimelineHistoryControls { · 23 unmodified lines | ||
| 234 | 273 | } |
| 235 | 274 | |
| 236 | 275 | interface MessagesTimelineProps { |
| 276 | + citationRequest?: AssistantCitationRequest | null; | |
| 277 | + citationHistoryLoading?: boolean; | |
| 278 | + onCiteAssistantText?: ( | |
| 279 | + citation: AssistantCitation, | |
| 280 | + sourceAnchor: AssistantCitationSourceAnchor, | |
| 281 | + ) => boolean; | |
| 237 | 282 | isWorking: boolean; |
| 238 | 283 | activeTurnInProgress: boolean; |
| 239 | 284 | activeTurnStartedAt?: string | null; |
| @@ -288,6 +333,8 @@ interface MessagesTimelineProps { · 48 unmodified lines | ||
| 288 | 333 | hideEmptyPlaceholder?: boolean; |
| 289 | 334 | topFadeEnabled?: boolean; |
| 290 | 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 | |
| 297 | 344 | export const MessagesTimeline = memo(function MessagesTimeline({ |
| 345 | + citationRequest = null, | |
| 346 | + citationHistoryLoading = false, | |
| 347 | + onCiteAssistantText, | |
| 298 | 348 | isWorking, |
| 299 | 349 | activeTurnInProgress, |
| 300 | 350 | activeTurnStartedAt = null, |
| @@ -334,17 +384,28 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 33 unmodified lines | ||
| 334 | 384 | hideEmptyPlaceholder = false, |
| 335 | 385 | topFadeEnabled = false, |
| 336 | 386 | historyControls, |
| 387 | + loadEarlier = null, | |
| 337 | 388 | }: MessagesTimelineProps) { |
| 338 | 389 | const [expandedRunIds, setExpandedRunIds] = useState<ReadonlySet<RunId>>(new Set()); |
| 339 | 390 | const [expandedAttemptIds, setExpandedAttemptIds] = useState<ReadonlySet<RunAttemptId>>( |
| 340 | 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 | 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 | 403 | const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>()); |
| 344 | 404 | const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); |
| 345 | 405 | const disclosureAnchorKeyRef = useRef<string | null>(null); |
| 346 | 406 | const disclosureSettleFrameRef = useRef<number | null>(null); |
| 347 | 407 | const disclosureSettleSecondFrameRef = useRef<number | null>(null); |
| 408 | + const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustment); | |
| 348 | 409 | |
| 349 | 410 | useEffect(() => { |
| 350 | 411 | return () => { |
| @@ -499,6 +560,31 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 148 unmodified lines | ||
| 499 | 560 | const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>( |
| 500 | 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 | 588 | const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); |
| 503 | 589 | const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); |
| 504 | 590 | const handleAnchorReady = useCallback( |
| @@ -518,8 +604,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 13 unmodified lines | ||
| 518 | 604 | [anchorMessageId, onAnchorSizeChanged], |
| 519 | 605 | ); |
| 520 | 606 | const anchoredEndSpace = useMemo(() => { |
| 521 | − const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => | |
| 522 | − row.kind === "message" && row.message.role === "user" ? row.message.id : null, | |
| 607 | + const config = resolveChatListAnchoredEndSpace( | |
| 608 | + rows, | |
| 609 | + anchorMessageId, | |
| 610 | + (row) => (row.kind === "message" && row.message.role === "user" ? row.message.id : null), | |
| 611 | + { anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET }, | |
| 523 | 612 | ); |
| 524 | 613 | return config |
| 525 | 614 | ? { ...config, onReady: handleAnchorReady, onSizeChanged: handleAnchorSizeChanged } |
| @@ -537,7 +626,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 11 unmodified lines | ||
| 537 | 626 | const handleScroll = useCallback(() => { |
| 538 | 627 | const state = listRef.current?.getState?.(); |
| 539 | 628 | const isAtEnd = resolveTimelineIsAtEnd(state, contentInsetEndAdjustment); |
| 540 | − if (isAtEnd !== undefined) { | |
| 629 | + if (isAtEnd !== undefined && !citationPositioning) { | |
| 541 | 630 | onIsAtEndChange(isAtEnd); |
| 542 | 631 | } |
| 543 | 632 | if (!state || minimapItems.length === 0) { |
| @@ -567,7 +656,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 23 unmodified lines | ||
| 567 | 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 | 668 | useEffect(() => { |
| 573 | 669 | const frame = requestAnimationFrame(handleScroll); |
| @@ -601,6 +697,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 27 unmodified lines | ||
| 601 | 697 | |
| 602 | 698 | const sharedState = useMemo<TimelineRowSharedState>( |
| 603 | 699 | () => ({ |
| 700 | + citationRequest: readyCitationRequest, | |
| 701 | + listRef, | |
| 604 | 702 | timestampFormat, |
| 605 | 703 | routeThreadKey, |
| 606 | 704 | threadRef: parseScopedThreadKey(routeThreadKey), |
| @@ -623,8 +721,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 16 unmodified lines | ||
| 623 | 721 | onToggleTurnFold, |
| 624 | 722 | onToggleAttemptFold, |
| 625 | 723 | onToggleWorkGroup, |
| 724 | + onToggleWorkEntry: suspendEndScrollMaintenanceForDisclosure, | |
| 725 | + workGroupViewState, | |
| 626 | 726 | }), |
| 627 | 727 | [ |
| 728 | + readyCitationRequest, | |
| 729 | + listRef, | |
| 628 | 730 | timestampFormat, |
| 629 | 731 | routeThreadKey, |
| 630 | 732 | markdownCwd, |
| @@ -646,6 +748,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 15 unmodified lines | ||
| 646 | 748 | onToggleTurnFold, |
| 647 | 749 | onToggleAttemptFold, |
| 648 | 750 | onToggleWorkGroup, |
| 751 | + suspendEndScrollMaintenanceForDisclosure, | |
| 752 | + workGroupViewState, | |
| 649 | 753 | ], |
| 650 | 754 | ); |
| 651 | 755 | const activityState = useMemo<TimelineRowActivityState>( |
| @@ -722,7 +826,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 70 unmodified lines | ||
| 722 | 826 | return ( |
| 723 | 827 | <TimelineRowCtx value={sharedState}> |
| 724 | 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 | 841 | <LegendList<MessagesTimelineRow> |
| 727 | 842 | ref={listRef} |
| 728 | 843 | data={rows} |
| @@ -730,7 +845,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 1 unmodified line | ||
| 730 | 845 | getItemType={getItemType} |
| 731 | 846 | renderItem={renderItem} |
| 732 | 847 | estimatedItemSize={90} |
| 733 | − initialScrollAtEnd | |
| 848 | + initialScrollAtEnd={citationRequest === null} | |
| 849 | + // Legend needs a data refresh to mount new pins without a scroll event. | |
| 850 | + {...(readyCitationRequest ? { dataVersion: readyCitationRequest.key } : {})} | |
| 851 | + {...(citationAlwaysRender ? { alwaysRender: citationAlwaysRender } : {})} | |
| 852 | + onLoad={onCitationListLoad} | |
| 734 | 853 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} |
| 735 | 854 | contentInsetEndAdjustment={contentInsetEndAdjustment} |
| 736 | 855 | // LegendList owns ordinary end-follow (#5449): the app only turns |
| @@ -738,11 +857,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ · 1 unmodified line | ||
| 738 | 857 | // sent turn anchors near the top (anchoredEndSpace), or for the |
| 739 | 858 | // two-frame settle window of a fold toggle. |
| 740 | 859 | maintainScrollAtEnd={ |
| 741 | − anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling | |
| 860 | + citationPositioning || | |
| 861 | + anchoredEndSpace || | |
| 862 | + !liveFollowEnabled || | |
| 863 | + disclosureToggleSettling | |
| 742 | 864 | ? false |
| 743 | 865 | : TIMELINE_MAINTAIN_SCROLL_AT_END |
| 744 | 866 | } |
| 745 | − maintainVisibleContentPosition={maintainVisibleContentPosition} | |
| 867 | + maintainVisibleContentPosition={ | |
| 868 | + citationPositioning ? false : maintainVisibleContentPosition | |
| 869 | + } | |
| 746 | 870 | onScroll={handleScroll} |
| 747 | 871 | className={cn( |
| 748 | 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 | ||
| 1101 | 1225 | type TimelineRow = MessagesTimelineRow; |
| 1102 | 1226 | |
| 1103 | 1227 | const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) { |
| 1104 | − const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry; | |
| 1105 | − const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry; | |
| 1228 | + const isExpandedToolGroup = row.kind === "work" && row.isExpandedToolGroup; | |
| 1106 | 1229 | const isExpandedToolGroupHeader = |
| 1107 | 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 | 1234 | className={cn( |
| 1112 | 1235 | // Commentary (non-terminal assistant) rows carry no metadata row, so |
| 1113 | 1236 | // they sit closer to the work that follows them. |
| 1114 | − isExpandedToolGroupEntry | |
| 1115 | − ? isLastExpandedToolGroupEntry | |
| 1116 | − ? "pb-1" | |
| 1117 | − : "pb-0" | |
| 1237 | + isExpandedToolGroup | |
| 1238 | + ? "pb-1" | |
| 1118 | 1239 | : isExpandedToolGroupHeader |
| 1119 | 1240 | ? "pb-0" |
| 1120 | 1241 | : row.kind === "turn-fold" |
| @@ -1141,8 +1262,9 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time · 20 unmodified lines | ||
| 1141 | 1262 | > |
| 1142 | 1263 | {row.kind === "work" ? ( |
| 1143 | 1264 | <WorkGroupSection |
| 1265 | + anchorKey={row.id} | |
| 1144 | 1266 | groupedEntries={row.groupedEntries} |
| 1145 | − isExpandedToolGroupEntry={row.isExpandedToolGroupEntry} | |
| 1267 | + isExpandedToolGroup={row.isExpandedToolGroup} | |
| 1146 | 1268 | /> |
| 1147 | 1269 | ) : null} |
| 1148 | 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 | 1608 | return ( |
| 1487 | 1609 | <> |
| 1488 | 1610 | <div className="relative min-w-0 px-1 py-0.5"> |
| 1489 | − <ChatMarkdown | |
| 1490 | − text={messageText} | |
| 1491 | − cwd={ctx.markdownCwd} | |
| 1492 | − threadRef={ctx.threadRef ?? undefined} | |
| 1493 | − isStreaming={Boolean(row.message.streaming)} | |
| 1494 | − lineBreaks={shouldPreserveAssistantLineBreaks(messageText)} | |
| 1495 | − skills={ctx.skills} | |
| 1496 | − onUseArtifactTemplate={ctx.onUseArtifactTemplate} | |
| 1497 | − onImageExpand={ctx.onImageExpand} | |
| 1498 | − /> | |
| 1611 | + <AssistantCitationSource | |
| 1612 | + messageId={row.message.id} | |
| 1613 | + {...(ctx.threadRef ? { threadRef: ctx.threadRef } : {})} | |
| 1614 | + itemKey={row.id} | |
| 1615 | + request={ctx.citationRequest} | |
| 1616 | + listRef={ctx.listRef} | |
| 1617 | + > | |
| 1618 | + <ChatMarkdown | |
| 1619 | + text={messageText} | |
| 1620 | + cwd={ctx.markdownCwd} | |
| 1621 | + threadRef={ctx.threadRef ?? undefined} | |
| 1622 | + isStreaming={Boolean(row.message.streaming)} | |
| 1623 | + lineBreaks={shouldPreserveAssistantLineBreaks(messageText)} | |
| 1624 | + skills={ctx.skills} | |
| 1625 | + onUseArtifactTemplate={ctx.onUseArtifactTemplate} | |
| 1626 | + onImageExpand={ctx.onImageExpand} | |
| 1627 | + /> | |
| 1628 | + </AssistantCitationSource> | |
| 1499 | 1629 | <AssistantChangedFilesSection |
| 1500 | 1630 | turnSummary={row.assistantTurnDiffSummary} |
| 1501 | 1631 | routeThreadKey={ctx.routeThreadKey} |
| @@ -1989,40 +2119,47 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({ · 487 unmodified lines | ||
| 1989 | 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. */ | |
| 1993 | 2123 | const WorkGroupSection = memo(function WorkGroupSection({ |
| 2124 | + anchorKey, | |
| 1994 | 2125 | groupedEntries, |
| 1995 | − isExpandedToolGroupEntry, | |
| 2126 | + isExpandedToolGroup, | |
| 1996 | 2127 | }: { |
| 2128 | + anchorKey: string; | |
| 1997 | 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 | 2133 | const nonEmptyEntries = useMemo( |
| 2002 | − () => | |
| 2003 | − groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)), | |
| 2004 | − [groupedEntries, isExpandedToolGroupEntry], | |
| 2134 | + () => groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroup)), | |
| 2135 | + [groupedEntries, isExpandedToolGroup], | |
| 2005 | 2136 | ); |
| 2006 | − const GroupContainer = isExpandedToolGroupEntry ? "div" : "section"; | |
| 2007 | 2137 | |
| 2008 | 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 | 2150 | return ( |
| 2011 | − <GroupContainer | |
| 2012 | − className={cn("-mx-1 px-1", isExpandedToolGroupEntry ? "py-0" : "space-y-0.5 py-0.5")} | |
| 2013 | − aria-label={isExpandedToolGroupEntry ? undefined : "Activity"} | |
| 2014 | − > | |
| 2151 | + <section className="-mx-1 space-y-0.5 px-1 py-0.5" aria-label="Activity"> | |
| 2015 | 2152 | <div className="space-y-px"> |
| 2016 | 2153 | {nonEmptyEntries.map((workEntry) => ( |
| 2017 | 2154 | <SimpleWorkEntryRow |
| 2018 | 2155 | key={workEntry.id} |
| 2019 | 2156 | workEntry={workEntry} |
| 2020 | 2157 | workspaceRoot={workspaceRoot} |
| 2021 | − isExpandedToolGroupEntry={isExpandedToolGroupEntry} | |
| 2158 | + isExpandedToolGroupEntry={false} | |
| 2022 | 2159 | /> |
| 2023 | 2160 | ))} |
| 2024 | 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 | |
| 2188 | +function ExpandedWorkGroupEntries({ | |
| 2189 | + anchorKey, | |
| 2190 | + entries, | |
| 2191 | + workspaceRoot, | |
| 2192 | +}: { | |
| 2193 | + anchorKey: string; | |
| 2194 | + entries: TimelineWorkEntry[]; | |
| 2195 | + workspaceRoot: string | undefined; | |
| 2196 | +}) { | |
| 2197 | + const { workGroupViewState: viewState, onToggleWorkEntry } = use(TimelineRowCtx); | |
| 2198 | + const [initialScrollIndex] = useState(() => | |
| 2199 | + resolveWorkGroupScrollIndex(entries, viewState.scrollPositions.get(anchorKey)), | |
| 2200 | + ); | |
| 2201 | + const [restoringPosition, setRestoringPosition] = useState(initialScrollIndex !== undefined); | |
| 2202 | + const listRef = useRef<LegendListRef>(null); | |
| 2203 | + const [fades, setFades] = useState({ top: false, bottom: false, viewportHeight: 0 }); | |
| 2204 | + const [appendState, setAppendState] = useState({ entries, follow: false }); | |
| 2205 | + // Capture the pre-change edge once per incoming array, before new layout | |
| 2206 | + // metrics arrive. Edge/viewport changes never turn a status update into a follow. | |
| 2207 | + if (appendState.entries !== entries) { | |
| 2208 | + setAppendState({ | |
| 2209 | + entries, | |
| 2210 | + follow: | |
| 2211 | + fades.viewportHeight > 0 && | |
| 2212 | + shouldFollowWorkGroupAppend(appendState.entries, entries, fades.bottom ? Infinity : 0), | |
| 2213 | + }); | |
| 2214 | + } | |
| 2215 | + | |
| 2216 | + const groupView = useMemo( | |
| 2217 | + () => ({ state: viewState, onToggleEntry: () => onToggleWorkEntry(anchorKey) }), | |
| 2218 | + [anchorKey, onToggleWorkEntry, viewState], | |
| 2219 | + ); | |
| 2220 | + const updateScrollFades = useCallback(() => { | |
| 2221 | + const element = listRef.current?.getScrollableNode(); | |
| 2222 | + if (!element) return; | |
| 2223 | + const distanceFromEnd = element.scrollHeight - element.clientHeight - element.scrollTop; | |
| 2224 | + const viewportHeight = element.clientHeight; | |
| 2225 | + const top = element.scrollTop > 1; | |
| 2226 | + const bottom = distanceFromEnd > 1; | |
| 2227 | + setFades((previous) => | |
| 2228 | + previous.top === top && | |
| 2229 | + previous.bottom === bottom && | |
| 2230 | + previous.viewportHeight === viewportHeight | |
| 2231 | + ? previous | |
| 2232 | + : { top, bottom, viewportHeight }, | |
| 2233 | + ); | |
| 2234 | + }, []); | |
| 2235 | + | |
| 2236 | + const handleScroll = useCallback(() => { | |
| 2237 | + const state = listRef.current?.getState(); | |
| 2238 | + const position = state && resolveWorkGroupScrollAnchor(state); | |
| 2239 | + if (position) { | |
| 2240 | + viewState.scrollPositions.set(anchorKey, { | |
| 2241 | + entryId: position.rowId, | |
| 2242 | + offset: position.offsetWithinRow, | |
| 2243 | + }); | |
| 2244 | + } | |
| 2245 | + updateScrollFades(); | |
| 2246 | + }, [anchorKey, updateScrollFades, viewState]); | |
| 2247 | + | |
| 2248 | + const handleLoad = useCallback(() => { | |
| 2249 | + const list = listRef.current; | |
| 2250 | + const element = list?.getScrollableNode(); | |
| 2251 | + if (initialScrollIndex && list && element) { | |
| 2252 | + // Bootstrap can report the restored target before the DOM has applied it. | |
| 2253 | + // Reconcile once at load, before releasing the measured anchor row. | |
| 2254 | + const offset = Math.max( | |
| 2255 | + 0, | |
| 2256 | + Math.min(list.getState().scroll, element.scrollHeight - element.clientHeight), | |
| 2257 | + ); | |
| 2258 | + if (Math.abs(element.scrollTop - offset) > 1) { | |
| 2259 | + void list.scrollToOffset({ offset, animated: false }); | |
| 2260 | + } | |
| 2261 | + } | |
| 2262 | + setRestoringPosition(false); | |
| 2263 | + }, [initialScrollIndex]); | |
| 2264 | + | |
| 2265 | + useLayoutEffect(() => { | |
| 2266 | + const element = listRef.current?.getScrollableNode(); | |
| 2267 | + if (!element) return; | |
| 2268 | + updateScrollFades(); | |
| 2269 | + const observer = new ResizeObserver(updateScrollFades); | |
| 2270 | + observer.observe(element); | |
| 2271 | + if (element.firstElementChild) observer.observe(element.firstElementChild); | |
| 2272 | + return () => observer.disconnect(); | |
| 2273 | + }, [updateScrollFades]); | |
| 2274 | + | |
| 2275 | + const renderEntry = useCallback( | |
| 2276 | + ({ item }: { item: TimelineWorkEntry }) => ( | |
| 2277 | + <SimpleWorkEntryRow | |
| 2278 | + key={item.id} | |
| 2279 | + workEntry={item} | |
| 2280 | + workspaceRoot={workspaceRoot} | |
| 2281 | + isExpandedToolGroupEntry | |
| 2282 | + /> | |
| 2283 | + ), | |
| 2284 | + [workspaceRoot], | |
| 2285 | + ); | |
| 2286 | + | |
| 2287 | + return ( | |
| 2288 | + <WorkGroupViewCtx value={groupView}> | |
| 2289 | + <LegendList | |
| 2290 | + ref={listRef} | |
| 2291 | + data={entries} | |
| 2292 | + extraData={workspaceRoot} | |
| 2293 | + keyExtractor={workEntryKey} | |
| 2294 | + renderItem={renderEntry} | |
| 2295 | + estimatedItemSize={24} | |
| 2296 | + drawDistance={240} | |
| 2297 | + recycleItems | |
| 2298 | + {...(initialScrollIndex ? { initialScrollIndex } : {})} | |
| 2299 | + maintainScrollAtEnd={ | |
| 2300 | + appendState.follow ? { animated: false, on: { dataChange: true } } : false | |
| 2301 | + } | |
| 2302 | + maintainScrollAtEndThreshold={1 / Math.max(1, fades.viewportHeight)} | |
| 2303 | + // Measure the restored row even when an intra-row offset puts its | |
| 2304 | + // estimated bounds outside the list's small bootstrap render window. | |
| 2305 | + {...(restoringPosition && initialScrollIndex | |
| 2306 | + ? { alwaysRender: { indices: [initialScrollIndex.index] } } | |
| 2307 | + : {})} | |
| 2308 | + maintainVisibleContentPosition | |
| 2309 | + onLoad={handleLoad} | |
| 2310 | + onScroll={handleScroll} | |
| 2311 | + onLayout={updateScrollFades} | |
| 2312 | + tabIndex={0} | |
| 2313 | + role="region" | |
| 2314 | + aria-label="Tool calls" | |
| 2315 | + data-tool-group-scroll | |
| 2316 | + className={cn( | |
| 2317 | + "scrollbar-gutter-stable max-h-[min(18rem,50dvh)] scroll-py-6 overflow-x-hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", | |
| 2318 | + getVirtualizedScrollFadeClassName(fades), | |
| 2319 | + )} | |
| 2320 | + /> | |
| 2321 | + </WorkGroupViewCtx> | |
| 2322 | + ); | |
| 2323 | +} | |
| 2324 | + | |
| 2325 | +const workEntryKey = (entry: TimelineWorkEntry) => entry.id; | |
| 2326 | + | |
| 2051 | 2327 | function ActivityShimmerOverlay({ children }: { children: ReactNode }) { |
| 2052 | 2328 | return ( |
| 2053 | 2329 | <span |
| @@ -2142,10 +2418,11 @@ function LiveActivityContent({ · 88 unmodified lines | ||
| 2142 | 2418 | announceFailure?: boolean; |
| 2143 | 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 | 2424 | return ( |
| 2148 | − <div | |
| 2425 | + <span | |
| 2149 | 2426 | className={cn( |
| 2150 | 2427 | "flex min-h-6 min-w-0 items-center gap-1.5 py-0.5", |
| 2151 | 2428 | resolvedIconName ? "px-0.5" : "px-1", |
| @@ -2161,14 +2438,15 @@ function LiveActivityContent({ · 9 unmodified lines | ||
| 2161 | 2438 | role={announceFailure ? "img" : undefined} |
| 2162 | 2439 | aria-label={announceFailure ? "Tool call failed" : undefined} |
| 2163 | 2440 | > |
| 2164 | − <WorkEntryIconSvg | |
| 2441 | + <WorkEntryIcon | |
| 2165 | 2442 | name={resolvedIconName} |
| 2166 | 2443 | className={cn("block size-4 shrink-0 stroke-[1.8]", !highlighted && "opacity-70")} |
| 2167 | 2444 | /> |
| 2168 | 2445 | </span> |
| 2169 | 2446 | ) : null} |
| 2170 | 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 | 2489 | return "square-pen"; |
| 2212 | 2490 | case "command": |
| 2213 | 2491 | return "terminal"; |
| 2492 | + case "browser": | |
| 2493 | + return "browser"; | |
| 2214 | 2494 | case "search": |
| 2215 | 2495 | return "globe"; |
| 2216 | 2496 | case "code-search": |
| @@ -2244,7 +2524,7 @@ function WorkGroupToggleTimelineRow({ · 27 unmodified lines | ||
| 2244 | 2524 | onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} |
| 2245 | 2525 | > |
| 2246 | 2526 | <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted"> |
| 2247 | − <WorkEntryIconSvg | |
| 2527 | + <WorkEntryIcon | |
| 2248 | 2528 | name={toolGroupSummaryIconName(row.summaryKind)} |
| 2249 | 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 | 2989 | collapsed: false, |
| 2710 | 2990 | diffStyle: "unified", |
| 2711 | 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 | ||
| 2780 | 3061 | type WorkEntryIconName = |
| 2781 | 3062 | | "bot" |
| 2782 | 3063 | | "brain" |
| 3064 | + | "browser" | |
| 2783 | 3065 | | "check" |
| 2784 | 3066 | | "circle-alert" |
| 2785 | 3067 | | "eye" |
| @@ -2789,16 +3071,21 @@ type WorkEntryIconName = · 3 unmodified lines | ||
| 2789 | 3071 | | "search" |
| 2790 | 3072 | | "square-pen" |
| 2791 | 3073 | | "terminal" |
| 3074 | + | "t3-code" | |
| 2792 | 3075 | | "wrench" |
| 2793 | 3076 | | "x" |
| 2794 | 3077 | | "zap"; |
| 2795 | 3078 | |
| 2796 | −function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; className: string }) { | |
| 3079 | +function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) { | |
| 2797 | 3080 | switch (name) { |
| 2798 | 3081 | case "bot": |
| 2799 | 3082 | return <BotIcon className={className} aria-hidden />; |
| 2800 | 3083 | case "brain": |
| 2801 | 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 | 3089 | case "check": |
| 2803 | 3090 | return <CheckIcon className={className} aria-hidden />; |
| 2804 | 3091 | case "circle-alert": |
| @@ -2869,33 +3156,6 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { · 64 unmodified lines | ||
| 2869 | 3156 | }; |
| 2870 | 3157 | } |
| 2871 | 3158 | |
| 2872 | −function workEntryPreview( | |
| 2873 | − workEntry: Pick<TimelineWorkEntry, "detail" | "command" | "changedFiles">, | |
| 2874 | − workspaceRoot: string | undefined, | |
| 2875 | −) { | |
| 2876 | − // Collapsed rows show the tool's INPUT (command, touched files, search | |
| 2877 | − // pattern); outputs like stdout and diffs stay behind the expander. | |
| 2878 | − if (workEntry.command) return workEntry.command; | |
| 2879 | − const [firstPath] = workEntry.changedFiles ?? []; | |
| 2880 | − if (firstPath) { | |
| 2881 | − const displayPath = formatWorkspaceRelativePath(firstPath, workspaceRoot); | |
| 2882 | − return workEntry.changedFiles!.length === 1 | |
| 2883 | − ? displayPath | |
| 2884 | − : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; | |
| 2885 | − } | |
| 2886 | − if (workEntry.detail?.trim()) { | |
| 2887 | − const lines = workEntry.detail | |
| 2888 | − .trim() | |
| 2889 | − .split(/\r?\n/) | |
| 2890 | − .map((line) => line.trim()) | |
| 2891 | − .filter((line) => line.length > 0); | |
| 2892 | − if (lines.length > 0) { | |
| 2893 | − return lines.slice(0, 3).join(" · "); | |
| 2894 | − } | |
| 2895 | − } | |
| 2896 | − return null; | |
| 2897 | −} | |
| 2898 | − | |
| 2899 | 3159 | function workEntryRawCommand( |
| 2900 | 3160 | workEntry: Pick<TimelineWorkEntry, "command" | "rawCommand">, |
| 2901 | 3161 | ): string | null { |
| @@ -2951,42 +3211,14 @@ function buildToolCallExpandedBody( · 49 unmodified lines | ||
| 2951 | 3211 | const toolCallExpandedBodyClassName = |
| 2952 | 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 | |
| 2954 | −function liveWorkEntryLabel( | |
| 2955 | − workEntry: TimelineWorkEntry, | |
| 2956 | − workspaceRoot: string | undefined, | |
| 2957 | − active: boolean, | |
| 2958 | −): string { | |
| 2959 | − const command = workEntry.command?.trim(); | |
| 2960 | − if (command) { | |
| 2961 | − const program = commandProgramName(command); | |
| 2962 | − const verb = active | |
| 2963 | − ? "Running" | |
| 2964 | − : workEntry.toolLifecycleStatus === "declined" | |
| 2965 | − ? "Declined" | |
| 2966 | − : "Ran"; | |
| 2967 | − if (program) return `${verb} ${program}`; | |
| 2968 | − return `${verb} command`; | |
| 2969 | − } | |
| 2970 | − | |
| 2971 | − return workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); | |
| 2972 | −} | |
| 2973 | − | |
| 2974 | 3214 | function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { |
| 2975 | 3215 | if (workEntry.itemType === "user_input_request") { |
| 2976 | 3216 | return "message-circle"; |
| 2977 | 3217 | } |
| 2978 | − if (workEntry.requestKind === "command") return "terminal"; | |
| 2979 | − if (workEntry.requestKind === "file-read") return "eye"; | |
| 2980 | − if (workEntry.requestKind === "file-change") return "square-pen"; | |
| 2981 | − | |
| 2982 | − if (workEntry.itemType === "command_execution" || workEntry.command) { | |
| 2983 | − return "terminal"; | |
| 2984 | − } | |
| 2985 | − if (workEntry.itemType === "file_change" || (workEntry.changedFiles?.length ?? 0) > 0) { | |
| 2986 | − return "square-pen"; | |
| 2987 | − } | |
| 2988 | − if (workEntry.itemType === "web_search") return "globe"; | |
| 2989 | − if (workEntry.itemType === "file_search") return "eye"; | |
| 3218 | + const toolPresentation = resolveWorkEntryToolPresentation(workEntry); | |
| 3219 | + if (toolPresentation) return toolPresentation.icon; | |
| 3220 | + const action = toolGroupAction(workEntry); | |
| 3221 | + if (action !== "other") return toolGroupSummaryIconName(action); | |
| 2990 | 3222 | |
| 2991 | 3223 | switch (workEntry.itemType) { |
| 2992 | 3224 | case "dynamic_tool": |
| @@ -3013,6 +3245,33 @@ function toolWorkEntryHeading(workEntry: TimelineWorkEntry): string { · 20 unmodified lines | ||
| 3013 | 3245 | return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle)); |
| 3014 | 3246 | } |
| 3015 | 3247 | |
| 3248 | +function workEntryPreview( | |
| 3249 | + workEntry: Pick<TimelineWorkEntry, "detail" | "command" | "changedFiles">, | |
| 3250 | + workspaceRoot: string | undefined, | |
| 3251 | +) { | |
| 3252 | + // Collapsed rows show the tool's INPUT (command, touched files, search | |
| 3253 | + // pattern); outputs like stdout and diffs stay behind the expander. | |
| 3254 | + if (workEntry.command) return workEntry.command; | |
| 3255 | + const [firstPath] = workEntry.changedFiles ?? []; | |
| 3256 | + if (firstPath) { | |
| 3257 | + const displayPath = formatWorkspaceRelativePath(firstPath, workspaceRoot); | |
| 3258 | + return workEntry.changedFiles!.length === 1 | |
| 3259 | + ? displayPath | |
| 3260 | + : `${displayPath} +${workEntry.changedFiles!.length - 1} more`; | |
| 3261 | + } | |
| 3262 | + if (workEntry.detail?.trim()) { | |
| 3263 | + const lines = workEntry.detail | |
| 3264 | + .trim() | |
| 3265 | + .split(/\r?\n/) | |
| 3266 | + .map((line) => line.trim()) | |
| 3267 | + .filter((line) => line.length > 0); | |
| 3268 | + if (lines.length > 0) { | |
| 3269 | + return lines.slice(0, 3).join(" · "); | |
| 3270 | + } | |
| 3271 | + } | |
| 3272 | + return null; | |
| 3273 | +} | |
| 3274 | + | |
| 3016 | 3275 | const stopRowToggle = (e: { stopPropagation: () => void }) => e.stopPropagation(); |
| 3017 | 3276 | |
| 3018 | 3277 | const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { |
| @@ -3023,7 +3282,19 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 4 unmodified lines | ||
| 3023 | 3282 | const { workEntry, workspaceRoot, isExpandedToolGroupEntry = false } = props; |
| 3024 | 3283 | const activity = use(TimelineRowActivityCtx); |
| 3025 | 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 | 3298 | const iconConfig = workToneIcon(workEntry.tone); |
| 3028 | 3299 | const showWarningIndicator = false; |
| 3029 | 3300 | const entryIconName = showWarningIndicator ? "circle-alert" : workEntryIconName(workEntry); |
| @@ -3083,12 +3354,13 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { · 53 unmodified lines | ||
| 3083 | 3354 | ? { |
| 3084 | 3355 | role: "button" as const, |
| 3085 | 3356 | tabIndex: 0 as const, |
| 3086 | − "aria-label": displayText, | |
| 3087 | − onClick: () => setExpanded((v) => !v), | |
| 3357 | + "aria-label": showFailedIndicator ? `${displayText}, tool call failed` : displayText, | |
| 3358 | + "aria-expanded": expanded, | |
| 3359 | + onClick: toggleExpanded, | |
| 3088 | 3360 | onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => { |
| 3089 | 3361 | if (e.key === "Enter" || e.key === " ") { |
| 3090 | 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 | 3387 | {toolPresentation?.logo === "t3-code" ? ( |
| 3116 | 3388 | <T3CodeToolLogo /> |
| 3117 | 3389 | ) : ( |
| 3118 | − <WorkEntryIconSvg | |
| 3390 | + <WorkEntryIcon | |
| 3119 | 3391 | name={entryIconName} |
| 3120 | 3392 | className="block size-3.5 shrink-0 stroke-[1.8] opacity-80" |
| 3121 | 3393 | /> |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import type { LegendListRef } from "@legendapp/list/react"; |
| 2 | −import type { TurnId } from "@t3tools/contracts"; | |
| 2 | +import type { RunId } from "@t3tools/contracts"; | |
| 3 | 3 | import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; |
| 4 | 4 | import type { TimelineEntry } from "../../session-logic"; |
| 5 | 5 | import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; |
| @@ -31,7 +31,7 @@ export function useAssistantCitationTarget({ · 25 unmodified lines | ||
| 31 | 31 | viewport: HTMLElement | null; |
| 32 | 32 | historyLoading: boolean; |
| 33 | 33 | loadEarlier: CitationHistoryPage | null; |
| 34 | − onExpandTurn: (turnId: TurnId) => void; | |
| 34 | + onExpandTurn: (runId: RunId) => void; | |
| 35 | 35 | onManualNavigation: () => void; |
| 36 | 36 | }) { |
| 37 | 37 | const [ready, setReady] = useState<AssistantCitationTarget | null>(null); |
| @@ -119,7 +119,7 @@ export function useAssistantCitationTarget({ · 81 unmodified lines | ||
| 119 | 119 | (row) => row.kind === "message" && row.message.id === navigation.target.citation.messageId, |
| 120 | 120 | ); |
| 121 | 121 | if (index < 0) { |
| 122 | − if (source.message.turnId) onExpandTurn(source.message.turnId); | |
| 122 | + if (source.message.runId) onExpandTurn(source.message.runId); | |
| 123 | 123 | return; |
| 124 | 124 | } |
| 125 | 125 | if (listLoaded && listRef.current) setReady(navigation.target); |
| @@ -13,6 +13,7 @@ import type { · 12 unmodified lines | ||
| 13 | 13 | PullRequestReviewThread, |
| 14 | 14 | PullRequestState, |
| 15 | 15 | PullRequestUpdateMethod, |
| 16 | + SourceControlProviderKind, | |
| 16 | 17 | VcsRef, |
| 17 | 18 | } from "@t3tools/contracts"; |
| 18 | 19 | |
| @@ -502,6 +502,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil · 501 unmodified lines | ||
| 502 | 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 | 511 | @layer base { |
| 506 | 512 | ::highlight(t3-assistant-citation) { |
| 507 | 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 | 1175 | /* Keep controls and floating surfaces close to the neutral-black canvas. |
| 1170 | 1176 | Borders and hover states provide separation without milky gray fills. */ |
| 1171 | 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 | 1179 | --foreground: var(--color-neutral-100); |
| 1174 | 1180 | --card: color-mix(in srgb, var(--background) 97%, var(--color-white)); |
| 1175 | 1181 | --card-foreground: var(--color-neutral-100); |
| 1176 | 1182 | --popover: color-mix(in srgb, var(--background) 97%, var(--color-white)); |
| 1177 | 1183 | --popover-foreground: var(--color-neutral-100); |
| 1178 | 1184 | --primary: oklch(0.571 0.21 264); |
| 1179 | − --secondary: --alpha(var(--color-white) / 4%); | |
| 1185 | + --secondary: --alpha(var(--color-white) / 3%); | |
| 1180 | 1186 | --secondary-foreground: var(--color-neutral-100); |
| 1181 | 1187 | --muted: --alpha(var(--color-white) / 3%); |
| 1182 | 1188 | --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white)); |
| @@ -22,28 +22,6 @@ export type ContextWindowSnapshot = NullableContextWindowUsage & { · 21 unmodified lines | ||
| 22 | 22 | readonly updatedAt: string; |
| 23 | 23 | }; |
| 24 | 24 | |
| 25 | −/** Map a provider driver kind to a user-facing display name. */ | |
| 26 | −export function formatProviderDisplayName(provider: string | null | undefined): string { | |
| 27 | − if (!provider) return "This agent"; | |
| 28 | − switch (provider) { | |
| 29 | − case "claudeAgent": | |
| 30 | − case "claude": | |
| 31 | − return "Claude"; | |
| 32 | − case "codex": | |
| 33 | − return "Codex"; | |
| 34 | − case "cursor": | |
| 35 | − return "Cursor"; | |
| 36 | − case "opencode": | |
| 37 | − return "OpenCode"; | |
| 38 | − default: { | |
| 39 | − // Title-case unknown driver kinds so they read reasonably. | |
| 40 | − const trimmed = provider.replace(/Agent$/i, "").trim(); | |
| 41 | − if (trimmed.length === 0) return provider; | |
| 42 | − return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); | |
| 43 | − } | |
| 44 | − } | |
| 45 | −} | |
| 46 | − | |
| 47 | 25 | /** Prefers the provider's live usage report (#8144); falls back to the last compaction item. */ |
| 48 | 26 | export function deriveLatestContextWindowSnapshot( |
| 49 | 27 | entries: ReadonlyArray<{ |
| @@ -22,16 +22,12 @@ import { · 21 unmodified lines | ||
| 22 | 22 | import { environmentThreadDetails, environmentThreadShells } from "./threads"; |
| 23 | 23 | import { waitForAtomValue } from "./waitForAtomValue"; |
| 24 | 24 | |
| 25 | −const EMPTY_PROJECT_REFS: ReadonlyArray<ScopedProjectRef> = Object.freeze([]); | |
| 26 | 25 | const EMPTY_THREAD_REFS: ReadonlyArray<ScopedThreadRef> = Object.freeze([]); |
| 27 | 26 | const EMPTY_VISIBLE_TURN_ITEMS: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = Object.freeze([]); |
| 28 | 27 | |
| 29 | 28 | const EMPTY_PROJECT_ATOM = Atom.make<EnvironmentProject | null>(null).pipe( |
| 30 | 29 | Atom.withLabel("web-project:empty"), |
| 31 | 30 | ); |
| 32 | −const EMPTY_PROJECT_REFS_ATOM = Atom.make(EMPTY_PROJECT_REFS).pipe( | |
| 33 | − Atom.withLabel("web-project-refs:empty"), | |
| 34 | −); | |
| 35 | 31 | const EMPTY_THREAD_REFS_ATOM = Atom.make(EMPTY_THREAD_REFS).pipe( |
| 36 | 32 | Atom.withLabel("web-thread-refs:empty"), |
| 37 | 33 | ); |
| @@ -60,32 +56,14 @@ export function useActiveEnvironmentId(): EnvironmentId | null { · 22 unmodified lines | ||
| 60 | 56 | return useAtomValue(activeEnvironmentIdAtom); |
| 61 | 57 | } |
| 62 | 58 | |
| 63 | −export function readActiveEnvironmentId(): EnvironmentId | null { | |
| 64 | − return appAtomRegistry.get(activeEnvironmentIdAtom); | |
| 65 | −} | |
| 66 | − | |
| 67 | 59 | export function setActiveEnvironmentId(environmentId: EnvironmentId | null): void { |
| 68 | 60 | appAtomRegistry.set(activeEnvironmentIdAtom, environmentId); |
| 69 | 61 | } |
| 70 | 62 | |
| 71 | −export function useProjectRefs(): ReadonlyArray<ScopedProjectRef> { | |
| 72 | − return useAtomValue(environmentProjects.projectRefsAtom); | |
| 73 | −} | |
| 74 | − | |
| 75 | 63 | export function useThreadRefs(): ReadonlyArray<ScopedThreadRef> { |
| 76 | 64 | return useAtomValue(environmentThreadShells.threadRefsAtom); |
| 77 | 65 | } |
| 78 | 66 | |
| 79 | −export function useEnvironmentProjectRefs( | |
| 80 | − environmentId: EnvironmentId | null, | |
| 81 | −): ReadonlyArray<ScopedProjectRef> { | |
| 82 | − return useAtomValue( | |
| 83 | − environmentId === null | |
| 84 | − ? EMPTY_PROJECT_REFS_ATOM | |
| 85 | − : environmentProjects.environmentProjectRefsAtom(environmentId), | |
| 86 | − ); | |
| 87 | −} | |
| 88 | − | |
| 89 | 67 | export function useEnvironmentThreadRefs( |
| 90 | 68 | environmentId: EnvironmentId | null, |
| 91 | 69 | ): ReadonlyArray<ScopedThreadRef> { |
| @@ -283,18 +261,6 @@ export function readEnvironmentThreadRefs( · 191 unmodified lines | ||
| 283 | 261 | return appAtomRegistry.get(environmentThreadShells.environmentThreadRefsAtom(environmentId)); |
| 284 | 262 | } |
| 285 | 263 | |
| 286 | −export function readThreadRefs(): ReadonlyArray<ScopedThreadRef> { | |
| 287 | − return appAtomRegistry.get(environmentThreadShells.threadRefsAtom); | |
| 288 | −} | |
| 289 | − | |
| 290 | 264 | export function readThreadShells(): ReadonlyArray<EnvironmentThreadShell> { |
| 291 | 265 | return appAtomRegistry.get(environmentThreadShells.threadShellsAtom); |
| 292 | 266 | } |
| 293 | − | |
| 294 | −export function findThreadRef(threadId: ThreadId): ScopedThreadRef | null { | |
| 295 | − return ( | |
| 296 | − appAtomRegistry | |
| 297 | − .get(environmentThreadShells.threadRefsAtom) | |
| 298 | − .find((ref) => ref.threadId === threadId) ?? null | |
| 299 | − ); | |
| 300 | −} | |
| @@ -230,6 +230,10 @@ · 229 unmodified lines | ||
| 230 | 230 | "./work-log/command-label": { |
| 231 | 231 | "types": "./src/work-log/commandLabel.ts", |
| 232 | 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 | 239 | "scripts": { |
| @@ -50,6 +50,7 @@ import * as EnvironmentRegistry from "./registry.ts"; · 49 unmodified lines | ||
| 50 | 50 | import * as RpcSession from "../rpc/session.ts"; |
| 51 | 51 | import * as EnvironmentSupervisor from "./supervisor.ts"; |
| 52 | 52 | import * as ConnectionWakeups from "./wakeups.ts"; |
| 53 | +import { runDesktopCommitWithReconnectObserver } from "../state/server.ts"; | |
| 53 | 54 | import { v2ShellSnapshot } from "../state/orchestrationV2TestFixtures.ts"; |
| 54 | 55 | |
| 55 | 56 | const TARGET = new PrimaryConnectionTarget({ |
| @@ -1,3 +1,4 @@ | ||
| 1 | +import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; | |
| 1 | 2 | import { truncate } from "@t3tools/shared/String"; |
| 2 | 3 | |
| 3 | 4 | export interface ThreadTitleSeedInput { |
| @@ -7,7 +8,7 @@ export interface ThreadTitleSeedInput { · 3 unmodified lines | ||
| 7 | 8 | } |
| 8 | 9 | |
| 9 | 10 | function normalizeTitleSeed(value: string): string { |
| 10 | − return value.trim().replace(/\s+/gu, " "); | |
| 11 | + return assistantCitationsToPlainText(value).trim().replace(/\s+/gu, " "); | |
| 11 | 12 | } |
| 12 | 13 | |
| 13 | 14 | export function deriveThreadTitleSeed(input: ThreadTitleSeedInput): string { |
| @@ -4,7 +4,6 @@ import type { · 3 unmodified lines | ||
| 4 | 4 | MessageId, |
| 5 | 5 | OrchestrationProjectShell, |
| 6 | 6 | OrchestrationV2RunStatus, |
| 7 | − OrchestrationV2ShellSnapshot, | |
| 8 | 7 | OrchestrationV2ThreadProjection, |
| 9 | 8 | OrchestrationV2ThreadShell, |
| 10 | 9 | PlanId, |
| @@ -237,12 +236,3 @@ export function presentThreadShell( · 226 unmodified lines | ||
| 237 | 236 | } |
| 238 | 237 | |
| 239 | 238 | export const scopeThreadShell = presentThreadShell; |
| 240 | − | |
| 241 | −export function selectEnvironmentThreadShell( | |
| 242 | − snapshot: OrchestrationV2ShellSnapshot | null, | |
| 243 | − environmentId: EnvironmentId, | |
| 244 | − threadId: ThreadId, | |
| 245 | −): EnvironmentThreadShell | null { | |
| 246 | − const thread = snapshot?.threads.find((candidate) => candidate.id === threadId) ?? null; | |
| 247 | − return thread ? presentThreadShell(environmentId, thread) : null; | |
| 248 | −} | |
| @@ -1,5 +1,21 @@ | ||
| 1 | 1 | type CommandWrapper = "env" | "sudo"; |
| 2 | 2 | |
| 3 | +const SHELL_PROGRAMS = new Set(["sh", "bash", "zsh", "dash", "ash", "ksh", "fish"]); | |
| 4 | +const SHELL_OPTIONS_WITH_VALUE = new Set(["-o", "-O", "--rcfile", "--init-file"]); | |
| 5 | + | |
| 6 | +function shellCommandArgumentIndex(tokens: ReadonlyArray<string>, start: number): number | null { | |
| 7 | + for (let index = start; index < tokens.length; index += 1) { | |
| 8 | + const option = tokens[index]!; | |
| 9 | + if (option === "--" || !option.startsWith("-")) return null; | |
| 10 | + if (SHELL_OPTIONS_WITH_VALUE.has(option)) { | |
| 11 | + index += 1; | |
| 12 | + continue; | |
| 13 | + } | |
| 14 | + if (option === "--command" || /^-[a-zA-Z]*c[a-zA-Z]*$/.test(option)) return index + 1; | |
| 15 | + } | |
| 16 | + return null; | |
| 17 | +} | |
| 18 | + | |
| 3 | 19 | const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = { |
| 4 | 20 | env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]), |
| 5 | 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 | 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 | 191 | return null; |
| @@ -11,78 +11,10 @@ import { · 10 unmodified lines | ||
| 11 | 11 | toolGroupAction, |
| 12 | 12 | toolGroupSummaryKind, |
| 13 | 13 | type WorkLogPresentationEntry, |
| 14 | + type WorkLogToolLifecycleStatus, | |
| 14 | 15 | workEntryViewedImagePath, |
| 15 | 16 | } from "./presentation.js"; |
| 16 | 17 | |
| 17 | −describe("summarizeToolGroup", () => { | |
| 18 | − it.each(["command", "file-read", "file-change"])( | |
| 19 | − "keeps %s approvals out of tool execution counts", | |
| 20 | − (requestKind) => { | |
| 21 | − const approvals = [ | |
| 22 | − { | |
| 23 | − label: "Approval requested", | |
| 24 | − sourceActivityKind: "approval.requested", | |
| 25 | − tone: "info", | |
| 26 | − requestKind, | |
| 27 | − }, | |
| 28 | − { | |
| 29 | − label: "Approval resolved", | |
| 30 | − sourceActivityKind: "approval.resolved", | |
| 31 | − tone: "info", | |
| 32 | − requestKind, | |
| 33 | − }, | |
| 34 | − { | |
| 35 | − label: "Provider approval response failed", | |
| 36 | − sourceActivityKind: "provider.approval.respond.failed", | |
| 37 | − tone: "error", | |
| 38 | − }, | |
| 39 | − ] satisfies WorkLogPresentationEntry[]; | |
| 40 | − | |
| 41 | − expect( | |
| 42 | − summarizeToolGroup([ | |
| 43 | − ...approvals, | |
| 44 | − { label: "Read", tone: "tool", itemType: "dynamic_tool_call" }, | |
| 45 | − ]), | |
| 46 | − ).toBe("Received 3 updates and used 1 tool"); | |
| 47 | − expect(summarizeToolGroup(approvals)).toBe("Received 3 updates"); | |
| 48 | − expect(toolGroupSummaryKind(approvals)).toBe("update"); | |
| 49 | − }, | |
| 50 | − ); | |
| 51 | − | |
| 52 | − it("deduplicates named sources ahead of ordinary actions", () => { | |
| 53 | − const source = { key: "browser-use:chrome", name: "Chrome", kind: "integration" as const }; | |
| 54 | − expect( | |
| 55 | − summarizeToolGroup([ | |
| 56 | − { label: "Open page", tone: "tool", toolSource: source }, | |
| 57 | − { label: "Inspect page", tone: "tool", toolSource: source }, | |
| 58 | − { | |
| 59 | − label: "Ran command", | |
| 60 | − tone: "tool", | |
| 61 | − itemType: "command_execution", | |
| 62 | − command: "git status", | |
| 63 | − }, | |
| 64 | − ]), | |
| 65 | − ).toBe("Used Chrome integration and ran 1 command"); | |
| 66 | − }); | |
| 67 | − | |
| 68 | − it("omits the integration suffix for special browser and computer sources", () => { | |
| 69 | − expect( | |
| 70 | − summarizeToolGroup([ | |
| 71 | − { | |
| 72 | − label: "Inspect page", | |
| 73 | − tone: "tool", | |
| 74 | − toolSource: { key: "browser-use", name: "Browser", kind: "browser" }, | |
| 75 | − }, | |
| 76 | − { | |
| 77 | − label: "Click", | |
| 78 | − tone: "tool", | |
| 79 | − toolSource: { key: "computer-use", name: "Computer Use", kind: "computer" }, | |
| 80 | − }, | |
| 81 | − ]), | |
| 82 | − ).toBe("Used Browser and Computer Use"); | |
| 83 | − }); | |
| 84 | −}); | |
| 85 | − | |
| 86 | 18 | describe("resolveWorkEntryToolPresentation", () => { |
| 87 | 19 | it.each([ |
| 88 | 20 | "mcp__t3-code__preview_click", |
| @@ -94,7 +26,7 @@ describe("resolveWorkEntryToolPresentation", () => { · 5 unmodified lines | ||
| 94 | 26 | "preview_click", |
| 95 | 27 | ])("recognizes browser tool names across providers: %s", (label) => { |
| 96 | 28 | expect(resolveWorkEntryToolPresentation({ label })).toEqual({ |
| 97 | − displayName: "Clicking in the preview browser", | |
| 29 | + displayName: "Click in the preview browser", | |
| 98 | 30 | icon: "browser", |
| 99 | 31 | }); |
| 100 | 32 | }); |
| @@ -106,7 +38,7 @@ describe("resolveWorkEntryToolPresentation", () => { · 5 unmodified lines | ||
| 106 | 38 | toolTitle: "Inspect the current page", |
| 107 | 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 | 44 | it.each([ |
| @@ -115,12 +47,12 @@ describe("resolveWorkEntryToolPresentation", () => { · 2 unmodified lines | ||
| 115 | 47 | ["failed", "Failed to click in the preview browser"], |
| 116 | 48 | ["declined", "Declined to click in the preview browser"], |
| 117 | 49 | ["stopped", "Stopped clicking in the preview browser"], |
| 118 | − ["unknown", "Clicking in the preview browser"], | |
| 119 | − ])("describes the tool's own %s state", (toolLifecycleStatus, displayName) => { | |
| 50 | + ["unknown", "Click in the preview browser"], | |
| 51 | + ] as const)("describes the tool's own %s state", (toolLifecycleStatus, displayName) => { | |
| 120 | 52 | expect( |
| 121 | 53 | resolveWorkEntryToolPresentation({ |
| 122 | 54 | label: "T3-code.preview_click", |
| 123 | − toolLifecycleStatus, | |
| 55 | + toolLifecycleStatus: toolLifecycleStatus as WorkLogToolLifecycleStatus, | |
| 124 | 56 | }), |
| 125 | 57 | ).toEqual({ displayName, icon: "browser" }); |
| 126 | 58 | }); |
| @@ -184,7 +116,7 @@ describe("resolveWorkEntryToolPresentation", () => { · 57 unmodified lines | ||
| 184 | 116 | label: "mcp__t3_code__task_status", |
| 185 | 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 | 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 | |
| 208 | 140 | describe("browser group summaries", () => { |
| 141 | + const summarizeGroupLabel = (entries: ReadonlyArray<WorkLogPresentationEntry>) => | |
| 142 | + summarizeToolGroup(entries).summary; | |
| 209 | 143 | const browserEntry: WorkLogPresentationEntry = { |
| 144 | + id: "browser", | |
| 145 | + createdAt: "2026-09-01T00:00:00Z", | |
| 210 | 146 | label: "MCP tool call", |
| 211 | 147 | toolData: { server: "t3-code", tool: "preview_click" }, |
| 212 | − itemType: "mcp_tool_call", | |
| 148 | + itemType: "dynamic_tool", | |
| 213 | 149 | toolLifecycleStatus: "completed", |
| 214 | 150 | tone: "tool", |
| 215 | 151 | }; |
| 216 | 152 | const commandEntry: WorkLogPresentationEntry = { |
| 153 | + id: "command", | |
| 154 | + createdAt: "2026-09-01T00:00:00Z", | |
| 217 | 155 | label: "Ran command", |
| 218 | 156 | command: "/bin/bash -lc 'vp test run'", |
| 219 | 157 | itemType: "command_execution", |
| @@ -226,7 +164,7 @@ describe("browser group summaries", () => { · 6 unmodified lines | ||
| 226 | 164 | ...browserEntry, |
| 227 | 165 | toolCallId: `browser-${index}`, |
| 228 | 166 | })); |
| 229 | − expect(summarizeToolGroup(entries)).toBe( | |
| 167 | + expect(summarizeGroupLabel(entries)).toBe( | |
| 230 | 168 | `Used browser ${count} ${count === 1 ? "time" : "times"}`, |
| 231 | 169 | ); |
| 232 | 170 | expect(toolGroupSummaryKind(entries)).toBe("browser"); |
| @@ -237,13 +175,13 @@ describe("browser group summaries", () => { · 4 unmodified lines | ||
| 237 | 175 | ...Array.from({ length: 4 }, () => commandEntry), |
| 238 | 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 | 179 | expect(toolGroupSummaryKind(entries)).toBe("mixed"); |
| 242 | 180 | }); |
| 243 | 181 | |
| 244 | 182 | it("preserves first-seen action ordering alongside non-browser tools", () => { |
| 245 | 183 | expect( |
| 246 | − summarizeToolGroup([ | |
| 184 | + summarizeGroupLabel([ | |
| 247 | 185 | browserEntry, |
| 248 | 186 | commandEntry, |
| 249 | 187 | { |
| @@ -251,12 +189,12 @@ describe("browser group summaries", () => { · 1 unmodified line | ||
| 251 | 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 | 195 | it("recognizes Claude browser identity without treating script metadata as a shell command", () => { |
| 258 | 196 | expect( |
| 259 | − summarizeToolGroup([ | |
| 197 | + summarizeGroupLabel([ | |
| 260 | 198 | { |
| 261 | 199 | ...browserEntry, |
| 262 | 200 | command: "node inspect-page.js", |
| @@ -268,21 +206,27 @@ describe("browser group summaries", () => { · 5 unmodified lines | ||
| 268 | 206 | |
| 269 | 207 | it("keeps foreign tools and web searches out of the browser count", () => { |
| 270 | 208 | expect( |
| 271 | − summarizeToolGroup([ | |
| 209 | + summarizeGroupLabel([ | |
| 272 | 210 | browserEntry, |
| 273 | 211 | { |
| 274 | 212 | ...browserEntry, |
| 275 | 213 | label: "preview_click", |
| 276 | 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 | 227 | it("keeps browser screenshots in the browser count while preserving their image path", () => { |
| 284 | 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 | 230 | expect(workEntryViewedImagePath(entry)).toBe("/workspace/page.png"); |
| 287 | 231 | }); |
| 288 | 232 | }); |
| @@ -393,7 +337,7 @@ describe("workEntryViewedImagePath", () => { · 104 unmodified lines | ||
| 393 | 337 | expect( |
| 394 | 338 | workEntryViewedImagePath({ |
| 395 | 339 | ...entry, |
| 396 | − itemType: "dynamic_tool_call", | |
| 340 | + itemType: "dynamic_tool", | |
| 397 | 341 | detail: 'Read: {"file_path":"truncated..."}', |
| 398 | 342 | viewedImagePath: " /workspace/reference image.webp ", |
| 399 | 343 | }), |
| @@ -415,9 +359,11 @@ describe("toolGroupAction", () => { · 15 unmodified lines | ||
| 415 | 359 | it("groups legacy Claude image reads with other reads", () => { |
| 416 | 360 | expect( |
| 417 | 361 | toolGroupAction({ |
| 362 | + id: "legacy-read", | |
| 363 | + createdAt: "2026-09-01T00:00:00Z", | |
| 418 | 364 | label: "Tool call", |
| 419 | 365 | tone: "tool", |
| 420 | − itemType: "dynamic_tool_call", | |
| 366 | + itemType: "dynamic_tool", | |
| 421 | 367 | viewedImagePath: "/workspace/reference.png", |
| 422 | 368 | }), |
| 423 | 369 | ).toBe("read"); |
| @@ -427,11 +373,17 @@ describe("toolGroupAction", () => { · 3 unmodified lines | ||
| 427 | 373 | describe("resolveViewedImageAsset", () => { |
| 428 | 374 | const threadId = ThreadId.make("thread-1"); |
| 429 | 375 | |
| 430 | − it("serves t3 attachment paths in place like any other host path", () => { | |
| 431 | − const path = "/Users/demo/.t3/dev/attachments/11111111-1111-4111-8111-111111111111.png"; | |
| 432 | − expect(resolveViewedImageAsset(path, { threadId, workspaceRoot: "/workspace" })).toEqual({ | |
| 433 | − resource: { _tag: "media-file", threadId, path }, | |
| 434 | − alt: "11111111-1111-4111-8111-111111111111.png", | |
| 376 | + it("loads t3 attachment paths as attachments", () => { | |
| 377 | + const attachmentId = | |
| 378 | + "11111111-1111-4111-8111-111111111111-22222222-2222-4222-8222-222222222222"; | |
| 379 | + expect( | |
| 380 | + resolveViewedImageAsset(`/Users/demo/.t3/dev/attachments/${attachmentId}.png`, { | |
| 381 | + threadId, | |
| 382 | + workspaceRoot: "/workspace", | |
| 383 | + }), | |
| 384 | + ).toEqual({ | |
| 385 | + resource: { _tag: "attachment", attachmentId }, | |
| 386 | + alt: `${attachmentId}.png`, | |
| 435 | 387 | srcFragment: "", |
| 436 | 388 | }); |
| 437 | 389 | }); |
| @@ -1,4 +1,9 @@ | ||
| 1 | −import type { AssetResource, OrchestrationV2TurnItem, ThreadId } from "@t3tools/contracts"; | |
| 1 | +import { | |
| 2 | + isToolLifecycleItemType, | |
| 3 | + type AssetResource, | |
| 4 | + type OrchestrationV2TurnItem, | |
| 5 | + type ThreadId, | |
| 6 | +} from "@t3tools/contracts"; | |
| 2 | 7 | import { |
| 3 | 8 | resolveT3McpToolSummaryAction, |
| 4 | 9 | type T3McpToolSummaryAction, |
| @@ -27,6 +32,7 @@ export interface WorkLogPresentationEntry { · 22 unmodified lines | ||
| 27 | 32 | readonly command?: string; |
| 28 | 33 | readonly rawCommand?: string; |
| 29 | 34 | readonly detail?: string; |
| 35 | + readonly viewedImagePath?: string; | |
| 30 | 36 | readonly changedFiles?: ReadonlyArray<string>; |
| 31 | 37 | readonly toolTitle?: string; |
| 32 | 38 | readonly toolData?: unknown; |
| @@ -40,6 +46,7 @@ export type ToolGroupAction = · 7 unmodified lines | ||
| 40 | 46 | | "read" |
| 41 | 47 | | "edit" |
| 42 | 48 | | "command" |
| 49 | + | "browser" | |
| 43 | 50 | | "code-search" |
| 44 | 51 | | "search" |
| 45 | 52 | | "other" |
| @@ -56,16 +63,251 @@ export function normalizeCompactToolLabel(value: string): string { · 10 unmodified lines | ||
| 56 | 63 | return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); |
| 57 | 64 | } |
| 58 | 65 | |
| 59 | −export function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean { | |
| 66 | +const T3_MCP_TOOL_LABELS: Record< | |
| 67 | + string, | |
| 68 | + readonly [action: string, running: string, completed: string, detail: string] | |
| 69 | +> = { | |
| 70 | + orchestrator_capabilities: ["Get", "Getting", "Got", "orchestration capabilities"], | |
| 71 | + delegate_task: ["Delegate", "Delegating", "Delegated", "a child task"], | |
| 72 | + task_status: ["Get", "Getting", "Got", "delegated task status"], | |
| 73 | + task_cancel: ["Cancel", "Canceling", "Canceled", "delegated task"], | |
| 74 | + schedule_task: ["Schedule", "Scheduling", "Scheduled", "a recurring task"], | |
| 75 | + list_scheduled_tasks: ["List", "Listing", "Listed", "scheduled tasks"], | |
| 76 | + update_scheduled_task: ["Update", "Updating", "Updated", "a scheduled task"], | |
| 77 | + delete_scheduled_task: ["Delete", "Deleting", "Deleted", "a scheduled task"], | |
| 78 | + create_threads: ["Create", "Creating", "Created", "T3 threads"], | |
| 79 | + t3_thread_start: ["Start", "Starting", "Started", "a T3 thread"], | |
| 80 | + t3_thread_list: ["List", "Listing", "Listed", "T3 threads"], | |
| 81 | + t3_thread_read: ["Read", "Reading", "Read", "a T3 thread"], | |
| 82 | + t3_thread_send: ["Send", "Sending", "Sent", "to a T3 thread"], | |
| 83 | + t3_thread_wait: ["Wait", "Waiting", "Waited", "for a T3 thread"], | |
| 84 | + t3_thread_interrupt: ["Interrupt", "Interrupting", "Interrupted", "a T3 thread"], | |
| 85 | + t3_worktree_handoff: ["Hand off", "Handing off", "Handed off", "thread to a git worktree"], | |
| 86 | + t3_worktree_status: ["Get", "Getting", "Got", "thread worktree status"], | |
| 87 | + preview_status: ["Get", "Getting", "Got", "preview browser status"], | |
| 88 | + preview_open: ["Open", "Opening", "Opened", "a page in the preview browser"], | |
| 89 | + preview_navigate: ["Navigate", "Navigating", "Navigated", "the preview browser"], | |
| 90 | + preview_snapshot: [ | |
| 91 | + "Take a snapshot of", | |
| 92 | + "Taking a snapshot of", | |
| 93 | + "Took a snapshot of", | |
| 94 | + "the preview page", | |
| 95 | + ], | |
| 96 | + preview_click: ["Click", "Clicking", "Clicked", "in the preview browser"], | |
| 97 | + preview_press: ["Press", "Pressing", "Pressed", "a key in the preview browser"], | |
| 98 | + preview_type: ["Type", "Typing", "Typed", "in the preview browser"], | |
| 99 | + preview_scroll: ["Scroll", "Scrolling", "Scrolled", "the preview browser"], | |
| 100 | + preview_resize: ["Resize", "Resizing", "Resized", "the preview browser"], | |
| 101 | + preview_evaluate: ["Evaluate", "Evaluating", "Evaluated", "script in the preview browser"], | |
| 102 | + preview_wait_for: ["Wait", "Waiting", "Waited", "for the preview page"], | |
| 103 | + preview_set_appearance: ["Set", "Setting", "Set", "preview browser appearance"], | |
| 104 | + preview_recording_start: ["Start", "Starting", "Started", "recording the preview browser"], | |
| 105 | + preview_recording_stop: ["Stop", "Stopping", "Stopped", "recording the preview browser"], | |
| 106 | +}; | |
| 107 | + | |
| 108 | +function resolveT3McpToolPresentation(value: string | undefined, status: string | undefined) { | |
| 109 | + if (!value) return null; | |
| 110 | + const name = normalizeCompactToolLabel(value).replace( | |
| 111 | + /^(?:mcp__(?:t3-code|t3_code|t3code)__|(?:t3-code|t3_code|t3code)(?:[.:/]|\s*·\s*))/i, | |
| 112 | + "", | |
| 113 | + ); | |
| 114 | + if (!Object.hasOwn(T3_MCP_TOOL_LABELS, name)) return null; | |
| 115 | + | |
| 116 | + const [action, running, completed, detail] = T3_MCP_TOOL_LABELS[name]!; | |
| 117 | + const verb = | |
| 118 | + status === "inProgress" | |
| 119 | + ? running | |
| 120 | + : status === "completed" | |
| 121 | + ? completed | |
| 122 | + : status === "failed" | |
| 123 | + ? `Failed to ${action.toLowerCase()}` | |
| 124 | + : status === "declined" | |
| 125 | + ? `Declined to ${action.toLowerCase()}` | |
| 126 | + : status === "stopped" | |
| 127 | + ? `Stopped ${running.toLowerCase()}` | |
| 128 | + : action; | |
| 129 | + | |
| 130 | + return { | |
| 131 | + displayName: `${verb} ${detail}`, | |
| 132 | + icon: name.startsWith("preview_") ? ("browser" as const) : ("t3-code" as const), | |
| 133 | + }; | |
| 134 | +} | |
| 135 | + | |
| 136 | +/** Resolves tool identity before choosing labels or icons in either client. */ | |
| 137 | +export function resolveWorkEntryToolPresentation( | |
| 138 | + entry: Pick<WorkLogPresentationEntry, "label" | "toolTitle" | "toolData" | "toolLifecycleStatus">, | |
| 139 | + fallbackStatus?: "inProgress" | "completed", | |
| 140 | +) { | |
| 141 | + const status = entry.toolLifecycleStatus ?? fallbackStatus; | |
| 142 | + const data = entry.toolData; | |
| 143 | + if (data !== null && typeof data === "object") { | |
| 144 | + if ( | |
| 145 | + "server" in data && | |
| 146 | + typeof data.server === "string" && | |
| 147 | + "tool" in data && | |
| 148 | + typeof data.tool === "string" | |
| 149 | + ) { | |
| 150 | + return resolveT3McpToolPresentation(`${data.server}.${data.tool}`, status); | |
| 151 | + } | |
| 152 | + if ("toolName" in data && typeof data.toolName === "string") { | |
| 153 | + return resolveT3McpToolPresentation(data.toolName, status); | |
| 154 | + } | |
| 155 | + } | |
| 156 | + | |
| 60 | 157 | return ( |
| 61 | − entry.tone === "tool" || | |
| 62 | − entry.tone === "thinking" || | |
| 63 | − entry.tone === "error" || | |
| 64 | − entry.command !== undefined || | |
| 65 | − entry.requestKind !== undefined | |
| 158 | + resolveT3McpToolPresentation(entry.toolTitle, status) ?? | |
| 159 | + resolveT3McpToolPresentation(entry.label, status) | |
| 66 | 160 | ); |
| 67 | 161 | } |
| 68 | 162 | |
| 163 | +function asRecord(value: unknown): Record<string, unknown> | null { | |
| 164 | + return value !== null && typeof value === "object" && !Array.isArray(value) | |
| 165 | + ? (value as Record<string, unknown>) | |
| 166 | + : null; | |
| 167 | +} | |
| 168 | + | |
| 169 | +function nonEmptyString(value: unknown): string | null { | |
| 170 | + return typeof value === "string" && value.trim().length > 0 ? value : null; | |
| 171 | +} | |
| 172 | + | |
| 173 | +function commandResultContent(value: unknown): string | null { | |
| 174 | + const direct = nonEmptyString(value); | |
| 175 | + if (direct) return direct; | |
| 176 | + | |
| 177 | + const directContent = Array.isArray(value) ? value : null; | |
| 178 | + const record = asRecord(value); | |
| 179 | + const content = record?.content; | |
| 180 | + const contentText = nonEmptyString(content); | |
| 181 | + if (contentText) return contentText; | |
| 182 | + const blocks = directContent ?? (Array.isArray(content) ? content : null); | |
| 183 | + if (!blocks) return null; | |
| 184 | + | |
| 185 | + const chunks = blocks.flatMap((entry) => { | |
| 186 | + const text = nonEmptyString(entry) ?? nonEmptyString(asRecord(entry)?.text); | |
| 187 | + return text ? [text] : []; | |
| 188 | + }); | |
| 189 | + return chunks.length > 0 ? chunks.join("\n") : null; | |
| 190 | +} | |
| 191 | + | |
| 192 | +/** Returns provider command output before it is formatted for a work-log row. */ | |
| 193 | +export function extractCommandOutputText(dataValue: unknown): string | null { | |
| 194 | + const data = asRecord(dataValue); | |
| 195 | + const item = asRecord(data?.item); | |
| 196 | + const itemResult = asRecord(item?.result); | |
| 197 | + const rawOutput = asRecord(data?.rawOutput); | |
| 198 | + const outputStreams = [ | |
| 199 | + nonEmptyString(rawOutput?.stdout), | |
| 200 | + nonEmptyString(rawOutput?.stderr), | |
| 201 | + ].filter((value): value is string => value !== null); | |
| 202 | + const acpContent = Array.isArray(data?.content) | |
| 203 | + ? data.content | |
| 204 | + .flatMap((entryValue) => { | |
| 205 | + const entry = asRecord(entryValue); | |
| 206 | + const content = asRecord(entry?.content); | |
| 207 | + const text = entry?.type === "content" ? nonEmptyString(content?.text) : null; | |
| 208 | + return text ? [text] : []; | |
| 209 | + }) | |
| 210 | + .join("\n") | |
| 211 | + : null; | |
| 212 | + | |
| 213 | + const candidates = [ | |
| 214 | + item?.aggregatedOutput, | |
| 215 | + itemResult?.content, | |
| 216 | + data?.rawOutput, | |
| 217 | + rawOutput?.content, | |
| 218 | + outputStreams.length > 0 ? outputStreams.join("\n") : null, | |
| 219 | + rawOutput?.output, | |
| 220 | + acpContent, | |
| 221 | + data?.result, | |
| 222 | + ]; | |
| 223 | + for (const candidate of candidates) { | |
| 224 | + const text = commandResultContent(candidate); | |
| 225 | + if (text) return text; | |
| 226 | + } | |
| 227 | + return null; | |
| 228 | +} | |
| 229 | + | |
| 230 | +/** | |
| 231 | + * Ingestion caps tool details at 180 chars and appends "...", so a long command | |
| 232 | + * echo no longer equals the command it repeats. Treat a truncated prefix of the | |
| 233 | + * command as the same echo. | |
| 234 | + */ | |
| 235 | +function textRepeatsCommand(text: string, commands: ReadonlyArray<string | null>): boolean { | |
| 236 | + const truncated = text.endsWith("...") | |
| 237 | + ? text.slice(0, -3) | |
| 238 | + : text.endsWith("\u2026") | |
| 239 | + ? text.slice(0, -1) | |
| 240 | + : null; | |
| 241 | + return commands.some((candidate) => { | |
| 242 | + const command = candidate?.trim(); | |
| 243 | + if (!command) return false; | |
| 244 | + if (command === text) return true; | |
| 245 | + return ( | |
| 246 | + truncated !== null && | |
| 247 | + truncated.length > 0 && | |
| 248 | + command.length > truncated.length && | |
| 249 | + command.startsWith(truncated) | |
| 250 | + ); | |
| 251 | + }); | |
| 252 | +} | |
| 253 | + | |
| 254 | +/** | |
| 255 | + * Decides whether a command row's `detail` is a synthetic echo of the command | |
| 256 | + * rather than real output. OpenCode stores completed output in `detail` with no | |
| 257 | + * other output channel, so plain equality is only treated as synthetic when the | |
| 258 | + * payload shape shows the detail came from the command: Codex item metadata, | |
| 259 | + * an ACP tool call (`data.toolCallId`, `kind: "execute"`), a Claude tool-name | |
| 260 | + * prefix, or no structured command at all. | |
| 261 | + */ | |
| 262 | +export function commandDetailRepeatsCommand(input: { | |
| 263 | + readonly detail: string; | |
| 264 | + readonly command: string | null; | |
| 265 | + readonly rawCommand: string | null; | |
| 266 | + readonly toolName: unknown; | |
| 267 | + readonly data: unknown; | |
| 268 | +}): boolean { | |
| 269 | + const toolName = nonEmptyString(input.toolName)?.trim(); | |
| 270 | + const detail = input.detail.trim(); | |
| 271 | + const commands = [input.command, input.rawCommand]; | |
| 272 | + if (toolName) { | |
| 273 | + const prefix = `${toolName}:`; | |
| 274 | + if (detail.toLowerCase().startsWith(prefix.toLowerCase())) { | |
| 275 | + const unprefixed = detail.slice(prefix.length).trim(); | |
| 276 | + if (textRepeatsCommand(unprefixed, commands)) return true; | |
| 277 | + } | |
| 278 | + } | |
| 279 | + | |
| 280 | + if (!textRepeatsCommand(detail, commands)) return false; | |
| 281 | + | |
| 282 | + const data = asRecord(input.data); | |
| 283 | + const item = asRecord(data?.item); | |
| 284 | + const itemInput = asRecord(item?.input); | |
| 285 | + const itemResult = asRecord(item?.result); | |
| 286 | + const hasStructuredCommand = [ | |
| 287 | + item?.command, | |
| 288 | + itemInput?.command, | |
| 289 | + itemResult?.command, | |
| 290 | + data?.command, | |
| 291 | + ].some((value) => | |
| 292 | + Array.isArray(value) | |
| 293 | + ? value.some((part) => nonEmptyString(part) !== null) | |
| 294 | + : nonEmptyString(value) !== null, | |
| 295 | + ); | |
| 296 | + return ( | |
| 297 | + !hasStructuredCommand || | |
| 298 | + item !== null || | |
| 299 | + data?.toolCallId !== undefined || | |
| 300 | + nonEmptyString(data?.kind)?.toLowerCase() === "execute" | |
| 301 | + ); | |
| 302 | +} | |
| 303 | + | |
| 304 | +export function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean { | |
| 305 | + if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") return true; | |
| 306 | + if (entry.command !== undefined && entry.command.trim().length > 0) return true; | |
| 307 | + if (entry.requestKind !== undefined) return true; | |
| 308 | + return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); | |
| 309 | +} | |
| 310 | + | |
| 69 | 311 | function toolDetailTextLooksLikeFailure(text: string): boolean { |
| 70 | 312 | const normalized = text.toLowerCase(); |
| 71 | 313 | return ( |
| @@ -110,7 +352,8 @@ export function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): · 38 unmodified lines | ||
| 110 | 352 | } |
| 111 | 353 | |
| 112 | 354 | export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupAction { |
| 113 | − if (entry.requestKind === "file-read") return "read"; | |
| 355 | + if (resolveWorkEntryToolPresentation(entry)?.icon === "browser") return "browser"; | |
| 356 | + if (entry.requestKind === "file-read" || entry.viewedImagePath !== undefined) return "read"; | |
| 114 | 357 | if ( |
| 115 | 358 | entry.itemType === "dynamic_tool" && |
| 116 | 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 | |
| 127 | 370 | export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): string | null { |
| 371 | + const viewedImagePath = entry.viewedImagePath?.trim(); | |
| 372 | + if ( | |
| 373 | + viewedImagePath !== undefined && | |
| 374 | + !/[\r\n]/.test(viewedImagePath) && | |
| 375 | + isWorkspaceImagePreviewPath(viewedImagePath) | |
| 376 | + ) { | |
| 377 | + return viewedImagePath; | |
| 378 | + } | |
| 128 | 379 | const detail = entry.detail?.trim(); |
| 129 | 380 | return toolGroupAction(entry) === "read" && |
| 130 | 381 | detail !== undefined && |
| @@ -196,6 +447,8 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string { · 65 unmodified lines | ||
| 196 | 447 | return `Changed ${count} ${count === 1 ? "file" : "files"}`; |
| 197 | 448 | case "command": |
| 198 | 449 | return `Ran ${count} ${count === 1 ? "command" : "commands"}`; |
| 450 | + case "browser": | |
| 451 | + return `Used browser ${count} ${count === 1 ? "time" : "times"}`; | |
| 199 | 452 | case "search": |
| 200 | 453 | return `Searched the web ${count} ${count === 1 ? "time" : "times"}`; |
| 201 | 454 | case "code-search": |
| @@ -779,7 +779,25 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 778 unmodified lines | ||
| 779 | 779 | } |
| 780 | 780 | const encoded = `${exit.value}\n`; |
| 781 | 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 | 803 | const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) { |