From 77e35c561259733d880ab62a43aad0894d301d9b Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:22:06 +0300 Subject: [PATCH 01/14] fix(web): send cited messages with Cmd+Enter (#9307) --- .../src/components/ComposerCitationNode.tsx | 8 +++++++- .../src/components/ComposerPromptEditor.tsx | 7 ++++++- .../components/chat/AssistantCitationChip.tsx | 10 ++++++++++ .../chat/AssistantCitationCommentEditor.tsx | 18 ++++++++++++++++-- apps/web/src/components/chat/ChatComposer.tsx | 10 ++++++++++ 5 files changed, 49 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ComposerCitationNode.tsx b/apps/web/src/components/ComposerCitationNode.tsx index b54a08173106..ffaae589343f 100644 --- a/apps/web/src/components/ComposerCitationNode.tsx +++ b/apps/web/src/components/ComposerCitationNode.tsx @@ -47,7 +47,8 @@ export type ComposerCitationCommentTarget = { export const ComposerCitationCommentContext = createContext<{ openComment: ComposerCitationCommentTarget | null; onOpenChange: (nodeKey: NodeKey, open: boolean) => void; -}>({ openComment: null, onOpenChange: () => {} }); + onSubmitAndSend: () => void; +}>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} }); /** Consume a cite action once its controlled prompt has been committed to the editor. */ export function $consumeComposerCitationCommentRequest(requestRef: { @@ -127,6 +128,11 @@ function ComposerCitationDecorator(props: { citation: AssistantCitation; nodeKey commentContext.onOpenChange(props.nodeKey, open); }, onSave: onSaveComment, + onSaveAndSend: (comment) => { + if (!onSaveComment(comment)) return false; + commentContext.onSubmitAndSend(); + return true; + }, }} onRemove={onRemove} /> diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 1676b4f01e7d..695c5e3d0858 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -924,6 +924,7 @@ interface ComposerPromptEditorProps { onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; onPageScrollKeyUp?: (key: string) => void; onPageScrollRelease?: () => void; + onCitationSubmitAndSend?: () => void; onPaste: React.ClipboardEventHandler; editorRef: React.RefObject; } @@ -1571,6 +1572,7 @@ function ComposerPromptEditorInner({ onPageScrollKeyDown, onPageScrollKeyUp, onPageScrollRelease, + onCitationSubmitAndSend, onPaste, editorRef, }: ComposerPromptEditorProps) { @@ -1603,8 +1605,9 @@ function ComposerPromptEditorInner({ open ? { nodeKey } : current?.nodeKey === nodeKey ? null : current, ); }, + onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), }), - [openCitationComment], + [onCitationSubmitAndSend, openCitationComment], ); const terminalContextActions = useMemo( () => ({ onRemoveTerminalContext }), @@ -1969,6 +1972,7 @@ export function ComposerPromptEditor({ onPageScrollKeyDown, onPageScrollKeyUp, onPageScrollRelease, + onCitationSubmitAndSend, onPaste, editorRef, }: ComposerPromptEditorProps) { @@ -2013,6 +2017,7 @@ export function ComposerPromptEditor({ onChange={onChange} {...(onVisibleSelectionChange ? { onVisibleSelectionChange } : {})} onPaste={onPaste} + {...(onCitationSubmitAndSend ? { onCitationSubmitAndSend } : {})} editorRef={editorRef} {...(onCommandKeyDown ? { onCommandKeyDown } : {})} {...(onPageScrollKeyDown ? { onPageScrollKeyDown } : {})} diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 4afaefe3f489..7582bb19c5c6 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -42,6 +42,7 @@ export function AssistantCitationChip({ sourceAnchor?: AssistantCitationSourceAnchor | undefined; onOpenChange: (open: boolean) => void; onSave: (comment: string) => boolean; + onSaveAndSend?: (comment: string) => boolean; }; }) { const navigate = useNavigate(); @@ -158,6 +159,15 @@ export function AssistantCitationChip({ commentEditor.onOpenChange(false); return true; }} + {...(commentEditor.onSaveAndSend + ? { + onSubmitAndSend: (comment: string) => { + if (!commentEditor.onSaveAndSend?.(comment)) return false; + commentEditor.onOpenChange(false); + return true; + }, + } + : {})} onCancel={() => commentEditor.onOpenChange(false)} /> diff --git a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx index 3968d4568655..4dc422210de0 100644 --- a/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx +++ b/apps/web/src/components/chat/AssistantCitationCommentEditor.tsx @@ -7,11 +7,13 @@ export function AssistantCitationCommentEditor({ citation, inputRef, onSubmit, + onSubmitAndSend, onCancel, }: { citation: AssistantCitation; inputRef?: Ref; onSubmit: (comment: string) => boolean; + onSubmitAndSend?: (comment: string) => boolean; onCancel: () => void; }) { const [comment, setComment] = useState(citation.comment ?? ""); @@ -19,6 +21,14 @@ export function AssistantCitationCommentEditor({ const submit = () => { if (!commentTooLong) onSubmit(comment); }; + const submitAndSend = () => { + if (commentTooLong) return; + if (onSubmitAndSend) { + onSubmitAndSend(comment); + } else { + onSubmit(comment); + } + }; return (
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 93ae287c702a..b24fb16c523b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2824,6 +2824,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) shouldBlurMobileComposerOnSubmit, ], ); + const submitCitationAndSend = useCallback(() => { + const intent = composerSubmissionIntentForEnter({ + isMobileViewport, + shiftKey: false, + modifierKey: true, + isDraftThread: routeKind === "draft", + }); + submitComposer(undefined, intent ?? "foreground"); + }, [isMobileViewport, routeKind, submitComposer]); const compactThreadContext = useCallback(() => { if ( compactDisabled || @@ -5246,6 +5255,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPageScrollKeyDown={onPageScrollKeyDown} onPageScrollKeyUp={onPageScrollKeyUp} onPageScrollRelease={onPageScrollRelease} + onCitationSubmitAndSend={submitCitationAndSend} onPaste={onComposerPaste} placeholder={ isComposerApprovalState From 098bf5329727fcd7d973bf842e6b4d50d6e7b924 Mon Sep 17 00:00:00 2001 From: Yukun Shan <92423096+nateEc@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:32:40 +0800 Subject: [PATCH 02/14] fix(web): preserve explicit preview navigation URLs (#8902) --- .../src/browser/browserTargetResolver.test.ts | 46 ++++++++++++++----- apps/web/src/browser/browserTargetResolver.ts | 42 ++++++----------- 2 files changed, 49 insertions(+), 39 deletions(-) diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index cbce157f9a05..c2b3432402ed 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -25,7 +25,7 @@ describe("browser target resolver", () => { }); }); - it("maps localhost URL navigation onto a remote Tailscale IPv4 host", async () => { + it("preserves explicit loopback URL navigation for a remote Tailscale environment", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( @@ -35,13 +35,29 @@ describe("browser target resolver", () => { }), ).toEqual({ requestedUrl: "http://localhost:5173/dashboard?mode=test#results", - resolvedUrl: "http://100.65.180.100:5173/dashboard?mode=test#results", - resolutionKind: "direct-private-network", + resolvedUrl: "http://localhost:5173/dashboard?mode=test#results", + resolutionKind: "direct", environmentId: "environment-1", }); }); - it("preserves URL credentials when mapping localhost onto a remote host", async () => { + it("preserves explicit IPv4 loopback URL navigation for a private network environment", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.50:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://127.0.0.1:5999/", + }), + ).toEqual({ + requestedUrl: "http://127.0.0.1:5999/", + resolvedUrl: "http://127.0.0.1:5999/", + resolutionKind: "direct", + environmentId: "environment-1", + }); + }); + + it("preserves URL credentials on explicit loopback navigation", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( @@ -49,10 +65,10 @@ describe("browser target resolver", () => { kind: "url", url: "http://user:p%40ss@localhost:5173/dashboard", }).resolvedUrl, - ).toBe("http://user:p%40ss@100.65.180.100:5173/dashboard"); + ).toBe("http://user:p%40ss@localhost:5173/dashboard"); }); - it("maps credentialed localhost URLs onto private IPv6 hosts", async () => { + it("preserves credentialed loopback URLs for private IPv6 environments", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", }); @@ -62,10 +78,10 @@ describe("browser target resolver", () => { kind: "url", url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results", }).resolvedUrl, - ).toBe("http://user:p%40ss@[fd7a:115c:a1e0::53]:5173/dashboard?mode=test#results"); + ).toBe("http://user:p%40ss@localhost:5173/dashboard?mode=test#results"); }); - it("maps schemeless localhost navigation onto a remote environment host", async () => { + it("preserves schemeless localhost navigation for a remote environment", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( @@ -73,7 +89,7 @@ describe("browser target resolver", () => { kind: "url", url: "localhost:3000/app", }).resolvedUrl, - ).toBe("http://192.168.1.25:3000/app"); + ).toBe("localhost:3000/app"); }); it("keeps localhost navigation local for a local environment", async () => { @@ -117,12 +133,12 @@ describe("browser target resolver", () => { port: 5173, }), ).toThrow(/authenticated preview gateway/); - expect(() => + expect( resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { kind: "url", url: "http://localhost:5173", }), - ).toThrow(/authenticated preview gateway/); + ).toMatchObject({ resolvedUrl: "http://localhost:5173", resolutionKind: "direct" }); }); it("normalizes schemeless localhost server-picker values", async () => { @@ -136,6 +152,14 @@ describe("browser target resolver", () => { ).toBe("http://localhost:3000/app"); }); + it("maps discovered loopback servers onto a remote environment host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect( + resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:3000/app"), + ).toBe("http://192.168.1.25:3000/app"); + }); + it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 684247e28022..c06c60b5f740 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -207,30 +207,6 @@ export function resolveBrowserNavigationTarget( target: BrowserNavigationTarget, ): PreviewUrlResolution { if (target.kind === "url") { - let parsed: URL | null = null; - try { - parsed = new URL(normalizePreviewUrl(target.url)); - } catch { - // Preserve the existing direct-navigation behavior so the preview host - // reports malformed URL errors through its normal navigation path. - } - if (parsed && isLoopbackHost(parsed.hostname)) { - const environmentUrl = readEnvironmentUrl(environmentId); - if (parsed.hostname === "0.0.0.0" || !isLocalLoopbackHost(environmentUrl.hostname)) { - return resolveEnvironmentPortTarget( - environmentId, - { - kind: "environment-port", - port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)), - protocol: parsed.protocol === "https:" ? "https" : "http", - path: `${parsed.pathname}${parsed.search}${parsed.hash}`, - }, - environmentUrl, - target.url, - parsed, - ); - } - } return { requestedUrl: target.url, resolvedUrl: target.url, @@ -244,10 +220,20 @@ export function resolveBrowserNavigationTarget( export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { try { const normalizedUrl = normalizePreviewUrl(rawUrl); - return resolveBrowserNavigationTarget(environmentId, { - kind: "url", - url: normalizedUrl, - }).resolvedUrl; + const parsed = new URL(normalizedUrl); + if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; + return resolveEnvironmentPortTarget( + environmentId, + { + kind: "environment-port", + port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)), + protocol: parsed.protocol === "https:" ? "https" : "http", + path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + }, + readEnvironmentUrl(environmentId), + rawUrl, + parsed, + ).resolvedUrl; } catch { return rawUrl; } From 57626eb6eaa436b22260068d23f4e3df5f389cd1 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:33:56 -0700 Subject: [PATCH 03/14] fix(web): prevent loading ssh environments from overriding navigation (#9168) --- apps/web/src/hooks/useHandleNewThread.test.ts | 154 ++++++++++++++++++ apps/web/src/hooks/useHandleNewThread.ts | 8 + 2 files changed, 162 insertions(+) create mode 100644 apps/web/src/hooks/useHandleNewThread.test.ts diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts new file mode 100644 index 000000000000..91b757f51e0d --- /dev/null +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +const testState = vi.hoisted(() => { + let completeProjectFileRead: (value: null) => void = () => undefined; + let projectFileRead = Promise.resolve(null); + let storedDraft: { + readonly draftId: string; + readonly environmentId: string; + readonly promotedTo: null; + readonly threadId: string; + } | null = null; + const router = { + state: { + location: { href: "/" }, + matches: [{ params: {} }], + }, + navigate: vi.fn(async (request: { readonly params: { readonly draftId: string } }) => { + router.state.location.href = `/draft/${request.params.draftId}`; + }), + }; + const draftStore = { + getComposerDraft: vi.fn(() => ({})), + getDraftSessionByLogicalProjectKey: vi.fn(() => storedDraft), + getDraftSession: vi.fn(() => null), + getDraftThread: vi.fn(() => null), + applyStickyState: vi.fn(), + setDraftThreadContext: vi.fn(), + setLogicalProjectDraftThreadId: vi.fn(), + setModelSelection: vi.fn(), + }; + + return { + completeProjectFileRead: (value: null) => completeProjectFileRead(value), + draftStore, + get projectFileRead() { + return projectFileRead; + }, + reset(nextStoredDraft: typeof storedDraft) { + storedDraft = nextStoredDraft; + router.state.location.href = "/"; + router.navigate.mockClear(); + draftStore.setLogicalProjectDraftThreadId.mockClear(); + projectFileRead = new Promise((resolve) => { + completeProjectFileRead = resolve; + }); + }, + router, + }; +}); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => ({ defaultThreadEnvMode: "local", newWorktreesStartFromOrigin: false }), +})); +vi.mock("@t3tools/client-runtime/environment", () => ({ + scopedProjectKey: () => "remote-project", + scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }), + scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }), +})); +vi.mock("@t3tools/contracts", () => ({ DEFAULT_RUNTIME_MODE: "default" })); +vi.mock("@t3tools/shared/threadEnvMode", () => ({ + resolveDefaultThreadEnvMode: (input: { + readonly projectFile: "local" | "worktree" | null; + readonly globalDefault: "local" | "worktree"; + }) => input.projectFile ?? input.globalDefault, +})); +vi.mock("@tanstack/react-router", () => ({ + useParams: () => null, + useRouter: () => testState.router, +})); +vi.mock("react", () => ({ + useCallback: (callback: T) => callback, + useMemo: (factory: () => T) => factory(), +})); +vi.mock("../components/Sidebar.logic", () => ({ orderItemsByPreferredIds: () => [] })); +vi.mock("../composerDraftStore", () => { + const useComposerDraftStore = Object.assign(() => null, { + getState: () => testState.draftStore, + }); + return { + composerDraftHasUserContent: () => false, + markPromotedDraftThreadByRef: vi.fn(), + useComposerDraftStore, + }; +}); +vi.mock("../lib/chatThreadActions", () => ({ + hasExplicitComposerModelSelection: () => false, + resolveNewDraftStartFromOrigin: () => false, + resolveNewThreadModelSelectionOverride: () => null, +})); +vi.mock("../lib/t3ProjectFileDefaults", () => ({ + readT3ProjectFileDefaultThreadEnvMode: () => testState.projectFileRead, +})); +vi.mock("../lib/utils", () => ({ + newDraftId: () => "draft-delayed", + newThreadId: () => "thread-delayed", +})); +vi.mock("../logicalProject", () => ({ + deriveLogicalProjectKeyFromSettings: () => "remote-project", + getProjectOrderKey: () => "remote-project", + selectProjectGroupingSettings: () => ({}), +})); +vi.mock("../state/entities", () => ({ + readProjects: () => [ + { + id: "project-remote", + environmentId: "environment-ssh", + workspaceRoot: "/remote/project", + defaultThreadEnvMode: null, + defaultModelSelection: null, + }, + ], + readThreadShell: () => null, + useProjects: () => [], + useThread: () => null, +})); +vi.mock("../state/server", () => ({ primaryServerSettingsAtom: {} })); +vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null })); +vi.mock("../uiStateStore", () => ({ + legacyProjectCwdPreferenceKey: () => "remote-project", + useUiStateStore: () => [], +})); +vi.mock("./useSettings", () => ({ useClientSettings: () => ({}) })); + +import { useNewThreadHandler } from "./useHandleNewThread"; + +describe("useNewThreadHandler", () => { + it.each([ + ["new", null], + [ + "reusable", + { + draftId: "draft-existing", + environmentId: "environment-ssh", + promotedTo: null, + threadId: "thread-existing", + }, + ], + ])("abandons a delayed %s draft open when the user navigates elsewhere", async (_, draft) => { + testState.reset(draft); + const openThread = useNewThreadHandler(); + const pendingOpen = openThread( + { environmentId: "environment-ssh", projectId: "project-remote" } as never, + { replace: true }, + ); + + testState.router.state.location.href = "/usage"; + testState.completeProjectFileRead(null); + await pendingOpen; + + expect(testState.router.state.location.href).toBe("/usage"); + expect(testState.router.navigate).not.toHaveBeenCalled(); + expect(testState.draftStore.setLogicalProjectDraftThreadId).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 0df26f455e04..c26b25d1316b 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -93,6 +93,8 @@ export function useNewThreadHandler() { setLogicalProjectDraftThreadId, setModelSelection, } = useComposerDraftStore.getState(); + const requestingRouteHref = router.state.location.href; + const routeChangedSinceRequest = () => router.state.location.href !== requestingRouteHref; const currentRouteTarget = getCurrentRouteTarget(); // A new thread carries the user's working mode from the thread being // viewed. The target project's configured model still wins; runtime and @@ -219,6 +221,9 @@ export function useNewThreadHandler() { workspaceContext = pickExplicitWorkspaceOptions(options); } else if (!isDraftAlreadyOpen) { const defaultEnvMode = await resolveDefaultEnvMode(); + if (routeChangedSinceRequest()) { + return null; + } // The await yields. If the draft was opened (a concurrent // invocation's navigation landed), promoted to a real thread, // remapped away (a concurrent invocation registered a fresh @@ -355,6 +360,9 @@ export function useNewThreadHandler() { const createdAt = new Date().toISOString(); return (async () => { const initialEnvMode = options?.envMode ?? (await resolveDefaultEnvMode()); + if (routeChangedSinceRequest()) { + return null; + } // The await yields, so a concurrent invocation may have registered a // draft for this logical project in the meantime. Registering ours // too would evict that draft while its navigation is in flight — From cfddb4201df8941bbfde008919da70fe5ec7552b Mon Sep 17 00:00:00 2001 From: Simone Date: Thu, 3 Sep 2026 18:35:25 +0200 Subject: [PATCH 04/14] fix(mobile): skip unsupported shared settings targets (#9381) --- .../features/settings/SettingsRouteScreen.tsx | 21 +++---- apps/web/src/hooks/useSettings.ts | 45 +++++---------- docs/internals/overview.md | 7 ++- docs/user/thread-sidebar.md | 12 ++-- .../src/state/sharedSettings.test.ts | 57 ++++++++++++++++--- .../src/state/sharedSettings.ts | 46 +++++++++++---- 6 files changed, 119 insertions(+), 69 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 58a2779840c3..41c2076ac7b4 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -46,6 +46,7 @@ import { import { findSharedSettingsMismatches, pickSharedServerSettings, + supportsSharedSettingsSync, } from "@t3tools/client-runtime/state/shared-settings"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { @@ -553,9 +554,9 @@ const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterD /** * Auto-settlement is a user preference that every server has to hold. Mobile - * has no primary environment, so the first connected environment that - * supports it is the reference value. Edits fan out to every connected - * environment, and a mismatch row lets the user push the reference out. + * has no primary environment, so the first eligible sync target provides the + * reference value. Edits fan out to every eligible target, and a mismatch row + * lets the user push the reference out. */ function AutoSettleSettingsRows() { const { environments } = useEnvironments(); @@ -564,12 +565,8 @@ function AutoSettleSettingsRows() { reportFailure: true, }); - const connected = environments.filter( - (environment) => - environment.connection.phase === "connected" && - environment.serverConfig?.environment.capabilities.threadAutoSettlement === true, - ); - const reference = connected[0] ?? null; + const syncTargets = environments.filter(supportsSharedSettingsSync); + const reference = syncTargets[0] ?? null; const referenceSettings = reference?.serverConfig?.settings ?? null; const [daysDraft, setDaysDraft] = useState(null); @@ -579,7 +576,7 @@ function AutoSettleSettingsRows() { } const writeToAll = (patch: ServerSettingsPatch) => { - for (const environment of connected) { + for (const environment of syncTargets) { void updateSettings({ environmentId: environment.environmentId, input: { patch } }); } }; @@ -590,7 +587,7 @@ function AutoSettleSettingsRows() { environments: environments.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, - connected: environment.connection.phase === "connected", + syncEligible: supportsSharedSettingsSync(environment), settings: environment.serverConfig?.settings ?? null, })), }); @@ -600,7 +597,7 @@ function AutoSettleSettingsRows() { const draft = (daysDraft ?? "").trim(); setDaysDraft(null); // Whole-string check so "3.5" and "3days" are rejected instead of - // silently becoming 3 on every connected environment. + // silently becoming 3 on every eligible sync target. const parsed = /^\d+$/.test(draft) ? Number(draft) : Number.NaN; if ( Number.isInteger(parsed) && diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 6eb571f70aac..70e16ff33d1a 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -29,6 +29,7 @@ import { findSharedSettingsMismatches, pickSharedServerSettings, splitSharedServerPatch, + supportsSharedSettingsSync, } from "@t3tools/client-runtime/state/shared-settings"; import { ensureLocalApi } from "~/localApi"; import { @@ -42,11 +43,7 @@ import * as Struct from "effect/Struct"; import { toastManager } from "~/components/ui/toast"; import { isHostedStaticApp } from "~/hostedPairing"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; -import { - type EnvironmentPresentation, - useEnvironments, - usePrimaryEnvironment, -} from "~/state/environments"; +import { useEnvironments, usePrimaryEnvironment } from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; import { useTheme } from "./useTheme"; @@ -332,26 +329,14 @@ export function usePrimarySettingsAvailable(): boolean { return primaryEnvironment !== null || !isHostedStaticApp(); } -/** - * Whether an environment can hold every shared key right now. Gated on the - * auto-settlement capability because it is the newest of the shared keys: a - * server that has it has all of them. Older servers drop unknown keys on - * write, so a mismatch against them could never clear, and their decoded - * defaults must not be treated as real values. - */ -function supportsSharedSettings(environment: EnvironmentPresentation): boolean { - return ( - environment.connection.phase === "connected" && - environment.serverConfig?.environment.capabilities.threadAutoSettlement === true - ); -} - /** Environments that can receive a shared settings write right now. */ -function useConnectedEnvironmentIds(): ReadonlyArray { +function useSharedSettingsSyncTargetIds(): ReadonlyArray { const { environments } = useEnvironments(); return useMemo( () => - environments.filter(supportsSharedSettings).map((environment) => environment.environmentId), + environments + .filter(supportsSharedSettingsSync) + .map((environment) => environment.environmentId), [environments], ); } @@ -361,16 +346,16 @@ function useConnectedEnvironmentIds(): ReadonlyArray { * * Server keys are optimistically patched in atom-backed server state, then * persisted via RPC. Shared server keys (see `SHARED_SERVER_SETTING_KEYS`) - * are written to every connected environment, not only the target, so a user - * preference does not silently drift between machines. Client keys go through - * client persistence. + * are written to every eligible sync target, not only the selected target, so + * a user preference does not silently drift between machines. Client keys go + * through client persistence. */ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { const persistServerSettings = useAtomCommand( serverEnvironment.updateSettings, "server settings update", ); - const connectedEnvironmentIds = useConnectedEnvironmentIds(); + const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(); const updateSettings = useCallback( (patch: UnifiedSettingsPatch) => { const { serverPatch, clientPatch } = splitPatch(patch); @@ -395,7 +380,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { } } if (Object.keys(sharedPatch).length > 0) { - const targets = new Set(connectedEnvironmentIds); + const targets = new Set(sharedSettingsSyncTargetIds); if (environmentId) { targets.add(environmentId); } @@ -417,14 +402,14 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { }); } }, - [connectedEnvironmentIds, environmentId, persistServerSettings], + [environmentId, persistServerSettings, sharedSettingsSyncTargetIds], ); return updateSettings; } /** - * Connected environments whose shared settings differ from the primary's, + * Shared-settings sync targets whose values differ from the primary's, * plus an action that writes the primary's values to all of them. Drift * happens when an environment was offline during an edit or was changed by * an older client. @@ -437,7 +422,7 @@ export function useSharedSettingsSync() { // must never push defaults over real values. Same for a primary too old to // hold the shared keys: its decoded defaults are not a source of truth. const primarySettings = - primaryEnvironment !== null && supportsSharedSettings(primaryEnvironment) + primaryEnvironment !== null && supportsSharedSettingsSync(primaryEnvironment) ? (primaryEnvironment.serverConfig?.settings ?? null) : null; const { environments } = useEnvironments(); @@ -454,7 +439,7 @@ export function useSharedSettingsSync() { environments: environments.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, - connected: supportsSharedSettings(environment), + syncEligible: supportsSharedSettingsSync(environment), settings: environment.serverConfig?.settings ?? null, })), }), diff --git a/docs/internals/overview.md b/docs/internals/overview.md index 37f8c60ac114..7ad971ae745d 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -90,9 +90,10 @@ A turn is complete when its session leaves `running` status, projected by does not define turn end. Thread settlement is server-owned. Each server's own settings control PR and inactivity -settlement. Those keys are user preferences, so clients write them to every connected environment -(`SHARED_SERVER_SETTING_KEYS` in `packages/client-runtime/src/state/sharedSettings.ts`) and warn -when a connected environment drifts. +settlement. Those keys are user preferences, so clients write them to every shared-settings sync +target (`SHARED_SERVER_SETTING_KEYS` in `packages/client-runtime/src/state/sharedSettings.ts`) and +warn when another target drifts. A target must have an active connection and advertise the +`threadAutoSettlement` capability, which signals that the server can hold every shared key. [`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and once per minute, including when no client is connected. It dispatches the guarded internal `thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 3bbdc6888fdd..204678eb1d2f 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -22,11 +22,13 @@ available, the inactivity rule still applies. A manual un-settle also keeps the sorts by the moment you settled it. A thread that settled on its own sorts by its last message or turn, not by when the server noticed it was inactive. -Change these rules in **Settings > General**. The change is written to every environment you are -connected to at that moment. An environment that is offline keeps its old value. When a connected -environment holds a different value, **Settings > General** shows a warning that names it. Choose -**Apply to all** to write your current values to every connected environment. The same applies to -the new-thread workspace mode and the source control writing style. +Change these rules in **Settings > General**. The change is written to every connected environment +whose server supports shared settings. An environment that is offline or needs a server update +keeps its old value and does not appear in mismatch warnings. When a connected environment whose +server supports shared settings holds a different value, **Settings > General** shows a warning +that names it. Choose **Apply to all** to write your current values to the environments named in +the warning. The same applies to the new-thread workspace mode and the source control writing +style. A settings change affects future settlement and does not reopen a settled thread. Settings saved by older clients on one device no longer control this behavior. diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index dbdf651180d3..8c46a9f33579 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -5,12 +5,36 @@ import { findSharedSettingsMismatches, pickSharedServerSettings, splitSharedServerPatch, + supportsSharedSettingsSync, } from "./sharedSettings.ts"; const primaryId = EnvironmentId.make("env-primary"); const laptopId = EnvironmentId.make("env-laptop"); const boxId = EnvironmentId.make("env-box"); +describe("supportsSharedSettingsSync", () => { + it("accepts only connected servers that advertise the shared-settings capability", () => { + expect( + supportsSharedSettingsSync({ + connection: { phase: "connected" }, + serverConfig: { environment: { capabilities: { threadAutoSettlement: true } } }, + }), + ).toBe(true); + expect( + supportsSharedSettingsSync({ + connection: { phase: "connected" }, + serverConfig: { environment: { capabilities: {} } }, + }), + ).toBe(false); + expect( + supportsSharedSettingsSync({ + connection: { phase: "reconnecting" }, + serverConfig: { environment: { capabilities: { threadAutoSettlement: true } } }, + }), + ).toBe(false); + }); +}); + describe("splitSharedServerPatch", () => { it("routes preference keys to the shared patch and machine keys to the local patch", () => { const { sharedPatch, localPatch } = splitSharedServerPatch({ @@ -38,17 +62,27 @@ describe("pickSharedServerSettings", () => { describe("findSharedSettingsMismatches", () => { const primarySettings = { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 7 }; - it("lists connected environments whose shared settings differ", () => { + it("lists sync-eligible environments whose shared settings differ", () => { const mismatches = findSharedSettingsMismatches({ primaryEnvironmentId: primaryId, primarySettings, environments: [ - { environmentId: primaryId, label: "Desktop", connected: true, settings: primarySettings }, - { environmentId: laptopId, label: "Laptop", connected: true, settings: primarySettings }, + { + environmentId: primaryId, + label: "Desktop", + syncEligible: true, + settings: primarySettings, + }, + { + environmentId: laptopId, + label: "Laptop", + syncEligible: true, + settings: primarySettings, + }, { environmentId: boxId, label: "Remote Box", - connected: true, + syncEligible: true, settings: DEFAULT_SERVER_SETTINGS, }, ], @@ -64,7 +98,7 @@ describe("findSharedSettingsMismatches", () => { { environmentId: boxId, label: "Remote Box", - connected: true, + syncEligible: true, settings: { ...primarySettings, enableAgentBrowserAccess: false }, }, ], @@ -74,7 +108,12 @@ describe("findSharedSettingsMismatches", () => { it("reports nothing until the primary environment's settings are loaded", () => { const environments = [ - { environmentId: boxId, label: "Remote Box", connected: true, settings: primarySettings }, + { + environmentId: boxId, + label: "Remote Box", + syncEligible: true, + settings: primarySettings, + }, ]; expect( findSharedSettingsMismatches({ primaryEnvironmentId: null, primarySettings, environments }), @@ -88,7 +127,7 @@ describe("findSharedSettingsMismatches", () => { ).toEqual([]); }); - it("skips offline environments and environments without a loaded config", () => { + it("skips ineligible environments and environments without a loaded config", () => { const mismatches = findSharedSettingsMismatches({ primaryEnvironmentId: primaryId, primarySettings, @@ -96,10 +135,10 @@ describe("findSharedSettingsMismatches", () => { { environmentId: laptopId, label: "Laptop", - connected: false, + syncEligible: false, settings: DEFAULT_SERVER_SETTINGS, }, - { environmentId: boxId, label: "Remote Box", connected: true, settings: null }, + { environmentId: boxId, label: "Remote Box", syncEligible: true, settings: null }, ], }); expect(mismatches).toEqual([]); diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index 35fa4adb46bf..f3236ef2035a 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -4,14 +4,21 @@ * Every server keeps its own `settings.json`, but some keys are user * preferences that only live on the server because the server has to act on * them (auto-settlement runs with no client attached). A user does not want - * those to differ per machine. Clients write these keys to every connected - * environment, and warn when a connected environment still holds a different - * value so the user can push their current value out. + * those to differ per machine. Clients write these keys to every shared-settings + * sync target, and warn when another target still holds a different value so + * the user can push their current value out. */ -import type { EnvironmentId, ServerSettings, ServerSettingsPatch } from "@t3tools/contracts"; +import type { + EnvironmentId, + ExecutionEnvironmentCapabilities, + ServerSettings, + ServerSettingsPatch, +} from "@t3tools/contracts"; import * as Equal from "effect/Equal"; import * as Struct from "effect/Struct"; +import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; + /** Server keys that hold a user preference rather than machine config. */ export const SHARED_SERVER_SETTING_KEYS = [ "sidebarAutoSettleAfterDays", @@ -50,18 +57,37 @@ export function pickSharedServerSettings(settings: ServerSettings): ServerSettin return Struct.pick(settings, SHARED_SERVER_SETTING_KEYS); } +/** + * Whether an environment can participate in shared-settings sync right now. + * Auto-settlement is the newest feature backed by a shared key, so a server + * advertising `threadAutoSettlement` can hold every shared key. + */ +export function supportsSharedSettingsSync(environment: { + readonly connection: { readonly phase: EnvironmentConnectionPhase }; + readonly serverConfig: { + readonly environment: { + readonly capabilities: Pick; + }; + } | null; +}): boolean { + return ( + environment.connection.phase === "connected" && + environment.serverConfig?.environment.capabilities.threadAutoSettlement === true + ); +} + export interface SharedSettingsEnvironment { readonly environmentId: EnvironmentId; readonly label: string; - readonly connected: boolean; + readonly syncEligible: boolean; readonly settings: ServerSettings | null; } /** - * Connected environments whose shared settings differ from the primary - * environment's. Offline environments are skipped: nothing can be read from - * or written to them, and the warning would never clear. With no primary - * settings loaded there is nothing to compare against, so nothing is + * Shared-settings sync targets whose values differ from the primary + * environment's. Other environments are skipped: nothing can be read from or + * written to them, or their server cannot hold every shared key. With no + * primary settings loaded there is nothing to compare against, so nothing is * reported. Callers must pass the real loaded settings, never a default * fallback, or "apply to all" would push defaults over real values. */ @@ -77,7 +103,7 @@ export function findSharedSettingsMismatches(input: { return input.environments.flatMap((environment) => { if ( environment.environmentId === input.primaryEnvironmentId || - !environment.connected || + !environment.syncEligible || environment.settings === null ) { return []; From 2120fbc185737b71f09de020b95224f9e636d1e5 Mon Sep 17 00:00:00 2001 From: Rakshith Bhat <88523594+RakshithBhat03@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:06:09 +0530 Subject: [PATCH 05/14] fix(web): avoid duplicate Antigravity install status (#9419) --- .../settings/ProviderSetupSection.test.tsx | 36 ++++++++++++++++++- .../settings/ProviderSetupSection.tsx | 30 ++++++++-------- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/settings/ProviderSetupSection.test.tsx b/apps/web/src/components/settings/ProviderSetupSection.test.tsx index aea1238e423d..7e4c2695821a 100644 --- a/apps/web/src/components/settings/ProviderSetupSection.test.tsx +++ b/apps/web/src/components/settings/ProviderSetupSection.test.tsx @@ -1,4 +1,4 @@ -import type { FunctionComponent, ReactElement } from "react"; +import { isValidElement, type FunctionComponent, type ReactElement } from "react"; import { EnvironmentId, ProviderDriverKind, @@ -142,6 +142,23 @@ function button(view: unknown, label: string) { ); } +function countElements( + node: unknown, + predicate: (element: ReactElement>) => boolean, +): number { + if (Array.isArray(node)) { + return node.reduce((total, child) => total + countElements(child, predicate), 0); + } + if (!isValidElement>(node)) return 0; + return ( + Number(predicate(node)) + + Object.values(node.props).reduce( + (total, value) => total + countElements(value, predicate), + 0, + ) + ); +} + function click(view: unknown, label: string) { const target = button(view, label); if (!target) throw new Error(`Missing button: ${label}`); @@ -285,6 +302,23 @@ describe("Antigravity setup", () => { await flushPromises(); }); + it("shows a repeated runtime status message only once", () => { + setup.installation = { + ...setup.installation!, + operationId: "install-1", + phase: "verifying", + message: "Checking the downloaded runtime.", + }; + + const view = renderSetup(); + expect( + countElements( + view, + (element) => element.props.children === "Checking the downloaded runtime.", + ), + ).toBe(1); + }); + it("removes an owned damaged runtime only after confirmation", async () => { setup.auth = authState({ phase: "idle", flowId: null, authorizationUrl: null }); setup.installation = { diff --git a/apps/web/src/components/settings/ProviderSetupSection.tsx b/apps/web/src/components/settings/ProviderSetupSection.tsx index bbc700762483..49ad14cddd92 100644 --- a/apps/web/src/components/settings/ProviderSetupSection.tsx +++ b/apps/web/src/components/settings/ProviderSetupSection.tsx @@ -169,6 +169,20 @@ function ProviderSetupActions({ const authorizationUrl = auth?.phase === "waiting" ? auth.authorizationUrl : null; const queryError = authQuery.error ?? installQuery.error; const actionsDisabled = pendingLabel !== null || queryError !== null; + const installationStatusMessage = + installation?.phase === "downloading" + ? `Downloading ${(installation.downloadedBytes / 1_000_000).toFixed(1)} MB${installation.totalBytes === null ? "" : ` of ${(installation.totalBytes / 1_000_000).toFixed(1)} MB`}.` + : installation?.phase === "extracting" + ? "Extracting Antigravity." + : installation?.phase === "verifying" + ? "Checking the downloaded runtime." + : installed + ? "Antigravity is installed." + : usesCustomBinary + ? enabled + ? "The configured Antigravity runtime is unavailable." + : "The configured Antigravity runtime has not been checked." + : "Install the official Antigravity runtime before signing in."; async function runCommand( label: string, @@ -252,19 +266,7 @@ function ProviderSetupActions({

Runtime

- {installation?.phase === "downloading" - ? `Downloading ${(installation.downloadedBytes / 1_000_000).toFixed(1)} MB${installation.totalBytes === null ? "" : ` of ${(installation.totalBytes / 1_000_000).toFixed(1)} MB`}.` - : installation?.phase === "extracting" - ? "Extracting Antigravity." - : installation?.phase === "verifying" - ? "Checking the downloaded runtime." - : installed - ? "Antigravity is installed." - : usesCustomBinary - ? enabled - ? "The configured Antigravity runtime is unavailable." - : "The configured Antigravity runtime has not been checked." - : "Install the official Antigravity runtime before signing in."} + {installationStatusMessage}

{installation?.phase === "downloading" && installation.totalBytes !== null && @@ -276,7 +278,7 @@ function ProviderSetupActions({ max={installation.totalBytes} /> ) : null} - {installation?.message ? ( + {installation?.message && installation.message !== installationStatusMessage ? (

{installation.message}

) : null} {usesCustomBinary ? ( From d4ba2a1f11498eae9683f6dc95a08b1c17c29765 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 13:29:38 -0400 Subject: [PATCH 06/14] fix(composer): mute fast icon when collapsed (#9451) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/chat/TraitsPicker.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index e21d3dd0f373..5feca2919684 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -592,7 +592,11 @@ export const TraitsPicker = memo(function TraitsPicker({ size={size} className={cn( "fill-current opacity-80", - provider === "claudeAgent" ? "text-[#d97757]" : "text-foreground", + size === "xs" + ? "text-current" + : provider === "claudeAgent" + ? "text-[#d97757]" + : "text-foreground", )} /> Fast mode on From 21b9dda5afb00a33e228a68d2ccc885bba7285dc Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 13:30:07 -0400 Subject: [PATCH 07/14] fix(web): unify skeleton loading animations on one pulse (#9448) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../pullRequest/PullRequestGhosts.tsx | 26 +++++++++------ apps/web/src/components/ui/skeleton.tsx | 5 +-- apps/web/src/components/usage/UsagePage.tsx | 32 ++++++++++--------- apps/web/src/index.css | 21 +++--------- 4 files changed, 39 insertions(+), 45 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 38a3ab70d642..2ae79c063c3c 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -3,11 +3,9 @@ * and a detail panel opening — use bars in the geometry of the content they stand for, pulsing * on one composited layer. Diff loading uses the shared diff-panel skeleton instead. * - * Deliberately not the app's shimmer skeleton. The sweep is a `transform` animation per bar — - * compositor-safe, but a layer for every bar on screen — and its white highlight over the - * near-white `muted` base all but disappears in light mode. Here one `animate-ghost-pulse` on the - * container is a single opacity animation however many bars sit under it, and the bars take - * their tone from `muted-foreground` at low alpha, which reads on both themes. + * The bars share the app-wide `Skeleton` tone (`muted-foreground` at low alpha, which reads on + * both themes) and the single `animate-skeleton` pulse, applied once on the container so any + * number of bars costs one opacity animation. */ import { cn } from "~/lib/utils"; @@ -32,7 +30,7 @@ export function PullRequestListGhost({
{caption ? (

{caption}

@@ -67,7 +65,7 @@ export function PullRequestDetailGhost() {
@@ -160,7 +158,11 @@ export function PullRequestDetailGhost() { /** People-shaped: an avatar and a name, in the reviewer picker's own row height. */ export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) { return ( -
+
{Array.from({ length: rows }, (_, index) => (
@@ -174,7 +176,11 @@ export function PullRequestPeopleGhost({ rows = 4 }: { rows?: number }) { /** The timeline's own shape: dots on the rail, a line and a date to each. */ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) { return ( -
+
{Array.from({ length: rows }, (_, index) => (
@@ -194,7 +200,7 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {
{Array.from({ length: rows }, (_, index) => (
diff --git a/apps/web/src/components/ui/skeleton.tsx b/apps/web/src/components/ui/skeleton.tsx index fb79a7d7744b..0d6e12eaa580 100644 --- a/apps/web/src/components/ui/skeleton.tsx +++ b/apps/web/src/components/ui/skeleton.tsx @@ -3,10 +3,7 @@ import { cn } from "~/lib/utils"; function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return (
diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 7474bb9d6120..9e4838c0de8b 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -23,6 +23,7 @@ import { Button } from "../ui/button"; import { ScrollArea } from "../ui/scroll-area"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { SidebarInset } from "../ui/sidebar"; +import { Skeleton } from "../ui/skeleton"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { WorkspaceBreadcrumb, @@ -585,8 +586,9 @@ function UsageDeviceStrip({ } /** - * Static stand-in with the loaded page's shape. No shimmer; blocks fill in - * exactly once when the last device answers. + * Stand-in with the loaded page's shape, using the shared `Skeleton` bars so it + * breathes with the same `animate-skeleton` pulse as every other loading state. + * Blocks fill in exactly once when the last device answers. */ function UsageSkeleton() { return ( @@ -594,29 +596,29 @@ function UsageSkeleton() {
-
-
+ +
{PROVIDER_ORDER.map((provider) => (
- - -
+ + + -
+
-
+
))}
-
+
-
-
+ +
@@ -628,7 +630,7 @@ function UsageSkeleton() { (label) => (
{label} -
+
), )} @@ -638,9 +640,9 @@ function UsageSkeleton() {

Breakdown

-
+
-
+
); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 94681211fe9c..961766110647 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -145,11 +145,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil @theme inline { --color-zinc-25: oklch(99.2% 0 0); - --animate-skeleton: skeleton 2s infinite linear; + --animate-skeleton: skeleton 2.4s infinite; /* Duty-cycled indicator animations: long holds with stepped ramps, so the compositor updates discrete frames instead of every vsync. */ --animate-status-pulse: status-pulse 2s infinite; - --animate-ghost-pulse: ghost-pulse 2.4s infinite; --animate-status-ping: status-ping 2s infinite; --color-warning-foreground: var(--warning-foreground); --color-warning: var(--warning); @@ -207,20 +206,10 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --radius-2xl: calc(var(--radius) + 8px); --radius-3xl: calc(var(--radius) + 12px); @keyframes skeleton { - /* Transform-only so the highlight sweep stays on the compositor, then a - long hold with the band parked off-screen instead of a constant shimmer. */ - 0% { - transform: translateX(-100%); - } - 60%, - 100% { - transform: translateX(100%); - } - } - @keyframes ghost-pulse { - /* The loading ghosts' breath. Stepped like the status indicators, so however many bars a - ghost holds, the compositor draws a handful of discrete frames per cycle rather than one - per vsync — which on a 120Hz display is the difference between ~14 and ~288 updates. */ + /* The single loading-bar breath used by every skeleton: one opacity pulse + per container, stepped so however many bars sit under it, the compositor + draws a handful of discrete frames per cycle rather than one per vsync — + which on a 120Hz display is the difference between ~14 and ~288 updates. */ 0%, 42% { opacity: 1; From 645d58547d282eb2aaf6c48e5907625fc112386a Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 13:47:01 -0400 Subject: [PATCH 08/14] fix(web): prioritize authored pull requests (#9453) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../pullRequest/pullRequestList.logic.test.ts | 66 +++++++++++++++++-- .../pullRequest/pullRequestList.logic.ts | 46 ++++++++++++- apps/web/src/routes/_chat.pull-requests.tsx | 52 +++------------ docs/user/source-control.md | 7 +- 4 files changed, 118 insertions(+), 53 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 08757b275b7e..b3fc1da8d0a3 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -23,6 +23,7 @@ import { rankPullRequestMatches, rankPullRequestsByMergeReadiness, scorePullRequestMatch, + sortPullRequestGroups, retainVisiblePullRequestStatsBatches, withDiffStat, resolveProjectScope, @@ -328,8 +329,8 @@ describe("pull request grouping", () => { VIEWERS, ); expect(groups.map((group) => [group.key, group.entries.length])).toEqual([ - ["reviewRequested", 1], ["authored", 1], + ["reviewRequested", 1], ]); }); @@ -753,6 +754,63 @@ describe("default merge-readiness ranking", () => { rankPullRequestsByMergeReadiness([larger, unknown, smaller]).map((row) => row.number), ).toEqual([2, 1, 3]); }); + + it("keeps authored work first and ranks each group by readiness", () => { + const authoredWaiting = entry({ number: 1, checksState: "pending" }); + const authoredReady = entry({ + number: 2, + checksState: "passing", + reviewDecision: "approved", + }); + const otherReady = entry({ + number: 3, + checksState: "passing", + reviewDecision: "approved", + }); + const sorted = sortPullRequestGroups( + [ + { key: "authored", label: "Authored", entries: [authoredWaiting, authoredReady] }, + { key: "others", label: "Others", entries: [otherReady] }, + ], + "ready", + "", + ); + + expect(sorted.map((group) => group.key)).toEqual(["authored", "others"]); + expect(sorted.flatMap((group) => group.entries).map((row) => row.number)).toEqual([2, 1, 3]); + }); + + it.each([ + ["updated", [1, 2]], + ["newest", [2, 1]], + ["oldest", [1, 2]], + ["largest", [1, 2]], + ["smallest", [2, 1]], + ] as const)("keeps authored first while applying the %s sort inside groups", (sort, order) => { + const olderLarger = entry({ + number: 1, + additions: 20, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-08-01T00:00:00Z", + }); + const newerSmaller = entry({ + number: 2, + additions: 2, + createdAt: "2026-08-01T00:00:00Z", + updatedAt: "2026-07-01T00:00:00Z", + }); + const sorted = sortPullRequestGroups( + [ + { key: "authored", label: "Authored", entries: [olderLarger, newerSmaller] }, + { key: "others", label: "Others", entries: [entry({ number: 3 })] }, + ], + sort, + "", + ); + + expect(sorted.map((group) => group.key)).toEqual(["authored", "others"]); + expect(sorted[0]!.entries.map((row) => row.number)).toEqual(order); + }); }); describe("line counts that arrive after the rows", () => { @@ -843,9 +901,9 @@ describe("partitioning with the hosts' own priority reads", () => { updatedAt: "2026-06-02T00:00:00Z", }); const groups = partitionPullRequestsWithPriority([], [both], [both, requestedOlder, requested]); - expect(groups.map((group) => group.key)).toEqual(["reviewRequested", "authored"]); - expect(groups[0]!.entries.map((item) => item.number)).toEqual([2, 3]); - expect(groups[1]!.entries.map((item) => item.number)).toEqual([1]); + expect(groups.map((group) => group.key)).toEqual(["authored", "reviewRequested"]); + expect(groups[0]!.entries.map((item) => item.number)).toEqual([1]); + expect(groups[1]!.entries.map((item) => item.number)).toEqual([2, 3]); }); it("lets the feed's copy of a partitioned row replace the partition's", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index 576f771e7e7e..af1bd6ab4fbe 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -18,6 +18,9 @@ import type { PullRequestListState, } from "@t3tools/contracts"; +import { toSortableTimestamp } from "../../lib/threadSort"; +import type { PullRequestListSort } from "./pullRequestListPreferences"; + /** * A listed change request with the environment that read it. Nothing on a row says which machine * it came from, and the page unions every connected one — so acting on a row, refreshing it, or @@ -415,7 +418,7 @@ export function groupPullRequestsByInvolvement( buckets.others.push(entry); } } - return (["reviewRequested", "authored", "others"] as const) + return (["authored", "reviewRequested", "others"] as const) .filter((key) => buckets[key].length > 0) .map((key) => ({ key, label: GROUP_LABELS[key], entries: buckets[key] })); } @@ -615,8 +618,8 @@ export function partitionPullRequestsWithPriority right.updatedAt.localeCompare(left.updatedAt); return ( [ - { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) }, { key: "authored", entries: [...authoredByKey.values()].toSorted(byRecency) }, + { key: "reviewRequested", entries: [...reviewByKey.values()].toSorted(byRecency) }, { key: "others", entries: others }, ] as const ) @@ -1027,6 +1030,45 @@ export function rankPullRequestsByMergeReadiness( + groups: ReadonlyArray>, + sort: PullRequestListSort, + searchText: string, + hasMeasuredSize: (entry: Entry) => boolean = (entry) => entry.additions + entry.deletions > 0, +): ReadonlyArray> { + const sortWithinGroups = (rank: (entries: ReadonlyArray) => ReadonlyArray) => + groups.map((group) => ({ ...group, entries: rank(group.entries) })); + + if (sort === "ready") { + return searchText.trim().length === 0 + ? sortWithinGroups((entries) => rankPullRequestsByMergeReadiness(entries, hasMeasuredSize)) + : groups; + } + if (sort === "updated") return groups; + + const timestamp = (entry: Entry) => + toSortableTimestamp(entry.updatedAt) ?? toSortableTimestamp(entry.createdAt) ?? 0; + return sortWithinGroups((entries) => + entries.toSorted((left, right) => { + if (sort === "newest" || sort === "oldest") { + const leftCreated = toSortableTimestamp(left.createdAt); + const rightCreated = toSortableTimestamp(right.createdAt); + const measured = Number(rightCreated !== null) - Number(leftCreated !== null); + const dated = (leftCreated ?? 0) - (rightCreated ?? 0); + return ( + measured || (sort === "newest" ? -dated : dated) || timestamp(right) - timestamp(left) + ); + } + const measured = Number(hasMeasuredSize(right)) - Number(hasMeasuredSize(left)); + const sized = left.additions + left.deletions - (right.additions + right.deletions); + return ( + measured || (sort === "largest" ? -sized : sized) || timestamp(right) - timestamp(left) + ); + }), + ); +} + /** * A row with the line counts that arrived after it did. Only where the host left them out — a * listing that carried them is not second-guessed — and only where they have arrived, since a row diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 09701423fae0..29e6b05c0b19 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -58,7 +58,7 @@ import { pullRequestEntryKey, pullRequestEntryViewer, rankPullRequestMatches, - rankPullRequestsByMergeReadiness, + sortPullRequestGroups, pullRequestEnvironmentSetKey, readPullRequestListSnapshot, resolveProjectScope, @@ -120,7 +120,6 @@ import { SidebarInset } from "../components/ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; import { useLiveRefresh } from "../hooks/useLiveRefresh"; import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; -import { toSortableTimestamp } from "../lib/threadSort"; import { pullRequestSurfaceId, selectActiveRightPanelSurface, @@ -1426,51 +1425,16 @@ function PullRequestsRouteView() { ...group, entries: group.entries.map((entry) => withDiffStat(entry, statsByRow)), })); - if (sort === "ready" && typedParsed.text.length === 0) { - return [ - { - key: "others" as const, - label: "", - entries: rankPullRequestsByMergeReadiness( - enriched.flatMap((group) => group.entries), - (entry) => - entry.additions + entry.deletions > 0 || - statsByRow.has(pullRequestDiffStatKey(entry)), - ), - }, - ]; - } // Searching keeps its relevance order and priority groups unless the reader explicitly asks // for another sort. The readiness queue is the default browse order, not a way to bury a // closer text match. - if (sort === "ready" || sort === "updated") return enriched; - const entries = enriched.flatMap((group) => group.entries); - const hasSize = (entry: (typeof entries)[number]) => - entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)); - const timestamp = (entry: (typeof entries)[number]) => - toSortableTimestamp(entry.updatedAt) ?? toSortableTimestamp(entry.createdAt) ?? 0; - return [ - { - key: "others" as const, - label: "", - entries: entries.toSorted((left, right) => { - if (sort === "newest" || sort === "oldest") { - const leftCreated = toSortableTimestamp(left.createdAt); - const rightCreated = toSortableTimestamp(right.createdAt); - const measured = Number(rightCreated !== null) - Number(leftCreated !== null); - const dated = (leftCreated ?? 0) - (rightCreated ?? 0); - return ( - measured || (sort === "newest" ? -dated : dated) || timestamp(right) - timestamp(left) - ); - } - const measured = Number(hasSize(right)) - Number(hasSize(left)); - const sized = left.additions + left.deletions - (right.additions + right.deletions); - return ( - measured || (sort === "largest" ? -sized : sized) || timestamp(right) - timestamp(left) - ); - }), - }, - ]; + return sortPullRequestGroups( + enriched, + sort, + typedParsed.text, + (entry) => + entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)), + ); }, [groups, sort, statsByRow, typedParsed.text]); const linkedSelection = useMemo( diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 937cf91c9037..5727be12ae86 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -41,9 +41,10 @@ T3 Code works with the platforms your team already uses: - See if your current branch already has an open PR/MR - Open several reviews from the **Pull requests** page as tabs in the right panel -- By default, see passing and approved reviews first, passing reviews awaiting approval next, and - conflicting reviews last. Smaller changes come first within each readiness group, and finished - reviews follow open work when all states are visible. +- Your authored reviews stay at the top and use the selected sort within their group. By default, + see passing and approved reviews first, passing reviews awaiting approval next, and conflicting + reviews last. Smaller changes come first within each readiness group, and finished reviews follow + open work when all states are visible. - Filter the list by author or labels, rank authors by merges in the loaded results, see label and change-size context on each row, and sort the results currently shown by readiness, update time, creation time, or change size. Your filters, search, scope, and sort are restored when you return. From 4e89d74436a167c01f25a6a5283638843398ea3a Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 13:53:16 -0400 Subject: [PATCH 09/14] fix(web): make project icons the default (#9457) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../src/components/ProjectFavicon.test.tsx | 24 ++++++++++---- .../settings/ProjectIconPickerDialog.test.tsx | 8 ++--- .../settings/ProjectIconPickerDialog.tsx | 8 ++--- apps/web/src/projectIconModel.test.ts | 7 ++--- apps/web/src/projectIconModel.ts | 31 +------------------ docs/user/project-settings.md | 2 +- 6 files changed, 31 insertions(+), 49 deletions(-) diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index 557f4d722adc..bfb5487031b7 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -115,28 +115,40 @@ describe("ProjectFavicon", () => { testState.faviconUrl = "https://environment.test/api/assets/token-a/v1-20-favicon.svg"; }); - it("shows a project-name emoji when no favicon exists", () => { + it("shows a project-name icon when no favicon exists", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ environmentId: "environment-test" as EnvironmentId, cwd: "/workspace/analytics-db", projectName: "analytics-db", - }) as ReactElement<{ readonly emoji?: string }>; + }) as ReactElement<{ + readonly colorClassName?: string; + readonly emoji?: string; + readonly icon?: ComponentType<{ className?: string }>; + }>; - expect(element.props.emoji).toBe("🗄️"); + expect(element.props.icon).toBeDefined(); + expect(element.props.emoji).toBeUndefined(); + expect(element.props.colorClassName).toContain("text-cyan-600"); }); - it("chooses a deterministic semantic emoji", () => { + it("chooses a deterministic semantic icon", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ environmentId: "environment-test" as EnvironmentId, cwd: "/workspace/agent-runtime", projectName: "agent-runtime", - }) as ReactElement<{ readonly emoji?: string }>; + }) as ReactElement<{ + readonly colorClassName?: string; + readonly emoji?: string; + readonly icon?: ComponentType<{ className?: string }>; + }>; - expect(element.props.emoji).toBe("🤖"); + expect(element.props.icon).toBeDefined(); + expect(element.props.emoji).toBeUndefined(); + expect(element.props.colorClassName).toContain("text-violet-600"); }); it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => { diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx index 9098b359d1ea..2280395ecf40 100644 --- a/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx @@ -39,13 +39,13 @@ vi.mock("../ui/toggle-group", () => ({ import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog"; describe("ProjectIconPickerDialog", () => { - it("shows emoji first and selects it for an automatic project", () => { + it("shows icons first and selects them for an automatic project", () => { const markup = renderToStaticMarkup( {}} onSelect={() => {}} />, ); - expect(markup).toContain('data-current="emoji"'); - expect(markup.indexOf(">Emoji<")).toBeLessThan(markup.indexOf(">Icons<")); - expect(markup).toContain("Or paste any emoji"); + expect(markup).toContain('data-current="lucide"'); + expect(markup.indexOf(">Icons<")).toBeLessThan(markup.indexOf(">Emoji<")); + expect(markup).toContain('aria-label="Icon color"'); }); }); diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx index 4ecdb0f653c5..7fce7a4fbb5b 100644 --- a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx @@ -45,7 +45,7 @@ export function ProjectIconPickerDialog({ readonly onSelect: (icon: ProjectIconOverride) => void; }) { const [mode, setMode] = useState<"lucide" | "emoji">( - current?.kind === "lucide" ? "lucide" : "emoji", + current?.kind === "emoji" ? "emoji" : "lucide", ); const [iconName, setIconName] = useState( current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON, @@ -60,7 +60,7 @@ export function ProjectIconPickerDialog({ useEffect(() => { if (open && !previousOpenRef.current) { - setMode(current?.kind === "lucide" ? "lucide" : "emoji"); + setMode(current?.kind === "emoji" ? "emoji" : "lucide"); setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON); setColor(current?.kind === "lucide" ? current.color : DEFAULT_COLOR); setEmoji(current?.kind === "emoji" ? current.emoji : "💻"); @@ -84,7 +84,7 @@ export function ProjectIconPickerDialog({ Choose project icon - Pick an emoji, or choose any Lucide icon and color. + Pick any Lucide icon and color, or use an emoji. - Emoji Icons + Emoji {mode === "lucide" ? ( diff --git a/apps/web/src/projectIconModel.test.ts b/apps/web/src/projectIconModel.test.ts index a2d6842d9695..77e0ca2acb19 100644 --- a/apps/web/src/projectIconModel.test.ts +++ b/apps/web/src/projectIconModel.test.ts @@ -19,18 +19,17 @@ describe("selectProjectIcon", () => { expect(selectProjectIcon("", "C:\\work\\mobile-app").icon).toBe("mobile"); }); - it("uses emoji for automatic project icons", () => { + it("uses Lucide icons for automatic project icons", () => { expect(selectProjectIcon("agent-runtime", "/workspace/agent-runtime")).toEqual({ - kind: "emoji", + kind: "lucide", icon: "ai", - emoji: "🤖", }); }); it("gives unknown names a stable generic icon", () => { const icon = selectProjectIcon("mercury", "/workspace/mercury"); - expect(icon.kind).toBe("emoji"); + expect(icon.kind).toBe("lucide"); expect(PROJECT_ICON_NAMES).toContain(icon.icon); expect(selectProjectIcon("mercury", "/elsewhere/mercury")).toEqual(icon); }); diff --git a/apps/web/src/projectIconModel.ts b/apps/web/src/projectIconModel.ts index 6f32e6381010..8614ff2a41ff 100644 --- a/apps/web/src/projectIconModel.ts +++ b/apps/web/src/projectIconModel.ts @@ -131,31 +131,6 @@ const GENERIC_PROJECT_ICONS: ReadonlyArray = [ "layers", ]; -const PROJECT_ICON_EMOJIS: Record = { - ai: "🤖", - book: "📚", - braces: "🧩", - circuit: "⚡", - cloud: "☁️", - code: "💻", - database: "🗄️", - desktop: "🖥️", - "folder-code": "🛠️", - game: "🎮", - image: "🖼️", - layers: "✨", - mobile: "📱", - music: "🎵", - package: "📦", - security: "🔒", - server: "⚙️", - shopping: "🛍️", - terminal: "⌨️", - test: "🧪", - video: "🎬", - web: "🌐", -}; - const projectIconCache = new Map(); function projectNameTokens(value: string): ReadonlyArray { @@ -211,11 +186,7 @@ export function selectProjectIcon( const iconName = bestIcon ?? GENERIC_PROJECT_ICONS[stableIndex(cacheKey, GENERIC_PROJECT_ICONS.length)]!; - const icon: ProjectIconSelection = { - kind: "emoji", - icon: iconName, - emoji: PROJECT_ICON_EMOJIS[iconName], - }; + const icon: ProjectIconSelection = { kind: "lucide", icon: iconName }; projectIconCache.set(cacheKey, icon); return icon; } diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 047f8baf5466..ef15a041c137 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -2,7 +2,7 @@ T3 Code selects a project icon automatically. It checks `t3.json`, common favicon and app icon paths, and icon links in project HTML files. If it does not find an image, it chooses a built-in -emoji from the project name. +icon from the project name. To choose a different icon or emoji: From c78f05a45e1c2b274d8b0ab2b4d2d88b4767c968 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 14:03:42 -0400 Subject: [PATCH 10/14] fix(server): reuse pr state when settling threads (#9459) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../ThreadSettlementReactor.test.ts | 42 +++++++++++++++++++ .../orchestration/ThreadSettlementReactor.ts | 34 +++++++++++---- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 382d5812c1a8..f6264200d2f5 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -17,6 +17,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; @@ -134,6 +135,7 @@ interface HarnessOptions { readonly settings?: ServerSettings; readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; readonly pullRequestSummary?: PullRequestService["Service"]["summary"]; + readonly existingWorktreePaths?: ReadonlyArray; readonly onDispatch?: ( command: AutoSettleCommand, ) => Effect.Effect; @@ -235,6 +237,9 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: Layer.succeed(ServerSettingsService, serverSettings), Layer.succeed(ServerActivation, Deferred.await(activation)), Layer.succeed(Crypto.Crypto, testCrypto), + FileSystem.layerNoop({ + exists: (path) => Effect.succeed(options.existingWorktreePaths?.includes(path) ?? false), + }), ); return { @@ -647,6 +652,43 @@ describe("ThreadSettlementReactor", () => { ), ); + it.effect("looks up the branch pull request from a thread's live worktree", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("live-worktree", { + branch: "feature/live", + worktreePath: "/workspace/project-root/.worktrees/live", + }), + makeThread("deleted-worktree", { + branch: "feature/deleted", + worktreePath: "/workspace/project-root/.worktrees/deleted", + }), + ], + [makeProject(PROJECT_ID, "/workspace/project-root")], + ), + existingWorktreePaths: ["/workspace/project-root/.worktrees/live"], + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual( + new Set(yield* Ref.get(fixture.branchCalls)), + new Set([ + { cwd: "/workspace/project-root/.worktrees/live", branch: "feature/live" }, + { cwd: "/workspace/project-root", branch: "feature/deleted" }, + ]), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("carries the snapshot guard and survives a stale dispatch rejection", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 9dd7cd5e76fd..9867a855a85e 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -5,6 +5,7 @@ import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; @@ -37,6 +38,7 @@ export const make = Effect.gen(function* () { const git = yield* GitManager.GitManager; const pullRequests = yield* PullRequestService.PullRequestService; const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* ( mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, @@ -54,6 +56,26 @@ export const make = Effect.gen(function* () { mergedPullRequest.repository.toLowerCase() && thread.linkedPullRequest.number === mergedPullRequest.number)), ); + // Use the same cwd as the sidebar so both paths share GitManager's PR cache. + const lookupCwdByThreadId = new Map(); + yield* Effect.forEach( + candidates, + (thread) => + Effect.gen(function* () { + const project = projects.get(thread.projectId); + if (project === undefined || thread.linkedPullRequest != null) return; + const worktreeExists = + thread.worktreePath !== null && + (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false))); + lookupCwdByThreadId.set( + thread.id, + worktreeExists && thread.worktreePath !== null + ? thread.worktreePath + : project.workspaceRoot, + ); + }), + { concurrency: 8, discard: true }, + ); const lookupKey = (thread: (typeof candidates)[number]) => { if (thread.linkedPullRequest != null) { return JSON.stringify([ @@ -64,11 +86,9 @@ export const make = Effect.gen(function* () { ]); } if (thread.branch === null) return JSON.stringify(["none", thread.id]); - const project = projects.get(thread.projectId); + const cwd = lookupCwdByThreadId.get(thread.id); return JSON.stringify( - project === undefined - ? ["missing-project", thread.id] - : ["branch", project.workspaceRoot, thread.branch], + cwd === undefined ? ["missing-project", thread.id] : ["branch", cwd, thread.branch], ); }; const groups = Map.groupBy(candidates, lookupKey); @@ -100,11 +120,11 @@ export const make = Effect.gen(function* () { } satisfies SettlementPullRequest; } if (thread.branch === null) return null; - const project = projects.get(thread.projectId); - if (project === undefined) { + const cwd = lookupCwdByThreadId.get(thread.id); + if (cwd === undefined) { return yield* Effect.die(new Error("thread project not found")); } - return yield* git.branchPullRequest({ cwd: project.workspaceRoot, branch: thread.branch }); + return yield* git.branchPullRequest({ cwd, branch: thread.branch }); }); yield* Effect.forEach( From 8bd544cdfd22aa38ab82bc1adc0b851755a299ef Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 14:04:10 -0400 Subject: [PATCH 11/14] fix(web): keep agent images collapsed (#9460) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/chat/MessagesTimeline.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c85f85cac122..2b0219012971 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -3049,16 +3049,17 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { const previewText = displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot); const displayText = !toolPresentation && expanded && workEntry.command?.trim() ? "Command" : previewText; + const viewedImagePath = workEntryViewedImagePath(workEntry); const canExpand = (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) || Boolean( workEntryRawCommand(workEntry) || workEntry.command?.trim() || workEntry.detail?.trim() || - workEntry.changedFiles?.length, + workEntry.changedFiles?.length || + viewedImagePath, ); const expandedBody = expanded ? buildToolCallExpandedBody(workEntry, workspaceRoot) : null; - const viewedImagePath = workEntryViewedImagePath(workEntry); const viewedImage = viewedImagePath && threadRef ? resolveViewedImageAsset(viewedImagePath, { @@ -3159,7 +3160,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
- {viewedImage && threadRef ? ( + {expanded && viewedImage && threadRef ? (
Date: Thu, 3 Sep 2026 11:09:51 -0700 Subject: [PATCH 12/14] fix(web): banner buttons no longer expand the resting composer (#9452) --- apps/web/src/components/chat/ChatComposer.tsx | 8 +++----- .../src/components/chat/composerEventScope.test.ts | 12 ++++++++++++ apps/web/src/components/chat/composerEventScope.ts | 10 ++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b24fb16c523b..c38dd1cbe054 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -60,6 +60,7 @@ import { } from "./composerMentionDrag"; import { composerFloatingLayerProps, + isInsideCollapsedComposerControls, isInsideComposerFloatingLayer, isInsideRestingComposerControlScope, } from "./composerEventScope"; @@ -4542,6 +4543,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPointerDownCapture={(event) => { const target = event.target; if (isInsideRestingComposerControlScope(target)) return; + if (isInsideCollapsedComposerControls(target)) return; if (!(target instanceof Element)) return; const isInteractive = Boolean( target.closest('button, a, input, select, [role="button"], [role="menuitem"]'), @@ -4564,11 +4566,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (composerControlsInStrip && isInsideRestingComposerControlScope(activeElement)) { return; } - if ( - isComposerCollapsedMobile && - activeElement instanceof HTMLElement && - activeElement.closest('[data-chat-composer-collapsed-controls="true"]') - ) { + if (isInsideCollapsedComposerControls(activeElement)) { return; } // Focus returning from another window or tab lands on the element diff --git a/apps/web/src/components/chat/composerEventScope.test.ts b/apps/web/src/components/chat/composerEventScope.test.ts index 7ca1e396960d..b559009ed43f 100644 --- a/apps/web/src/components/chat/composerEventScope.test.ts +++ b/apps/web/src/components/chat/composerEventScope.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { + isInsideCollapsedComposerControls, isInsideComposerFloatingLayer, isInsideRestingComposerControlScope, } from "./composerEventScope"; @@ -58,4 +59,15 @@ describe("composer event scopes", () => { expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(false); expect(isInsideRestingComposerControlScope(null)).toBe(false); }); + + it("recognizes banner and drawer controls docked above the surface", () => { + vi.stubGlobal("Element", FakeElement); + + const target = new FakeElement('[data-chat-composer-collapsed-controls="true"]'); + expect(isInsideCollapsedComposerControls(target as unknown as EventTarget)).toBe(true); + expect(isInsideCollapsedComposerControls(new FakeElement(null) as unknown as EventTarget)).toBe( + false, + ); + expect(isInsideCollapsedComposerControls(null)).toBe(false); + }); }); diff --git a/apps/web/src/components/chat/composerEventScope.ts b/apps/web/src/components/chat/composerEventScope.ts index 2275fdd21a2e..60aedb096156 100644 --- a/apps/web/src/components/chat/composerEventScope.ts +++ b/apps/web/src/components/chat/composerEventScope.ts @@ -11,6 +11,16 @@ export function isInsideComposerFloatingLayer(target: EventTarget | null): boole return target instanceof Element && target.closest(COMPOSER_FLOATING_LAYER_SELECTOR) !== null; } +// Banners, the approval row, and the tasks badge dock above the surface. A +// pointer or focus landing on one of them acts on that control and must not +// expand a resting or collapsed composer. +export function isInsideCollapsedComposerControls(target: EventTarget | null): boolean { + return ( + target instanceof Element && + target.closest('[data-chat-composer-collapsed-controls="true"]') !== null + ); +} + export function isInsideRestingComposerControlScope(target: EventTarget | null): boolean { return ( target instanceof Element && From d5825e1d2fb1703ced2bbe2661f6a4937dd530bf Mon Sep 17 00:00:00 2001 From: Zortos Date: Thu, 3 Sep 2026 20:35:49 +0200 Subject: [PATCH 13/14] fix(web): stop clipping the traits chevron on long Codex effort labels (#9433) Co-authored-by: Cursor --- apps/web/src/components/chat/TraitsPicker.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 5feca2919684..c48b8eb0f6cf 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -627,11 +627,10 @@ export const TraitsPicker = memo(function TraitsPicker({ } > {isCodexStyle ? ( + // The label truncates itself; clipping the wrapper too would cut off + // the chevron, whose negative end margin overhangs the wrapper edge. {fastModeIcon} {triggerLabel} From 46e8b1a23ab14fa2c128c6b315955d8eb976d85f Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 15:09:54 -0400 Subject: [PATCH 14/14] fix(web): make right panel tabs easier to scroll (#9461) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/RightPanelTabs.tsx | 136 ++++++++++++++++++++- 1 file changed, 134 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 5a9356a0fffd..8f1fff8dc728 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -9,6 +9,8 @@ import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { Bot, ChevronDown, + ChevronLeft, + ChevronRight, FileDiff, Files, GitPullRequest, @@ -170,6 +172,12 @@ type TabContextMenuAction = | "close-to-right" | "close-all"; +const TAB_SCROLL_EDGE_TOLERANCE = 1; + +function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { + return root?.querySelector('[data-slot="scroll-area-viewport"]') ?? null; +} + /** * Desktop preview tab backing a surface, or null for non-preview surfaces, the * "new browser tab" placeholder, and the web build where no desktop tab exists. @@ -720,6 +728,42 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); + const [tabScrollState, setTabScrollState] = useState({ + hasOverflow: false, + canScrollLeft: false, + canScrollRight: false, + }); + + const updateTabScrollState = useCallback(() => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + + const hasOverflow = viewport.scrollWidth - viewport.clientWidth > TAB_SCROLL_EDGE_TOLERANCE; + const canScrollLeft = hasOverflow && viewport.scrollLeft > TAB_SCROLL_EDGE_TOLERANCE; + const canScrollRight = + hasOverflow && + viewport.scrollLeft + viewport.clientWidth < viewport.scrollWidth - TAB_SCROLL_EDGE_TOLERANCE; + setTabScrollState((current) => { + if ( + current.hasOverflow === hasOverflow && + current.canScrollLeft === canScrollLeft && + current.canScrollRight === canScrollRight + ) { + return current; + } + return { hasOverflow, canScrollLeft, canScrollRight }; + }); + }, []); + + const scrollTabs = useCallback((direction: -1 | 1) => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + viewport.scrollBy({ + left: direction * Math.max(120, viewport.clientWidth * 0.75), + behavior: reduceMotion ? "auto" : "smooth", + }); + }, []); const addSurfaceActions = [ { @@ -886,9 +930,49 @@ export function RightPanelTabs(props: RightPanelTabsProps) { ); useEffect(() => { + if (!props.activeSurfaceId || !tabScrollState.hasOverflow) return; const activeTab = tabListRef.current?.querySelector("[data-active-tab='true']"); activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); - }, [props.activeSurfaceId]); + }, [props.activeSurfaceId, tabScrollState.hasOverflow]); + + useEffect(() => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + + const content = viewport.firstElementChild; + const resizeObserver = new ResizeObserver(updateTabScrollState); + resizeObserver.observe(viewport); + if (content) resizeObserver.observe(content); + viewport.addEventListener("scroll", updateTabScrollState, { passive: true }); + updateTabScrollState(); + + return () => { + resizeObserver.disconnect(); + viewport.removeEventListener("scroll", updateTabScrollState); + }; + }, [updateTabScrollState]); + + useEffect(() => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + + const handleWheel = (event: WheelEvent) => { + if (event.ctrlKey) return; + let delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; + if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) delta *= 16; + if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) delta *= viewport.clientWidth; + if (delta === 0) return; + + const previousScrollLeft = viewport.scrollLeft; + viewport.scrollLeft += delta; + if (viewport.scrollLeft === previousScrollLeft) return; + event.preventDefault(); + updateTabScrollState(); + }; + + viewport.addEventListener("wheel", handleWheel, { passive: false }); + return () => viewport.removeEventListener("wheel", handleWheel); + }, [updateTabScrollState]); return (
@@ -1099,6 +1187,50 @@ export function RightPanelTabs(props: RightPanelTabsProps) { ) : null}
+ {tabScrollState.hasOverflow ? ( +
+ + + + + } + /> + Scroll tabs left + + + + + + } + /> + Scroll tabs right + +
+ ) : null} {props.layoutControls}