History

fix: reconcile main's round-18 features after the rebase

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.

Julius Marminge committed 2 months ago

Commit 7b13bca1 parent 786a25847 files · +1855 −2616Browse files

Showing the changes to apps/web/src (16 of 47 files). Show all

Unified · Split

16 files
  1. apps/web/src/components/ChatMarkdown.workspace-images.test.tsx +64 −57
  2. apps/web/src/components/ChatView.logic.test.ts +2 −2
  3. apps/web/src/components/ChatView.logic.ts +6 −0
  4. apps/web/src/components/ChatView.tsx +151 −91
  5. apps/web/src/components/Sidebar.tsx +30 −30
  6. apps/web/src/components/chat/ChatComposer.tsx +1 −0
  7. apps/web/src/components/chat/MessagesTimeline.logic.test.ts +10 −9
  8. apps/web/src/components/chat/MessagesTimeline.logic.ts +16 −207
  9. apps/web/src/components/chat/MessagesTimeline.tsx +7 −3
  10. apps/web/src/components/chat/externalLinkContextMenu.test.ts +1 −1
  11. apps/web/src/components/settings/SettingsPanels.tsx +2 −25
  12. apps/web/src/components/settings/settingsSearch.test.ts +21 −0
  13. apps/web/src/components/settings/settingsSearch.ts +8 −2
  14. apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +13 −1
  15. apps/web/src/composerDraftStore.ts +119 −0
  16. apps/web/src/hooks/useHandleNewThread.ts +1 −1
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx+64−57
@@ -5,12 +5,11 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; · 4 unmodified lines
55 const testState = vi.hoisted(() => ({
66 resources: [] as Array<unknown>,
77 assetState: "success" as "success" | "loading" | "failure",
8− editorEnvironmentIds: [] as Array<unknown>,
9− remoteEnvironmentIds: [] as Array<unknown>,
108 }));
119
1210 vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
1311 vi.mock("../assets/assetUrls", () => ({
12+ useAssetUrlRefresh: () => vi.fn(),
1413 useAssetUrlState: (_environmentId: unknown, resource: unknown) => {
1514 testState.resources.push(resource);
1615 if (testState.assetState === "loading") return { _tag: "Loading" };
@@ -25,23 +24,23 @@ vi.mock("../state/session", async (importOriginal) => ({ · 8 unmodified lines
2524 ...(await importOriginal<typeof import("../state/session")>()),
2625 usePreparedConnection: () => ({ _tag: "Loading" }),
2726 }));
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: () => [],
3430 }));
3531 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(),
4343 }));
44−vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn() }));
4544
4645 import ChatMarkdown from "./ChatMarkdown";
4746 import { FileMarkdownPreview } from "./files/FileMarkdownPreview";
@@ -93,8 +92,6 @@ describe("ChatMarkdown workspace images", () => { · 45 unmodified lines
9392 beforeEach(() => {
9493 testState.resources = [];
9594 testState.assetState = "success";
96− testState.editorEnvironmentIds = [];
97− testState.remoteEnvironmentIds = [];
9895 });
9996
10097 it.each([
@@ -110,52 +107,55 @@ describe("ChatMarkdown workspace images", () => { · 9 unmodified lines
110107
111108 expect(testState.resources).toEqual([
112109 {
113− _tag: "workspace-file",
110+ _tag: "media-file",
114111 threadId: threadRef.threadId,
115112 path: expectedPath,
116113 },
117114 ]);
118115 });
119116
120− it("loads relative workspace paths through signed asset URLs", () => {
121− const html = render("![relative](.t3/workspace-image.svg)");
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+ "![relative](.t3/workspace-image.svg)",
122+ `![absolute](${imagePath})`,
123+ `![file URL](file:///${imagePath})`,
124+ "![UNC file URL](file://server/share/workspace-image.svg)",
125+ ].join("\n\n"),
126+ );
122127
123128 expect(testState.resources).toEqual([
124129 {
125− _tag: "workspace-file",
130+ _tag: "media-file",
126131 threadId: threadRef.threadId,
127132 path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg",
128133 },
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+ },
129141 ]);
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");
131146 });
132147
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">`);
138150
139− it("loads drive-absolute markdown and raw HTML images through assets", () => {
140− const html = render(
141− [
142− "![absolute](C:/Users/shawn/project/.t3/workspace-image.svg)",
143− String.raw`<img src="D:\screens\workspace-image.svg" alt="raw">`,
144− ].join("\n\n"),
145− );
146151 expect(testState.resources).toEqual([
147152 {
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",
154154 threadId: threadRef.threadId,
155155 path: "D:/screens/workspace-image.svg",
156156 },
157157 ]);
158− expect(html.match(/https:\/\/signed\.test\/workspace-image\.svg/g)).toHaveLength(2);
158+ expect(html).toContain("https://signed.test/workspace-image.svg");
159159 });
160160
161161 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
261261
262262 it("uses a static bounded-width placeholder while a signed asset URL loads", () => {
263263 testState.assetState = "loading";
264+
264265 const html = render("![loading](.t3/workspace-image.svg)");
265266 const className = /<span[^>]*aria-label="Loading image"[^>]*class="([^"]*)"/.exec(html)?.[1];
266267
@@ -269,25 +270,31 @@ describe("ChatMarkdown workspace images", () => { · 2 unmodified lines
269270 expect(className?.split(" ")).toContain("w-64");
270271 });
271272
272− it("keeps remote images directly loadable", () => {
273− const html = render("![remote](https://example.com/image.png)");
273+ it("never passes a workspace source to a raw image when thread context is unavailable", () => {
274+ const html = renderWithoutThread(
275+ "![file URL](file:///C:/Users/shawn/project/workspace-image.svg)",
276+ );
277+
274278 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://");
277281 });
278282
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("![unsupported](content://media/image/1)");
281285
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+ });
289290
290− expect(testState.editorEnvironmentIds).toEqual([owningEnvironmentId]);
291− expect(testState.remoteEnvironmentIds).toEqual([owningEnvironmentId]);
291+ it("keeps remote images directly loadable", () => {
292+ const html = render("![remote](https://example.com/image.png)");
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");
292299 });
293300 });
apps/web/src/components/ChatView.logic.test.ts+2−2
@@ -24,10 +24,10 @@ import { · 23 unmodified lines
2424 deriveComposerSendState,
2525 dismissBranchMismatchForSession,
2626 getStartedThreadModelChangeBlockReason,
27+ isVideoPreviewRequestCurrent,
28+ hasEnvironmentReconnectWarningGraceElapsed,
2729 hasServerAcknowledgedLocalDispatch,
2830 isBranchMismatchDismissedForSession,
29− isVideoPreviewRequestCurrent,
30− loadVideoPreviewUrl,
3131 reconcileMountedTerminalThreadIds,
3232 reconcileRetainedMountedThreadIds,
3333 resolveDraftPromotionNavigationTarget,
apps/web/src/components/ChatView.logic.ts+6−0
@@ -17,6 +17,12 @@ import { · 16 unmodified lines
1717 type RunId,
1818 } from "@t3tools/contracts";
1919 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";
2026 import {
2127 appendCodexArtifactTemplateUsePrompt,
2228 codexArtifactTemplateUsePrompt,
apps/web/src/components/ChatView.tsx+151−91
@@ -39,11 +39,6 @@ import { · 38 unmodified lines
3939 threadWokeAt,
4040 } from "@t3tools/client-runtime/state/thread-settled";
4141 import { useThreadActions } from "../hooks/useThreadActions";
42−import {
43− resolveDisplayedThreadPr,
44− threadChangeRequestSnapshotsAtom,
45− useLinkedThreadPullRequest,
46−} from "./ThreadStatusIndicators";
4742 import {
4843 deriveThreadActivityRun,
4944 deriveLatestThreadRun,
@@ -74,7 +69,12 @@ import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; · 24 unmodified lines
7469 import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
7570 import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
7671 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";
7878 import { Debouncer } from "@tanstack/react-pacer";
7979 import { useAtomValue } from "@effect/atom-react";
8080 import {
@@ -325,8 +325,14 @@ import { · 244 unmodified lines
325325 ThreadErrorBanner,
326326 } from "./chat/ThreadErrorBanner";
327327 import { resolveThreadPr } from "./ThreadStatusIndicators";
328−import { type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
329328 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";
330336 import { ComposerSurface } from "./chat/ComposerSurface";
331337 import { resolveThreadSyncPhase } from "../threadSync";
332338 import {
@@ -1338,6 +1344,7 @@ function ChatViewContent(props: ChatViewProps) { · 1005 unmodified lines
13381344 });
13391345 const createAttachmentAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, {
13401346 reportFailure: false,
1347+ refresh: true,
13411348 });
13421349 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, {
13431350 reportFailure: false,
@@ -1907,7 +1914,7 @@ function ChatViewContent(props: ChatViewProps) { · 563 unmodified lines
19071914 >({});
19081915 const activePullRequestSurfaceId =
19091916 activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined;
1910− const handlePullRequestTabStatusChange = useCallback(
1917+ const updatePullRequestTabStatusFromPanel = useCallback(
19111918 (status: PullRequestTabStatus) => {
19121919 const id = activePullRequestSurfaceId;
19131920 if (id === undefined) return;
@@ -1915,6 +1922,8 @@ function ChatViewContent(props: ChatViewProps) { · 1 unmodified line
19151922 },
19161923 [activePullRequestSurfaceId],
19171924 );
1925+ const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false });
1926+ const sidebarPrRefreshKeyRef = useRef<string | null>(null);
19181927 const activeFileSurface =
19191928 activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null;
19201929 const activePreviewState = useThreadPreviewState(activeThreadRef);
@@ -2758,14 +2767,8 @@ function ChatViewContent(props: ChatViewProps) { · 837 unmodified lines
27582767 toastManager.add({ type: "error", title: "The environment is not connected." });
27592768 return;
27602769 }
2761− const videoMime = videoMimeType(attachment);
2762− const isVideo = videoMime !== null;
2770+ const isVideo = videoMimeType(attachment) !== null;
27632771 const action = isVideo ? "play" : "download";
2764− const videoPreviewAbortController = isVideo ? new AbortController() : null;
2765− if (isVideo) {
2766− videoPreviewAbortControllerRef.current?.abort();
2767− videoPreviewAbortControllerRef.current = videoPreviewAbortController;
2768− }
27692772 const videoPreviewRequestId = isVideo ? ++videoPreviewRequestIdRef.current : 0;
27702773 const isCurrentRequest = () =>
27712774 !isVideo ||
@@ -2775,75 +2778,37 @@ function ChatViewContent(props: ChatViewProps) { · 3 unmodified lines
27752778 videoPreviewRequestId,
27762779 videoPreviewRequestIdRef.current,
27772780 );
2778− const finishVideoPreviewRequest = () => {
2779− if (videoPreviewRequestIdRef.current === videoPreviewRequestId) {
2780− setOpeningVideoAttachmentId(null);
2781− videoPreviewAbortControllerRef.current = null;
2782− }
2783− };
27842781 if (isVideo) setOpeningVideoAttachmentId(attachment.id);
27852782
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,
28102789 });
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) {
28272792 setExpandedImage({
2828− images: [{ src: previewUrl, name: attachment.name, type: "video" }],
2793+ images: [{ src: url, name: attachment.name, type: "video" }],
28292794 index: 0,
28302795 });
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;
28402797 }
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);
28422811 }
2843− const anchor = document.createElement("a");
2844− anchor.href = url;
2845− anchor.download = attachment.name;
2846− anchor.click();
28472812 },
28482813 [createAttachmentAssetUrl, environmentId, routeThreadKey],
28492814 );
@@ -4885,8 +4850,14 @@ function ChatViewContent(props: ChatViewProps) { · 2035 unmodified lines
48854850 );
48864851 // The server-projected settled state keeps the banner and sidebar in sync.
48874852 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+ );
48904861 const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom);
48914862 // The thread's own change request, placed against the project it belongs to.
48924863 const linkedThreadPullRequest = activeThread?.linkedPullRequest ?? null;
@@ -4902,6 +4873,97 @@ function ChatViewContent(props: ChatViewProps) { · 9 unmodified lines
49024873 linkedPullRequest: linkedThreadPullRequest,
49034874 linkedPullRequestStatus,
49044875 });
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]);
49054967 // The right panel offers the thread's own change request, so it can only offer it once the
49064968 // branch has one; until then the picker says so rather than opening an empty panel.
49074969 const addPullRequestSurface = useCallback(() => {
@@ -4950,21 +5012,13 @@ function ChatViewContent(props: ChatViewProps) { · 42 unmodified lines
49505012 if (activeThreadRef === null || activeThreadWokeAt === null) return;
49515013 markThreadVisited(scopedThreadKey(activeThreadRef), activeThreadWokeAt);
49525014 }, [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.
49555016 const activeThreadLastVisitedAt = useUiStateStore((store) =>
49565017 activeThreadKey === null ? undefined : store.threadLastVisitedAtById[activeThreadKey],
49575018 );
49585019 const activeThreadWokeVisible = useMemo(() => {
49595020 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;
49685022 const wokeAtMs = Date.parse(activeThreadWokeAt);
49695023 if (Number.isNaN(wokeAtMs)) return false;
49705024 // Having the thread open counts as a visit at completedAt (the effect
@@ -4984,7 +5038,6 @@ function ChatViewContent(props: ChatViewProps) { · 13 unmodified lines
49845038 }, [
49855039 activeLatestRun?.completedAt,
49865040 activeThreadLastVisitedAt,
4987− activeThreadChangeRequest,
49885041 activeThreadShell,
49895042 activeThreadWokeAt,
49905043 autoSettleOnMerge,
@@ -5006,8 +5059,6 @@ function ChatViewContent(props: ChatViewProps) { · 15 unmodified lines
50065059 nowMinute,
50075060 supportsSettlement,
50085061 ]);
5009− const activeThreadSettled =
5010− supportsSettlement && activeThreadShell?.settledOverride === "settled";
50115062 const unsettleThreadMutation = useAtomCommand(threadEnvironment.unsettle, {
50125063 reportFailure: false,
50135064 });
@@ -5394,6 +5445,13 @@ function ChatViewContent(props: ChatViewProps) { · 380 unmodified lines
53945445 });
53955446 if (!command) return;
53965447
5448+ if (command === "thread.copyReference") {
5449+ event.preventDefault();
5450+ event.stopPropagation();
5451+ if (!event.repeat) copyActiveThreadReference();
5452+ return;
5453+ }
5454+
53975455 if (command === "thread.settle") {
53985456 event.preventDefault();
53995457 event.stopPropagation();
@@ -5561,6 +5619,7 @@ function ChatViewContent(props: ChatViewProps) { · 161 unmodified lines
55615619 supportsPinning,
55625620 supportsSettlement,
55635621 confirmAndUnpinThread,
5622+ copyActiveThreadReference,
55645623 toggleRightPanel,
55655624 toggleThreadPanel,
55665625 toggleTerminalVisibility,
@@ -7512,6 +7571,7 @@ function ChatViewContent(props: ChatViewProps) { · 1945 unmodified lines
75127571 }
75137572 >
75147573 <DraftHeroHeadline
7574+ draftId={draftId}
75157575 activeProjectRef={activeProjectRef}
75167576 activeProjectTitle={activeProject?.title ?? null}
75177577 />
apps/web/src/components/Sidebar.tsx+30−30
@@ -19,6 +19,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- · 18 unmodified lines
1919 import { CSS } from "@dnd-kit/utilities";
2020 import {
2121 canSnooze,
22+ effectiveSettled,
2223 effectiveSnoozed,
2324 threadWokeAt,
2425 } from "@t3tools/client-runtime/state/thread-settled";
@@ -99,7 +100,7 @@ import { useThreadActions } from "../hooks/useThreadActions"; · 74 unmodified lines
99100 import { useHandleNewThread } from "../hooks/useHandleNewThread";
100101 import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
101102 import { startNewThreadFromContext } from "../lib/chatThreadActions";
102−import { useClientSettings } from "../hooks/useSettings";
103+import { useClientSettings, usePrimarySettings } from "../hooks/useSettings";
103104 import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
104105 import { useLocalStorage } from "../hooks/useLocalStorage";
105106 import { useNowMinute } from "../hooks/useNowMinute";
@@ -171,7 +172,7 @@ import { ProjectFavicon } from "./ProjectFavicon"; · 65 unmodified lines
171172 import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
172173 import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
173174 import {
174− deriveProviderInstanceEntries,
175+ deriveProviderEntriesByEnvironment,
175176 shouldShowInstanceBadge,
176177 type ProviderInstanceEntry,
177178 } from "../providerInstances";
@@ -851,10 +852,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 673 unmodified lines
851852 const isWoke =
852853 wokeAtDate !== null &&
853854 (lastVisitedDate === null || lastVisitedDate < wokeAtDate) &&
854− !changeRequestAutoSettles(pr, {
855− autoSettleOnMerge: props.autoSettleOnMerge,
856− thread,
857− });
855+ thread.settledOverride !== "settled";
858856 // In-flight rows (working, or waiting on approval/input) fade as a whole:
859857 // there is nothing for the user to do yet, so prominence is reserved for
860858 // rows that need a human — done (unread), read-but-unsettled, failed, and
@@ -1772,6 +1770,8 @@ export default function Sidebar() { · 911 unmodified lines
17721770 const { isMobile, setOpenMobile } = useSidebar();
17731771 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
17741772 const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
1773+ const autoSettleAfterDays = usePrimarySettings((s) => s.sidebarAutoSettleAfterDays);
1774+ const autoSettleOnMerge = usePrimarySettings((s) => s.sidebarAutoSettleOnMerge);
17751775 const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
17761776 const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder);
17771777 const timestampFormat = useClientSettings((s) => s.timestampFormat);
@@ -1925,15 +1925,20 @@ export default function Sidebar() { · 147 unmodified lines
19251925 () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder),
19261926 [sidebarProjectSortOrder, threads, unsortedProjectGroups],
19271927 );
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(
19301935 () =>
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,
19341939 ),
19351940 ),
1936− [serverProviders],
1941+ [serverConfigs],
19371942 );
19381943 const projectCwdByKey = useMemo(
19391944 () =>
@@ -2081,11 +2086,8 @@ export default function Sidebar() { · 141 unmodified lines
20812086 clearSelection();
20822087 }, [clearSelection, projectScopeKey]);
20832088
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) => {
20892091 if (isMobile) {
20902092 setOpenMobile(false);
20912093 }
@@ -2118,7 +2120,6 @@ export default function Sidebar() { · 26 unmodified lines
21182120 // the partition works directly off live shells: no archived-snapshot
21192121 // merging, no optimistic holds. Archived threads remain hidden here —
21202122 // archive keeps its original "remove from sidebar" meaning.
2121− const serverConfigs = useAtomValue(environmentServerConfigsAtom);
21222123 const {
21232124 pinnedThreads,
21242125 reorderablePinnedKeys,
@@ -2133,6 +2134,7 @@ export default function Sidebar() { · 8 unmodified lines
21332134 // memo exactly at the next wake boundary.
21342135 void snoozeWakeTick;
21352136 const preciseNow = new Date().toISOString();
2137+ const now = `${nowMinute}:00.000Z`;
21362138 // Subagent child threads live in the parent's Agents surface, not the
21372139 // sidebar roster (v2 models them as real threads with lineage).
21382140 const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys);
@@ -2209,16 +2211,7 @@ export default function Sidebar() { · 70 unmodified lines
22092211 settledThreads: sortSettledThreadsForSidebar(settled),
22102212 snoozeNow: preciseNow,
22112213 };
2212− }, [
2213− autoSettleAfterDays,
2214− autoSettleOnMerge,
2215− changeRequestSnapshotByKey,
2216− nowMinute,
2217− scopedProjectKeys,
2218− serverConfigs,
2219− snoozeWakeTick,
2220− threads,
2221− ]);
2214+ }, [nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]);
22222215
22232216 const threadSearchInputRef = useRef<HTMLInputElement>(null);
22242217 const [threadSearchQuery, setThreadSearchQuery] = useState("");
@@ -3788,7 +3781,10 @@ export default function Sidebar() { · 1563 unmodified lines
37883781 ) ?? null
37893782 }
37903783 environmentLabel={environmentLabelById.get(thread.environmentId) ?? null}
3791− providerEntryByInstanceId={providerEntryByInstanceId}
3784+ providerEntryByInstanceId={
3785+ providerEntriesByEnvironment.get(thread.environmentId) ??
3786+ EMPTY_PROVIDER_ENTRIES
3787+ }
37923788 isHighlighted={activeSearchResultIndex === index}
37933789 isRouteActive={routeThreadKey === threadKey}
37943790 resultId={`sidebar-thread-search-result-${index}`}
@@ -3894,7 +3890,10 @@ export default function Sidebar() { · 99 unmodified lines
38943890 `${thread.environmentId}:${thread.projectId}`,
38953891 ) ?? null
38963892 }
3897− providerEntryByInstanceId={providerEntryByInstanceId}
3893+ providerEntryByInstanceId={
3894+ providerEntriesByEnvironment.get(thread.environmentId) ??
3895+ EMPTY_PROVIDER_ENTRIES
3896+ }
38983897 timestampFormat={timestampFormat}
38993898 onThreadClick={handleThreadClick}
39003899 onThreadActivate={navigateToThread}
@@ -4099,4 +4098,5 @@ export default function Sidebar() { · 198 unmodified lines
40994098 <SidebarChromeFooter />
41004099 </>
41014100 );
4101+ const EMPTY_PROVIDER_ENTRIES: ReadonlyMap<string, ProviderInstanceEntry> = new Map();
41024102 }
apps/web/src/components/chat/ChatComposer.tsx+1−0
@@ -172,6 +172,7 @@ import { · 171 unmodified lines
172172 submitComposerDraft,
173173 } from "./composerSubmission";
174174 import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";
175+import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
175176
176177 function ComposerVideoThumbnail({ file }: { file: File }) {
177178 const setVideo = useCallback(
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+10−9
@@ -1580,7 +1580,7 @@ describe("deriveMessagesTimelineRows", () => { · 1579 unmodified lines
15801580 "assistant-final-entry",
15811581 "user-followup-entry",
15821582 "working-indicator-row",
1583− "thinking-indicator-row",
1583+ "live-activity-row",
15841584 ]);
15851585 const finalRow = rows.find((row) => row.id === "assistant-final-entry");
15861586 expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true);
@@ -1618,14 +1618,15 @@ describe("deriveMessagesTimelineRows", () => { · 31 unmodified lines
16181618 revertTurnCountByUserMessageId: new Map(),
16191619 });
16201620
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"]);
16241625 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
1625− active: false,
1626+ active: true,
1627+ id: "live-activity-row",
16261628 entry: { id: "work-1" },
16271629 });
1628− expect(rows.at(-1)).toMatchObject({ kind: "thinking" });
16291630 });
16301631
16311632 it("does not fold the active in-progress turn", () => {
@@ -1675,7 +1676,7 @@ describe("deriveMessagesTimelineRows", () => { · 43 unmodified lines
16751676 expect(rows.map((row) => row.id)).toEqual([
16761677 "working-indicator-row",
16771678 "assistant-thought-entry",
1678− "work-live:work-entry-1",
1679+ "live-activity-row",
16791680 ]);
16801681 const liveRow = rows.find((row) => row.kind === "work-live");
16811682 expect(liveRow?.kind === "work-live" ? liveRow.entry.id : null).toBe("work-1");
@@ -1948,8 +1949,8 @@ describe("computeStableMessagesTimelineRows", () => { · 266 unmodified lines
19481949 initial,
19491950 );
19501951
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");
19531954 expect(initialThinking).toMatchObject({ kind: "thinking" });
19541955 expect(updatedThinking).toBe(initialThinking);
19551956 expect(updated.result.at(-1)).toBe(updatedThinking);
apps/web/src/components/chat/MessagesTimeline.logic.ts+16−207
@@ -1,9 +1,6 @@
11 import * as Equal from "effect/Equal";
22 import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
33 import {
4− normalizeCompactToolLabel,
5− omitSupersededLifecycleMarkers,
6− resolveWorkEntryToolPresentation,
74 summarizeToolGroup,
85 toolGroupSummaryKind,
96 type ToolGroupSummaryKind,
@@ -18,6 +15,7 @@ import { · 8 unmodified lines
1815 formatDuration,
1916 timelineEntryIsPersistentResourceCard,
2017 workEntryDisplayIndicatesToolFailure,
18+ workEntryIndicatesToolSuccess,
2119 workEntryIndicatesToolNeutralStatus,
2220 workLogEntryIsToolLike,
2321 type TimelineEntry,
@@ -337,200 +335,6 @@ export function computeMessageDurationStart( · 313 unmodified lines
337335 return result;
338336 }
339337
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−
534338 function workGroupId(timelineEntryId: string): string {
535339 return `work-group:${timelineEntryId}`;
536340 }
@@ -911,21 +715,26 @@ export function deriveMessagesTimelineRows(input: { · 374 unmodified lines
911715 const latestRunningToolEntry = visibleActiveToolEntries.findLast((entry) =>
912716 workEntryIsActiveTurnActivity(entry.entry),
913717 );
914− const displayedToolEntry = latestRunningToolEntry ?? latestVisibleToolEntry;
718+ const latestToolKeepsActivityLive =
719+ latestRunningToolEntry !== undefined ||
720+ (latestVisibleToolEntry !== undefined &&
721+ workEntryIndicatesToolSuccess(latestVisibleToolEntry.entry));
915722 const activeWorkPlacementEntryId = latestVisibleToolEntry?.id;
916723 const activeWorkRow =
917− activeWorkAnchor && displayedToolEntry
724+ activeWorkAnchor && latestVisibleToolEntry
918725 ? (() => {
919726 const groupId = workGroupId(activeWorkAnchor.id);
920727 return {
921728 kind: "work-live" as const,
922− id: `work-live:${activeWorkAnchor.id}`,
729+ id: latestToolKeepsActivityLive
730+ ? LIVE_ACTIVITY_ROW_ID
731+ : `work-live:${activeWorkAnchor.id}`,
923732 createdAt: activeWorkAnchor.createdAt,
924− entry: displayedToolEntry.entry,
733+ entry: (latestRunningToolEntry ?? latestVisibleToolEntry).entry,
925734 groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry),
926735 groupId,
927736 expanded: input.expandedWorkGroupIds?.has(groupId) ?? false,
928− active: latestRunningToolEntry !== undefined,
737+ active: latestToolKeepsActivityLive,
929738 };
930739 })()
931740 : null;
@@ -939,11 +748,11 @@ export function deriveMessagesTimelineRows(input: { · 7 unmodified lines
939748 createdAt: input.activeTurnStartedAt ?? null,
940749 });
941750 };
942− let hasLiveWorkRow = false;
751+ let hasActivityRow = false;
943752 const appendActiveWorkRows = () => {
944753 if (activeWorkRow === null) return;
945754 nextRows.push(activeWorkRow);
946− hasLiveWorkRow ||= activeWorkRow.active;
755+ hasActivityRow ||= activeWorkRow.active;
947756 if (!activeWorkRow.expanded) return;
948757 for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) {
949758 nextRows.push({
@@ -1059,7 +868,6 @@ export function deriveMessagesTimelineRows(input: { · 109 unmodified lines
1059868 const groupId = workGroupId(timelineEntry.id);
1060869 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
1061870 const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!;
1062− hasLiveWorkRow = true;
1063871 nextRows.push({
1064872 kind: "work-live",
1065873 id: `work-live:${timelineEntry.id}`,
@@ -1070,6 +878,7 @@ export function deriveMessagesTimelineRows(input: { · 4 unmodified lines
1070878 expanded,
1071879 active: true,
1072880 });
881+ hasActivityRow = true;
1073882 if (expanded) {
1074883 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
1075884 nextRows.push({
@@ -1192,10 +1001,10 @@ export function deriveMessagesTimelineRows(input: { · 116 unmodified lines
11921001 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
11931002 appendWorkingRow();
11941003 }
1195− if (input.isWorking && !hasLiveWorkRow) {
1004+ if (input.isWorking && !hasActivityRow) {
11961005 nextRows.push({
11971006 kind: "thinking",
1198− id: "thinking-indicator-row",
1007+ id: LIVE_ACTIVITY_ROW_ID,
11991008 createdAt: input.activeTurnStartedAt ?? null,
12001009 });
12011010 }
apps/web/src/components/chat/MessagesTimeline.tsx+7−3
@@ -64,6 +64,7 @@ import { · 63 unmodified lines
6464 import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";
6565 import {
6666 BotIcon,
67+ BrainIcon,
6768 CheckIcon,
6869 ChevronDownIcon,
6970 ChevronRightIcon,
@@ -2092,7 +2093,7 @@ function ThinkingTimelineRow() { · 2022 unmodified lines
20922093 // Reserve the activity row during setup so the handoff keeps the same height.
20932094 return (
20942095 <div className="min-h-7">
2095− {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" />}
2096+ {isPreparingWorktree ? null : <LiveActivityRow label="Thinking" iconName="brain" />}
20962097 </div>
20972098 );
20982099 }
@@ -2778,6 +2779,7 @@ function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { · 679 unmodified lines
27782779
27792780 type WorkEntryIconName =
27802781 | "bot"
2782+ | "brain"
27812783 | "check"
27822784 | "circle-alert"
27832785 | "eye"
@@ -2795,6 +2797,8 @@ function WorkEntryIconSvg({ name, className }: { name: WorkEntryIconName; classN · 11 unmodified lines
27952797 switch (name) {
27962798 case "bot":
27972799 return <BotIcon className={className} aria-hidden />;
2800+ case "brain":
2801+ return <BrainIcon className={className} aria-hidden />;
27982802 case "check":
27992803 return <CheckIcon className={className} aria-hidden />;
28002804 case "circle-alert":
@@ -2849,8 +2853,8 @@ function workToneIcon(tone: TimelineWorkEntry["tone"]): { · 48 unmodified lines
28492853 }
28502854 if (tone === "thinking") {
28512855 return {
2852− iconName: "bot",
2853− className: "text-foreground/92",
2856+ iconName: "brain",
2857+ className: "text-foreground",
28542858 };
28552859 }
28562860 if (tone === "info") {
apps/web/src/components/chat/externalLinkContextMenu.test.ts+1−1
@@ -203,6 +203,7 @@ describe("external chat link context menu", () => { · 202 unmodified lines
203203 "https://example.com/docs?topic=security#links",
204204 ],
205205 ["HTTP://EXAMPLE.COM", "http://example.com/"],
206+ ["//example.com/path", "https://example.com/path"],
206207 ])("resolves the safe external web-link href for %s as %s", (href, expected) => {
207208 expect(resolveExternalWebLinkHref(href)).toBe(expected);
208209 });
@@ -213,7 +214,6 @@ describe("external chat link context menu", () => { · 4 unmodified lines
213214 "data:text/html,<script>alert(document.domain)</script>",
214215 "file:///tmp/example.txt",
215216 "mailto:hello@example.com",
216− "//example.com/path",
217217 "/relative/path",
218218 "not a URL",
219219 "",
apps/web/src/components/settings/SettingsPanels.tsx+2−25
@@ -2254,31 +2254,8 @@ export function GeneralSettingsPanel() { · 2253 unmodified lines
22542254 />
22552255 }
22562256 />
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+ </>
22822259 ) : null}
22832260 </SettingsSection>
22842261
apps/web/src/components/settings/settingsSearch.test.ts+21−0
@@ -147,6 +147,7 @@ describe("searchSettings", () => { · 146 unmodified lines
147147 hasProviderSettingsEnvironment: false,
148148 canManageLocalBackend: false,
149149 isWslSettingsRowVisible: false,
150+ hasThreadAutoSettlement: false,
150151 });
151152
152153 const gatedIds = new Set<string>([
@@ -160,10 +161,30 @@ describe("searchSettings", () => { · 7 unmodified lines
160161 "t3-connect",
161162 "tailscale-https",
162163 "wsl-backend",
164+ "auto-settle-inactive-threads",
165+ "auto-settle-merged-threads",
166+ "days-before-auto-settle",
163167 ]);
164168 expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]);
165169 });
166170
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+
167188 it("keeps catalog result ids unique", () => {
168189 const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id);
169190 expect(new Set(ids).size).toBe(ids.length);
apps/web/src/components/settings/settingsSearch.ts+8−2
@@ -31,6 +31,7 @@ export interface SettingsSearchItem { · 30 unmodified lines
3131 readonly providerSettingsOnly?: boolean;
3232 readonly localBackendManagementOnly?: boolean;
3333 readonly wslAvailableOnly?: boolean;
34+ readonly requiresThreadAutoSettlement?: boolean;
3435 }
3536
3637 export interface SettingsSearchAvailability {
@@ -39,6 +40,7 @@ export interface SettingsSearchAvailability { · 2 unmodified lines
3940 readonly hasProviderSettingsEnvironment: boolean;
4041 readonly canManageLocalBackend: boolean;
4142 readonly isWslSettingsRowVisible: boolean;
43+ readonly hasThreadAutoSettlement: boolean;
4244 }
4345
4446 /**
@@ -155,12 +157,14 @@ export const SETTINGS_SEARCH_ITEMS = [ · 110 unmodified lines
155157 title: "Auto-settle inactive threads",
156158 to: "/settings/general",
157159 searchTerms: ["sidebar inactivity days no activity automatically"],
160+ requiresThreadAutoSettlement: true,
158161 },
159162 {
160163 id: "auto-settle-merged-threads",
161164 title: "Auto-settle merged threads",
162165 to: "/settings/general",
163166 searchTerms: ["pull request merge closed automatically sidebar"],
167+ requiresThreadAutoSettlement: true,
164168 },
165169 {
166170 id: "days-before-auto-settle",
@@ -168,6 +172,7 @@ export const SETTINGS_SEARCH_ITEMS = [ · 1 unmodified line
168172 to: "/settings/general",
169173 targetId: "auto-settle-inactive-threads",
170174 searchTerms: ["thread timeout activity sidebar"],
175+ requiresThreadAutoSettlement: true,
171176 },
172177 {
173178 id: "time-format",
@@ -250,7 +255,7 @@ export const SETTINGS_SEARCH_ITEMS = [ · 76 unmodified lines
250255 id: "quit-confirmation",
251256 title: "Quit shortcut",
252257 to: "/settings/general",
253− searchTerms: ["confirmation shortcut desktop app exit"],
258+ searchTerms: ["confirmation desktop app exit direct hold double click press twice"],
254259 desktopOnly: true,
255260 },
256261 {
@@ -491,7 +496,8 @@ export function filterAvailableSettingsSearchItems( · 234 unmodified lines
491496 (!item.primaryOnly || availability.hasPrimaryEnvironment) &&
492497 (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) &&
493498 (!item.localBackendManagementOnly || availability.canManageLocalBackend) &&
494− (!item.wslAvailableOnly || availability.isWslSettingsRowVisible),
499+ (!item.wslAvailableOnly || availability.isWslSettingsRowVisible) &&
500+ (!item.requiresThreadAutoSettlement || availability.hasThreadAutoSettlement),
495501 );
496502 }
497503
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+13−1
@@ -1,4 +1,5 @@
11 import { useMemo } from "react";
2+import { useAtomValue } from "@effect/atom-react";
23 import { AuthAccessWriteScope } from "@t3tools/contracts";
34
45 import { hasCloudPublicConfig } from "~/cloud/publicConfig";
@@ -7,6 +8,7 @@ import { desktopWslStateAtom } from "~/state/desktopWslState"; · 2 unmodified lines
78 import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
89 import { useEnvironmentQuery } from "~/state/query";
910 import { usePrimarySessionState } from "~/environments/primary";
11+import { primaryServerConfigAtom } from "~/state/server";
1012 import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
1113 import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";
1214 import { filterAvailableSettingsSearchItems } from "./settingsSearch";
@@ -15,6 +17,7 @@ export function useAvailableSettingsSearchItems() { · 2 unmodified lines
1517 const primaryEnvironmentId = usePrimaryEnvironmentId();
1618 const { environments } = useEnvironments();
1719 const primarySessionState = usePrimarySessionState();
20+ const primaryServerConfig = useAtomValue(primaryServerConfigAtom);
1821 const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null);
1922 const canManageLocalBackend =
2023 isElectron ||
@@ -38,7 +41,16 @@ export function useAvailableSettingsSearchItems() { · 17 unmodified lines
3841 state: desktopWsl.data,
3942 error: desktopWsl.error,
4043 }),
44+ hasThreadAutoSettlement:
45+ primaryServerConfig?.environment.capabilities.threadAutoSettlement === true,
4146 }),
42− [canManageLocalBackend, desktopWsl.data, desktopWsl.error, environments, primaryEnvironmentId],
47+ [
48+ canManageLocalBackend,
49+ desktopWsl.data,
50+ desktopWsl.error,
51+ environments,
52+ primaryEnvironmentId,
53+ primaryServerConfig,
54+ ],
4355 );
4456 }
apps/web/src/composerDraftStore.ts+119−0
@@ -45,6 +45,7 @@ import { · 44 unmodified lines
4545 type TerminalContextDraft,
4646 ensureInlineTerminalContextPlaceholders,
4747 normalizeTerminalContextText,
48+ stripInlineTerminalContextPlaceholders,
4849 } from "./lib/terminalContext";
4950 import {
5051 type ElementContextDraft,
@@ -660,6 +661,13 @@ interface ComposerDraftStoreState { · 609 unmodified lines
660661 * prompt stash. Session-bound context stays in the source draft.
661662 */
662663 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;
663671 }
664672
665673 export interface EffectiveComposerModelState {
@@ -3900,6 +3908,117 @@ const composerDraftStore = create<ComposerDraftStoreState>()( · 3234 unmodified lines
39003908 return { draftsByThreadKey: nextDraftsByThreadKey };
39013909 });
39023910 },
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+ },
39034022 };
39044023 },
39054024 {
apps/web/src/hooks/useHandleNewThread.ts+1−1
@@ -23,7 +23,7 @@ import { · 22 unmodified lines
2323 selectProjectGroupingSettings,
2424 } from "../logicalProject";
2525 import { resolveDefaultThreadEnvMode } from "@t3tools/shared/threadEnvMode";
26−import { readProjects, readThreadShell, useProjects, useThread } from "../state/entities";
26+import { readProjects, readThreadShell, useProjects, useThreadShell } from "../state/entities";
2727 import {
2828 hasExplicitComposerModelSelection,
2929 resolveNewDraftStartFromOrigin,