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/components (14 of 47 files). Show all

Unified · Split

14 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
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
5const testState = vi.hoisted(() => ({5const 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
12vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));10vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null }));
13vi.mock("../assets/assetUrls", () => ({11vi.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}));
28vi.mock("../editorPreferences", () => ({27vi.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}));
35vi.mock("../remoteOpen", () => ({31vi.mock("../remoteOpen", () => ({
36 useRemoteOpenResolution: (environmentId: unknown) => {32 useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }),
37 testState.remoteEnvironmentIds.push(environmentId);33}));
38 return {34vi.mock("../editorPreferences", () => ({
39 isResolved: true,35 useOpenInPreferredEditor: () => vi.fn(),
40 state: { mode: "local-exec", localExecutionUnavailableReason: null },36 usePreferredEditor: () => [null, vi.fn()],
41 };37}));
42 },38vi.mock("~/lib/openPullRequestLink", () => ({
39 findProjectForChangeRequest: () => undefined,
40 matchesLinkedPullRequestUrl: () => false,
41 parseChangeRequestUrl: () => null,
42 useOpenChangeRequestLink: () => vi.fn(),
43}));43}));
44vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn() }));
45 44
46import ChatMarkdown from "./ChatMarkdown";45import ChatMarkdown from "./ChatMarkdown";
47import { FileMarkdownPreview } from "./files/FileMarkdownPreview";46import { 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("![relative](.t3/workspace-image.svg)");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 );
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 "![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 );
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("![loading](.t3/workspace-image.svg)");265 const html = render("![loading](.t3/workspace-image.svg)");
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("![remote](https://example.com/image.png)");274 const html = renderWithoutThread(
275 "![file URL](file:///C:/Users/shawn/project/workspace-image.svg)",
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("![unsupported](content://media/image/1)");
281 285
282 renderToStaticMarkup(286 expect(testState.resources).toEqual([]);
283 <ChatMarkdown287 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("![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");
292 });299 });
293});300});
apps/web/src/components/ChatView.logic.test.ts+2−2
@@ -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,
apps/web/src/components/ChatView.logic.ts+6−0
@@ -17,6 +17,12 @@ import { · 16 unmodified lines
17 type RunId,17 type RunId,
18} from "@t3tools/contracts";18} from "@t3tools/contracts";
19import * as DateTime from "effect/DateTime";19import * as DateTime from "effect/DateTime";
20import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
21import {
22 squashAtomCommandFailure,
23 type AtomCommandResult,
24} from "@t3tools/client-runtime/state/runtime";
25import { videoMimeType } from "@t3tools/shared/video";
20import {26import {
21 appendCodexArtifactTemplateUsePrompt,27 appendCodexArtifactTemplateUsePrompt,
22 codexArtifactTemplateUsePrompt,28 codexArtifactTemplateUsePrompt,
apps/web/src/components/ChatView.tsx+151−91
@@ -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";
41import { useThreadActions } from "../hooks/useThreadActions";41import { useThreadActions } from "../hooks/useThreadActions";
42import {
43 resolveDisplayedThreadPr,
44 threadChangeRequestSnapshotsAtom,
45 useLinkedThreadPullRequest,
46} from "./ThreadStatusIndicators";
47import {42import {
48 deriveThreadActivityRun,43 deriveThreadActivityRun,
49 deriveLatestThreadRun,44 deriveLatestThreadRun,
@@ -74,7 +69,12 @@ import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; · 24 unmodified lines
74import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";69import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
75import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";70import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
76import { truncate } from "@t3tools/shared/String";71import { truncate } from "@t3tools/shared/String";
77import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";72import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference";
73import {
74 getTerminalLabel,
75 nextTerminalId,
76 resolveTerminalSessionLabel,
77} from "@t3tools/shared/terminalLabels";
78import { Debouncer } from "@tanstack/react-pacer";78import { Debouncer } from "@tanstack/react-pacer";
79import { useAtomValue } from "@effect/atom-react";79import { useAtomValue } from "@effect/atom-react";
80import {80import {
@@ -325,8 +325,14 @@ import { · 244 unmodified lines
325 ThreadErrorBanner,325 ThreadErrorBanner,
326} from "./chat/ThreadErrorBanner";326} from "./chat/ThreadErrorBanner";
327import { resolveThreadPr } from "./ThreadStatusIndicators";327import { resolveThreadPr } from "./ThreadStatusIndicators";
328import { type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
329import { QueuedRunsControl, type EditQueuedRunRequest } from "./chat/QueuedRunsControl";328import { QueuedRunsControl, type EditQueuedRunRequest } from "./chat/QueuedRunsControl";
329import {
330 resolveDisplayedThreadPr,
331 threadPullRequestRefreshSource,
332 threadChangeRequestSnapshotsAtom,
333 useLinkedThreadPullRequest,
334} from "./ThreadStatusIndicators";
335import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
330import { ComposerSurface } from "./chat/ComposerSurface";336import { ComposerSurface } from "./chat/ComposerSurface";
331import { resolveThreadSyncPhase } from "../threadSync";337import { resolveThreadSyncPhase } from "../threadSync";
332import {338import {
@@ -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 render2783 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 the4967 // 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 same5015 // 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 effect5024 // 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 <DraftHeroHeadline7573 <DraftHeroHeadline
7574 draftId={draftId}
7515 activeProjectRef={activeProjectRef}7575 activeProjectRef={activeProjectRef}
7516 activeProjectTitle={activeProject?.title ?? null}7576 activeProjectTitle={activeProject?.title ?? null}
7517 />7577 />
apps/web/src/components/Sidebar.tsx+30−30
@@ -19,6 +19,7 @@ import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd- · 18 unmodified lines
19import { CSS } from "@dnd-kit/utilities";19import { CSS } from "@dnd-kit/utilities";
20import {20import {
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
99import { useHandleNewThread } from "../hooks/useHandleNewThread";100import { useHandleNewThread } from "../hooks/useHandleNewThread";
100import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";101import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
101import { startNewThreadFromContext } from "../lib/chatThreadActions";102import { startNewThreadFromContext } from "../lib/chatThreadActions";
102import { useClientSettings } from "../hooks/useSettings";103import { useClientSettings, usePrimarySettings } from "../hooks/useSettings";
103import { useCopyToClipboard } from "../hooks/useCopyToClipboard";104import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
104import { useLocalStorage } from "../hooks/useLocalStorage";105import { useLocalStorage } from "../hooks/useLocalStorage";
105import { useNowMinute } from "../hooks/useNowMinute";106import { useNowMinute } from "../hooks/useNowMinute";
@@ -171,7 +172,7 @@ import { ProjectFavicon } from "./ProjectFavicon"; · 65 unmodified lines
171import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";172import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon";
172import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";173import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils";
173import {174import {
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 for857 // 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, and858 // 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-snapshot2120 // 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 the2138 // 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 ) ?? null3781 ) ?? 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 ) ?? null3891 ) ?? 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}
apps/web/src/components/chat/ChatComposer.tsx+1−0
@@ -172,6 +172,7 @@ import { · 171 unmodified lines
172 submitComposerDraft,172 submitComposerDraft,
173} from "./composerSubmission";173} from "./composerSubmission";
174import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";174import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation";
175import { prepareVideoFirstFrame } from "../../lib/videoFirstFrame";
175 176
176function ComposerVideoThumbnail({ file }: { file: File }) {177function ComposerVideoThumbnail({ file }: { file: File }) {
177 const setVideo = useCallback(178 const setVideo = useCallback(
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+10−9
@@ -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, and1621 // 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);
apps/web/src/components/chat/MessagesTimeline.logic.ts+16−207
@@ -1,9 +1,6 @@
1import * as Equal from "effect/Equal";1import * as Equal from "effect/Equal";
2import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";2import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
3import {3import {
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
340export function normalizeCompactToolLabel(value: string): string {
341 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
342}
343
344type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other" | "update";
345export type ToolGroupSummaryKind =
346 | ToolGroupAction
347 | "dynamic-tool"
348 | "agent-tool"
349 | "tone-tool"
350 | "mixed";
351
352export 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
360export 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
378function 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
396function 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
415function 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
437function 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. */
459export 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
515function 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
534function workGroupId(timelineEntryId: string): string {338function 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 && displayedToolEntry724 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 }
apps/web/src/components/chat/MessagesTimeline.tsx+7−3
@@ -64,6 +64,7 @@ import { · 63 unmodified lines
64import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";64import ChatMarkdown, { ChatMarkdownAssetImage } from "../ChatMarkdown";
65import {65import {
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
2779type WorkEntryIconName =2780type 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") {
apps/web/src/components/chat/externalLinkContextMenu.test.ts+1−1
@@ -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 "",
apps/web/src/components/settings/SettingsPanels.tsx+2−25
@@ -2254,31 +2254,8 @@ export function GeneralSettingsPanel() { · 2253 unmodified lines
2254 />2254 />
2255 }2255 }
2256 />2256 />
2257 ) : null2257 ) : 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
apps/web/src/components/settings/settingsSearch.test.ts+21−0
@@ -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);
apps/web/src/components/settings/settingsSearch.ts+8−2
@@ -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
36export interface SettingsSearchAvailability {37export 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
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+13−1
@@ -1,4 +1,5 @@
1import { useMemo } from "react";1import { useMemo } from "react";
2import { useAtomValue } from "@effect/atom-react";
2import { AuthAccessWriteScope } from "@t3tools/contracts";3import { AuthAccessWriteScope } from "@t3tools/contracts";
3 4
4import { hasCloudPublicConfig } from "~/cloud/publicConfig";5import { hasCloudPublicConfig } from "~/cloud/publicConfig";
@@ -7,6 +8,7 @@ import { desktopWslStateAtom } from "~/state/desktopWslState"; · 2 unmodified lines
7import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";8import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
8import { useEnvironmentQuery } from "~/state/query";9import { useEnvironmentQuery } from "~/state/query";
9import { usePrimarySessionState } from "~/environments/primary";10import { usePrimarySessionState } from "~/environments/primary";
11import { primaryServerConfigAtom } from "~/state/server";
10import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";12import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
11import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";13import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";
12import { filterAvailableSettingsSearchItems } from "./settingsSearch";14import { 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}