Adopts the round's main features into the v2 architecture: the #9023 media rework (streamed videos, media-file assets, protocol-relative links), #9098 shared live-activity row folded into the v2 working and thinking rows, the #9084/#9078 Claude model catalog for v2 consumers, a native #9005 OpenCode child-session abort in the v2 adapter, #9013's landed LegendList patch, and per-environment sidebar provider entries. For #8600 the server-side pieces land, but auto-settle evaluation stays client-side (reading the new server-owned settings) until the v2 orchestrator grows its own settlement reactor; main's v1-only reactor and coalescer additions are dropped with the rest of the v1 path.
Showing the changes to apps/web/src/components (14 of 47 files). Show all
Unified · Split
| @@ -5,12 +5,11 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; · 4 unmodified lines | ||
| 5 | 5 | const testState = vi.hoisted(() => ({ |
| 6 | 6 | resources: [] as Array<unknown>, |
| 7 | 7 | assetState: "success" as "success" | "loading" | "failure", |
| 8 | − editorEnvironmentIds: [] as Array<unknown>, | |
| 9 | − remoteEnvironmentIds: [] as Array<unknown>, | |
| 10 | 8 | })); |
| 11 | 9 | |
| 12 | 10 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); |
| 13 | 11 | vi.mock("../assets/assetUrls", () => ({ |
| 12 | + useAssetUrlRefresh: () => vi.fn(), | |
| 14 | 13 | useAssetUrlState: (_environmentId: unknown, resource: unknown) => { |
| 15 | 14 | testState.resources.push(resource); |
| 16 | 15 | if (testState.assetState === "loading") return { _tag: "Loading" }; |
| @@ -25,23 +24,23 @@ vi.mock("../state/session", async (importOriginal) => ({ · 8 unmodified lines | ||
| 25 | 24 | ...(await importOriginal<typeof import("../state/session")>()), |
| 26 | 25 | usePreparedConnection: () => ({ _tag: "Loading" }), |
| 27 | 26 | })); |
| 28 | −vi.mock("../editorPreferences", () => ({ | |
| 29 | − useOpenInPreferredEditor: (environmentId: unknown) => { | |
| 30 | − testState.editorEnvironmentIds.push(environmentId); | |
| 31 | − return vi.fn(); | |
| 32 | − }, | |
| 33 | − usePreferredEditor: () => ["vscode", vi.fn()], | |
| 27 | +vi.mock("../state/entities", () => ({ | |
| 28 | + readThreadShell: () => null, | |
| 29 | + useProjects: () => [], | |
| 34 | 30 | })); |
| 35 | 31 | vi.mock("../remoteOpen", () => ({ |
| 36 | − useRemoteOpenResolution: (environmentId: unknown) => { | |
| 37 | − testState.remoteEnvironmentIds.push(environmentId); | |
| 38 | − return { | |
| 39 | − isResolved: true, | |
| 40 | − state: { mode: "local-exec", localExecutionUnavailableReason: null }, | |
| 41 | − }; | |
| 42 | − }, | |
| 32 | + useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), | |
| 33 | +})); | |
| 34 | +vi.mock("../editorPreferences", () => ({ | |
| 35 | + useOpenInPreferredEditor: () => vi.fn(), | |
| 36 | + usePreferredEditor: () => [null, vi.fn()], | |
| 37 | +})); | |
| 38 | +vi.mock("~/lib/openPullRequestLink", () => ({ | |
| 39 | + findProjectForChangeRequest: () => undefined, | |
| 40 | + matchesLinkedPullRequestUrl: () => false, | |
| 41 | + parseChangeRequestUrl: () => null, | |
| 42 | + useOpenChangeRequestLink: () => vi.fn(), | |
| 43 | 43 | })); |
| 44 | −vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn() })); | |
| 45 | 44 | |
| 46 | 45 | import ChatMarkdown from "./ChatMarkdown"; |
| 47 | 46 | import { FileMarkdownPreview } from "./files/FileMarkdownPreview"; |
| @@ -93,8 +92,6 @@ describe("ChatMarkdown workspace images", () => { · 45 unmodified lines | ||
| 93 | 92 | beforeEach(() => { |
| 94 | 93 | testState.resources = []; |
| 95 | 94 | testState.assetState = "success"; |
| 96 | − testState.editorEnvironmentIds = []; | |
| 97 | − testState.remoteEnvironmentIds = []; | |
| 98 | 95 | }); |
| 99 | 96 | |
| 100 | 97 | it.each([ |
| @@ -110,52 +107,55 @@ describe("ChatMarkdown workspace images", () => { · 9 unmodified lines | ||
| 110 | 107 | |
| 111 | 108 | expect(testState.resources).toEqual([ |
| 112 | 109 | { |
| 113 | − _tag: "workspace-file", | |
| 110 | + _tag: "media-file", | |
| 114 | 111 | threadId: threadRef.threadId, |
| 115 | 112 | path: expectedPath, |
| 116 | 113 | }, |
| 117 | 114 | ]); |
| 118 | 115 | }); |
| 119 | 116 | |
| 120 | − it("loads relative workspace paths through signed asset URLs", () => { | |
| 121 | − const html = render(""); | |
| 117 | + it("loads every Windows workspace path form through a signed asset URL", () => { | |
| 118 | + const imagePath = "C:/Users/shawn/project/.t3/workspace-image.svg"; | |
| 119 | + const html = render( | |
| 120 | + [ | |
| 121 | + "", | |
| 122 | + ``, | |
| 123 | + ``, | |
| 124 | + "", | |
| 125 | + ].join("\n\n"), | |
| 126 | + ); | |
| 122 | 127 | |
| 123 | 128 | expect(testState.resources).toEqual([ |
| 124 | 129 | { |
| 125 | − _tag: "workspace-file", | |
| 130 | + _tag: "media-file", | |
| 126 | 131 | threadId: threadRef.threadId, |
| 127 | 132 | path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg", |
| 128 | 133 | }, |
| 134 | + { _tag: "media-file", threadId: threadRef.threadId, path: imagePath }, | |
| 135 | + { _tag: "media-file", threadId: threadRef.threadId, path: imagePath }, | |
| 136 | + { | |
| 137 | + _tag: "media-file", | |
| 138 | + threadId: threadRef.threadId, | |
| 139 | + path: "\\\\server\\share\\workspace-image.svg", | |
| 140 | + }, | |
| 129 | 141 | ]); |
| 130 | − expect(html).toContain("https://signed.test/workspace-image.svg"); | |
| 142 | + expect(html.match(/https:\/\/signed\.test\/workspace-image\.svg/g)).toHaveLength(4); | |
| 143 | + expect(html.match(/max-w-\[min\(100%,30rem\)\]/g)).toHaveLength(4); | |
| 144 | + expect(html.match(/max-h-\[30rem\]/g)).toHaveLength(4); | |
| 145 | + expect(html).not.toContain("Image unavailable"); | |
| 131 | 146 | }); |
| 132 | 147 | |
| 133 | − it("preserves Windows drive links through markdown sanitization", () => { | |
| 134 | − const html = render(String.raw`[Open](C:\Users\shawn\project\src\main.ts)`); | |
| 135 | − expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"'); | |
| 136 | − expect(html).toContain("chat-markdown-file-link"); | |
| 137 | − }); | |
| 148 | + it("normalizes a drive-absolute src in raw image HTML", () => { | |
| 149 | + const html = render(String.raw`<img src="D:\screens\workspace-image.svg" alt="raw">`); | |
| 138 | 150 | |
| 139 | − it("loads drive-absolute markdown and raw HTML images through assets", () => { | |
| 140 | − const html = render( | |
| 141 | − [ | |
| 142 | − "", | |
| 143 | − String.raw`<img src="D:\screens\workspace-image.svg" alt="raw">`, | |
| 144 | − ].join("\n\n"), | |
| 145 | − ); | |
| 146 | 151 | expect(testState.resources).toEqual([ |
| 147 | 152 | { |
| 148 | − _tag: "workspace-file", | |
| 149 | − threadId: threadRef.threadId, | |
| 150 | − path: "C:/Users/shawn/project/.t3/workspace-image.svg", | |
| 151 | − }, | |
| 152 | − { | |
| 153 | − _tag: "workspace-file", | |
| 153 | + _tag: "media-file", | |
| 154 | 154 | threadId: threadRef.threadId, |
| 155 | 155 | path: "D:/screens/workspace-image.svg", |
| 156 | 156 | }, |
| 157 | 157 | ]); |
| 158 | − expect(html.match(/https:\/\/signed\.test\/workspace-image\.svg/g)).toHaveLength(2); | |
| 158 | + expect(html).toContain("https://signed.test/workspace-image.svg"); | |
| 159 | 159 | }); |
| 160 | 160 | |
| 161 | 161 | it("keeps a tall image placeholder and loaded image at the same proportional bounds", () => { |
| @@ -261,6 +261,7 @@ describe("ChatMarkdown workspace images", () => { · 99 unmodified lines | ||
| 261 | 261 | |
| 262 | 262 | it("uses a static bounded-width placeholder while a signed asset URL loads", () => { |
| 263 | 263 | testState.assetState = "loading"; |
| 264 | + | |
| 264 | 265 | const html = render(""); |
| 265 | 266 | const className = /<span[^>]*aria-label="Loading image"[^>]*class="([^"]*)"/.exec(html)?.[1]; |
| 266 | 267 | |
| @@ -269,25 +270,31 @@ describe("ChatMarkdown workspace images", () => { · 2 unmodified lines | ||
| 269 | 270 | expect(className?.split(" ")).toContain("w-64"); |
| 270 | 271 | }); |
| 271 | 272 | |
| 272 | − it("keeps remote images directly loadable", () => { | |
| 273 | − const html = render(""); | |
| 273 | + it("never passes a workspace source to a raw image when thread context is unavailable", () => { | |
| 274 | + const html = renderWithoutThread( | |
| 275 | + "", | |
| 276 | + ); | |
| 277 | + | |
| 274 | 278 | expect(testState.resources).toEqual([]); |
| 275 | − expect(html).toContain('src="https://example.com/image.png"'); | |
| 276 | − expect(html).toContain("max-h-[30rem]"); | |
| 279 | + expect(html).toContain("Image unavailable"); | |
| 280 | + expect(html).not.toContain("file://"); | |
| 277 | 281 | }); |
| 278 | 282 | |
| 279 | − it("uses the markdown owner's environment for file actions", () => { | |
| 280 | − const owningEnvironmentId = EnvironmentId.make("env-pull-request"); | |
| 283 | + it("blocks unsupported image schemes instead of passing them to a raw image", () => { | |
| 284 | + const html = render(""); | |
| 281 | 285 | |
| 282 | − renderToStaticMarkup( | |
| 283 | − <ChatMarkdown | |
| 284 | − cwd="/workspace/project" | |
| 285 | − environmentId={owningEnvironmentId} | |
| 286 | − text="[Open](src/main.ts)" | |
| 287 | − />, | |
| 288 | − ); | |
| 286 | + expect(testState.resources).toEqual([]); | |
| 287 | + expect(html).toContain("Image unavailable"); | |
| 288 | + expect(html).not.toContain("content://"); | |
| 289 | + }); | |
| 289 | 290 | |
| 290 | − expect(testState.editorEnvironmentIds).toEqual([owningEnvironmentId]); | |
| 291 | − expect(testState.remoteEnvironmentIds).toEqual([owningEnvironmentId]); | |
| 291 | + it("keeps remote images directly loadable", () => { | |
| 292 | + const html = render(""); | |
| 293 | + | |
| 294 | + expect(testState.resources).toEqual([]); | |
| 295 | + expect(html).toContain('src="https://example.com/image.png"'); | |
| 296 | + expect(html).toContain("max-w-[min(100%,30rem)]"); | |
| 297 | + expect(html).toContain("max-h-[30rem]"); | |
| 298 | + expect(html).not.toContain("Image unavailable"); | |
| 292 | 299 | }); |
| 293 | 300 | }); |
| @@ -24,10 +24,10 @@ import { · 23 unmodified lines | ||
| 24 | 24 | deriveComposerSendState, |
| 25 | 25 | dismissBranchMismatchForSession, |
| 26 | 26 | getStartedThreadModelChangeBlockReason, |
| 27 | + isVideoPreviewRequestCurrent, | |
| 28 | + hasEnvironmentReconnectWarningGraceElapsed, | |
| 27 | 29 | hasServerAcknowledgedLocalDispatch, |
| 28 | 30 | isBranchMismatchDismissedForSession, |
| 29 | − isVideoPreviewRequestCurrent, | |
| 30 | − loadVideoPreviewUrl, | |
| 31 | 31 | reconcileMountedTerminalThreadIds, |
| 32 | 32 | reconcileRetainedMountedThreadIds, |
| 33 | 33 | resolveDraftPromotionNavigationTarget, |
| @@ -17,6 +17,12 @@ import { · 16 unmodified lines | ||
| 17 | 17 | type RunId, |
| 18 | 18 | } from "@t3tools/contracts"; |
| 19 | 19 | import * as DateTime from "effect/DateTime"; |
| 20 | +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | |
| 21 | +import { | |
| 22 | + squashAtomCommandFailure, | |
| 23 | + type AtomCommandResult, | |
| 24 | +} from "@t3tools/client-runtime/state/runtime"; | |
| 25 | +import { videoMimeType } from "@t3tools/shared/video"; | |
| 20 | 26 | import { |
| 21 | 27 | appendCodexArtifactTemplateUsePrompt, |
| 22 | 28 | codexArtifactTemplateUsePrompt, |
| @@ -39,11 +39,6 @@ import { · 38 unmodified lines | ||
| 39 | 39 | threadWokeAt, |
| 40 | 40 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 41 | 41 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 42 | −import { | |
| 43 | − resolveDisplayedThreadPr, | |
| 44 | − threadChangeRequestSnapshotsAtom, | |
| 45 | − useLinkedThreadPullRequest, | |
| 46 | −} from "./ThreadStatusIndicators"; | |
| 47 | 42 | import { |
| 48 | 43 | deriveThreadActivityRun, |
| 49 | 44 | deriveLatestThreadRun, |
| @@ -74,7 +69,12 @@ import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; · 24 unmodified lines | ||
| 74 | 69 | import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; |
| 75 | 70 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 76 | 71 | import { truncate } from "@t3tools/shared/String"; |
| 77 | −import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; | |
| 72 | +import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; | |
| 73 | +import { | |
| 74 | + getTerminalLabel, | |
| 75 | + nextTerminalId, | |
| 76 | + resolveTerminalSessionLabel, | |
| 77 | +} from "@t3tools/shared/terminalLabels"; | |
| 78 | 78 | import { Debouncer } from "@tanstack/react-pacer"; |
| 79 | 79 | import { useAtomValue } from "@effect/atom-react"; |
| 80 | 80 | import { |
| @@ -325,8 +325,14 @@ import { · 244 unmodified lines | ||
| 325 | 325 | ThreadErrorBanner, |
| 326 | 326 | } from "./chat/ThreadErrorBanner"; |
| 327 | 327 | import { resolveThreadPr } from "./ThreadStatusIndicators"; |
| 328 | −import { type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; | |
| 329 | 328 | import { QueuedRunsControl, type EditQueuedRunRequest } from "./chat/QueuedRunsControl"; |
| 329 | +import { | |
| 330 | + resolveDisplayedThreadPr, | |
| 331 | + threadPullRequestRefreshSource, | |
| 332 | + threadChangeRequestSnapshotsAtom, | |
| 333 | + useLinkedThreadPullRequest, | |
| 334 | +} from "./ThreadStatusIndicators"; | |
| 335 | +import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; | |
| 330 | 336 | import { ComposerSurface } from "./chat/ComposerSurface"; |
| 331 | 337 | import { resolveThreadSyncPhase } from "../threadSync"; |
| 332 | 338 | import { |
| @@ -1338,6 +1344,7 @@ function ChatViewContent(props: ChatViewProps) { · 1005 unmodified lines | ||
| 1338 | 1344 | }); |
| 1339 | 1345 | const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 1340 | 1346 | reportFailure: false, |
| 1347 | + refresh: true, | |
| 1341 | 1348 | }); |
| 1342 | 1349 | const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { |
| 1343 | 1350 | reportFailure: false, |
| @@ -1907,7 +1914,7 @@ function ChatViewContent(props: ChatViewProps) { · 563 unmodified lines | ||
| 1907 | 1914 | >({}); |
| 1908 | 1915 | const activePullRequestSurfaceId = |
| 1909 | 1916 | activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined; |
| 1910 | − const handlePullRequestTabStatusChange = useCallback( | |
| 1917 | + const updatePullRequestTabStatusFromPanel = useCallback( | |
| 1911 | 1918 | (status: PullRequestTabStatus) => { |
| 1912 | 1919 | const id = activePullRequestSurfaceId; |
| 1913 | 1920 | if (id === undefined) return; |
| @@ -1915,6 +1922,8 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line | ||
| 1915 | 1922 | }, |
| 1916 | 1923 | [activePullRequestSurfaceId], |
| 1917 | 1924 | ); |
| 1925 | + const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); | |
| 1926 | + const sidebarPrRefreshKeyRef = useRef<string | null>(null); | |
| 1918 | 1927 | const activeFileSurface = |
| 1919 | 1928 | activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; |
| 1920 | 1929 | const activePreviewState = useThreadPreviewState(activeThreadRef); |
| @@ -2758,14 +2767,8 @@ function ChatViewContent(props: ChatViewProps) { · 837 unmodified lines | ||
| 2758 | 2767 | toastManager.add({ type: "error", title: "The environment is not connected." }); |
| 2759 | 2768 | return; |
| 2760 | 2769 | } |
| 2761 | − const videoMime = videoMimeType(attachment); | |
| 2762 | − const isVideo = videoMime !== null; | |
| 2770 | + const isVideo = videoMimeType(attachment) !== null; | |
| 2763 | 2771 | const action = isVideo ? "play" : "download"; |
| 2764 | − const videoPreviewAbortController = isVideo ? new AbortController() : null; | |
| 2765 | − if (isVideo) { | |
| 2766 | − videoPreviewAbortControllerRef.current?.abort(); | |
| 2767 | − videoPreviewAbortControllerRef.current = videoPreviewAbortController; | |
| 2768 | − } | |
| 2769 | 2772 | const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0; |
| 2770 | 2773 | const isCurrentRequest = () => |
| 2771 | 2774 | !isVideo || |
| @@ -2775,75 +2778,37 @@ function ChatViewContent(props: ChatViewProps) { · 3 unmodified lines | ||
| 2775 | 2778 | videoPreviewRequestId, |
| 2776 | 2779 | videoPreviewRequestIdRef.current, |
| 2777 | 2780 | ); |
| 2778 | − const finishVideoPreviewRequest = () => { | |
| 2779 | − if (videoPreviewRequestIdRef.current === videoPreviewRequestId) { | |
| 2780 | − setOpeningVideoAttachmentId(null); | |
| 2781 | − videoPreviewAbortControllerRef.current = null; | |
| 2782 | − } | |
| 2783 | − }; | |
| 2784 | 2781 | if (isVideo) setOpeningVideoAttachmentId(attachment.id); |
| 2785 | 2782 | |
| 2786 | − // fileName and mimeType ride in the signed claims so videos render | |
| 2787 | − // inline while other files keep their real download name and type. | |
| 2788 | − const result = await createAttachmentAssetUrl({ | |
| 2789 | − environmentId, | |
| 2790 | − input: { | |
| 2791 | − resource: { | |
| 2792 | − _tag: "attachment", | |
| 2793 | − attachmentId: attachment.id, | |
| 2794 | − fileName: attachment.name, | |
| 2795 | − mimeType: videoMime ?? attachment.mimeType, | |
| 2796 | − }, | |
| 2797 | − }, | |
| 2798 | − }); | |
| 2799 | − if (!isCurrentRequest()) { | |
| 2800 | − finishVideoPreviewRequest(); | |
| 2801 | − return; | |
| 2802 | − } | |
| 2803 | − if (result._tag === "Failure") { | |
| 2804 | − finishVideoPreviewRequest(); | |
| 2805 | − const error = squashAtomCommandFailure(result); | |
| 2806 | − toastManager.add({ | |
| 2807 | − type: "error", | |
| 2808 | − title: "Could not " + action + " " + attachment.name, | |
| 2809 | − description: error instanceof Error ? error.message : "The attachment is unavailable.", | |
| 2783 | + try { | |
| 2784 | + const url = await resolveFileAttachmentUrl({ | |
| 2785 | + attachment, | |
| 2786 | + environmentId, | |
| 2787 | + httpBaseUrl: connection.httpBaseUrl, | |
| 2788 | + createAssetUrl: createAttachmentAssetUrl, | |
| 2810 | 2789 | }); |
| 2811 | − return; | |
| 2812 | − } | |
| 2813 | − | |
| 2814 | − const url = resolveAssetUrl(connection.httpBaseUrl, result.value.relativeUrl); | |
| 2815 | − if (!url) { | |
| 2816 | − finishVideoPreviewRequest(); | |
| 2817 | − toastManager.add({ type: "error", title: "Could not " + action + " " + attachment.name }); | |
| 2818 | − return; | |
| 2819 | − } | |
| 2820 | − if (isVideo) { | |
| 2821 | − try { | |
| 2822 | − const previewUrl = await loadVideoPreviewUrl(url, videoPreviewAbortController?.signal); | |
| 2823 | − if (!isCurrentRequest()) { | |
| 2824 | − revokeBlobPreviewUrl(previewUrl); | |
| 2825 | − return; | |
| 2826 | − } | |
| 2790 | + if (!isCurrentRequest()) return; | |
| 2791 | + if (isVideo) { | |
| 2827 | 2792 | setExpandedImage({ |
| 2828 | − images: [{ src: previewUrl, name: attachment.name, type: "video" }], | |
| 2793 | + images: [{ src: url, name: attachment.name, type: "video" }], | |
| 2829 | 2794 | index: 0, |
| 2830 | 2795 | }); |
| 2831 | − } catch (error) { | |
| 2832 | − if (!isCurrentRequest()) return; | |
| 2833 | − toastManager.add({ | |
| 2834 | − type: "error", | |
| 2835 | − title: "Could not play " + attachment.name, | |
| 2836 | − description: error instanceof Error ? error.message : "The attachment is unavailable.", | |
| 2837 | − }); | |
| 2838 | − } finally { | |
| 2839 | − finishVideoPreviewRequest(); | |
| 2796 | + return; | |
| 2840 | 2797 | } |
| 2841 | − return; | |
| 2798 | + const anchor = document.createElement("a"); | |
| 2799 | + anchor.href = url; | |
| 2800 | + anchor.download = attachment.name; | |
| 2801 | + anchor.click(); | |
| 2802 | + } catch (error) { | |
| 2803 | + if (!isCurrentRequest()) return; | |
| 2804 | + toastManager.add({ | |
| 2805 | + type: "error", | |
| 2806 | + title: "Could not " + action + " " + attachment.name, | |
| 2807 | + description: error instanceof Error ? error.message : "The attachment is unavailable.", | |
| 2808 | + }); | |
| 2809 | + } finally { | |
| 2810 | + if (isVideo && isCurrentRequest()) setOpeningVideoAttachmentId(null); | |
| 2842 | 2811 | } |
| 2843 | − const anchor = document.createElement("a"); | |
| 2844 | − anchor.href = url; | |
| 2845 | − anchor.download = attachment.name; | |
| 2846 | − anchor.click(); | |
| 2847 | 2812 | }, |
| 2848 | 2813 | [createAttachmentAssetUrl, environmentId, routeThreadKey], |
| 2849 | 2814 | ); |
| @@ -4885,8 +4850,14 @@ function ChatViewContent(props: ChatViewProps) { · 2035 unmodified lines | ||
| 4885 | 4850 | ); |
| 4886 | 4851 | // The server-projected settled state keeps the banner and sidebar in sync. |
| 4887 | 4852 | const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); |
| 4888 | − const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); | |
| 4889 | − const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); | |
| 4853 | + const autoSettleAfterDays = useEnvironmentSettings( | |
| 4854 | + environmentId, | |
| 4855 | + (settings) => settings.sidebarAutoSettleAfterDays, | |
| 4856 | + ); | |
| 4857 | + const autoSettleOnMerge = useEnvironmentSettings( | |
| 4858 | + environmentId, | |
| 4859 | + (settings) => settings.sidebarAutoSettleOnMerge, | |
| 4860 | + ); | |
| 4890 | 4861 | const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); |
| 4891 | 4862 | // The thread's own change request, placed against the project it belongs to. |
| 4892 | 4863 | const linkedThreadPullRequest = activeThread?.linkedPullRequest ?? null; |
| @@ -4902,6 +4873,97 @@ function ChatViewContent(props: ChatViewProps) { · 9 unmodified lines | ||
| 4902 | 4873 | linkedPullRequest: linkedThreadPullRequest, |
| 4903 | 4874 | linkedPullRequestStatus, |
| 4904 | 4875 | }); |
| 4876 | + const handlePullRequestTabStatusChange = useCallback( | |
| 4877 | + (status: PullRequestTabStatus) => { | |
| 4878 | + updatePullRequestTabStatusFromPanel(status); | |
| 4879 | + const source = threadPullRequestRefreshSource({ | |
| 4880 | + panel: status, | |
| 4881 | + thread: { | |
| 4882 | + repository: threadRepository, | |
| 4883 | + number: linkedThreadPullRequest?.number ?? activeThreadPr?.number ?? null, | |
| 4884 | + state: activeThreadPr?.state ?? null, | |
| 4885 | + linked: linkedThreadPullRequest !== null, | |
| 4886 | + }, | |
| 4887 | + }); | |
| 4888 | + if (source === null) { | |
| 4889 | + sidebarPrRefreshKeyRef.current = null; | |
| 4890 | + return; | |
| 4891 | + } | |
| 4892 | + const refreshKey = `${activeThreadKey}:${source}:${status.repository}#${status.number}:${status.state}`; | |
| 4893 | + if (sidebarPrRefreshKeyRef.current === refreshKey) return; | |
| 4894 | + sidebarPrRefreshKeyRef.current = refreshKey; | |
| 4895 | + | |
| 4896 | + if (source === "linked-detail" && activeThreadRef && linkedThreadPullRequest) { | |
| 4897 | + appAtomRegistry.refresh( | |
| 4898 | + linkedPullRequestDetailAtom({ | |
| 4899 | + environmentId: activeThreadRef.environmentId, | |
| 4900 | + input: { | |
| 4901 | + projectId: linkedThreadPullRequest.projectId, | |
| 4902 | + repository: linkedThreadPullRequest.repository, | |
| 4903 | + number: linkedThreadPullRequest.number, | |
| 4904 | + }, | |
| 4905 | + }), | |
| 4906 | + ); | |
| 4907 | + return; | |
| 4908 | + } | |
| 4909 | + if (source === "vcs" && activeThreadRef && gitCwd !== null) { | |
| 4910 | + void refreshVcsStatus({ | |
| 4911 | + environmentId: activeThreadRef.environmentId, | |
| 4912 | + input: { cwd: gitCwd }, | |
| 4913 | + }).then(() => { | |
| 4914 | + if (sidebarPrRefreshKeyRef.current === refreshKey) { | |
| 4915 | + sidebarPrRefreshKeyRef.current = null; | |
| 4916 | + } | |
| 4917 | + }); | |
| 4918 | + } | |
| 4919 | + }, | |
| 4920 | + [ | |
| 4921 | + activeThreadKey, | |
| 4922 | + activeThreadPr?.number, | |
| 4923 | + activeThreadPr?.state, | |
| 4924 | + activeThreadRef, | |
| 4925 | + gitCwd, | |
| 4926 | + linkedThreadPullRequest, | |
| 4927 | + refreshVcsStatus, | |
| 4928 | + threadRepository, | |
| 4929 | + updatePullRequestTabStatusFromPanel, | |
| 4930 | + ], | |
| 4931 | + ); | |
| 4932 | + const activeThreadReferenceCopyTarget = useMemo( | |
| 4933 | + () => | |
| 4934 | + activeThreadId === null || !isServerThread | |
| 4935 | + ? null | |
| 4936 | + : resolveThreadReferenceCopyTarget({ | |
| 4937 | + threadId: activeThreadId, | |
| 4938 | + linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null, | |
| 4939 | + detectedPullRequestUrl: activeThreadPr?.url ?? null, | |
| 4940 | + }), | |
| 4941 | + [activeThreadId, activeThreadPr?.url, isServerThread, linkedThreadPullRequest?.url], | |
| 4942 | + ); | |
| 4943 | + const copyActiveThreadReference = useCallback(() => { | |
| 4944 | + const target = activeThreadReferenceCopyTarget; | |
| 4945 | + if (target === null) return; | |
| 4946 | + void writeTextToClipboard(target.value, target.clipboardTarget).then( | |
| 4947 | + (didCopy) => { | |
| 4948 | + if (!didCopy) return; | |
| 4949 | + toastManager.add({ | |
| 4950 | + type: "success", | |
| 4951 | + title: target.successTitle, | |
| 4952 | + description: target.value, | |
| 4953 | + }); | |
| 4954 | + }, | |
| 4955 | + (error) => { | |
| 4956 | + console.error(error); | |
| 4957 | + toastManager.add( | |
| 4958 | + stackedThreadToast({ | |
| 4959 | + type: "error", | |
| 4960 | + title: target.failureTitle, | |
| 4961 | + description: error instanceof Error ? error.message : "An error occurred.", | |
| 4962 | + }), | |
| 4963 | + ); | |
| 4964 | + }, | |
| 4965 | + ); | |
| 4966 | + }, [activeThreadReferenceCopyTarget]); | |
| 4905 | 4967 | // The right panel offers the thread's own change request, so it can only offer it once the |
| 4906 | 4968 | // branch has one; until then the picker says so rather than opening an empty panel. |
| 4907 | 4969 | const addPullRequestSurface = useCallback(() => { |
| @@ -4950,21 +5012,13 @@ function ChatViewContent(props: ChatViewProps) { · 42 unmodified lines | ||
| 4950 | 5012 | if (activeThreadRef === null || activeThreadWokeAt === null) return; |
| 4951 | 5013 | markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); |
| 4952 | 5014 | }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); |
| 4953 | − // Mirror of the sidebar's Woke pill for the open thread. It uses the same | |
| 4954 | − // visit comparison and change request settle rule. | |
| 5015 | + // Mirror of the sidebar's Woke pill for the open thread. | |
| 4955 | 5016 | const activeThreadLastVisitedAt = useUiStateStore((store) => |
| 4956 | 5017 | activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], |
| 4957 | 5018 | ); |
| 4958 | 5019 | const activeThreadWokeVisible = useMemo(() => { |
| 4959 | 5020 | if (activeThreadWokeAt === null) return false; |
| 4960 | − if ( | |
| 4961 | − changeRequestAutoSettles(activeThreadChangeRequest, { | |
| 4962 | − autoSettleOnMerge, | |
| 4963 | − thread: activeThreadShell, | |
| 4964 | − }) | |
| 4965 | − ) { | |
| 4966 | − return false; | |
| 4967 | − } | |
| 5021 | + if (activeThreadShell?.settledOverride === "settled") return false; | |
| 4968 | 5022 | const wokeAtMs = Date.parse(activeThreadWokeAt); |
| 4969 | 5023 | if (Number.isNaN(wokeAtMs)) return false; |
| 4970 | 5024 | // Having the thread open counts as a visit at completedAt (the effect |
| @@ -4984,7 +5038,6 @@ function ChatViewContent(props: ChatViewProps) { · 13 unmodified lines | ||
| 4984 | 5038 | }, [ |
| 4985 | 5039 | activeLatestRun?.completedAt, |
| 4986 | 5040 | activeThreadLastVisitedAt, |
| 4987 | − activeThreadChangeRequest, | |
| 4988 | 5041 | activeThreadShell, |
| 4989 | 5042 | activeThreadWokeAt, |
| 4990 | 5043 | autoSettleOnMerge, |
| @@ -5006,8 +5059,6 @@ function ChatViewContent(props: ChatViewProps) { · 15 unmodified lines | ||
| 5006 | 5059 | nowMinute, |
| 5007 | 5060 | supportsSettlement, |
| 5008 | 5061 | ]); |
| 5009 | − const activeThreadSettled = | |
| 5010 | − supportsSettlement && activeThreadShell?.settledOverride === "settled"; | |
| 5011 | 5062 | const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { |
| 5012 | 5063 | reportFailure: false, |
| 5013 | 5064 | }); |
| @@ -5394,6 +5445,13 @@ function ChatViewContent(props: ChatViewProps) { · 380 unmodified lines | ||
| 5394 | 5445 | }); |
| 5395 | 5446 | if (!command) return; |
| 5396 | 5447 | |
| 5448 | + if (command === "thread.copyReference") { | |
| 5449 | + event.preventDefault(); | |
| 5450 | + event.stopPropagation(); | |
| 5451 | + if (!event.repeat) copyActiveThreadReference(); | |
| 5452 | + return; | |
| 5453 | + } | |
| 5454 | + | |
| 5397 | 5455 | if (command === "thread.settle") { |
| 5398 | 5456 | event.preventDefault(); |
| 5399 | 5457 | event.stopPropagation(); |
| @@ -5561,6 +5619,7 @@ function ChatViewContent(props: ChatViewProps) { · 161 unmodified lines | ||
| 5561 | 5619 | supportsPinning, |
| 5562 | 5620 | supportsSettlement, |
| 5563 | 5621 | confirmAndUnpinThread, |
| 5622 | + copyActiveThreadReference, | |
| 5564 | 5623 | toggleRightPanel, |
| 5565 | 5624 | toggleThreadPanel, |
| 5566 | 5625 | toggleTerminalVisibility, |
| @@ -7512,6 +7571,7 @@ function ChatViewContent(props: ChatViewProps) { · 1945 unmodified lines | ||
| 7512 | 7571 | } |
| 7513 | 7572 | > |
| 7514 | 7573 | <DraftHeroHeadline |
| 7574 | + draftId={draftId} | |
| 7515 | 7575 | activeProjectRef={activeProjectRef} |
| 7516 | 7576 | activeProjectTitle={activeProject?.title ?? null} |
| 7517 | 7577 | /> |
| @@ -19,6 +19,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- · 18 unmodified lines | ||
| 19 | 19 | import { CSS } from "@dnd-kit/utilities"; |
| 20 | 20 | import { |
| 21 | 21 | canSnooze, |
| 22 | + effectiveSettled, | |
| 22 | 23 | effectiveSnoozed, |
| 23 | 24 | threadWokeAt, |
| 24 | 25 | } from "@t3tools/client-runtime/state/thread-settled"; |
| @@ -99,7 +100,7 @@ import { useThreadActions } from "../hooks/useThreadActions"; · 74 unmodified lines | ||
| 99 | 100 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 100 | 101 | import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; |
| 101 | 102 | import { startNewThreadFromContext } from "../lib/chatThreadActions"; |
| 102 | −import { useClientSettings } from "../hooks/useSettings"; | |
| 103 | +import { useClientSettings, usePrimarySettings } from "../hooks/useSettings"; | |
| 103 | 104 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; |
| 104 | 105 | import { useLocalStorage } from "../hooks/useLocalStorage"; |
| 105 | 106 | import { useNowMinute } from "../hooks/useNowMinute"; |
| @@ -171,7 +172,7 @@ import { ProjectFavicon } from "./ProjectFavicon"; · 65 unmodified lines | ||
| 171 | 172 | import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; |
| 172 | 173 | import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; |
| 173 | 174 | import { |
| 174 | − deriveProviderInstanceEntries, | |
| 175 | + deriveProviderEntriesByEnvironment, | |
| 175 | 176 | shouldShowInstanceBadge, |
| 176 | 177 | type ProviderInstanceEntry, |
| 177 | 178 | } from "../providerInstances"; |
| @@ -851,10 +852,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 673 unmodified lines | ||
| 851 | 852 | const isWoke = |
| 852 | 853 | wokeAtDate !== null && |
| 853 | 854 | (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && |
| 854 | − !changeRequestAutoSettles(pr, { | |
| 855 | − autoSettleOnMerge: props.autoSettleOnMerge, | |
| 856 | − thread, | |
| 857 | − }); | |
| 855 | + thread.settledOverride !== "settled"; | |
| 858 | 856 | // In-flight rows (working, or waiting on approval/input) fade as a whole: |
| 859 | 857 | // there is nothing for the user to do yet, so prominence is reserved for |
| 860 | 858 | // rows that need a human — done (unread), read-but-unsettled, failed, and |
| @@ -1772,6 +1770,8 @@ export default function Sidebar() { · 911 unmodified lines | ||
| 1772 | 1770 | const { isMobile, setOpenMobile } = useSidebar(); |
| 1773 | 1771 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 1774 | 1772 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); |
| 1773 | + const autoSettleAfterDays = usePrimarySettings((s) => s.sidebarAutoSettleAfterDays); | |
| 1774 | + const autoSettleOnMerge = usePrimarySettings((s) => s.sidebarAutoSettleOnMerge); | |
| 1775 | 1775 | const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); |
| 1776 | 1776 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); |
| 1777 | 1777 | const timestampFormat = useClientSettings((s) => s.timestampFormat); |
| @@ -1925,15 +1925,20 @@ export default function Sidebar() { · 147 unmodified lines | ||
| 1925 | 1925 | () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), |
| 1926 | 1926 | [sidebarProjectSortOrder, threads, unsortedProjectGroups], |
| 1927 | 1927 | ); |
| 1928 | − const serverProviders = useAtomValue(primaryServerProvidersAtom); | |
| 1929 | − const providerEntryByInstanceId = useMemo( | |
| 1928 | + const projectGroupsRef = useRef(projectGroups); | |
| 1929 | + projectGroupsRef.current = projectGroups; | |
| 1930 | + const serverConfigs = useAtomValue(environmentServerConfigsAtom); | |
| 1931 | + // Threads on non-primary environments (T3 Connect, hosted) resolve their | |
| 1932 | + // provider entry from their own environment's config: default instance ids | |
| 1933 | + // are driver slugs, so a flat map would collide across environments. | |
| 1934 | + const providerEntriesByEnvironment = useMemo( | |
| 1930 | 1935 | () => |
| 1931 | − new Map( | |
| 1932 | − deriveProviderInstanceEntries(serverProviders).map( | |
| 1933 | − (entry) => [entry.instanceId as string, entry] as const, | |
| 1936 | + deriveProviderEntriesByEnvironment( | |
| 1937 | + [...serverConfigs].map( | |
| 1938 | + ([environmentId, config]) => [environmentId, config.providers] as const, | |
| 1934 | 1939 | ), |
| 1935 | 1940 | ), |
| 1936 | − [serverProviders], | |
| 1941 | + [serverConfigs], | |
| 1937 | 1942 | ); |
| 1938 | 1943 | const projectCwdByKey = useMemo( |
| 1939 | 1944 | () => |
| @@ -2081,11 +2086,8 @@ export default function Sidebar() { · 141 unmodified lines | ||
| 2081 | 2086 | clearSelection(); |
| 2082 | 2087 | }, [clearSelection, projectScopeKey]); |
| 2083 | 2088 | |
| 2084 | − const handleProjectSettings = useCallback( | |
| 2085 | − (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => { | |
| 2086 | − event.preventDefault(); | |
| 2087 | − event.stopPropagation(); | |
| 2088 | − dispatchProjectScopeMenu({ type: "project-settings-opened" }); | |
| 2089 | + const openProjectSettings = useCallback( | |
| 2090 | + (projectGroup: SidebarProjectSnapshot) => { | |
| 2089 | 2091 | if (isMobile) { |
| 2090 | 2092 | setOpenMobile(false); |
| 2091 | 2093 | } |
| @@ -2118,7 +2120,6 @@ export default function Sidebar() { · 26 unmodified lines | ||
| 2118 | 2120 | // the partition works directly off live shells: no archived-snapshot |
| 2119 | 2121 | // merging, no optimistic holds. Archived threads remain hidden here — |
| 2120 | 2122 | // archive keeps its original "remove from sidebar" meaning. |
| 2121 | − const serverConfigs = useAtomValue(environmentServerConfigsAtom); | |
| 2122 | 2123 | const { |
| 2123 | 2124 | pinnedThreads, |
| 2124 | 2125 | reorderablePinnedKeys, |
| @@ -2133,6 +2134,7 @@ export default function Sidebar() { · 8 unmodified lines | ||
| 2133 | 2134 | // memo exactly at the next wake boundary. |
| 2134 | 2135 | void snoozeWakeTick; |
| 2135 | 2136 | const preciseNow = new Date().toISOString(); |
| 2137 | + const now = `${nowMinute}:00.000Z`; | |
| 2136 | 2138 | // Subagent child threads live in the parent's Agents surface, not the |
| 2137 | 2139 | // sidebar roster (v2 models them as real threads with lineage). |
| 2138 | 2140 | const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys); |
| @@ -2209,16 +2211,7 @@ export default function Sidebar() { · 70 unmodified lines | ||
| 2209 | 2211 | settledThreads: sortSettledThreadsForSidebar(settled), |
| 2210 | 2212 | snoozeNow: preciseNow, |
| 2211 | 2213 | }; |
| 2212 | − }, [ | |
| 2213 | − autoSettleAfterDays, | |
| 2214 | − autoSettleOnMerge, | |
| 2215 | − changeRequestSnapshotByKey, | |
| 2216 | − nowMinute, | |
| 2217 | − scopedProjectKeys, | |
| 2218 | − serverConfigs, | |
| 2219 | − snoozeWakeTick, | |
| 2220 | − threads, | |
| 2221 | − ]); | |
| 2214 | + }, [nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); | |
| 2222 | 2215 | |
| 2223 | 2216 | const threadSearchInputRef = useRef<HTMLInputElement>(null); |
| 2224 | 2217 | const [threadSearchQuery, setThreadSearchQuery] = useState(""); |
| @@ -3788,7 +3781,10 @@ export default function Sidebar() { · 1563 unmodified lines | ||
| 3788 | 3781 | ) ?? null |
| 3789 | 3782 | } |
| 3790 | 3783 | environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} |
| 3791 | − providerEntryByInstanceId={providerEntryByInstanceId} | |
| 3784 | + providerEntryByInstanceId={ | |
| 3785 | + providerEntriesByEnvironment.get(thread.environmentId) ?? | |
| 3786 | + EMPTY_PROVIDER_ENTRIES | |
| 3787 | + } | |
| 3792 | 3788 | isHighlighted={activeSearchResultIndex === index} |
| 3793 | 3789 | isRouteActive={routeThreadKey === threadKey} |
| 3794 | 3790 | resultId={`sidebar-thread-search-result-${index}`} |
| @@ -3894,7 +3890,10 @@ export default function Sidebar() { · 99 unmodified lines | ||
| 3894 | 3890 | `${thread.environmentId}:${thread.projectId}`, |
| 3895 | 3891 | ) ?? null |
| 3896 | 3892 | } |
| 3897 | − providerEntryByInstanceId={providerEntryByInstanceId} | |
| 3893 | + providerEntryByInstanceId={ | |
| 3894 | + providerEntriesByEnvironment.get(thread.environmentId) ?? | |
| 3895 | + EMPTY_PROVIDER_ENTRIES | |
| 3896 | + } | |
| 3898 | 3897 | timestampFormat={timestampFormat} |
| 3899 | 3898 | onThreadClick={handleThreadClick} |
| 3900 | 3899 | onThreadActivate={navigateToThread} |
| @@ -4099,4 +4098,5 @@ export default function Sidebar() { · 198 unmodified lines | ||
| 4099 | 4098 | <SidebarChromeFooter /> |
| 4100 | 4099 | </> |
| 4101 | 4100 | ); |
| 4101 | + const EMPTY_PROVIDER_ENTRIES: ReadonlyMap<string, ProviderInstanceEntry> = new Map(); | |
| 4102 | 4102 | } |
| @@ -172,6 +172,7 @@ import { · 171 unmodified lines | ||
| 172 | 172 | submitComposerDraft, |
| 173 | 173 | } from "./composerSubmission"; |
| 174 | 174 | import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; |
| 175 | +import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; | |
| 175 | 176 | |
| 176 | 177 | function ComposerVideoThumbnail({ file }: { file: File }) { |
| 177 | 178 | const setVideo = useCallback( |
| @@ -1580,7 +1580,7 @@ describe("deriveMessagesTimelineRows", () => { · 1579 unmodified lines | ||
| 1580 | 1580 | "assistant-final-entry", |
| 1581 | 1581 | "user-followup-entry", |
| 1582 | 1582 | "working-indicator-row", |
| 1583 | − "thinking-indicator-row", | |
| 1583 | + "live-activity-row", | |
| 1584 | 1584 | ]); |
| 1585 | 1585 | const finalRow = rows.find((row) => row.id === "assistant-final-entry"); |
| 1586 | 1586 | expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); |
| @@ -1618,14 +1618,15 @@ describe("deriveMessagesTimelineRows", () => { · 31 unmodified lines | ||
| 1618 | 1618 | revertTurnCountByUserMessageId: new Map(), |
| 1619 | 1619 | }); |
| 1620 | 1620 | |
| 1621 | − // The settled tool stays visible in past tense instead of vanishing, and | |
| 1622 | − // the thinking indicator marks that the turn itself is still running. | |
| 1623 | − expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "thinking"]); | |
| 1621 | + // A successfully settled command keeps the shared live-activity row alive | |
| 1622 | + // between actions (#9098) instead of dropping to past tense, so no extra | |
| 1623 | + // thinking row appears while the turn is still running. | |
| 1624 | + expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); | |
| 1624 | 1625 | expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ |
| 1625 | − active: false, | |
| 1626 | + active: true, | |
| 1627 | + id: "live-activity-row", | |
| 1626 | 1628 | entry: { id: "work-1" }, |
| 1627 | 1629 | }); |
| 1628 | − expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); | |
| 1629 | 1630 | }); |
| 1630 | 1631 | |
| 1631 | 1632 | it("does not fold the active in-progress turn", () => { |
| @@ -1675,7 +1676,7 @@ describe("deriveMessagesTimelineRows", () => { · 43 unmodified lines | ||
| 1675 | 1676 | expect(rows.map((row) => row.id)).toEqual([ |
| 1676 | 1677 | "working-indicator-row", |
| 1677 | 1678 | "assistant-thought-entry", |
| 1678 | − "work-live:work-entry-1", | |
| 1679 | + "live-activity-row", | |
| 1679 | 1680 | ]); |
| 1680 | 1681 | const liveRow = rows.find((row) => row.kind === "work-live"); |
| 1681 | 1682 | expect(liveRow?.kind === "work-live" ? liveRow.entry.id : null).toBe("work-1"); |
| @@ -1948,8 +1949,8 @@ describe("computeStableMessagesTimelineRows", () => { · 266 unmodified lines | ||
| 1948 | 1949 | initial, |
| 1949 | 1950 | ); |
| 1950 | 1951 | |
| 1951 | − const initialThinking = initial.byId.get("thinking-indicator-row"); | |
| 1952 | − const updatedThinking = updated.byId.get("thinking-indicator-row"); | |
| 1952 | + const initialThinking = initial.byId.get("live-activity-row"); | |
| 1953 | + const updatedThinking = updated.byId.get("live-activity-row"); | |
| 1953 | 1954 | expect(initialThinking).toMatchObject({ kind: "thinking" }); |
| 1954 | 1955 | expect(updatedThinking).toBe(initialThinking); |
| 1955 | 1956 | expect(updated.result.at(-1)).toBe(updatedThinking); |
| @@ -1,9 +1,6 @@ | ||
| 1 | 1 | import * as Equal from "effect/Equal"; |
| 2 | 2 | import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives"; |
| 3 | 3 | import { |
| 4 | − normalizeCompactToolLabel, | |
| 5 | − omitSupersededLifecycleMarkers, | |
| 6 | − resolveWorkEntryToolPresentation, | |
| 7 | 4 | summarizeToolGroup, |
| 8 | 5 | toolGroupSummaryKind, |
| 9 | 6 | type ToolGroupSummaryKind, |
| @@ -18,6 +15,7 @@ import { · 8 unmodified lines | ||
| 18 | 15 | formatDuration, |
| 19 | 16 | timelineEntryIsPersistentResourceCard, |
| 20 | 17 | workEntryDisplayIndicatesToolFailure, |
| 18 | + workEntryIndicatesToolSuccess, | |
| 21 | 19 | workEntryIndicatesToolNeutralStatus, |
| 22 | 20 | workLogEntryIsToolLike, |
| 23 | 21 | type TimelineEntry, |
| @@ -337,200 +335,6 @@ export function computeMessageDurationStart( · 313 unmodified lines | ||
| 337 | 335 | return result; |
| 338 | 336 | } |
| 339 | 337 | |
| 340 | −export function normalizeCompactToolLabel(value: string): string { | |
| 341 | − return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); | |
| 342 | −} | |
| 343 | − | |
| 344 | −type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other" | "update"; | |
| 345 | −export type ToolGroupSummaryKind = | |
| 346 | − | ToolGroupAction | |
| 347 | − | "dynamic-tool" | |
| 348 | − | "agent-tool" | |
| 349 | − | "tone-tool" | |
| 350 | − | "mixed"; | |
| 351 | − | |
| 352 | −export function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean { | |
| 353 | − return ( | |
| 354 | − entry.itemType === "file_search" || | |
| 355 | − (entry.itemType === "web_search" && | |
| 356 | − /\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label))) | |
| 357 | − ); | |
| 358 | −} | |
| 359 | − | |
| 360 | −export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction { | |
| 361 | − if ( | |
| 362 | − entry.itemType === "dynamic_tool" && | |
| 363 | − /^read(?:\s+file)?$/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label)) | |
| 364 | − ) { | |
| 365 | − return "read"; | |
| 366 | − } | |
| 367 | − if (entry.itemType === "file_change" || (entry.changedFiles?.length ?? 0) > 0) { | |
| 368 | − return "edit"; | |
| 369 | − } | |
| 370 | − if (entry.itemType === "command_execution" || entry.command) { | |
| 371 | − return "command"; | |
| 372 | − } | |
| 373 | − if (workLogEntryIsLocalCodeSearch(entry)) return "code-search"; | |
| 374 | − if (entry.itemType === "web_search") return "search"; | |
| 375 | − return workLogEntryIsToolLike(entry) ? "other" : "update"; | |
| 376 | −} | |
| 377 | − | |
| 378 | −function toolGroupActionCount( | |
| 379 | − action: ToolGroupAction, | |
| 380 | − entries: ReadonlyArray<WorkLogEntry>, | |
| 381 | −): number { | |
| 382 | − if (action !== "edit") return entries.length; | |
| 383 | − | |
| 384 | − const changedFiles = new Set<string>(); | |
| 385 | − let editsWithoutFileDetails = 0; | |
| 386 | − for (const entry of entries) { | |
| 387 | − if (!entry.changedFiles || entry.changedFiles.length === 0) { | |
| 388 | − editsWithoutFileDetails += 1; | |
| 389 | − continue; | |
| 390 | − } | |
| 391 | − for (const file of entry.changedFiles) changedFiles.add(file); | |
| 392 | − } | |
| 393 | − return changedFiles.size + editsWithoutFileDetails; | |
| 394 | −} | |
| 395 | − | |
| 396 | −function toolGroupActionLabel(action: ToolGroupAction, count: number): string { | |
| 397 | − switch (action) { | |
| 398 | − case "read": | |
| 399 | − return `Read ${count} ${count === 1 ? "file" : "files"}`; | |
| 400 | − case "edit": | |
| 401 | − return `Changed ${count} ${count === 1 ? "file" : "files"}`; | |
| 402 | − case "command": | |
| 403 | − return `Ran ${count} ${count === 1 ? "command" : "commands"}`; | |
| 404 | − case "search": | |
| 405 | − return `Searched the web ${count} ${count === 1 ? "time" : "times"}`; | |
| 406 | − case "code-search": | |
| 407 | − return `Searched code ${count} ${count === 1 ? "time" : "times"}`; | |
| 408 | − case "other": | |
| 409 | − return `Used ${count} ${count === 1 ? "tool" : "tools"}`; | |
| 410 | − case "update": | |
| 411 | − return `Received ${count} ${count === 1 ? "update" : "updates"}`; | |
| 412 | − } | |
| 413 | −} | |
| 414 | − | |
| 415 | −function t3ToolSummaryCall(entry: WorkLogEntry): T3ToolSummaryCall { | |
| 416 | − const item = entry.structuredPayload ?? entry.projectedItem?.item; | |
| 417 | − const data = | |
| 418 | − entry.toolData !== null && typeof entry.toolData === "object" | |
| 419 | − ? (entry.toolData as Record<string, unknown>) | |
| 420 | − : undefined; | |
| 421 | − return { | |
| 422 | − input: item?.type === "dynamic_tool" ? item.input : data?.input, | |
| 423 | − output: item?.type === "dynamic_tool" ? item.output : data?.output, | |
| 424 | − // A status/read result may describe a failed child. Only the call's own lifecycle | |
| 425 | − // and MCP error envelope determine whether the orchestration action failed. | |
| 426 | − outcome: | |
| 427 | − entry.toolLifecycleStatus === "failed" || | |
| 428 | − entry.toolLifecycleStatus === "declined" || | |
| 429 | − entry.tone === "error" | |
| 430 | − ? "failed" | |
| 431 | − : entry.toolLifecycleStatus === "completed" | |
| 432 | − ? "completed" | |
| 433 | − : "unfinished", | |
| 434 | − }; | |
| 435 | −} | |
| 436 | − | |
| 437 | −function summaryActionPriority(action: ToolGroupAction | T3McpToolSummaryAction): number { | |
| 438 | − switch (action) { | |
| 439 | − case "command": | |
| 440 | − case "edit": | |
| 441 | − case "delegate": | |
| 442 | − case "task-cancel": | |
| 443 | − case "thread-create": | |
| 444 | − case "thread-send": | |
| 445 | − case "thread-interrupt": | |
| 446 | − case "schedule-create": | |
| 447 | − case "schedule-update": | |
| 448 | − case "schedule-delete": | |
| 449 | − return 0; | |
| 450 | − case "other": | |
| 451 | − case "update": | |
| 452 | − return 2; | |
| 453 | − default: | |
| 454 | − return 1; | |
| 455 | − } | |
| 456 | −} | |
| 457 | − | |
| 458 | −/** Summarizes at most two action categories; every omitted call still counts in the remainder. */ | |
| 459 | −export function summarizeToolGroup(entries: ReadonlyArray<WorkLogEntry>): { | |
| 460 | − summary: string; | |
| 461 | − hasFailure: boolean; | |
| 462 | −} { | |
| 463 | − const groups = new Map< | |
| 464 | − ToolGroupAction | T3McpToolSummaryAction, | |
| 465 | − { | |
| 466 | − action: ToolGroupAction; | |
| 467 | − t3Action: T3McpToolSummaryAction | null; | |
| 468 | − entries: WorkLogEntry[]; | |
| 469 | − } | |
| 470 | − >(); | |
| 471 | − for (const entry of entries) { | |
| 472 | − const item = entry.structuredPayload ?? entry.projectedItem?.item; | |
| 473 | − const t3Action = resolveT3McpToolSummaryAction( | |
| 474 | − (item?.type === "dynamic_tool" ? item.toolName : null) ?? entry.toolTitle ?? entry.label, | |
| 475 | − ); | |
| 476 | − const action = toolGroupAction(entry); | |
| 477 | − const key = t3Action ?? action; | |
| 478 | − const group = groups.get(key); | |
| 479 | − if (group) group.entries.push(entry); | |
| 480 | − else groups.set(key, { action, t3Action, entries: [entry] }); | |
| 481 | − } | |
| 482 | − const summaries = [...groups].map(([action, group], index) => ({ | |
| 483 | − index, | |
| 484 | − count: group.entries.length, | |
| 485 | − priority: summaryActionPriority(action), | |
| 486 | − ...(group.t3Action | |
| 487 | − ? summarizeT3ToolCalls(group.t3Action, group.entries.map(t3ToolSummaryCall)) | |
| 488 | − : { | |
| 489 | − label: toolGroupActionLabel( | |
| 490 | − group.action, | |
| 491 | − toolGroupActionCount(group.action, group.entries), | |
| 492 | − ), | |
| 493 | − failedCount: group.entries.filter(workEntryDisplayIndicatesToolFailure).length, | |
| 494 | − }), | |
| 495 | − })); | |
| 496 | − const selected = summaries | |
| 497 | − .toSorted((a, b) => a.priority - b.priority || a.index - b.index) | |
| 498 | − .slice(0, 2) | |
| 499 | − .sort((a, b) => a.index - b.index); | |
| 500 | − const labels = selected.map(({ label }) => label); | |
| 501 | − const remainingCount = entries.length - selected.reduce((count, group) => count + group.count, 0); | |
| 502 | − if (remainingCount > 0) { | |
| 503 | − labels.push(`Performed ${remainingCount} other ${remainingCount === 1 ? "action" : "actions"}`); | |
| 504 | − } | |
| 505 | − const sentenceLabels = labels.map((label, index) => | |
| 506 | − index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1), | |
| 507 | − ); | |
| 508 | − const summary = | |
| 509 | − sentenceLabels.length < 3 | |
| 510 | − ? sentenceLabels.join(" and ") | |
| 511 | − : `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`; | |
| 512 | − return { summary, hasFailure: summaries.some((group) => group.failedCount > 0) }; | |
| 513 | −} | |
| 514 | − | |
| 515 | −function toolGroupSummaryKind(entries: ReadonlyArray<WorkLogEntry>): ToolGroupSummaryKind { | |
| 516 | − const actions = new Set(entries.map(toolGroupAction)); | |
| 517 | − if (actions.size !== 1) return "mixed"; | |
| 518 | − | |
| 519 | − const action = actions.values().next().value!; | |
| 520 | − if (action !== "other") return action; | |
| 521 | − | |
| 522 | − const fallbackKinds = new Set( | |
| 523 | − entries.map((entry): ToolGroupSummaryKind => { | |
| 524 | − if (entry.itemType === "dynamic_tool") return "dynamic-tool"; | |
| 525 | − if (entry.itemType === "subagent") return "agent-tool"; | |
| 526 | − if (entry.tone === "thinking") return "agent-tool"; | |
| 527 | − if (entry.tone === "tool") return "tone-tool"; | |
| 528 | − return "other"; | |
| 529 | − }), | |
| 530 | − ); | |
| 531 | − return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed"; | |
| 532 | −} | |
| 533 | − | |
| 534 | 338 | function workGroupId(timelineEntryId: string): string { |
| 535 | 339 | return `work-group:${timelineEntryId}`; |
| 536 | 340 | } |
| @@ -911,21 +715,26 @@ export function deriveMessagesTimelineRows(input: { · 374 unmodified lines | ||
| 911 | 715 | const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => |
| 912 | 716 | workEntryIsActiveTurnActivity(entry.entry), |
| 913 | 717 | ); |
| 914 | − const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry; | |
| 718 | + const latestToolKeepsActivityLive = | |
| 719 | + latestRunningToolEntry !== undefined || | |
| 720 | + (latestVisibleToolEntry !== undefined && | |
| 721 | + workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry)); | |
| 915 | 722 | const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; |
| 916 | 723 | const activeWorkRow = |
| 917 | − activeWorkAnchor && displayedToolEntry | |
| 724 | + activeWorkAnchor && latestVisibleToolEntry | |
| 918 | 725 | ? (() => { |
| 919 | 726 | const groupId = workGroupId(activeWorkAnchor.id); |
| 920 | 727 | return { |
| 921 | 728 | kind: "work-live" as const, |
| 922 | − id: `work-live:${activeWorkAnchor.id}`, | |
| 729 | + id: latestToolKeepsActivityLive | |
| 730 | + ? LIVE_ACTIVITY_ROW_ID | |
| 731 | + : `work-live:${activeWorkAnchor.id}`, | |
| 923 | 732 | createdAt: activeWorkAnchor.createdAt, |
| 924 | − entry: displayedToolEntry.entry, | |
| 733 | + entry: (latestRunningToolEntry ?? latestVisibleToolEntry).entry, | |
| 925 | 734 | groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), |
| 926 | 735 | groupId, |
| 927 | 736 | expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, |
| 928 | − active: latestRunningToolEntry !== undefined, | |
| 737 | + active: latestToolKeepsActivityLive, | |
| 929 | 738 | }; |
| 930 | 739 | })() |
| 931 | 740 | : null; |
| @@ -939,11 +748,11 @@ export function deriveMessagesTimelineRows(input: { · 7 unmodified lines | ||
| 939 | 748 | createdAt: input.activeTurnStartedAt ?? null, |
| 940 | 749 | }); |
| 941 | 750 | }; |
| 942 | − let hasLiveWorkRow = false; | |
| 751 | + let hasActivityRow = false; | |
| 943 | 752 | const appendActiveWorkRows = () => { |
| 944 | 753 | if (activeWorkRow === null) return; |
| 945 | 754 | nextRows.push(activeWorkRow); |
| 946 | − hasLiveWorkRow ||= activeWorkRow.active; | |
| 755 | + hasActivityRow ||= activeWorkRow.active; | |
| 947 | 756 | if (!activeWorkRow.expanded) return; |
| 948 | 757 | for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { |
| 949 | 758 | nextRows.push({ |
| @@ -1059,7 +868,6 @@ export function deriveMessagesTimelineRows(input: { · 109 unmodified lines | ||
| 1059 | 868 | const groupId = workGroupId(timelineEntry.id); |
| 1060 | 869 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; |
| 1061 | 870 | const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!; |
| 1062 | − hasLiveWorkRow = true; | |
| 1063 | 871 | nextRows.push({ |
| 1064 | 872 | kind: "work-live", |
| 1065 | 873 | id: `work-live:${timelineEntry.id}`, |
| @@ -1070,6 +878,7 @@ export function deriveMessagesTimelineRows(input: { · 4 unmodified lines | ||
| 1070 | 878 | expanded, |
| 1071 | 879 | active: true, |
| 1072 | 880 | }); |
| 881 | + hasActivityRow = true; | |
| 1073 | 882 | if (expanded) { |
| 1074 | 883 | for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { |
| 1075 | 884 | nextRows.push({ |
| @@ -1192,10 +1001,10 @@ export function deriveMessagesTimelineRows(input: { · 116 unmodified lines | ||
| 1192 | 1001 | if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { |
| 1193 | 1002 | appendWorkingRow(); |
| 1194 | 1003 | } |
| 1195 | − if (input.isWorking && !hasLiveWorkRow) { | |
| 1004 | + if (input.isWorking && !hasActivityRow) { | |
| 1196 | 1005 | nextRows.push({ |
| 1197 | 1006 | kind: "thinking", |
| 1198 | − id: "thinking-indicator-row", | |
| 1007 | + id: LIVE_ACTIVITY_ROW_ID, | |
| 1199 | 1008 | createdAt: input.activeTurnStartedAt ?? null, |
| 1200 | 1009 | }); |
| 1201 | 1010 | } |
| @@ -64,6 +64,7 @@ import { · 63 unmodified lines | ||
| 64 | 64 | import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; |
| 65 | 65 | import { |
| 66 | 66 | BotIcon, |
| 67 | + BrainIcon, | |
| 67 | 68 | CheckIcon, |
| 68 | 69 | ChevronDownIcon, |
| 69 | 70 | ChevronRightIcon, |
| @@ -2092,7 +2093,7 @@ function ThinkingTimelineRow() { · 2022 unmodified lines | ||
| 2092 | 2093 | // Reserve the activity row during setup so the handoff keeps the same height. |
| 2093 | 2094 | return ( |
| 2094 | 2095 | <div className="min-h-7"> |
| 2095 | − {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />} | |
| 2096 | + {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" iconName="brain" />} | |
| 2096 | 2097 | </div> |
| 2097 | 2098 | ); |
| 2098 | 2099 | } |
| @@ -2778,6 +2779,7 @@ function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { · 679 unmodified lines | ||
| 2778 | 2779 | |
| 2779 | 2780 | type WorkEntryIconName = |
| 2780 | 2781 | | "bot" |
| 2782 | + | "brain" | |
| 2781 | 2783 | | "check" |
| 2782 | 2784 | | "circle-alert" |
| 2783 | 2785 | | "eye" |
| @@ -2795,6 +2797,8 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN · 11 unmodified lines | ||
| 2795 | 2797 | switch (name) { |
| 2796 | 2798 | case "bot": |
| 2797 | 2799 | return <BotIcon className={className} aria-hidden />; |
| 2800 | + case "brain": | |
| 2801 | + return <BrainIcon className={className} aria-hidden />; | |
| 2798 | 2802 | case "check": |
| 2799 | 2803 | return <CheckIcon className={className} aria-hidden />; |
| 2800 | 2804 | case "circle-alert": |
| @@ -2849,8 +2853,8 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { · 48 unmodified lines | ||
| 2849 | 2853 | } |
| 2850 | 2854 | if (tone === "thinking") { |
| 2851 | 2855 | return { |
| 2852 | − iconName: "bot", | |
| 2853 | − className: "text-foreground/92", | |
| 2856 | + iconName: "brain", | |
| 2857 | + className: "text-foreground", | |
| 2854 | 2858 | }; |
| 2855 | 2859 | } |
| 2856 | 2860 | if (tone === "info") { |
| @@ -203,6 +203,7 @@ describe("external chat link context menu", () => { · 202 unmodified lines | ||
| 203 | 203 | "https://example.com/docs?topic=security#links", |
| 204 | 204 | ], |
| 205 | 205 | ["HTTP://EXAMPLE.COM", "http://example.com/"], |
| 206 | + ["//example.com/path", "https://example.com/path"], | |
| 206 | 207 | ])("resolves the safe external web-link href for %s as %s", (href, expected) => { |
| 207 | 208 | expect(resolveExternalWebLinkHref(href)).toBe(expected); |
| 208 | 209 | }); |
| @@ -213,7 +214,6 @@ describe("external chat link context menu", () => { · 4 unmodified lines | ||
| 213 | 214 | "data:text/html,<script>alert(document.domain)</script>", |
| 214 | 215 | "file:///tmp/example.txt", |
| 215 | 216 | "mailto:hello@example.com", |
| 216 | − "//example.com/path", | |
| 217 | 217 | "/relative/path", |
| 218 | 218 | "not a URL", |
| 219 | 219 | "", |
| @@ -2254,31 +2254,8 @@ export function GeneralSettingsPanel() { · 2253 unmodified lines | ||
| 2254 | 2254 | /> |
| 2255 | 2255 | } |
| 2256 | 2256 | /> |
| 2257 | − ) : null | |
| 2258 | − } | |
| 2259 | − control={ | |
| 2260 | − <Switch | |
| 2261 | − checked={settings.sidebarAutoSettleAfterDays !== null} | |
| 2262 | − onCheckedChange={(checked) => | |
| 2263 | − updateSettings({ | |
| 2264 | − sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, | |
| 2265 | − }) | |
| 2266 | − } | |
| 2267 | − aria-label="Auto-settle inactive threads" | |
| 2268 | − /> | |
| 2269 | − } | |
| 2270 | − /> | |
| 2271 | − {settings.sidebarAutoSettleAfterDays !== null ? ( | |
| 2272 | − <SettingsRow | |
| 2273 | − title={searchableSetting("days-before-auto-settle").title} | |
| 2274 | − description="Any new activity un-settles a thread automatically." | |
| 2275 | − control={ | |
| 2276 | − <AutoSettleDaysInput | |
| 2277 | − value={settings.sidebarAutoSettleAfterDays} | |
| 2278 | − onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })} | |
| 2279 | − /> | |
| 2280 | − } | |
| 2281 | − /> | |
| 2257 | + ) : null} | |
| 2258 | + </> | |
| 2282 | 2259 | ) : null} |
| 2283 | 2260 | </SettingsSection> |
| 2284 | 2261 | |
| @@ -147,6 +147,7 @@ describe("searchSettings", () => { · 146 unmodified lines | ||
| 147 | 147 | hasProviderSettingsEnvironment: false, |
| 148 | 148 | canManageLocalBackend: false, |
| 149 | 149 | isWslSettingsRowVisible: false, |
| 150 | + hasThreadAutoSettlement: false, | |
| 150 | 151 | }); |
| 151 | 152 | |
| 152 | 153 | const gatedIds = new Set<string>([ |
| @@ -160,10 +161,30 @@ describe("searchSettings", () => { · 7 unmodified lines | ||
| 160 | 161 | "t3-connect", |
| 161 | 162 | "tailscale-https", |
| 162 | 163 | "wsl-backend", |
| 164 | + "auto-settle-inactive-threads", | |
| 165 | + "auto-settle-merged-threads", | |
| 166 | + "days-before-auto-settle", | |
| 163 | 167 | ]); |
| 164 | 168 | expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]); |
| 165 | 169 | }); |
| 166 | 170 | |
| 171 | + it("shows automatic settlement settings when the server supports them", () => { | |
| 172 | + const available = filterAvailableSettingsSearchItems({ | |
| 173 | + hasCloudPublicConfig: false, | |
| 174 | + hasPrimaryEnvironment: false, | |
| 175 | + hasProviderSettingsEnvironment: false, | |
| 176 | + canManageLocalBackend: false, | |
| 177 | + isWslSettingsRowVisible: false, | |
| 178 | + hasThreadAutoSettlement: true, | |
| 179 | + }); | |
| 180 | + | |
| 181 | + expect(searchSettings("auto-settle", available).map((item) => item.id)).toEqual([ | |
| 182 | + "auto-settle-inactive-threads", | |
| 183 | + "auto-settle-merged-threads", | |
| 184 | + "days-before-auto-settle", | |
| 185 | + ]); | |
| 186 | + }); | |
| 187 | + | |
| 167 | 188 | it("keeps catalog result ids unique", () => { |
| 168 | 189 | const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); |
| 169 | 190 | expect(new Set(ids).size).toBe(ids.length); |
| @@ -31,6 +31,7 @@ export interface SettingsSearchItem { · 30 unmodified lines | ||
| 31 | 31 | readonly providerSettingsOnly?: boolean; |
| 32 | 32 | readonly localBackendManagementOnly?: boolean; |
| 33 | 33 | readonly wslAvailableOnly?: boolean; |
| 34 | + readonly requiresThreadAutoSettlement?: boolean; | |
| 34 | 35 | } |
| 35 | 36 | |
| 36 | 37 | export interface SettingsSearchAvailability { |
| @@ -39,6 +40,7 @@ export interface SettingsSearchAvailability { · 2 unmodified lines | ||
| 39 | 40 | readonly hasProviderSettingsEnvironment: boolean; |
| 40 | 41 | readonly canManageLocalBackend: boolean; |
| 41 | 42 | readonly isWslSettingsRowVisible: boolean; |
| 43 | + readonly hasThreadAutoSettlement: boolean; | |
| 42 | 44 | } |
| 43 | 45 | |
| 44 | 46 | /** |
| @@ -155,12 +157,14 @@ export const SETTINGS_SEARCH_ITEMS = [ · 110 unmodified lines | ||
| 155 | 157 | title: "Auto-settle inactive threads", |
| 156 | 158 | to: "/settings/general", |
| 157 | 159 | searchTerms: ["sidebar inactivity days no activity automatically"], |
| 160 | + requiresThreadAutoSettlement: true, | |
| 158 | 161 | }, |
| 159 | 162 | { |
| 160 | 163 | id: "auto-settle-merged-threads", |
| 161 | 164 | title: "Auto-settle merged threads", |
| 162 | 165 | to: "/settings/general", |
| 163 | 166 | searchTerms: ["pull request merge closed automatically sidebar"], |
| 167 | + requiresThreadAutoSettlement: true, | |
| 164 | 168 | }, |
| 165 | 169 | { |
| 166 | 170 | id: "days-before-auto-settle", |
| @@ -168,6 +172,7 @@ export const SETTINGS_SEARCH_ITEMS = [ · 1 unmodified line | ||
| 168 | 172 | to: "/settings/general", |
| 169 | 173 | targetId: "auto-settle-inactive-threads", |
| 170 | 174 | searchTerms: ["thread timeout activity sidebar"], |
| 175 | + requiresThreadAutoSettlement: true, | |
| 171 | 176 | }, |
| 172 | 177 | { |
| 173 | 178 | id: "time-format", |
| @@ -250,7 +255,7 @@ export const SETTINGS_SEARCH_ITEMS = [ · 76 unmodified lines | ||
| 250 | 255 | id: "quit-confirmation", |
| 251 | 256 | title: "Quit shortcut", |
| 252 | 257 | to: "/settings/general", |
| 253 | − searchTerms: ["confirmation shortcut desktop app exit"], | |
| 258 | + searchTerms: ["confirmation desktop app exit direct hold double click press twice"], | |
| 254 | 259 | desktopOnly: true, |
| 255 | 260 | }, |
| 256 | 261 | { |
| @@ -491,7 +496,8 @@ export function filterAvailableSettingsSearchItems( · 234 unmodified lines | ||
| 491 | 496 | (!item.primaryOnly || availability.hasPrimaryEnvironment) && |
| 492 | 497 | (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) && |
| 493 | 498 | (!item.localBackendManagementOnly || availability.canManageLocalBackend) && |
| 494 | − (!item.wslAvailableOnly || availability.isWslSettingsRowVisible), | |
| 499 | + (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) && | |
| 500 | + (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement), | |
| 495 | 501 | ); |
| 496 | 502 | } |
| 497 | 503 | |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { useMemo } from "react"; |
| 2 | +import { useAtomValue } from "@effect/atom-react"; | |
| 2 | 3 | import { AuthAccessWriteScope } from "@t3tools/contracts"; |
| 3 | 4 | |
| 4 | 5 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| @@ -7,6 +8,7 @@ import { desktopWslStateAtom } from "~/state/desktopWslState"; · 2 unmodified lines | ||
| 7 | 8 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; |
| 8 | 9 | import { useEnvironmentQuery } from "~/state/query"; |
| 9 | 10 | import { usePrimarySessionState } from "~/environments/primary"; |
| 11 | +import { primaryServerConfigAtom } from "~/state/server"; | |
| 10 | 12 | import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; |
| 11 | 13 | import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; |
| 12 | 14 | import { filterAvailableSettingsSearchItems } from "./settingsSearch"; |
| @@ -15,6 +17,7 @@ export function useAvailableSettingsSearchItems() { · 2 unmodified lines | ||
| 15 | 17 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 16 | 18 | const { environments } = useEnvironments(); |
| 17 | 19 | const primarySessionState = usePrimarySessionState(); |
| 20 | + const primaryServerConfig = useAtomValue(primaryServerConfigAtom); | |
| 18 | 21 | const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); |
| 19 | 22 | const canManageLocalBackend = |
| 20 | 23 | isElectron || |
| @@ -38,7 +41,16 @@ export function useAvailableSettingsSearchItems() { · 17 unmodified lines | ||
| 38 | 41 | state: desktopWsl.data, |
| 39 | 42 | error: desktopWsl.error, |
| 40 | 43 | }), |
| 44 | + hasThreadAutoSettlement: | |
| 45 | + primaryServerConfig?.environment.capabilities.threadAutoSettlement === true, | |
| 41 | 46 | }), |
| 42 | − [canManageLocalBackend, desktopWsl.data, desktopWsl.error, environments, primaryEnvironmentId], | |
| 47 | + [ | |
| 48 | + canManageLocalBackend, | |
| 49 | + desktopWsl.data, | |
| 50 | + desktopWsl.error, | |
| 51 | + environments, | |
| 52 | + primaryEnvironmentId, | |
| 53 | + primaryServerConfig, | |
| 54 | + ], | |
| 43 | 55 | ); |
| 44 | 56 | } |