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