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 (16 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 | const testState = vi.hoisted(() => ({ | 5 | const testState = vi.hoisted(() => ({ |
| 6 | resources: [] as Array<unknown>, | 6 | resources: [] as Array<unknown>, |
| 7 | assetState: "success" as "success" | "loading" | "failure", | 7 | assetState: "success" as "success" | "loading" | "failure", |
| 8 | editorEnvironmentIds: [] as Array<unknown>, | ||
| 9 | remoteEnvironmentIds: [] as Array<unknown>, | ||
| 10 | })); | 8 | })); |
| 11 | 9 | ||
| 12 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); | 10 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); |
| 13 | vi.mock("../assets/assetUrls", () => ({ | 11 | vi.mock("../assets/assetUrls", () => ({ |
| 12 | useAssetUrlRefresh: () => vi.fn(), | ||
| 14 | useAssetUrlState: (_environmentId: unknown, resource: unknown) => { | 13 | useAssetUrlState: (_environmentId: unknown, resource: unknown) => { |
| 15 | testState.resources.push(resource); | 14 | testState.resources.push(resource); |
| 16 | if (testState.assetState === "loading") return { _tag: "Loading" }; | 15 | if (testState.assetState === "loading") return { _tag: "Loading" }; |
| @@ -25,23 +24,23 @@ vi.mock("../state/session", async (importOriginal) => ({ · 8 unmodified lines | |||
| 25 | ...(await importOriginal<typeof import("../state/session")>()), | 24 | ...(await importOriginal<typeof import("../state/session")>()), |
| 26 | usePreparedConnection: () => ({ _tag: "Loading" }), | 25 | usePreparedConnection: () => ({ _tag: "Loading" }), |
| 27 | })); | 26 | })); |
| 28 | vi.mock("../editorPreferences", () => ({ | 27 | vi.mock("../state/entities", () => ({ |
| 29 | useOpenInPreferredEditor: (environmentId: unknown) => { | 28 | readThreadShell: () => null, |
| 30 | testState.editorEnvironmentIds.push(environmentId); | 29 | useProjects: () => [], |
| 31 | return vi.fn(); | ||
| 32 | }, | ||
| 33 | usePreferredEditor: () => ["vscode", vi.fn()], | ||
| 34 | })); | 30 | })); |
| 35 | vi.mock("../remoteOpen", () => ({ | 31 | vi.mock("../remoteOpen", () => ({ |
| 36 | useRemoteOpenResolution: (environmentId: unknown) => { | 32 | useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }), |
| 37 | testState.remoteEnvironmentIds.push(environmentId); | 33 | })); |
| 38 | return { | 34 | vi.mock("../editorPreferences", () => ({ |
| 39 | isResolved: true, | 35 | useOpenInPreferredEditor: () => vi.fn(), |
| 40 | state: { mode: "local-exec", localExecutionUnavailableReason: null }, | 36 | usePreferredEditor: () => [null, vi.fn()], |
| 41 | }; | 37 | })); |
| 42 | }, | 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 | import ChatMarkdown from "./ChatMarkdown"; | 45 | import ChatMarkdown from "./ChatMarkdown"; |
| 47 | import { FileMarkdownPreview } from "./files/FileMarkdownPreview"; | 46 | import { FileMarkdownPreview } from "./files/FileMarkdownPreview"; |
| @@ -93,8 +92,6 @@ describe("ChatMarkdown workspace images", () => { · 45 unmodified lines | |||
| 93 | beforeEach(() => { | 92 | beforeEach(() => { |
| 94 | testState.resources = []; | 93 | testState.resources = []; |
| 95 | testState.assetState = "success"; | 94 | testState.assetState = "success"; |
| 96 | testState.editorEnvironmentIds = []; | ||
| 97 | testState.remoteEnvironmentIds = []; | ||
| 98 | }); | 95 | }); |
| 99 | 96 | ||
| 100 | it.each([ | 97 | it.each([ |
| @@ -110,52 +107,55 @@ describe("ChatMarkdown workspace images", () => { · 9 unmodified lines | |||
| 110 | 107 | ||
| 111 | expect(testState.resources).toEqual([ | 108 | expect(testState.resources).toEqual([ |
| 112 | { | 109 | { |
| 113 | _tag: "workspace-file", | 110 | _tag: "media-file", |
| 114 | threadId: threadRef.threadId, | 111 | threadId: threadRef.threadId, |
| 115 | path: expectedPath, | 112 | path: expectedPath, |
| 116 | }, | 113 | }, |
| 117 | ]); | 114 | ]); |
| 118 | }); | 115 | }); |
| 119 | 116 | ||
| 120 | it("loads relative workspace paths through signed asset URLs", () => { | 117 | it("loads every Windows workspace path form through a signed asset URL", () => { |
| 121 | const html = render(""); | 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 | expect(testState.resources).toEqual([ | 128 | expect(testState.resources).toEqual([ |
| 124 | { | 129 | { |
| 125 | _tag: "workspace-file", | 130 | _tag: "media-file", |
| 126 | threadId: threadRef.threadId, | 131 | threadId: threadRef.threadId, |
| 127 | path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg", | 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", () => { | 148 | it("normalizes a drive-absolute src in raw image HTML", () => { |
| 134 | const html = render(String.raw`[Open](C:\Users\shawn\project\src\main.ts)`); | 149 | const html = render(String.raw`<img src="D:\screens\workspace-image.svg" alt="raw">`); |
| 135 | expect(html).toContain('href="C:/Users/shawn/project/src/main.ts"'); | ||
| 136 | expect(html).toContain("chat-markdown-file-link"); | ||
| 137 | }); | ||
| 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 | expect(testState.resources).toEqual([ | 151 | expect(testState.resources).toEqual([ |
| 147 | { | 152 | { |
| 148 | _tag: "workspace-file", | 153 | _tag: "media-file", |
| 149 | threadId: threadRef.threadId, | ||
| 150 | path: "C:/Users/shawn/project/.t3/workspace-image.svg", | ||
| 151 | }, | ||
| 152 | { | ||
| 153 | _tag: "workspace-file", | ||
| 154 | threadId: threadRef.threadId, | 154 | threadId: threadRef.threadId, |
| 155 | path: "D:/screens/workspace-image.svg", | 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 | it("keeps a tall image placeholder and loaded image at the same proportional bounds", () => { | 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 | it("uses a static bounded-width placeholder while a signed asset URL loads", () => { | 262 | it("uses a static bounded-width placeholder while a signed asset URL loads", () => { |
| 263 | testState.assetState = "loading"; | 263 | testState.assetState = "loading"; |
| 264 | |||
| 264 | const html = render(""); | 265 | const html = render(""); |
| 265 | const className = /<span[^>]*aria-label="Loading image"[^>]*class="([^"]*)"/.exec(html)?.[1]; | 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 | expect(className?.split(" ")).toContain("w-64"); | 270 | expect(className?.split(" ")).toContain("w-64"); |
| 270 | }); | 271 | }); |
| 271 | 272 | ||
| 272 | it("keeps remote images directly loadable", () => { | 273 | it("never passes a workspace source to a raw image when thread context is unavailable", () => { |
| 273 | const html = render(""); | 274 | const html = renderWithoutThread( |
| 275 | "", | ||
| 276 | ); | ||
| 277 | |||
| 274 | expect(testState.resources).toEqual([]); | 278 | expect(testState.resources).toEqual([]); |
| 275 | expect(html).toContain('src="https://example.com/image.png"'); | 279 | expect(html).toContain("Image unavailable"); |
| 276 | expect(html).toContain("max-h-[30rem]"); | 280 | expect(html).not.toContain("file://"); |
| 277 | }); | 281 | }); |
| 278 | 282 | ||
| 279 | it("uses the markdown owner's environment for file actions", () => { | 283 | it("blocks unsupported image schemes instead of passing them to a raw image", () => { |
| 280 | const owningEnvironmentId = EnvironmentId.make("env-pull-request"); | 284 | const html = render(""); |
| 281 | 285 | ||
| 282 | renderToStaticMarkup( | 286 | expect(testState.resources).toEqual([]); |
| 283 | <ChatMarkdown | 287 | expect(html).toContain("Image unavailable"); |
| 284 | cwd="/workspace/project" | 288 | expect(html).not.toContain("content://"); |
| 285 | environmentId={owningEnvironmentId} | 289 | }); |
| 286 | text="[Open](src/main.ts)" | ||
| 287 | />, | ||
| 288 | ); | ||
| 289 | 290 | ||
| 290 | expect(testState.editorEnvironmentIds).toEqual([owningEnvironmentId]); | 291 | it("keeps remote images directly loadable", () => { |
| 291 | expect(testState.remoteEnvironmentIds).toEqual([owningEnvironmentId]); | 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 | deriveComposerSendState, | 24 | deriveComposerSendState, |
| 25 | dismissBranchMismatchForSession, | 25 | dismissBranchMismatchForSession, |
| 26 | getStartedThreadModelChangeBlockReason, | 26 | getStartedThreadModelChangeBlockReason, |
| 27 | isVideoPreviewRequestCurrent, | ||
| 28 | hasEnvironmentReconnectWarningGraceElapsed, | ||
| 27 | hasServerAcknowledgedLocalDispatch, | 29 | hasServerAcknowledgedLocalDispatch, |
| 28 | isBranchMismatchDismissedForSession, | 30 | isBranchMismatchDismissedForSession, |
| 29 | isVideoPreviewRequestCurrent, | ||
| 30 | loadVideoPreviewUrl, | ||
| 31 | reconcileMountedTerminalThreadIds, | 31 | reconcileMountedTerminalThreadIds, |
| 32 | reconcileRetainedMountedThreadIds, | 32 | reconcileRetainedMountedThreadIds, |
| 33 | resolveDraftPromotionNavigationTarget, | 33 | resolveDraftPromotionNavigationTarget, |
| @@ -17,6 +17,12 @@ import { · 16 unmodified lines | |||
| 17 | type RunId, | 17 | type RunId, |
| 18 | } from "@t3tools/contracts"; | 18 | } from "@t3tools/contracts"; |
| 19 | import * as DateTime from "effect/DateTime"; | 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 | import { | 26 | import { |
| 21 | appendCodexArtifactTemplateUsePrompt, | 27 | appendCodexArtifactTemplateUsePrompt, |
| 22 | codexArtifactTemplateUsePrompt, | 28 | codexArtifactTemplateUsePrompt, |
| @@ -39,11 +39,6 @@ import { · 38 unmodified lines | |||
| 39 | threadWokeAt, | 39 | threadWokeAt, |
| 40 | } from "@t3tools/client-runtime/state/thread-settled"; | 40 | } from "@t3tools/client-runtime/state/thread-settled"; |
| 41 | import { useThreadActions } from "../hooks/useThreadActions"; | 41 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 42 | import { | ||
| 43 | resolveDisplayedThreadPr, | ||
| 44 | threadChangeRequestSnapshotsAtom, | ||
| 45 | useLinkedThreadPullRequest, | ||
| 46 | } from "./ThreadStatusIndicators"; | ||
| 47 | import { | 42 | import { |
| 48 | deriveThreadActivityRun, | 43 | deriveThreadActivityRun, |
| 49 | deriveLatestThreadRun, | 44 | deriveLatestThreadRun, |
| @@ -74,7 +69,12 @@ import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; · 24 unmodified lines | |||
| 74 | import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; | 69 | import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; |
| 75 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; | 70 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 76 | import { truncate } from "@t3tools/shared/String"; | 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 | import { Debouncer } from "@tanstack/react-pacer"; | 78 | import { Debouncer } from "@tanstack/react-pacer"; |
| 79 | import { useAtomValue } from "@effect/atom-react"; | 79 | import { useAtomValue } from "@effect/atom-react"; |
| 80 | import { | 80 | import { |
| @@ -325,8 +325,14 @@ import { · 244 unmodified lines | |||
| 325 | ThreadErrorBanner, | 325 | ThreadErrorBanner, |
| 326 | } from "./chat/ThreadErrorBanner"; | 326 | } from "./chat/ThreadErrorBanner"; |
| 327 | import { resolveThreadPr } from "./ThreadStatusIndicators"; | 327 | import { resolveThreadPr } from "./ThreadStatusIndicators"; |
| 328 | import { type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; | ||
| 329 | import { QueuedRunsControl, type EditQueuedRunRequest } from "./chat/QueuedRunsControl"; | 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 | import { ComposerSurface } from "./chat/ComposerSurface"; | 336 | import { ComposerSurface } from "./chat/ComposerSurface"; |
| 331 | import { resolveThreadSyncPhase } from "../threadSync"; | 337 | import { resolveThreadSyncPhase } from "../threadSync"; |
| 332 | import { | 338 | import { |
| @@ -1338,6 +1344,7 @@ function ChatViewContent(props: ChatViewProps) { · 1005 unmodified lines | |||
| 1338 | }); | 1344 | }); |
| 1339 | const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { | 1345 | const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { |
| 1340 | reportFailure: false, | 1346 | reportFailure: false, |
| 1347 | refresh: true, | ||
| 1341 | }); | 1348 | }); |
| 1342 | const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { | 1349 | const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { |
| 1343 | reportFailure: false, | 1350 | reportFailure: false, |
| @@ -1907,7 +1914,7 @@ function ChatViewContent(props: ChatViewProps) { · 563 unmodified lines | |||
| 1907 | >({}); | 1914 | >({}); |
| 1908 | const activePullRequestSurfaceId = | 1915 | const activePullRequestSurfaceId = |
| 1909 | activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined; | 1916 | activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined; |
| 1910 | const handlePullRequestTabStatusChange = useCallback( | 1917 | const updatePullRequestTabStatusFromPanel = useCallback( |
| 1911 | (status: PullRequestTabStatus) => { | 1918 | (status: PullRequestTabStatus) => { |
| 1912 | const id = activePullRequestSurfaceId; | 1919 | const id = activePullRequestSurfaceId; |
| 1913 | if (id === undefined) return; | 1920 | if (id === undefined) return; |
| @@ -1915,6 +1922,8 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line | |||
| 1915 | }, | 1922 | }, |
| 1916 | [activePullRequestSurfaceId], | 1923 | [activePullRequestSurfaceId], |
| 1917 | ); | 1924 | ); |
| 1925 | const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); | ||
| 1926 | const sidebarPrRefreshKeyRef = useRef<string | null>(null); | ||
| 1918 | const activeFileSurface = | 1927 | const activeFileSurface = |
| 1919 | activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; | 1928 | activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; |
| 1920 | const activePreviewState = useThreadPreviewState(activeThreadRef); | 1929 | const activePreviewState = useThreadPreviewState(activeThreadRef); |
| @@ -2758,14 +2767,8 @@ function ChatViewContent(props: ChatViewProps) { · 837 unmodified lines | |||
| 2758 | toastManager.add({ type: "error", title: "The environment is not connected." }); | 2767 | toastManager.add({ type: "error", title: "The environment is not connected." }); |
| 2759 | return; | 2768 | return; |
| 2760 | } | 2769 | } |
| 2761 | const videoMime = videoMimeType(attachment); | 2770 | const isVideo = videoMimeType(attachment) !== null; |
| 2762 | const isVideo = videoMime !== null; | ||
| 2763 | const action = isVideo ? "play" : "download"; | 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 | const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0; | 2772 | const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0; |
| 2770 | const isCurrentRequest = () => | 2773 | const isCurrentRequest = () => |
| 2771 | !isVideo || | 2774 | !isVideo || |
| @@ -2775,75 +2778,37 @@ function ChatViewContent(props: ChatViewProps) { · 3 unmodified lines | |||
| 2775 | videoPreviewRequestId, | 2778 | videoPreviewRequestId, |
| 2776 | videoPreviewRequestIdRef.current, | 2779 | videoPreviewRequestIdRef.current, |
| 2777 | ); | 2780 | ); |
| 2778 | const finishVideoPreviewRequest = () => { | ||
| 2779 | if (videoPreviewRequestIdRef.current === videoPreviewRequestId) { | ||
| 2780 | setOpeningVideoAttachmentId(null); | ||
| 2781 | videoPreviewAbortControllerRef.current = null; | ||
| 2782 | } | ||
| 2783 | }; | ||
| 2784 | if (isVideo) setOpeningVideoAttachmentId(attachment.id); | 2781 | if (isVideo) setOpeningVideoAttachmentId(attachment.id); |
| 2785 | 2782 | ||
| 2786 | // fileName and mimeType ride in the signed claims so videos render | 2783 | try { |
| 2787 | // inline while other files keep their real download name and type. | 2784 | const url = await resolveFileAttachmentUrl({ |
| 2788 | const result = await createAttachmentAssetUrl({ | 2785 | attachment, |
| 2789 | environmentId, | 2786 | environmentId, |
| 2790 | input: { | 2787 | httpBaseUrl: connection.httpBaseUrl, |
| 2791 | resource: { | 2788 | createAssetUrl: createAttachmentAssetUrl, |
| 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.", | ||
| 2810 | }); | 2789 | }); |
| 2811 | return; | 2790 | if (!isCurrentRequest()) return; |
| 2812 | } | 2791 | if (isVideo) { |
| 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 | } | ||
| 2827 | setExpandedImage({ | 2792 | setExpandedImage({ |
| 2828 | images: [{ src: previewUrl, name: attachment.name, type: "video" }], | 2793 | images: [{ src: url, name: attachment.name, type: "video" }], |
| 2829 | index: 0, | 2794 | index: 0, |
| 2830 | }); | 2795 | }); |
| 2831 | } catch (error) { | 2796 | return; |
| 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(); | ||
| 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 | [createAttachmentAssetUrl, environmentId, routeThreadKey], | 2813 | [createAttachmentAssetUrl, environmentId, routeThreadKey], |
| 2849 | ); | 2814 | ); |
| @@ -4885,8 +4850,14 @@ function ChatViewContent(props: ChatViewProps) { · 2035 unmodified lines | |||
| 4885 | ); | 4850 | ); |
| 4886 | // The server-projected settled state keeps the banner and sidebar in sync. | 4851 | // The server-projected settled state keeps the banner and sidebar in sync. |
| 4887 | const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); | 4852 | const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); |
| 4888 | const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); | 4853 | const autoSettleAfterDays = useEnvironmentSettings( |
| 4889 | const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); | 4854 | environmentId, |
| 4855 | (settings) => settings.sidebarAutoSettleAfterDays, | ||
| 4856 | ); | ||
| 4857 | const autoSettleOnMerge = useEnvironmentSettings( | ||
| 4858 | environmentId, | ||
| 4859 | (settings) => settings.sidebarAutoSettleOnMerge, | ||
| 4860 | ); | ||
| 4890 | const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); | 4861 | const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); |
| 4891 | // The thread's own change request, placed against the project it belongs to. | 4862 | // The thread's own change request, placed against the project it belongs to. |
| 4892 | const linkedThreadPullRequest = activeThread?.linkedPullRequest ?? null; | 4863 | const linkedThreadPullRequest = activeThread?.linkedPullRequest ?? null; |
| @@ -4902,6 +4873,97 @@ function ChatViewContent(props: ChatViewProps) { · 9 unmodified lines | |||
| 4902 | linkedPullRequest: linkedThreadPullRequest, | 4873 | linkedPullRequest: linkedThreadPullRequest, |
| 4903 | linkedPullRequestStatus, | 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 | // The right panel offers the thread's own change request, so it can only offer it once the | 4967 | // The right panel offers the thread's own change request, so it can only offer it once the |
| 4906 | // branch has one; until then the picker says so rather than opening an empty panel. | 4968 | // branch has one; until then the picker says so rather than opening an empty panel. |
| 4907 | const addPullRequestSurface = useCallback(() => { | 4969 | const addPullRequestSurface = useCallback(() => { |
| @@ -4950,21 +5012,13 @@ function ChatViewContent(props: ChatViewProps) { · 42 unmodified lines | |||
| 4950 | if (activeThreadRef === null || activeThreadWokeAt === null) return; | 5012 | if (activeThreadRef === null || activeThreadWokeAt === null) return; |
| 4951 | markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); | 5013 | markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt); |
| 4952 | }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); | 5014 | }, [activeThreadRef, activeThreadWokeAt, markThreadVisited]); |
| 4953 | // Mirror of the sidebar's Woke pill for the open thread. It uses the same | 5015 | // Mirror of the sidebar's Woke pill for the open thread. |
| 4954 | // visit comparison and change request settle rule. | ||
| 4955 | const activeThreadLastVisitedAt = useUiStateStore((store) => | 5016 | const activeThreadLastVisitedAt = useUiStateStore((store) => |
| 4956 | activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], | 5017 | activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey], |
| 4957 | ); | 5018 | ); |
| 4958 | const activeThreadWokeVisible = useMemo(() => { | 5019 | const activeThreadWokeVisible = useMemo(() => { |
| 4959 | if (activeThreadWokeAt === null) return false; | 5020 | if (activeThreadWokeAt === null) return false; |
| 4960 | if ( | 5021 | if (activeThreadShell?.settledOverride === "settled") return false; |
| 4961 | changeRequestAutoSettles(activeThreadChangeRequest, { | ||
| 4962 | autoSettleOnMerge, | ||
| 4963 | thread: activeThreadShell, | ||
| 4964 | }) | ||
| 4965 | ) { | ||
| 4966 | return false; | ||
| 4967 | } | ||
| 4968 | const wokeAtMs = Date.parse(activeThreadWokeAt); | 5022 | const wokeAtMs = Date.parse(activeThreadWokeAt); |
| 4969 | if (Number.isNaN(wokeAtMs)) return false; | 5023 | if (Number.isNaN(wokeAtMs)) return false; |
| 4970 | // Having the thread open counts as a visit at completedAt (the effect | 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 | activeLatestRun?.completedAt, | 5039 | activeLatestRun?.completedAt, |
| 4986 | activeThreadLastVisitedAt, | 5040 | activeThreadLastVisitedAt, |
| 4987 | activeThreadChangeRequest, | ||
| 4988 | activeThreadShell, | 5041 | activeThreadShell, |
| 4989 | activeThreadWokeAt, | 5042 | activeThreadWokeAt, |
| 4990 | autoSettleOnMerge, | 5043 | autoSettleOnMerge, |
| @@ -5006,8 +5059,6 @@ function ChatViewContent(props: ChatViewProps) { · 15 unmodified lines | |||
| 5006 | nowMinute, | 5059 | nowMinute, |
| 5007 | supportsSettlement, | 5060 | supportsSettlement, |
| 5008 | ]); | 5061 | ]); |
| 5009 | const activeThreadSettled = | ||
| 5010 | supportsSettlement && activeThreadShell?.settledOverride === "settled"; | ||
| 5011 | const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { | 5062 | const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, { |
| 5012 | reportFailure: false, | 5063 | reportFailure: false, |
| 5013 | }); | 5064 | }); |
| @@ -5394,6 +5445,13 @@ function ChatViewContent(props: ChatViewProps) { · 380 unmodified lines | |||
| 5394 | }); | 5445 | }); |
| 5395 | if (!command) return; | 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 | if (command === "thread.settle") { | 5455 | if (command === "thread.settle") { |
| 5398 | event.preventDefault(); | 5456 | event.preventDefault(); |
| 5399 | event.stopPropagation(); | 5457 | event.stopPropagation(); |
| @@ -5561,6 +5619,7 @@ function ChatViewContent(props: ChatViewProps) { · 161 unmodified lines | |||
| 5561 | supportsPinning, | 5619 | supportsPinning, |
| 5562 | supportsSettlement, | 5620 | supportsSettlement, |
| 5563 | confirmAndUnpinThread, | 5621 | confirmAndUnpinThread, |
| 5622 | copyActiveThreadReference, | ||
| 5564 | toggleRightPanel, | 5623 | toggleRightPanel, |
| 5565 | toggleThreadPanel, | 5624 | toggleThreadPanel, |
| 5566 | toggleTerminalVisibility, | 5625 | toggleTerminalVisibility, |
| @@ -7512,6 +7571,7 @@ function ChatViewContent(props: ChatViewProps) { · 1945 unmodified lines | |||
| 7512 | } | 7571 | } |
| 7513 | > | 7572 | > |
| 7514 | <DraftHeroHeadline | 7573 | <DraftHeroHeadline |
| 7574 | draftId={draftId} | ||
| 7515 | activeProjectRef={activeProjectRef} | 7575 | activeProjectRef={activeProjectRef} |
| 7516 | activeProjectTitle={activeProject?.title ?? null} | 7576 | activeProjectTitle={activeProject?.title ?? null} |
| 7517 | /> | 7577 | /> |
| @@ -19,6 +19,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- · 18 unmodified lines | |||
| 19 | import { CSS } from "@dnd-kit/utilities"; | 19 | import { CSS } from "@dnd-kit/utilities"; |
| 20 | import { | 20 | import { |
| 21 | canSnooze, | 21 | canSnooze, |
| 22 | effectiveSettled, | ||
| 22 | effectiveSnoozed, | 23 | effectiveSnoozed, |
| 23 | threadWokeAt, | 24 | threadWokeAt, |
| 24 | } from "@t3tools/client-runtime/state/thread-settled"; | 25 | } from "@t3tools/client-runtime/state/thread-settled"; |
| @@ -99,7 +100,7 @@ import { useThreadActions } from "../hooks/useThreadActions"; · 74 unmodified lines | |||
| 99 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 100 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 100 | import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; | 101 | import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; |
| 101 | import { startNewThreadFromContext } from "../lib/chatThreadActions"; | 102 | import { startNewThreadFromContext } from "../lib/chatThreadActions"; |
| 102 | import { useClientSettings } from "../hooks/useSettings"; | 103 | import { useClientSettings, usePrimarySettings } from "../hooks/useSettings"; |
| 103 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; | 104 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; |
| 104 | import { useLocalStorage } from "../hooks/useLocalStorage"; | 105 | import { useLocalStorage } from "../hooks/useLocalStorage"; |
| 105 | import { useNowMinute } from "../hooks/useNowMinute"; | 106 | import { useNowMinute } from "../hooks/useNowMinute"; |
| @@ -171,7 +172,7 @@ import { ProjectFavicon } from "./ProjectFavicon"; · 65 unmodified lines | |||
| 171 | import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; | 172 | import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; |
| 172 | import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; | 173 | import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; |
| 173 | import { | 174 | import { |
| 174 | deriveProviderInstanceEntries, | 175 | deriveProviderEntriesByEnvironment, |
| 175 | shouldShowInstanceBadge, | 176 | shouldShowInstanceBadge, |
| 176 | type ProviderInstanceEntry, | 177 | type ProviderInstanceEntry, |
| 177 | } from "../providerInstances"; | 178 | } from "../providerInstances"; |
| @@ -851,10 +852,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 673 unmodified lines | |||
| 851 | const isWoke = | 852 | const isWoke = |
| 852 | wokeAtDate !== null && | 853 | wokeAtDate !== null && |
| 853 | (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && | 854 | (lastVisitedDate === null || lastVisitedDate < wokeAtDate) && |
| 854 | !changeRequestAutoSettles(pr, { | 855 | thread.settledOverride !== "settled"; |
| 855 | autoSettleOnMerge: props.autoSettleOnMerge, | ||
| 856 | thread, | ||
| 857 | }); | ||
| 858 | // In-flight rows (working, or waiting on approval/input) fade as a whole: | 856 | // In-flight rows (working, or waiting on approval/input) fade as a whole: |
| 859 | // there is nothing for the user to do yet, so prominence is reserved for | 857 | // there is nothing for the user to do yet, so prominence is reserved for |
| 860 | // rows that need a human — done (unread), read-but-unsettled, failed, and | 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 | const { isMobile, setOpenMobile } = useSidebar(); | 1770 | const { isMobile, setOpenMobile } = useSidebar(); |
| 1773 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | 1771 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 1774 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); | 1772 | const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); |
| 1773 | const autoSettleAfterDays = usePrimarySettings((s) => s.sidebarAutoSettleAfterDays); | ||
| 1774 | const autoSettleOnMerge = usePrimarySettings((s) => s.sidebarAutoSettleOnMerge); | ||
| 1775 | const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); | 1775 | const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); |
| 1776 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); | 1776 | const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); |
| 1777 | const timestampFormat = useClientSettings((s) => s.timestampFormat); | 1777 | const timestampFormat = useClientSettings((s) => s.timestampFormat); |
| @@ -1925,15 +1925,20 @@ export default function Sidebar() { · 147 unmodified lines | |||
| 1925 | () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), | 1925 | () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), |
| 1926 | [sidebarProjectSortOrder, threads, unsortedProjectGroups], | 1926 | [sidebarProjectSortOrder, threads, unsortedProjectGroups], |
| 1927 | ); | 1927 | ); |
| 1928 | const serverProviders = useAtomValue(primaryServerProvidersAtom); | 1928 | const projectGroupsRef = useRef(projectGroups); |
| 1929 | const providerEntryByInstanceId = useMemo( | 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( | 1936 | deriveProviderEntriesByEnvironment( |
| 1932 | deriveProviderInstanceEntries(serverProviders).map( | 1937 | [...serverConfigs].map( |
| 1933 | (entry) => [entry.instanceId as string, entry] as const, | 1938 | ([environmentId, config]) => [environmentId, config.providers] as const, |
| 1934 | ), | 1939 | ), |
| 1935 | ), | 1940 | ), |
| 1936 | [serverProviders], | 1941 | [serverConfigs], |
| 1937 | ); | 1942 | ); |
| 1938 | const projectCwdByKey = useMemo( | 1943 | const projectCwdByKey = useMemo( |
| 1939 | () => | 1944 | () => |
| @@ -2081,11 +2086,8 @@ export default function Sidebar() { · 141 unmodified lines | |||
| 2081 | clearSelection(); | 2086 | clearSelection(); |
| 2082 | }, [clearSelection, projectScopeKey]); | 2087 | }, [clearSelection, projectScopeKey]); |
| 2083 | 2088 | ||
| 2084 | const handleProjectSettings = useCallback( | 2089 | const openProjectSettings = useCallback( |
| 2085 | (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => { | 2090 | (projectGroup: SidebarProjectSnapshot) => { |
| 2086 | event.preventDefault(); | ||
| 2087 | event.stopPropagation(); | ||
| 2088 | dispatchProjectScopeMenu({ type: "project-settings-opened" }); | ||
| 2089 | if (isMobile) { | 2091 | if (isMobile) { |
| 2090 | setOpenMobile(false); | 2092 | setOpenMobile(false); |
| 2091 | } | 2093 | } |
| @@ -2118,7 +2120,6 @@ export default function Sidebar() { · 26 unmodified lines | |||
| 2118 | // the partition works directly off live shells: no archived-snapshot | 2120 | // the partition works directly off live shells: no archived-snapshot |
| 2119 | // merging, no optimistic holds. Archived threads remain hidden here — | 2121 | // merging, no optimistic holds. Archived threads remain hidden here — |
| 2120 | // archive keeps its original "remove from sidebar" meaning. | 2122 | // archive keeps its original "remove from sidebar" meaning. |
| 2121 | const serverConfigs = useAtomValue(environmentServerConfigsAtom); | ||
| 2122 | const { | 2123 | const { |
| 2123 | pinnedThreads, | 2124 | pinnedThreads, |
| 2124 | reorderablePinnedKeys, | 2125 | reorderablePinnedKeys, |
| @@ -2133,6 +2134,7 @@ export default function Sidebar() { · 8 unmodified lines | |||
| 2133 | // memo exactly at the next wake boundary. | 2134 | // memo exactly at the next wake boundary. |
| 2134 | void snoozeWakeTick; | 2135 | void snoozeWakeTick; |
| 2135 | const preciseNow = new Date().toISOString(); | 2136 | const preciseNow = new Date().toISOString(); |
| 2137 | const now = `${nowMinute}:00.000Z`; | ||
| 2136 | // Subagent child threads live in the parent's Agents surface, not the | 2138 | // Subagent child threads live in the parent's Agents surface, not the |
| 2137 | // sidebar roster (v2 models them as real threads with lineage). | 2139 | // sidebar roster (v2 models them as real threads with lineage). |
| 2138 | const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys); | 2140 | const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys); |
| @@ -2209,16 +2211,7 @@ export default function Sidebar() { · 70 unmodified lines | |||
| 2209 | settledThreads: sortSettledThreadsForSidebar(settled), | 2211 | settledThreads: sortSettledThreadsForSidebar(settled), |
| 2210 | snoozeNow: preciseNow, | 2212 | snoozeNow: preciseNow, |
| 2211 | }; | 2213 | }; |
| 2212 | }, [ | 2214 | }, [nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); |
| 2213 | autoSettleAfterDays, | ||
| 2214 | autoSettleOnMerge, | ||
| 2215 | changeRequestSnapshotByKey, | ||
| 2216 | nowMinute, | ||
| 2217 | scopedProjectKeys, | ||
| 2218 | serverConfigs, | ||
| 2219 | snoozeWakeTick, | ||
| 2220 | threads, | ||
| 2221 | ]); | ||
| 2222 | 2215 | ||
| 2223 | const threadSearchInputRef = useRef<HTMLInputElement>(null); | 2216 | const threadSearchInputRef = useRef<HTMLInputElement>(null); |
| 2224 | const [threadSearchQuery, setThreadSearchQuery] = useState(""); | 2217 | const [threadSearchQuery, setThreadSearchQuery] = useState(""); |
| @@ -3788,7 +3781,10 @@ export default function Sidebar() { · 1563 unmodified lines | |||
| 3788 | ) ?? null | 3781 | ) ?? null |
| 3789 | } | 3782 | } |
| 3790 | environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} | 3783 | environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} |
| 3791 | providerEntryByInstanceId={providerEntryByInstanceId} | 3784 | providerEntryByInstanceId={ |
| 3785 | providerEntriesByEnvironment.get(thread.environmentId) ?? | ||
| 3786 | EMPTY_PROVIDER_ENTRIES | ||
| 3787 | } | ||
| 3792 | isHighlighted={activeSearchResultIndex === index} | 3788 | isHighlighted={activeSearchResultIndex === index} |
| 3793 | isRouteActive={routeThreadKey === threadKey} | 3789 | isRouteActive={routeThreadKey === threadKey} |
| 3794 | resultId={`sidebar-thread-search-result-${index}`} | 3790 | resultId={`sidebar-thread-search-result-${index}`} |
| @@ -3894,7 +3890,10 @@ export default function Sidebar() { · 99 unmodified lines | |||
| 3894 | `${thread.environmentId}:${thread.projectId}`, | 3890 | `${thread.environmentId}:${thread.projectId}`, |
| 3895 | ) ?? null | 3891 | ) ?? null |
| 3896 | } | 3892 | } |
| 3897 | providerEntryByInstanceId={providerEntryByInstanceId} | 3893 | providerEntryByInstanceId={ |
| 3894 | providerEntriesByEnvironment.get(thread.environmentId) ?? | ||
| 3895 | EMPTY_PROVIDER_ENTRIES | ||
| 3896 | } | ||
| 3898 | timestampFormat={timestampFormat} | 3897 | timestampFormat={timestampFormat} |
| 3899 | onThreadClick={handleThreadClick} | 3898 | onThreadClick={handleThreadClick} |
| 3900 | onThreadActivate={navigateToThread} | 3899 | onThreadActivate={navigateToThread} |
| @@ -4099,4 +4098,5 @@ export default function Sidebar() { · 198 unmodified lines | |||
| 4099 | <SidebarChromeFooter /> | 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 | submitComposerDraft, | 172 | submitComposerDraft, |
| 173 | } from "./composerSubmission"; | 173 | } from "./composerSubmission"; |
| 174 | import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; | 174 | import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; |
| 175 | import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame"; | ||
| 175 | 176 | ||
| 176 | function ComposerVideoThumbnail({ file }: { file: File }) { | 177 | function ComposerVideoThumbnail({ file }: { file: File }) { |
| 177 | const setVideo = useCallback( | 178 | const setVideo = useCallback( |
| @@ -1580,7 +1580,7 @@ describe("deriveMessagesTimelineRows", () => { · 1579 unmodified lines | |||
| 1580 | "assistant-final-entry", | 1580 | "assistant-final-entry", |
| 1581 | "user-followup-entry", | 1581 | "user-followup-entry", |
| 1582 | "working-indicator-row", | 1582 | "working-indicator-row", |
| 1583 | "thinking-indicator-row", | 1583 | "live-activity-row", |
| 1584 | ]); | 1584 | ]); |
| 1585 | const finalRow = rows.find((row) => row.id === "assistant-final-entry"); | 1585 | const finalRow = rows.find((row) => row.id === "assistant-final-entry"); |
| 1586 | expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); | 1586 | expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true); |
| @@ -1618,14 +1618,15 @@ describe("deriveMessagesTimelineRows", () => { · 31 unmodified lines | |||
| 1618 | revertTurnCountByUserMessageId: new Map(), | 1618 | revertTurnCountByUserMessageId: new Map(), |
| 1619 | }); | 1619 | }); |
| 1620 | 1620 | ||
| 1621 | // The settled tool stays visible in past tense instead of vanishing, and | 1621 | // A successfully settled command keeps the shared live-activity row alive |
| 1622 | // the thinking indicator marks that the turn itself is still running. | 1622 | // between actions (#9098) instead of dropping to past tense, so no extra |
| 1623 | expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "thinking"]); | 1623 | // thinking row appears while the turn is still running. |
| 1624 | expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); | ||
| 1624 | expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ | 1625 | expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ |
| 1625 | active: false, | 1626 | active: true, |
| 1627 | id: "live-activity-row", | ||
| 1626 | entry: { id: "work-1" }, | 1628 | entry: { id: "work-1" }, |
| 1627 | }); | 1629 | }); |
| 1628 | expect(rows.at(-1)).toMatchObject({ kind: "thinking" }); | ||
| 1629 | }); | 1630 | }); |
| 1630 | 1631 | ||
| 1631 | it("does not fold the active in-progress turn", () => { | 1632 | it("does not fold the active in-progress turn", () => { |
| @@ -1675,7 +1676,7 @@ describe("deriveMessagesTimelineRows", () => { · 43 unmodified lines | |||
| 1675 | expect(rows.map((row) => row.id)).toEqual([ | 1676 | expect(rows.map((row) => row.id)).toEqual([ |
| 1676 | "working-indicator-row", | 1677 | "working-indicator-row", |
| 1677 | "assistant-thought-entry", | 1678 | "assistant-thought-entry", |
| 1678 | "work-live:work-entry-1", | 1679 | "live-activity-row", |
| 1679 | ]); | 1680 | ]); |
| 1680 | const liveRow = rows.find((row) => row.kind === "work-live"); | 1681 | const liveRow = rows.find((row) => row.kind === "work-live"); |
| 1681 | expect(liveRow?.kind === "work-live" ? liveRow.entry.id : null).toBe("work-1"); | 1682 | expect(liveRow?.kind === "work-live" ? liveRow.entry.id : null).toBe("work-1"); |
| @@ -1948,8 +1949,8 @@ describe("computeStableMessagesTimelineRows", () => { · 266 unmodified lines | |||
| 1948 | initial, | 1949 | initial, |
| 1949 | ); | 1950 | ); |
| 1950 | 1951 | ||
| 1951 | const initialThinking = initial.byId.get("thinking-indicator-row"); | 1952 | const initialThinking = initial.byId.get("live-activity-row"); |
| 1952 | const updatedThinking = updated.byId.get("thinking-indicator-row"); | 1953 | const updatedThinking = updated.byId.get("live-activity-row"); |
| 1953 | expect(initialThinking).toMatchObject({ kind: "thinking" }); | 1954 | expect(initialThinking).toMatchObject({ kind: "thinking" }); |
| 1954 | expect(updatedThinking).toBe(initialThinking); | 1955 | expect(updatedThinking).toBe(initialThinking); |
| 1955 | expect(updated.result.at(-1)).toBe(updatedThinking); | 1956 | expect(updated.result.at(-1)).toBe(updatedThinking); |
| @@ -1,9 +1,6 @@ | |||
| 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 { | 3 | import { |
| 4 | normalizeCompactToolLabel, | ||
| 5 | omitSupersededLifecycleMarkers, | ||
| 6 | resolveWorkEntryToolPresentation, | ||
| 7 | summarizeToolGroup, | 4 | summarizeToolGroup, |
| 8 | toolGroupSummaryKind, | 5 | toolGroupSummaryKind, |
| 9 | type ToolGroupSummaryKind, | 6 | type ToolGroupSummaryKind, |
| @@ -18,6 +15,7 @@ import { · 8 unmodified lines | |||
| 18 | formatDuration, | 15 | formatDuration, |
| 19 | timelineEntryIsPersistentResourceCard, | 16 | timelineEntryIsPersistentResourceCard, |
| 20 | workEntryDisplayIndicatesToolFailure, | 17 | workEntryDisplayIndicatesToolFailure, |
| 18 | workEntryIndicatesToolSuccess, | ||
| 21 | workEntryIndicatesToolNeutralStatus, | 19 | workEntryIndicatesToolNeutralStatus, |
| 22 | workLogEntryIsToolLike, | 20 | workLogEntryIsToolLike, |
| 23 | type TimelineEntry, | 21 | type TimelineEntry, |
| @@ -337,200 +335,6 @@ export function computeMessageDurationStart( · 313 unmodified lines | |||
| 337 | return result; | 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 | function workGroupId(timelineEntryId: string): string { | 338 | function workGroupId(timelineEntryId: string): string { |
| 535 | return `work-group:${timelineEntryId}`; | 339 | return `work-group:${timelineEntryId}`; |
| 536 | } | 340 | } |
| @@ -911,21 +715,26 @@ export function deriveMessagesTimelineRows(input: { · 374 unmodified lines | |||
| 911 | const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => | 715 | const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) => |
| 912 | workEntryIsActiveTurnActivity(entry.entry), | 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 | const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; | 722 | const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; |
| 916 | const activeWorkRow = | 723 | const activeWorkRow = |
| 917 | activeWorkAnchor && displayedToolEntry | 724 | activeWorkAnchor && latestVisibleToolEntry |
| 918 | ? (() => { | 725 | ? (() => { |
| 919 | const groupId = workGroupId(activeWorkAnchor.id); | 726 | const groupId = workGroupId(activeWorkAnchor.id); |
| 920 | return { | 727 | return { |
| 921 | kind: "work-live" as const, | 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 | createdAt: activeWorkAnchor.createdAt, | 732 | createdAt: activeWorkAnchor.createdAt, |
| 924 | entry: displayedToolEntry.entry, | 733 | entry: (latestRunningToolEntry ?? latestVisibleToolEntry).entry, |
| 925 | groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), | 734 | groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry), |
| 926 | groupId, | 735 | groupId, |
| 927 | expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, | 736 | expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, |
| 928 | active: latestRunningToolEntry !== undefined, | 737 | active: latestToolKeepsActivityLive, |
| 929 | }; | 738 | }; |
| 930 | })() | 739 | })() |
| 931 | : null; | 740 | : null; |
| @@ -939,11 +748,11 @@ export function deriveMessagesTimelineRows(input: { · 7 unmodified lines | |||
| 939 | createdAt: input.activeTurnStartedAt ?? null, | 748 | createdAt: input.activeTurnStartedAt ?? null, |
| 940 | }); | 749 | }); |
| 941 | }; | 750 | }; |
| 942 | let hasLiveWorkRow = false; | 751 | let hasActivityRow = false; |
| 943 | const appendActiveWorkRows = () => { | 752 | const appendActiveWorkRows = () => { |
| 944 | if (activeWorkRow === null) return; | 753 | if (activeWorkRow === null) return; |
| 945 | nextRows.push(activeWorkRow); | 754 | nextRows.push(activeWorkRow); |
| 946 | hasLiveWorkRow ||= activeWorkRow.active; | 755 | hasActivityRow ||= activeWorkRow.active; |
| 947 | if (!activeWorkRow.expanded) return; | 756 | if (!activeWorkRow.expanded) return; |
| 948 | for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { | 757 | for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) { |
| 949 | nextRows.push({ | 758 | nextRows.push({ |
| @@ -1059,7 +868,6 @@ export function deriveMessagesTimelineRows(input: { · 109 unmodified lines | |||
| 1059 | const groupId = workGroupId(timelineEntry.id); | 868 | const groupId = workGroupId(timelineEntry.id); |
| 1060 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; | 869 | const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false; |
| 1061 | const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!; | 870 | const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!; |
| 1062 | hasLiveWorkRow = true; | ||
| 1063 | nextRows.push({ | 871 | nextRows.push({ |
| 1064 | kind: "work-live", | 872 | kind: "work-live", |
| 1065 | id: `work-live:${timelineEntry.id}`, | 873 | id: `work-live:${timelineEntry.id}`, |
| @@ -1070,6 +878,7 @@ export function deriveMessagesTimelineRows(input: { · 4 unmodified lines | |||
| 1070 | expanded, | 878 | expanded, |
| 1071 | active: true, | 879 | active: true, |
| 1072 | }); | 880 | }); |
| 881 | hasActivityRow = true; | ||
| 1073 | if (expanded) { | 882 | if (expanded) { |
| 1074 | for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { | 883 | for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) { |
| 1075 | nextRows.push({ | 884 | nextRows.push({ |
| @@ -1192,10 +1001,10 @@ export function deriveMessagesTimelineRows(input: { · 116 unmodified lines | |||
| 1192 | if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { | 1001 | if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) { |
| 1193 | appendWorkingRow(); | 1002 | appendWorkingRow(); |
| 1194 | } | 1003 | } |
| 1195 | if (input.isWorking && !hasLiveWorkRow) { | 1004 | if (input.isWorking && !hasActivityRow) { |
| 1196 | nextRows.push({ | 1005 | nextRows.push({ |
| 1197 | kind: "thinking", | 1006 | kind: "thinking", |
| 1198 | id: "thinking-indicator-row", | 1007 | id: LIVE_ACTIVITY_ROW_ID, |
| 1199 | createdAt: input.activeTurnStartedAt ?? null, | 1008 | createdAt: input.activeTurnStartedAt ?? null, |
| 1200 | }); | 1009 | }); |
| 1201 | } | 1010 | } |
| @@ -64,6 +64,7 @@ import { · 63 unmodified lines | |||
| 64 | import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; | 64 | import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown"; |
| 65 | import { | 65 | import { |
| 66 | BotIcon, | 66 | BotIcon, |
| 67 | BrainIcon, | ||
| 67 | CheckIcon, | 68 | CheckIcon, |
| 68 | ChevronDownIcon, | 69 | ChevronDownIcon, |
| 69 | ChevronRightIcon, | 70 | ChevronRightIcon, |
| @@ -2092,7 +2093,7 @@ function ThinkingTimelineRow() { · 2022 unmodified lines | |||
| 2092 | // Reserve the activity row during setup so the handoff keeps the same height. | 2093 | // Reserve the activity row during setup so the handoff keeps the same height. |
| 2093 | return ( | 2094 | return ( |
| 2094 | <div className="min-h-7"> | 2095 | <div className="min-h-7"> |
| 2095 | {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />} | 2096 | {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" iconName="brain" />} |
| 2096 | </div> | 2097 | </div> |
| 2097 | ); | 2098 | ); |
| 2098 | } | 2099 | } |
| @@ -2778,6 +2779,7 @@ function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { · 679 unmodified lines | |||
| 2778 | 2779 | ||
| 2779 | type WorkEntryIconName = | 2780 | type WorkEntryIconName = |
| 2780 | | "bot" | 2781 | | "bot" |
| 2782 | | "brain" | ||
| 2781 | | "check" | 2783 | | "check" |
| 2782 | | "circle-alert" | 2784 | | "circle-alert" |
| 2783 | | "eye" | 2785 | | "eye" |
| @@ -2795,6 +2797,8 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN · 11 unmodified lines | |||
| 2795 | switch (name) { | 2797 | switch (name) { |
| 2796 | case "bot": | 2798 | case "bot": |
| 2797 | return <BotIcon className={className} aria-hidden />; | 2799 | return <BotIcon className={className} aria-hidden />; |
| 2800 | case "brain": | ||
| 2801 | return <BrainIcon className={className} aria-hidden />; | ||
| 2798 | case "check": | 2802 | case "check": |
| 2799 | return <CheckIcon className={className} aria-hidden />; | 2803 | return <CheckIcon className={className} aria-hidden />; |
| 2800 | case "circle-alert": | 2804 | case "circle-alert": |
| @@ -2849,8 +2853,8 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { · 48 unmodified lines | |||
| 2849 | } | 2853 | } |
| 2850 | if (tone === "thinking") { | 2854 | if (tone === "thinking") { |
| 2851 | return { | 2855 | return { |
| 2852 | iconName: "bot", | 2856 | iconName: "brain", |
| 2853 | className: "text-foreground/92", | 2857 | className: "text-foreground", |
| 2854 | }; | 2858 | }; |
| 2855 | } | 2859 | } |
| 2856 | if (tone === "info") { | 2860 | if (tone === "info") { |
| @@ -203,6 +203,7 @@ describe("external chat link context menu", () => { · 202 unmodified lines | |||
| 203 | "https://example.com/docs?topic=security#links", | 203 | "https://example.com/docs?topic=security#links", |
| 204 | ], | 204 | ], |
| 205 | ["HTTP://EXAMPLE.COM", "http://example.com/"], | 205 | ["HTTP://EXAMPLE.COM", "http://example.com/"], |
| 206 | ["//example.com/path", "https://example.com/path"], | ||
| 206 | ])("resolves the safe external web-link href for %s as %s", (href, expected) => { | 207 | ])("resolves the safe external web-link href for %s as %s", (href, expected) => { |
| 207 | expect(resolveExternalWebLinkHref(href)).toBe(expected); | 208 | expect(resolveExternalWebLinkHref(href)).toBe(expected); |
| 208 | }); | 209 | }); |
| @@ -213,7 +214,6 @@ describe("external chat link context menu", () => { · 4 unmodified lines | |||
| 213 | "data:text/html,<script>alert(document.domain)</script>", | 214 | "data:text/html,<script>alert(document.domain)</script>", |
| 214 | "file:///tmp/example.txt", | 215 | "file:///tmp/example.txt", |
| 215 | "mailto:hello@example.com", | 216 | "mailto:hello@example.com", |
| 216 | "//example.com/path", | ||
| 217 | "/relative/path", | 217 | "/relative/path", |
| 218 | "not a URL", | 218 | "not a URL", |
| 219 | "", | 219 | "", |
| @@ -2254,31 +2254,8 @@ export function GeneralSettingsPanel() { · 2253 unmodified lines | |||
| 2254 | /> | 2254 | /> |
| 2255 | } | 2255 | } |
| 2256 | /> | 2256 | /> |
| 2257 | ) : null | 2257 | ) : null} |
| 2258 | } | 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 | /> | ||
| 2282 | ) : null} | 2259 | ) : null} |
| 2283 | </SettingsSection> | 2260 | </SettingsSection> |
| 2284 | 2261 | ||
| @@ -147,6 +147,7 @@ describe("searchSettings", () => { · 146 unmodified lines | |||
| 147 | hasProviderSettingsEnvironment: false, | 147 | hasProviderSettingsEnvironment: false, |
| 148 | canManageLocalBackend: false, | 148 | canManageLocalBackend: false, |
| 149 | isWslSettingsRowVisible: false, | 149 | isWslSettingsRowVisible: false, |
| 150 | hasThreadAutoSettlement: false, | ||
| 150 | }); | 151 | }); |
| 151 | 152 | ||
| 152 | const gatedIds = new Set<string>([ | 153 | const gatedIds = new Set<string>([ |
| @@ -160,10 +161,30 @@ describe("searchSettings", () => { · 7 unmodified lines | |||
| 160 | "t3-connect", | 161 | "t3-connect", |
| 161 | "tailscale-https", | 162 | "tailscale-https", |
| 162 | "wsl-backend", | 163 | "wsl-backend", |
| 164 | "auto-settle-inactive-threads", | ||
| 165 | "auto-settle-merged-threads", | ||
| 166 | "days-before-auto-settle", | ||
| 163 | ]); | 167 | ]); |
| 164 | expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]); | 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 | it("keeps catalog result ids unique", () => { | 188 | it("keeps catalog result ids unique", () => { |
| 168 | const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); | 189 | const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id); |
| 169 | expect(new Set(ids).size).toBe(ids.length); | 190 | expect(new Set(ids).size).toBe(ids.length); |
| @@ -31,6 +31,7 @@ export interface SettingsSearchItem { · 30 unmodified lines | |||
| 31 | readonly providerSettingsOnly?: boolean; | 31 | readonly providerSettingsOnly?: boolean; |
| 32 | readonly localBackendManagementOnly?: boolean; | 32 | readonly localBackendManagementOnly?: boolean; |
| 33 | readonly wslAvailableOnly?: boolean; | 33 | readonly wslAvailableOnly?: boolean; |
| 34 | readonly requiresThreadAutoSettlement?: boolean; | ||
| 34 | } | 35 | } |
| 35 | 36 | ||
| 36 | export interface SettingsSearchAvailability { | 37 | export interface SettingsSearchAvailability { |
| @@ -39,6 +40,7 @@ export interface SettingsSearchAvailability { · 2 unmodified lines | |||
| 39 | readonly hasProviderSettingsEnvironment: boolean; | 40 | readonly hasProviderSettingsEnvironment: boolean; |
| 40 | readonly canManageLocalBackend: boolean; | 41 | readonly canManageLocalBackend: boolean; |
| 41 | readonly isWslSettingsRowVisible: boolean; | 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 | title: "Auto-settle inactive threads", | 157 | title: "Auto-settle inactive threads", |
| 156 | to: "/settings/general", | 158 | to: "/settings/general", |
| 157 | searchTerms: ["sidebar inactivity days no activity automatically"], | 159 | searchTerms: ["sidebar inactivity days no activity automatically"], |
| 160 | requiresThreadAutoSettlement: true, | ||
| 158 | }, | 161 | }, |
| 159 | { | 162 | { |
| 160 | id: "auto-settle-merged-threads", | 163 | id: "auto-settle-merged-threads", |
| 161 | title: "Auto-settle merged threads", | 164 | title: "Auto-settle merged threads", |
| 162 | to: "/settings/general", | 165 | to: "/settings/general", |
| 163 | searchTerms: ["pull request merge closed automatically sidebar"], | 166 | searchTerms: ["pull request merge closed automatically sidebar"], |
| 167 | requiresThreadAutoSettlement: true, | ||
| 164 | }, | 168 | }, |
| 165 | { | 169 | { |
| 166 | id: "days-before-auto-settle", | 170 | id: "days-before-auto-settle", |
| @@ -168,6 +172,7 @@ export const SETTINGS_SEARCH_ITEMS = [ · 1 unmodified line | |||
| 168 | to: "/settings/general", | 172 | to: "/settings/general", |
| 169 | targetId: "auto-settle-inactive-threads", | 173 | targetId: "auto-settle-inactive-threads", |
| 170 | searchTerms: ["thread timeout activity sidebar"], | 174 | searchTerms: ["thread timeout activity sidebar"], |
| 175 | requiresThreadAutoSettlement: true, | ||
| 171 | }, | 176 | }, |
| 172 | { | 177 | { |
| 173 | id: "time-format", | 178 | id: "time-format", |
| @@ -250,7 +255,7 @@ export const SETTINGS_SEARCH_ITEMS = [ · 76 unmodified lines | |||
| 250 | id: "quit-confirmation", | 255 | id: "quit-confirmation", |
| 251 | title: "Quit shortcut", | 256 | title: "Quit shortcut", |
| 252 | to: "/settings/general", | 257 | to: "/settings/general", |
| 253 | searchTerms: ["confirmation shortcut desktop app exit"], | 258 | searchTerms: ["confirmation desktop app exit direct hold double click press twice"], |
| 254 | desktopOnly: true, | 259 | desktopOnly: true, |
| 255 | }, | 260 | }, |
| 256 | { | 261 | { |
| @@ -491,7 +496,8 @@ export function filterAvailableSettingsSearchItems( · 234 unmodified lines | |||
| 491 | (!item.primaryOnly || availability.hasPrimaryEnvironment) && | 496 | (!item.primaryOnly || availability.hasPrimaryEnvironment) && |
| 492 | (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) && | 497 | (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) && |
| 493 | (!item.localBackendManagementOnly || availability.canManageLocalBackend) && | 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 | import { useMemo } from "react"; | 1 | import { useMemo } from "react"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { AuthAccessWriteScope } from "@t3tools/contracts"; | 3 | import { AuthAccessWriteScope } from "@t3tools/contracts"; |
| 3 | 4 | ||
| 4 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; | 5 | import { hasCloudPublicConfig } from "~/cloud/publicConfig"; |
| @@ -7,6 +8,7 @@ import { desktopWslStateAtom } from "~/state/desktopWslState"; · 2 unmodified lines | |||
| 7 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; | 8 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; |
| 8 | import { useEnvironmentQuery } from "~/state/query"; | 9 | import { useEnvironmentQuery } from "~/state/query"; |
| 9 | import { usePrimarySessionState } from "~/environments/primary"; | 10 | import { usePrimarySessionState } from "~/environments/primary"; |
| 11 | import { primaryServerConfigAtom } from "~/state/server"; | ||
| 10 | import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; | 12 | import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; |
| 11 | import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; | 13 | import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic"; |
| 12 | import { filterAvailableSettingsSearchItems } from "./settingsSearch"; | 14 | import { filterAvailableSettingsSearchItems } from "./settingsSearch"; |
| @@ -15,6 +17,7 @@ export function useAvailableSettingsSearchItems() { · 2 unmodified lines | |||
| 15 | const primaryEnvironmentId = usePrimaryEnvironmentId(); | 17 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| 16 | const { environments } = useEnvironments(); | 18 | const { environments } = useEnvironments(); |
| 17 | const primarySessionState = usePrimarySessionState(); | 19 | const primarySessionState = usePrimarySessionState(); |
| 20 | const primaryServerConfig = useAtomValue(primaryServerConfigAtom); | ||
| 18 | const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); | 21 | const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); |
| 19 | const canManageLocalBackend = | 22 | const canManageLocalBackend = |
| 20 | isElectron || | 23 | isElectron || |
| @@ -38,7 +41,16 @@ export function useAvailableSettingsSearchItems() { · 17 unmodified lines | |||
| 38 | state: desktopWsl.data, | 41 | state: desktopWsl.data, |
| 39 | error: desktopWsl.error, | 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 | } |
| @@ -45,6 +45,7 @@ import { · 44 unmodified lines | |||
| 45 | type TerminalContextDraft, | 45 | type TerminalContextDraft, |
| 46 | ensureInlineTerminalContextPlaceholders, | 46 | ensureInlineTerminalContextPlaceholders, |
| 47 | normalizeTerminalContextText, | 47 | normalizeTerminalContextText, |
| 48 | stripInlineTerminalContextPlaceholders, | ||
| 48 | } from "./lib/terminalContext"; | 49 | } from "./lib/terminalContext"; |
| 49 | import { | 50 | import { |
| 50 | type ElementContextDraft, | 51 | type ElementContextDraft, |
| @@ -660,6 +661,13 @@ interface ComposerDraftStoreState { · 609 unmodified lines | |||
| 660 | * prompt stash. Session-bound context stays in the source draft. | 661 | * prompt stash. Session-bound context stays in the source draft. |
| 661 | */ | 662 | */ |
| 662 | clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void; | 663 | clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void; |
| 664 | /** | ||
| 665 | * Moves prompt text and transferable attachments into another composer. | ||
| 666 | * Attachments over the destination limit and uploaded files that belong to | ||
| 667 | * another environment stay in the source draft. Terminal and element | ||
| 668 | * context, preview annotations, and review comments also stay in the source. | ||
| 669 | */ | ||
| 670 | moveComposerPromptAndImages: (from: ComposerThreadTarget, to: ComposerThreadTarget) => void; | ||
| 663 | } | 671 | } |
| 664 | 672 | ||
| 665 | export interface EffectiveComposerModelState { | 673 | export interface EffectiveComposerModelState { |
| @@ -3900,6 +3908,117 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 3234 unmodified lines | |||
| 3900 | return { draftsByThreadKey: nextDraftsByThreadKey }; | 3908 | return { draftsByThreadKey: nextDraftsByThreadKey }; |
| 3901 | }); | 3909 | }); |
| 3902 | }, | 3910 | }, |
| 3911 | moveComposerPromptAndImages: (from, to) => { | ||
| 3912 | const fromKey = resolveComposerDraftKey(get(), from) ?? ""; | ||
| 3913 | const toKey = resolveComposerDraftKey(get(), to) ?? ""; | ||
| 3914 | if (fromKey.length === 0 || toKey.length === 0 || fromKey === toKey) { | ||
| 3915 | return; | ||
| 3916 | } | ||
| 3917 | set((state) => { | ||
| 3918 | const source = state.draftsByThreadKey[fromKey]; | ||
| 3919 | if (!source) { | ||
| 3920 | return state; | ||
| 3921 | } | ||
| 3922 | const destination = state.draftsByThreadKey[toKey] ?? createEmptyThreadDraft(); | ||
| 3923 | const destinationEnvironmentId = | ||
| 3924 | typeof to === "string" | ||
| 3925 | ? (state.draftThreadsByThreadKey[toKey]?.environmentId ?? | ||
| 3926 | parseScopedThreadKey(toKey)?.environmentId ?? | ||
| 3927 | null) | ||
| 3928 | : to.environmentId; | ||
| 3929 | // A file the destination already holds (same id, or same | ||
| 3930 | // metadata key) stays behind instead of duplicating there. | ||
| 3931 | const destinationFileIds = new Set(destination.files.map((file) => file.id)); | ||
| 3932 | const destinationFileKeys = new Set(destination.files.map(composerFileDedupKey)); | ||
| 3933 | const transferableFiles = source.files.filter( | ||
| 3934 | (file) => | ||
| 3935 | (file.file !== null || file.uploadEnvironmentId === destinationEnvironmentId) && | ||
| 3936 | !destinationFileIds.has(file.id) && | ||
| 3937 | !destinationFileKeys.has(composerFileDedupKey(file)), | ||
| 3938 | ); | ||
| 3939 | const remainingAttachmentSlots = Math.max( | ||
| 3940 | 0, | ||
| 3941 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS - | ||
| 3942 | destination.images.length - | ||
| 3943 | destination.files.length, | ||
| 3944 | ); | ||
| 3945 | const movedImages = source.images.slice(0, remainingAttachmentSlots); | ||
| 3946 | const movedImageIds = new Set(movedImages.map((image) => image.id)); | ||
| 3947 | const retainedImages = source.images.filter((image) => !movedImageIds.has(image.id)); | ||
| 3948 | const movedFiles = transferableFiles | ||
| 3949 | .slice(0, remainingAttachmentSlots - movedImages.length) | ||
| 3950 | .map((file) => { | ||
| 3951 | // A byte-backed file moving across environments re-uploads at | ||
| 3952 | // the destination. Keeping the source-environment upload id | ||
| 3953 | // would mark it uploaded after a reload with no local bytes | ||
| 3954 | // and no valid upload anywhere the destination can reach. The | ||
| 3955 | // upload queue keeps the source upload as fallback, then | ||
| 3956 | // deletes it after the destination upload succeeds. Abandoned | ||
| 3957 | // uploads still expire through the server sweep. | ||
| 3958 | if ( | ||
| 3959 | file.file === null || | ||
| 3960 | file.uploadEnvironmentId === undefined || | ||
| 3961 | file.uploadEnvironmentId === destinationEnvironmentId | ||
| 3962 | ) { | ||
| 3963 | return file; | ||
| 3964 | } | ||
| 3965 | const { uploadedAttachmentId: _a, uploadEnvironmentId: _e, ...rest } = file; | ||
| 3966 | return rest; | ||
| 3967 | }); | ||
| 3968 | const movedFileIds = new Set(movedFiles.map((file) => file.id)); | ||
| 3969 | const retainedFiles = source.files.filter((file) => !movedFileIds.has(file.id)); | ||
| 3970 | // Inline placeholders reference the source's terminal contexts, | ||
| 3971 | // which stay behind; re-anchor the moved prompt to whatever | ||
| 3972 | // contexts the destination already holds. | ||
| 3973 | const movedPrompt = ensureInlineTerminalContextPlaceholders( | ||
| 3974 | stripInlineTerminalContextPlaceholders(source.prompt), | ||
| 3975 | destination.terminalContexts.length, | ||
| 3976 | ); | ||
| 3977 | const nextDestination: ComposerThreadDraftState = { | ||
| 3978 | ...destination, | ||
| 3979 | prompt: movedPrompt, | ||
| 3980 | images: [...destination.images, ...movedImages], | ||
| 3981 | files: [...destination.files, ...movedFiles], | ||
| 3982 | nonPersistedImageIds: [ | ||
| 3983 | ...destination.nonPersistedImageIds, | ||
| 3984 | ...source.nonPersistedImageIds.filter((imageId) => movedImageIds.has(imageId)), | ||
| 3985 | ], | ||
| 3986 | persistedAttachments: [ | ||
| 3987 | ...destination.persistedAttachments, | ||
| 3988 | ...source.persistedAttachments.filter((attachment) => | ||
| 3989 | movedImageIds.has(attachment.id), | ||
| 3990 | ), | ||
| 3991 | ], | ||
| 3992 | }; | ||
| 3993 | // Same clearing shape as clearComposerPromptAndImages, but the | ||
| 3994 | // preview URLs are NOT revoked: the images moved and their blobs | ||
| 3995 | // are still referenced from the destination. | ||
| 3996 | const nextSource: ComposerThreadDraftState = { | ||
| 3997 | ...source, | ||
| 3998 | prompt: ensureInlineTerminalContextPlaceholders("", source.terminalContexts.length), | ||
| 3999 | images: retainedImages, | ||
| 4000 | files: retainedFiles, | ||
| 4001 | nonPersistedImageIds: source.nonPersistedImageIds.filter( | ||
| 4002 | (imageId) => !movedImageIds.has(imageId), | ||
| 4003 | ), | ||
| 4004 | persistedAttachments: source.persistedAttachments.filter( | ||
| 4005 | (attachment) => !movedImageIds.has(attachment.id), | ||
| 4006 | ), | ||
| 4007 | }; | ||
| 4008 | const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; | ||
| 4009 | if (shouldRemoveDraft(nextSource)) { | ||
| 4010 | delete nextDraftsByThreadKey[fromKey]; | ||
| 4011 | } else { | ||
| 4012 | nextDraftsByThreadKey[fromKey] = nextSource; | ||
| 4013 | } | ||
| 4014 | if (shouldRemoveDraft(nextDestination)) { | ||
| 4015 | delete nextDraftsByThreadKey[toKey]; | ||
| 4016 | } else { | ||
| 4017 | nextDraftsByThreadKey[toKey] = nextDestination; | ||
| 4018 | } | ||
| 4019 | return { draftsByThreadKey: nextDraftsByThreadKey }; | ||
| 4020 | }); | ||
| 4021 | }, | ||
| 3903 | }; | 4022 | }; |
| 3904 | }, | 4023 | }, |
| 3905 | { | 4024 | { |
| @@ -23,7 +23,7 @@ import { · 22 unmodified lines | |||
| 23 | selectProjectGroupingSettings, | 23 | selectProjectGroupingSettings, |
| 24 | } from "../logicalProject"; | 24 | } from "../logicalProject"; |
| 25 | import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; | 25 | import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode"; |
| 26 | import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities"; | 26 | import { readProjects, readThreadShell, useProjects, useThreadShell } from "../state/entities"; |
| 27 | import { | 27 | import { |
| 28 | hasExplicitComposerModelSelection, | 28 | hasExplicitComposerModelSelection, |
| 29 | resolveNewDraftStartFromOrigin, | 29 | resolveNewDraftStartFromOrigin, |