From e33b710d5f1147993e52076a9e033d97ef4106ad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 15 Sep 2026 08:06:09 -0700 Subject: [PATCH 01/12] fix(mobile): match command palette colors to sheets (#11861) --- apps/mobile/src/features/keyboard/CommandPalette.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index 0b96f7d6a126..a79e99fc212a 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -8,6 +8,7 @@ import { KeyboardAvoidingView, Modal, Pressable, + Text as NativeText, TextInput, useWindowDimensions, View, @@ -15,10 +16,10 @@ import { import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { GlassSurface } from "../../components/GlassSurface"; import { RowPressable } from "../../components/RowPressable"; import { AppText as Text } from "../../components/AppText"; import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; -import { GlassSurface } from "../../components/GlassSurface"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; import { useProjects, useThreadShell, useThreadShells } from "../../state/entities"; @@ -101,7 +102,9 @@ function PaletteRow(props: { ) : null} {props.index < 9 ? ( - ⌘{props.index + 1} + + ⌘{props.index + 1} + ) : null} ); @@ -395,6 +398,8 @@ export function CommandPalette(props: { /> Date: Tue, 15 Sep 2026 18:18:38 +0300 Subject: [PATCH 02/12] fix(web): keep the composer ready during background worktree setup (#11883) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- apps/web/src/components/ChatView.tsx | 138 ++++++++++++------- apps/web/src/composerDraftStore.test.ts | 63 +++++++++ apps/web/src/composerDraftStore.ts | 17 +++ apps/web/src/routes/_chat.draft.$draftId.tsx | 1 + 4 files changed, 168 insertions(+), 51 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e43bd23e130b..d0d05146c489 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -288,6 +288,7 @@ import { type DraftThreadEnvMode, finalizePromotedDraftThreadByRef, markPromotedDraftThreadByRef, + restoreFailedBackgroundDraftThread, useComposerDraftStore, DraftId, } from "../composerDraftStore"; @@ -7893,6 +7894,7 @@ export default function ChatView(props: ChatViewProps) { } let turnStartSucceeded = false; + let backgroundDraftOpened = false; if (failure === null && turnAttachmentsResult._tag === "Success") { const bootstrap = isLocalDraftThread || baseBranchForWorktree @@ -7931,7 +7933,7 @@ export default function ChatView(props: ChatViewProps) { if (backgroundThreadRef) { beginBackgroundDraftSubmissionByRef(backgroundThreadRef); } - const startResult = await startThreadTurn({ + const startPromise = startThreadTurn({ environmentId, input: { threadId: threadIdForSend, @@ -7975,10 +7977,32 @@ export default function ChatView(props: ChatViewProps) { createdAt: messageCreatedAt, }, }); - if (startResult._tag === "Failure") { - if (backgroundThreadRef) { + if (backgroundThreadRef) { + markPromotedDraftThreadByRef(backgroundThreadRef); + try { + backgroundDraftOpened = Boolean( + await handleNewThread( + scopeProjectRef(activeProject.environmentId, activeProject.id), + resolveBackgroundDraftWorkspaceOptions({ + envMode: sendEnvMode, + branch: activeThreadBranch, + startFromOrigin, + }), + ), + ); + } catch (error) { clearBackgroundDraftSubmissionByRef(backgroundThreadRef); + toastManager.add( + stackedThreadToast({ + type: "warning", + title: "Could not open a fresh composer", + description: error instanceof Error ? error.message : undefined, + }), + ); } + } + const startResult = await startPromise; + if (startResult._tag === "Failure") { failure = startResult; } else { turnStartSucceeded = true; @@ -7991,48 +8015,26 @@ export default function ChatView(props: ChatViewProps) { } acknowledgeActiveThreadWoke(); if (backgroundThreadRef) { - markPromotedDraftThreadByRef(backgroundThreadRef); - try { - const nextDraft = await handleNewThread( - scopeProjectRef(activeProject.environmentId, activeProject.id), - resolveBackgroundDraftWorkspaceOptions({ - envMode: sendEnvMode, - branch: activeThreadBranch, - startFromOrigin, - }), - ); - if (nextDraft) { - finalizePromotedDraftThreadByRef(backgroundThreadRef); - toastManager.add( - stackedThreadToast({ - type: "success", - title: "Started in background", - timeout: 5_000, - actionProps: { - children: "Open", - onClick: () => { - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(backgroundThreadRef), - }); - }, - }, - }), - ); - } else { - clearBackgroundDraftSubmissionByRef(backgroundThreadRef); - } - } catch (error) { + if (backgroundDraftOpened || currentRouteThreadKeyRef.current !== routeThreadKey) { + finalizePromotedDraftThreadByRef(backgroundThreadRef); + } else { clearBackgroundDraftSubmissionByRef(backgroundThreadRef); - resetLocalDispatch(); + } + if (backgroundDraftOpened) { toastManager.add( stackedThreadToast({ - type: "warning", - title: "Task started in the background", - description: - error instanceof Error - ? `Could not open a fresh composer: ${error.message}` - : "Could not open a fresh composer.", + type: "success", + title: "Started in background", + timeout: 5_000, + actionProps: { + children: "Open", + onClick: () => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(backgroundThreadRef), + }); + }, + }, }), ); } @@ -8041,6 +8043,16 @@ export default function ChatView(props: ChatViewProps) { } if (failure !== null) { + if (resolvedSubmissionIntent === "background" && draftId && draftThread) { + restoreFailedBackgroundDraftThread( + draftId, + draftThread, + wasBootstrapThreadDeleted(squashAtomCommandFailure(failure)) + ? newThreadId() + : threadIdForSend, + ); + clearBackgroundDraftSubmissionByRef(scopeThreadRef(environmentId, threadIdForSend)); + } if (queuedMessage) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); @@ -8059,14 +8071,18 @@ export default function ChatView(props: ChatViewProps) { }); } } else if ( - promptRef.current.length === 0 && - composerImagesRef.current.length === 0 && - composerFilesRef.current.length === 0 && - composerTerminalContextsRef.current.length === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations - .length ?? 0) === 0 && - (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments - .length ?? 0) === 0 + backgroundDraftOpened + ? !composerDraftHasUserContent( + useComposerDraftStore.getState().getComposerDraft(composerDraftTarget), + ) + : promptRef.current.length === 0 && + composerImagesRef.current.length === 0 && + composerFilesRef.current.length === 0 && + composerTerminalContextsRef.current.length === 0 && + (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) + ?.previewAnnotations.length ?? 0) === 0 && + (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments + .length ?? 0) === 0 ) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); @@ -8095,7 +8111,12 @@ export default function ChatView(props: ChatViewProps) { } if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); - if (isLocalDraftThread && draftId && wasBootstrapThreadDeleted(error)) { + if ( + resolvedSubmissionIntent !== "background" && + isLocalDraftThread && + draftId && + wasBootstrapThreadDeleted(error) + ) { const failedDraftSession = getDraftSession(draftId); if (failedDraftSession?.threadId === threadIdForSend) { setLogicalProjectDraftThreadId( @@ -8113,6 +8134,21 @@ export default function ChatView(props: ChatViewProps) { threadIdForSend, error instanceof Error ? error.message : "Failed to send message.", ); + if (backgroundDraftOpened && draftId) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Background task failed", + description: error instanceof Error ? error.message : "Failed to send message.", + actionProps: { + children: "Open draft", + onClick: () => { + void navigate({ to: "/draft/$draftId", params: { draftId } }); + }, + }, + }), + ); + } } } sendInFlightRef.current = false; diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 7b44b1b71128..92672348f97e 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -66,10 +66,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { COMPOSER_DRAFT_STORAGE_KEY, + beginBackgroundDraftSubmissionByRef, clearComposerDraftsEnvironment, composerDraftHasUserContent, finalizePromotedDraftThreadByRef, markPromotedDraftThreadByRef, + restoreFailedBackgroundDraftThread, type ComposerFileAttachment, type ComposerImageAttachment, composerFileNeedsReattach, @@ -1337,6 +1339,45 @@ describe("composerDraftStore project draft thread mapping", () => { ); }); + it.each([false, true])( + "restores a failed background draft without replacing the next draft (finalized: %s)", + (finalized) => { + const store = useComposerDraftStore.getState(); + const nextDraftId = DraftId.make("next-draft"); + const retryThreadId = ThreadId.make("retry-thread"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, + branch: "main", + envMode: "worktree", + startFromOrigin: true, + }); + const sentDraft = store.getDraftSession(draftId)!; + markPromotedDraftThreadByRef(threadRef); + store.setProjectDraftThreadId(projectRef, nextDraftId, { + threadId: ThreadId.make("next-thread"), + }); + store.setPrompt(nextDraftId, "My next task"); + const nextDraft = store.getDraftSession(nextDraftId); + if (finalized) finalizePromotedDraftThreadByRef(threadRef); + + restoreFailedBackgroundDraftThread(draftId, sentDraft, retryThreadId); + store.setPrompt(draftId, "Retry the first task"); + + expect(store.getDraftThreadByProjectRef(projectRef)?.draftId).toBe(nextDraftId); + expect(store.getDraftSession(nextDraftId)).toBe(nextDraft); + expect(store.getComposerDraft(nextDraftId)?.prompt).toBe("My next task"); + expect(store.getDraftSession(draftId)).toMatchObject({ + threadId: retryThreadId, + promotedTo: null, + branch: "main", + envMode: "worktree", + startFromOrigin: true, + }); + expect(store.getComposerDraft(draftId)?.prompt).toBe("Retry the first task"); + }, + ); + it("clears only matching project draft mapping entries", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); @@ -1588,6 +1629,28 @@ describe("composerDraftStore project draft thread mapping", () => { expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.prompt).toBe("typed during setup"); }); + it("cleans up a completed background draft without replacing the active draft", () => { + const store = useComposerDraftStore.getState(); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + const nextDraftId = DraftId.make("next-draft"); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + beginBackgroundDraftSubmissionByRef(threadRef); + markPromotedDraftThreadByRef(threadRef); + store.setProjectDraftThreadId(projectRef, nextDraftId, { + threadId: ThreadId.make("next-thread"), + }); + store.setPrompt(nextDraftId, "Keep my next task"); + + finalizePromotedDraftThreadByRef(threadRef); + + expect(store.getDraftSession(draftId)).toBeNull(); + expect(store.getDraftThreadByProjectRef(projectRef)?.draftId).toBe(nextDraftId); + expect(store.getComposerDraft(nextDraftId)?.prompt).toBe("Keep my next task"); + expect( + useComposerDraftStore.getState().backgroundSubmissionThreadKeys[scopedThreadKey(threadRef)], + ).toBeUndefined(); + }); + it("finalizes a matching materialized draft even when promotion was not pre-marked", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index e8c60911caa5..a5e333f218d6 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -4254,6 +4254,23 @@ export function markPromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { } } +export function restoreFailedBackgroundDraftThread( + draftId: DraftId, + draftThread: DraftThreadState, + threadId: ThreadId, +): void { + useComposerDraftStore.setState((state) => ({ + draftThreadsByThreadKey: { + ...state.draftThreadsByThreadKey, + [draftId]: { + ...draftThread, + threadId, + promotedTo: null, + }, + }, + })); +} + export function finalizePromotedDraftThreadByRef(threadRef: ScopedThreadRef): void { const draftStore = useComposerDraftStore.getState(); for (const [draftId, draftThread] of Object.entries(draftStore.draftThreadsByThreadKey)) { diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx index 04cdf3ce8c9b..9d393f27e0bd 100644 --- a/apps/web/src/routes/_chat.draft.$draftId.tsx +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -78,6 +78,7 @@ function DraftChatThreadRouteView() { return ( Date: Tue, 15 Sep 2026 21:37:03 +0530 Subject: [PATCH 03/12] fix(desktop): keep the sidebar brand and window buttons aligned (#11906) Co-authored-by: T3 Code Test --- apps/desktop/src/preload.ts | 15 +++++++- apps/desktop/src/window/DesktopWindow.test.ts | 36 +++++++++++++++++++ apps/desktop/src/window/DesktopWindow.ts | 24 ++++++++++++- apps/web/src/components/AppSidebarLayout.tsx | 2 +- .../src/components/sidebar/SidebarChrome.tsx | 7 ++-- 5 files changed, 78 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 453879d37afe..d4339c21e5fc 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -6,7 +6,7 @@ import type { DesktopSnapShotEvent, } from "@t3tools/contracts"; import { exposeClerkBridge } from "@clerk/electron/preload"; -import { contextBridge, ipcRenderer } from "electron"; +import { contextBridge, ipcRenderer, webFrame } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; @@ -32,6 +32,19 @@ exposeClerkBridge({ passkeys: true }); // oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process. const clientPlatform = process.platform; +if (clientPlatform === "darwin") { + // Native window buttons do not scale with Chromium zoom. Keep their reserved + // space in native points, including when a zoomed page is reloaded. + const syncWindowControlInset = () => { + document.documentElement.style.setProperty( + "--desktop-window-controls-inset", + `${90 / webFrame.getZoomFactor()}px`, + ); + }; + window.addEventListener("DOMContentLoaded", syncWindowControlInset, { once: true }); + window.addEventListener("resize", syncWindowControlInset); +} + function unwrapEnsureSshEnvironmentResult(result: unknown) { if ( typeof result === "object" && diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 338a02b26a1f..78834a55690e 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -77,6 +77,7 @@ function makeFakeBrowserWindow() { isDestroyed: vi.fn(() => false), getURL: vi.fn(() => "t3code-dev://app/"), getZoomLevel: vi.fn(() => zoomLevel), + getZoomFactor: vi.fn(() => 1.2 ** zoomLevel), setZoomLevel: vi.fn((level: number) => { zoomLevel = level; }), @@ -118,6 +119,7 @@ function makeFakeBrowserWindow() { setOpacity: vi.fn(), setTitle: vi.fn(), setTitleBarOverlay: vi.fn(), + setWindowButtonPosition: vi.fn(), show: vi.fn(), webContents, }; @@ -136,6 +138,7 @@ function makeFakeBrowserWindow() { reload: webContents.reload, send: webContents.send, setZoomLevel: webContents.setZoomLevel, + setWindowButtonPosition: window.setWindowButtonPosition, setBackgroundThrottling: webContents.setBackgroundThrottling, setAutoHideCursor: window.setAutoHideCursor, setFullScreen: window.setFullScreen, @@ -742,6 +745,39 @@ describe("DesktopWindow", () => { }), ); + it.effect("keeps macOS window buttons centered when zooming and leaving fullscreen", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); + + for (const direction of ["in", "in", "out", "reset", "out"] as const) { + yield* desktopWindow.zoomMain(direction); + const position = fakeWindow.setWindowButtonPosition.mock.lastCall?.[0]; + assert.isDefined(position); + // The 14-point native buttons should share the zoomed 52px header's center. + const headerCenter = 26 * fakeWindow.window.webContents.getZoomFactor(); + assert.isAtMost(Math.abs(position.y + 7 - headerCenter), 0.5); + assert.equal(position.x, 16); + } + + fakeWindow.isFullScreen.mockReturnValue(true); + fakeWindow.setWindowButtonPosition.mockClear(); + yield* desktopWindow.zoomMain("reset"); + assert.equal(fakeWindow.setWindowButtonPosition.mock.calls.length, 0); + + fakeWindow.isFullScreen.mockReturnValue(false); + fakeWindow.windowListeners.get("leave-full-screen")?.(); + assert.deepEqual(fakeWindow.setWindowButtonPosition.mock.lastCall, [{ x: 16, y: 19 }]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("uses the persisted main window bounds when opening the window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index e19a75962126..b3964cb929c7 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -31,6 +31,22 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; import { makeQuitShortcutHandler } from "./QuitHold.ts"; const TITLEBAR_HEIGHT = 40; +// Matches --workspace-topbar-height in apps/web/src/index.css. Native macOS +// buttons are 14 points tall and do not scale with the renderer's zoom. +const MACOS_WORKSPACE_TOPBAR_HEIGHT = 52; +const MACOS_WINDOW_BUTTON_RADIUS = 7; + +function syncMacosWindowButtons(window: Electron.BrowserWindow): void { + if (window.isDestroyed() || window.isFullScreen()) return; + window.setWindowButtonPosition({ + x: 16, + y: Math.round( + (MACOS_WORKSPACE_TOPBAR_HEIGHT * window.webContents.getZoomFactor()) / 2 - + MACOS_WINDOW_BUTTON_RADIUS, + ), + }); +} + const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; @@ -241,7 +257,10 @@ function getWindowTitleBarOptions( if (platform === "darwin") { return { titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 16, y: 18 }, + trafficLightPosition: { + x: 16, + y: MACOS_WORKSPACE_TOPBAR_HEIGHT / 2 - MACOS_WINDOW_BUTTON_RADIUS, + }, }; } @@ -660,6 +679,7 @@ export const make = Effect.gen(function* () { window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true); }); window.on("leave-full-screen", () => { + syncMacosWindowButtons(window); window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, false); }); } @@ -720,6 +740,7 @@ export const make = Effect.gen(function* () { clearDevelopmentLoadRetry(); developmentLoadRetryIndex = 0; window.setTitle(environment.displayName); + if (environment.platform === "darwin") syncMacosWindowButtons(window); }); window.webContents.on( "did-fail-load", @@ -990,6 +1011,7 @@ export const make = Effect.gen(function* () { webContents.setZoomLevel( direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), ); + if (environment.platform === "darwin") syncMacosWindowButtons(window.value); // Chromium pushes the new level down to embedded guests, which would zoom // the previewed page along with the app UI. The preview browser keeps its // own zoom, so put each guest back where the preview left it. diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 6769586f7fa8..c00565f45902 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -46,7 +46,7 @@ import { } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; +const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "var(--desktop-window-controls-inset, 90px)"; function subscribeToViewportWidth(onChange: () => void): () => void { window.addEventListener("resize", onChange); diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index afbbf7671dfc..5a332b86057f 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -91,11 +91,12 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { )} to="/" > - - + {/* Center the visible capitals, without the font's ascender/descender space. */} + + From 9a6b57be2ae601e6969669a8ad276f974b2a5978 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:58:56 -0700 Subject: [PATCH 04/12] fix(web): drop the filled well behind the sidebar header buttons (#11660) --- apps/web/src/components/sidebar/SidebarThreadHeader.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index 878235615b39..6f9a686e5219 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -122,9 +122,10 @@ export function SidebarThreadHeader({ ) : null} - {/* Segmented well: the icons read as one control instead of three loose - buttons competing with the search field beside them. */} -
+ {/* Unfilled like the search field beside it: the buttons carry their own + hover states, and a background well reads far louder on themed + palettes than on the base light and dark ones. */} +
{hasProjects ? ( <> {projectScope} From 2a264adc6f6c65f98d4273acaa1af567eca83f2a Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:41:54 +0530 Subject: [PATCH 05/12] fix(server): explain how to configure a missing Codex executable (#11345) --- .../src/provider/Layers/CodexProvider.ts | 5 ++- .../provider/Layers/ProviderRegistry.test.ts | 37 +++++++++++++------ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index a0e2b744c2a7..cdf40f73b1bd 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -628,7 +628,10 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu auth: { status: "unknown" }, message: installed ? `Codex app-server provider probe failed: ${error.message}.` - : "Codex CLI (`codex`) was not found on PATH.", + : `Could not start Codex CLI (\`${codexSettings.binaryPath}\`). Check Settings → Providers → Codex → Binary path on the server.` + + (codexSettings.binaryPath === "codex" + ? " Installing ChatGPT or Codex desktop may not add codex to PATH." + : " Make sure the configured executable exists and can be run."), }, }); } diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 988c89e1e679..caee1981d79f 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -513,20 +513,35 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); - it.effect("returns unavailable when codex is missing", () => + it.effect.each([ + "codex", + "/Applications/Custom App.app/Contents/Resources/codex", + "C:\\Tools\\codex.exe", + ])("explains how to configure a Codex executable that cannot start: %s", (binaryPath) => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.fail( + const settings = { ...defaultCodexSettings, binaryPath }; + const status = yield* checkCodexProviderStatus(settings, (input) => { + assert.strictEqual(input.binaryPath, binaryPath); + return Effect.fail( new CodexErrors.CodexAppServerSpawnError({ - command: "codex app-server", - cause: new Error("spawn codex ENOENT"), + command: `${binaryPath} app-server`, + cause: new Error("spawn ENOENT"), }), - ), - ); + ); + }); assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, false); assert.strictEqual(status.auth.status, "unknown"); - assert.strictEqual(status.message, "Codex CLI (`codex`) was not found on PATH."); + assert.include(status.message, binaryPath); + assert.include( + status.message, + "Settings → Providers → Codex → Binary path on the server", + ); + assert.strictEqual( + status.message?.includes("Installing ChatGPT or Codex desktop"), + binaryPath === "codex", + ); + assert.strictEqual(settings.binaryPath, binaryPath); }), ); @@ -2331,10 +2346,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "Real Codex probe against a missing binary should surface as 'error' in the aggregator", ); assert.strictEqual(codexPersonal?.installed, false); - assert.strictEqual( - codexPersonal?.message, - "Codex CLI (`codex`) was not found on PATH.", - ); + assert.include(codexPersonal?.message, missingBinary); + assert.include(codexPersonal?.message, "Settings → Providers → Codex → Binary path"); }).pipe(Effect.provide(runtimeServices)); }), ); From d1790aa14639a0361c02fbc5c326d0aa76f95a83 Mon Sep 17 00:00:00 2001 From: Michel Liao <107891771+Michel-Liao@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:26:06 -0400 Subject: [PATCH 06/12] fix(mobile): add missing thread rename action (#11503) --- .../src/components/ConfirmDialogHost.tsx | 83 ++++++++++++++----- .../src/features/home/HomeRouteScreen.tsx | 2 + apps/mobile/src/features/home/HomeScreen.tsx | 9 ++ .../src/features/home/useThreadListActions.ts | 51 +++++++++++- .../threads/ThreadNavigationSidebar.tsx | 4 + .../features/threads/thread-list-items.tsx | 10 ++- .../features/threads/thread-list-v2-items.tsx | 33 +++++--- .../threads/thread-title-rename.test.ts | 21 +++++ .../features/threads/thread-title-rename.ts | 14 ++++ 9 files changed, 190 insertions(+), 37 deletions(-) create mode 100644 apps/mobile/src/features/threads/thread-title-rename.test.ts create mode 100644 apps/mobile/src/features/threads/thread-title-rename.ts diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx index 521c5e36c32f..d7db39d39ae1 100644 --- a/apps/mobile/src/components/ConfirmDialogHost.tsx +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { Modal, Pressable, View } from "react-native"; +import { Modal, Pressable, TextInput, View } from "react-native"; import { cn } from "../lib/cn"; import { AppText } from "./AppText"; @@ -14,7 +14,20 @@ export type ConfirmDialogRequest = { readonly onCancel?: () => void; }; -let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null; +export type TextInputDialogRequest = { + readonly title: string; + readonly initialValue: string; + readonly cancelText?: string; + readonly confirmText: string; + readonly onConfirm: (value: string) => void; + readonly onCancel?: () => void; +}; + +type DialogRequest = + | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest } + | { readonly kind: "text-input"; readonly request: TextInputDialogRequest }; + +let presentRequest: ((request: DialogRequest) => void) | null = null; /** * Imperative confirm dialog, Alert.alert-shaped. Native iOS alerts already @@ -23,7 +36,11 @@ let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null; * once. Requires ConfirmDialogHost to be mounted at the app root. */ export function showConfirmDialog(request: ConfirmDialogRequest): void { - presentRequest?.(request); + presentRequest?.({ kind: "confirm", request }); +} + +export function showTextInputDialog(request: TextInputDialogRequest): void { + presentRequest?.({ kind: "text-input", request }); } /** @@ -33,42 +50,64 @@ export function showConfirmDialog(request: ConfirmDialogRequest): void { * button color and a dimmer message than the title. */ export function ConfirmDialogHost() { - const [request, setRequest] = useState(null); + const [presented, setPresented] = useState(null); + const [inputValue, setInputValue] = useState(""); useEffect(() => { - presentRequest = setRequest; + presentRequest = (request) => { + setInputValue(request.kind === "text-input" ? request.request.initialValue : ""); + setPresented(request); + }; return () => { presentRequest = null; }; }, []); const handleCancel = useCallback(() => { - request?.onCancel?.(); - setRequest(null); - }, [request]); + presented?.request.onCancel?.(); + setPresented(null); + }, [presented]); const handleConfirm = useCallback(() => { - request?.onConfirm(); - setRequest(null); - }, [request]); + if (presented?.kind === "confirm") { + presented.request.onConfirm(); + } else if (presented?.kind === "text-input") { + presented.request.onConfirm(inputValue); + } + setPresented(null); + }, [inputValue, presented]); + + const confirmDisabled = presented?.kind === "text-input" && inputValue.trim().length === 0; return ( - {request === null ? null : ( + {presented === null ? null : ( - {request.title} - {request.message === undefined ? null : ( + {presented.request.title} + {presented.kind === "confirm" && presented.request.message !== undefined ? ( - {request.message} + {presented.request.message} - )} + ) : null} + {presented.kind === "text-input" ? ( + + ) : null} - {request.cancelText ?? "Cancel"} + {presented.request.cancelText ?? "Cancel"} - {request.confirmText} + {presented.request.confirmText} diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 00731ce5aeec..b2da5af8ed26 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -50,6 +50,7 @@ export function HomeRouteScreen() { pinThread, unpinThread, moveThread, + renameThread, regenerateThreadTitle, unsettleThread, } = useThreadListActions(); @@ -200,6 +201,7 @@ export function HomeRouteScreen() { onPinThread={pinThread} onUnpinThread={unpinThread} onMoveThread={moveThread} + onRenameThread={renameThread} onRegenerateThreadTitle={regenerateThreadTitle} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 2b933c50315e..820f80f2d632 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -126,6 +126,7 @@ interface HomeScreenProps { thread: EnvironmentThreadShell, direction: ThreadMoveDestination, ) => Promise; + readonly onRenameThread: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; @@ -538,6 +539,10 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onRegenerateThreadTitle], ); + const handleRenameThread = useCallback( + (thread: EnvironmentThreadShell) => props.onRenameThread(thread), + [props.onRenameThread], + ); const handleDeleteThread = props.onDeleteThread; const handleUnsettleThread = props.onUnsettleThread; // The settled tail renders in pages; expansion resets when the filter @@ -868,6 +873,7 @@ export function HomeScreen(props: HomeScreenProps) { onSelectThread={props.onSelectThread} onDeleteThread={handleDeleteThread} onArchiveThread={props.onArchiveThread} + onRenameThread={handleRenameThread} onRegenerateThreadTitle={handleRegenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} @@ -901,6 +907,7 @@ export function HomeScreen(props: HomeScreenProps) { handleMoveThread, handlePinThread, handleRegenerateThreadTitle, + handleRenameThread, handleSettleThread, handleSnoozeThread, handleUnpinThread, @@ -1025,6 +1032,7 @@ export function HomeScreen(props: HomeScreenProps) { searchQuery={props.searchQuery} onArchiveThread={props.onArchiveThread} onDeleteThread={props.onDeleteThread} + onRenameThread={handleRenameThread} onRegenerateThreadTitle={handleRegenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} onSelectThread={props.onSelectThread} @@ -1049,6 +1057,7 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableClose, handleSwipeableWillOpen, handleRegenerateThreadTitle, + handleRenameThread, machineByEnvironmentId, queuedThreadKeys, props.onArchiveThread, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 72e45c9bbb4e..9b599110883c 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -4,10 +4,10 @@ import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/threa import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; -import { Alert } from "react-native"; +import { Alert, Platform } from "react-native"; import { withThreadDismissal } from "./thread-dismissal"; -import { showConfirmDialog } from "../../components/ConfirmDialogHost"; +import { showConfirmDialog, showTextInputDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; @@ -27,6 +27,7 @@ import { threadDropLifecycle, } from "../threads/threadOrder"; import { getThreadListV2OrderedSection } from "../threads/threadListV2"; +import { resolveThreadTitleRename } from "../threads/thread-title-rename"; /** Version skew: never send settle/unsettle to a server that predates them (capability defaults false on decode for older servers). */ @@ -240,6 +241,7 @@ export function useThreadListActions(): { thread: EnvironmentThreadShell, direction: ThreadMoveDestination, ) => Promise; + readonly renameThread: (thread: EnvironmentThreadShell) => void; readonly regenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise; } { const executeAction = useThreadActionExecutor(); @@ -474,6 +476,50 @@ export function useThreadListActions(): { }, [updateThreadMetadata], ); + const renameThread = useCallback( + (thread: EnvironmentThreadShell) => { + const commit = (title: string) => { + const resolution = resolveThreadTitleRename({ title, originalTitle: thread.title }); + if (resolution.action === "reject-empty") { + Alert.alert("Could not rename thread", "Thread title cannot be empty."); + return; + } + if (resolution.action === "noop") return; + selectionHaptic(); + void updateThreadMetadata({ + environmentId: thread.environmentId, + input: { threadId: thread.id, title: resolution.title }, + }).then((result) => { + if (result._tag === "Success") return; + const error = Cause.squash(result.cause); + Alert.alert( + "Could not rename thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread could not be renamed.", + ); + }); + }; + + if (Platform.OS === "ios") { + Alert.prompt( + "Rename thread", + undefined, + (title) => commit(title ?? ""), + "plain-text", + thread.title, + ); + return; + } + showTextInputDialog({ + title: "Rename thread", + initialValue: thread.title, + confirmText: "Rename", + onConfirm: commit, + }); + }, + [updateThreadMetadata], + ); // Plan against the complete section so filtering does not change a move. const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, { @@ -653,6 +699,7 @@ export function useThreadListActions(): { pinThread, unpinThread, moveThread, + renameThread, regenerateThreadTitle, }; } diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 0fb3aa5e6537..26093e87536f 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -171,6 +171,7 @@ function ThreadNavigationSidebarPane( pinThread, unpinThread, moveThread, + renameThread, regenerateThreadTitle, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); @@ -932,6 +933,7 @@ function ThreadNavigationSidebarPane( onSelectThread={handleSelectThread} onDeleteThread={confirmDeleteThread} onArchiveThread={archiveThread} + onRenameThread={renameThread} onRegenerateThreadTitle={regenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} @@ -1049,6 +1051,7 @@ function ThreadNavigationSidebarPane( fullSwipeWidth={props.width - 20} onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} + onRenameThread={renameThread} onRegenerateThreadTitle={regenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} onSelectThread={handleSelectThread} @@ -1091,6 +1094,7 @@ function ThreadNavigationSidebarPane( projectByKey, projectTitleByProjectKey, regenerateThreadTitle, + renameThread, props.onNewThreadInProject, props.onNewThreadOnBranch, props.searchQuery, diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index be6ca24b4001..af71ab613440 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -437,6 +437,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [ { id: "archive", title: "Archive", image: "archivebox" }, + { id: "rename", title: "Rename", image: "square.and.pencil" }, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; @@ -458,6 +459,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; + readonly onRenameThread: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; readonly titleRegenerationSupported: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; @@ -484,6 +486,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { onSelectThread, onArchiveThread, onDeleteThread, + onRenameThread, onRegenerateThreadTitle, onNewThreadOnBranch, } = props; @@ -525,6 +528,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); + const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); const handleRegenerateTitle = useCallback( () => onRegenerateThreadTitle(thread), [onRegenerateThreadTitle, thread], @@ -542,11 +546,12 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ] : []), THREAD_ROW_MENU_ACTIONS[0]!, + THREAD_ROW_MENU_ACTIONS[1]!, ...buildThreadTitleRegenerationMenuItems({ supported: props.titleRegenerationSupported, isRegenerating: thread.titleRegeneration != null, }), - THREAD_ROW_MENU_ACTIONS[1]!, + THREAD_ROW_MENU_ACTIONS[2]!, ], [props.titleRegenerationSupported, thread.branch, thread.titleRegeneration], ); @@ -563,10 +568,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread); if (nativeEvent.event === "archive") handleArchive(); + if (nativeEvent.event === "rename") handleRename(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); if (nativeEvent.event === "delete") handleDelete(); }, - [handleArchive, handleDelete, handleRegenerateTitle, onNewThreadOnBranch, thread], + [handleArchive, handleDelete, handleRegenerateTitle, handleRename, onNewThreadOnBranch, thread], ); const statusPill = effectiveStatus ? ( diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 60f96b2a06f3..02870c3ff22e 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -382,6 +382,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; + readonly onRenameThread: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void; @@ -423,6 +424,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { variant, onSelectThread, onDeleteThread, + onRenameThread, onRegenerateThreadTitle, onNewThreadOnBranch, onSettleThread, @@ -468,6 +470,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { settledTimestamp !== null ? relativeTime(settledTimestamp) : threadTimeLabel(thread); const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); + const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); const handleRegenerateTitle = useCallback( () => onRegenerateThreadTitle(thread), [onRegenerateThreadTitle, thread], @@ -560,12 +563,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { variant, ], ); - const titleRegenerationMenuItems = useMemo( - () => - buildThreadTitleRegenerationMenuItems({ + const titleMenuItems = useMemo( + () => [ + { id: "rename", title: "Rename", image: "square.and.pencil" }, + ...buildThreadTitleRegenerationMenuItems({ supported: props.titleRegenerationSupported, isRegenerating: thread.titleRegeneration != null, }), + ], [props.titleRegenerationSupported, thread.titleRegeneration], ); const snoozableCardMenuActions = useMemo( @@ -578,19 +583,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { subactions: snoozePresetActions, }, ...arrangementMenuItems, - ...titleRegenerationMenuItems, + ...titleMenuItems, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], - [arrangementMenuItems, snoozePresetActions, titleRegenerationMenuItems], + [arrangementMenuItems, snoozePresetActions, titleMenuItems], ); const cardMenuActions = useMemo( () => [ CARD_MENU_ACTIONS[0]!, ...arrangementMenuItems, - ...titleRegenerationMenuItems, + ...titleMenuItems, ...CARD_MENU_ACTIONS.slice(1), ], - [arrangementMenuItems, titleRegenerationMenuItems], + [arrangementMenuItems, titleMenuItems], ); const slimMenuActions = useMemo( () => [ @@ -598,23 +603,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ...arrangementMenuItems.filter( (action) => action.id !== "move-up" && action.id !== "move-down", ), - ...titleRegenerationMenuItems, + ...titleMenuItems, SLIM_MENU_ACTIONS[1]!, ], - [arrangementMenuItems, titleRegenerationMenuItems], + [arrangementMenuItems, titleMenuItems], ); const snoozedMenuActions = useMemo( - () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], - [titleRegenerationMenuItems], + () => [SNOOZED_MENU_ACTIONS[0]!, ...titleMenuItems, SNOOZED_MENU_ACTIONS[1]!], + [titleMenuItems], ); const legacyMenuActions = useMemo( () => [ LEGACY_MENU_ACTIONS[0]!, ...arrangementMenuItems, - ...titleRegenerationMenuItems, + ...titleMenuItems, LEGACY_MENU_ACTIONS[1]!, ], - [arrangementMenuItems, titleRegenerationMenuItems], + [arrangementMenuItems, titleMenuItems], ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -628,6 +633,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "move-up") handleMoveUp(); if (nativeEvent.event === "move-down") handleMoveDown(); if (nativeEvent.event === "archive") handleArchive(); + if (nativeEvent.event === "rename") handleRename(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); if (nativeEvent.event === "delete") handleDelete(); if (nativeEvent.event === "snooze:custom") { @@ -651,6 +657,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleArchive, handleDelete, handleRegenerateTitle, + handleRename, handleMoveDown, handleMoveUp, handlePin, diff --git a/apps/mobile/src/features/threads/thread-title-rename.test.ts b/apps/mobile/src/features/threads/thread-title-rename.test.ts new file mode 100644 index 000000000000..75bc5cdc4133 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-title-rename.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveThreadTitleRename } from "./thread-title-rename"; + +describe("resolveThreadTitleRename", () => { + it("trims a changed title", () => { + expect(resolveThreadTitleRename({ title: " New title ", originalTitle: "Old" })).toEqual({ + action: "rename", + title: "New title", + }); + }); + + it("rejects empty and unchanged titles", () => { + expect(resolveThreadTitleRename({ title: " ", originalTitle: "Old" })).toEqual({ + action: "reject-empty", + }); + expect(resolveThreadTitleRename({ title: " Old ", originalTitle: "Old" })).toEqual({ + action: "noop", + }); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-title-rename.ts b/apps/mobile/src/features/threads/thread-title-rename.ts new file mode 100644 index 000000000000..23f3e449eff2 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-title-rename.ts @@ -0,0 +1,14 @@ +export type ThreadTitleRenameResolution = + | { readonly action: "rename"; readonly title: string } + | { readonly action: "noop" } + | { readonly action: "reject-empty" }; + +export function resolveThreadTitleRename(input: { + readonly title: string; + readonly originalTitle: string; +}): ThreadTitleRenameResolution { + const title = input.title.trim(); + if (title.length === 0) return { action: "reject-empty" }; + if (title === input.originalTitle) return { action: "noop" }; + return { action: "rename", title }; +} From caf8b5d798909780a7b197cb264e782888b919c1 Mon Sep 17 00:00:00 2001 From: Michel Liao <107891771+Michel-Liao@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:28:18 -0400 Subject: [PATCH 07/12] fix(mobile): wait for thread deep link hydration (#11502) --- .../features/threads/ThreadRouteScreen.tsx | 43 +++++++++-- .../threads/thread-route-hydration.test.ts | 74 +++++++++++++++++++ .../threads/thread-route-hydration.ts | 38 ++++++++++ apps/mobile/src/state/shell.ts | 27 +++++++ 4 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/src/features/threads/thread-route-hydration.test.ts create mode 100644 apps/mobile/src/features/threads/thread-route-hydration.ts diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 0942647c7a71..f78553dbb7e9 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -25,6 +25,7 @@ import { import { Alert, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; +import { useEnvironmentShellState } from "../../state/shell"; import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key"; import { clearPendingThreadCreationOutcome } from "../../state/pending-thread-creation"; import { recoverFailedThreadDraft } from "../../state/recover-failed-thread-draft"; @@ -88,6 +89,7 @@ import { ThreadInspectorContentStack, type ThreadInspectorMode, } from "./thread-inspector-content-stack"; +import { threadRouteIsHydrating } from "./thread-route-hydration"; interface ThreadInspectorSelection { readonly routeThreadIdentity: string | null; @@ -123,7 +125,11 @@ interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps { readonly renderInspector?: (headerInset: number) => ReactNode; } -function ThreadUnavailableScreen() { +/** Shows recovery only after the target route has reached a terminal unavailable state. */ +function ThreadUnavailableScreen(props: { + readonly actionLabel: string; + readonly onAction: () => void; +}) { return ( ); @@ -152,6 +160,9 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { const threadIdRaw = firstRouteParam(params.threadId); const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); + const routeEnvironmentShellState = useEnvironmentShellState(environmentId); + const { onReconnectEnvironment } = useRemoteConnections(); + const navigation = useNavigation(); const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); const routeThreadKey = @@ -177,16 +188,36 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { return ; } - const stillHydrating = - workspaceState.isLoadingConnections || - routeConnectionState === "connecting" || - routeConnectionState === "reconnecting"; + const stillHydrating = threadRouteIsHydrating({ + isLoadingConnections: workspaceState.isLoadingConnections, + connectionState: routeConnectionState, + shellStatus: routeEnvironmentShellState.status, + shellHasError: Option.isSome(routeEnvironmentShellState.error), + detailStatus: selectedThreadDetailState.status, + detailHasError: Option.isSome(selectedThreadDetailState.error), + }); if (stillHydrating) { return ; } - return ; + return ( + { + if (routeEnvironmentRuntime !== null) { + onReconnectEnvironment(environmentId); + return; + } + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironments" }, + }); + }} + /> + ); } function ThreadRouteContent( diff --git a/apps/mobile/src/features/threads/thread-route-hydration.test.ts b/apps/mobile/src/features/threads/thread-route-hydration.test.ts new file mode 100644 index 000000000000..dfea7a57840c --- /dev/null +++ b/apps/mobile/src/features/threads/thread-route-hydration.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { threadRouteIsHydrating } from "./thread-route-hydration"; + +const settled = { + isLoadingConnections: false, + connectionState: "connected" as const, + shellStatus: "live" as const, + shellHasError: false, + detailStatus: "live" as const, + detailHasError: false, +}; + +describe("threadRouteIsHydrating", () => { + it("waits for shell and thread detail hydration", () => { + expect(threadRouteIsHydrating({ ...settled, shellStatus: "synchronizing" })).toBe(true); + expect(threadRouteIsHydrating({ ...settled, shellStatus: "empty" })).toBe(true); + expect(threadRouteIsHydrating({ ...settled, detailStatus: "synchronizing" })).toBe(true); + expect(threadRouteIsHydrating({ ...settled, detailStatus: "empty" })).toBe(true); + }); + + it("stops waiting once an empty detail has an actionable outcome", () => { + expect( + threadRouteIsHydrating({ + ...settled, + shellStatus: "empty", + shellHasError: true, + detailStatus: "deleted", + }), + ).toBe(false); + expect( + threadRouteIsHydrating({ + ...settled, + detailStatus: "empty", + detailHasError: true, + }), + ).toBe(false); + expect( + threadRouteIsHydrating({ + ...settled, + connectionState: "available", + detailStatus: "empty", + }), + ).toBe(false); + expect(threadRouteIsHydrating({ ...settled, detailStatus: "deleted" })).toBe(false); + }); + + it("prioritizes terminal outcomes over unrelated hydration", () => { + expect( + threadRouteIsHydrating({ + ...settled, + connectionState: "reconnecting", + shellStatus: "synchronizing", + detailStatus: "deleted", + }), + ).toBe(false); + expect( + threadRouteIsHydrating({ + ...settled, + connectionState: "reconnecting", + shellStatus: "synchronizing", + detailHasError: true, + }), + ).toBe(false); + expect( + threadRouteIsHydrating({ + ...settled, + connectionState: "offline", + shellStatus: "synchronizing", + detailStatus: "synchronizing", + }), + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-route-hydration.ts b/apps/mobile/src/features/threads/thread-route-hydration.ts new file mode 100644 index 000000000000..95a86b7bcb4c --- /dev/null +++ b/apps/mobile/src/features/threads/thread-route-hydration.ts @@ -0,0 +1,38 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; + +/** + * Reports whether the route-local projections can still yield the requested + * thread. Explicit terminal outcomes win over unrelated synchronization. + */ +export function threadRouteIsHydrating(input: { + readonly isLoadingConnections: boolean; + readonly connectionState: EnvironmentConnectionPhase; + readonly shellStatus: EnvironmentShellStatus; + readonly shellHasError: boolean; + readonly detailStatus: EnvironmentThreadStatus; + readonly detailHasError: boolean; +}): boolean { + if (input.detailStatus === "deleted" || input.shellHasError || input.detailHasError) { + return false; + } + if (input.isLoadingConnections) { + return true; + } + if ( + input.connectionState === "available" || + input.connectionState === "offline" || + input.connectionState === "error" + ) { + return false; + } + return ( + input.connectionState === "connecting" || + input.connectionState === "reconnecting" || + input.shellStatus === "synchronizing" || + (input.connectionState === "connected" && input.shellStatus === "empty") || + input.detailStatus === "synchronizing" || + (input.connectionState === "connected" && input.detailStatus === "empty") + ); +} diff --git a/apps/mobile/src/state/shell.ts b/apps/mobile/src/state/shell.ts index e879dd25e292..e20209c7cd81 100644 --- a/apps/mobile/src/state/shell.ts +++ b/apps/mobile/src/state/shell.ts @@ -3,7 +3,12 @@ import { createEnvironmentShellSummaryAtom, createEnvironmentSnapshotAtom, createShellEnvironmentAtoms, + type EnvironmentShellState, } from "@t3tools/client-runtime/state/shell"; +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; @@ -15,3 +20,25 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, shellStateValueAtom: environmentShell.stateValueAtom, }); + +const EMPTY_ENVIRONMENT_SHELL_STATE_ATOM = Atom.make( + AsyncResult.success({ + snapshot: Option.none(), + status: "empty", + error: Option.none(), + }), +).pipe(Atom.withLabel("mobile-environment-shell:empty")); + +/** Reads one environment's shell projection without waiting on other environments. */ +export function useEnvironmentShellState(environmentId: EnvironmentId | null) { + const result = useAtomValue( + environmentId === null + ? EMPTY_ENVIRONMENT_SHELL_STATE_ATOM + : environmentShell.stateAtom(environmentId), + ); + return Option.getOrElse(AsyncResult.value(result), () => ({ + snapshot: Option.none(), + status: "empty" as const, + error: Option.none(), + })); +} From d07ffbe351a10e1001f6d8eafa5b7533b8bd7e23 Mon Sep 17 00:00:00 2001 From: Dominic Roy Date: Tue, 15 Sep 2026 13:30:02 -0400 Subject: [PATCH 08/12] fix(mobile): keep iOS chat rows aligned after measurement (#11813) --- apps/mobile/src/features/threads/ThreadFeed.tsx | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 66c4359b8190..c0ab6f726ccf 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -83,7 +83,7 @@ import { isPdfFile } from "../../lib/filePreview"; import { flattenThemeColor } from "../../lib/mobileTheme"; import { PresentationSource } from "../../components/NativePresentation"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import Animated, { FadeIn, LinearTransition, type SharedValue } from "react-native-reanimated"; +import Animated, { FadeIn, type SharedValue } from "react-native-reanimated"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useFontFamily } from "../../lib/useFontFamily"; @@ -216,8 +216,6 @@ function formatMessageTime(input: string): string { // Fixed heights mirror renderFeedEntry's classNames and are only used while // text fits at the current font settings. Larger accessibility text is measured. const TURN_FOLD_HEIGHT = 42; // min-h-11 (38.5) + mb-1 (3.5), with the mobile 14px rem -const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); -const THREAD_FEED_IMMEDIATE_TRANSITION = LinearTransition.duration(0); // Tailwind spacing on the mobile 14px rem: px-3.5 on the user bubble, px-1 on // assistant rows. Images size their frame from these before their own layout. const USER_BUBBLE_HORIZONTAL_PADDING = 3.5 * 3.5; @@ -2825,17 +2823,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { entry.type === "message" ? `message:${entry.message.role}` : entry.type } getFixedItemSize={getFixedItemSize} - // Android can retain stale native row positions when layout transitions - // race the measurements arriving during sync, even with duration 0. - // Keep its rows on LegendList's non-animated positioning path. On iOS, - // keep a transition installed between disclosures so containers don't remount. - itemLayoutAnimation={ - Platform.OS === "android" - ? undefined - : disclosureToggleSettling - ? THREAD_FEED_LAYOUT_TRANSITION - : THREAD_FEED_IMMEDIATE_TRANSITION - } + // Virtualized rows must move with their measurements. Native layout + // transitions can retain stale positions during sync, even at duration 0. onItemSizeChanged={handleItemSizeChanged} // Measure rows well before they scroll into view so estimate→actual // corrections land offscreen instead of under the user's finger. From 438465d6b7dd2e50dd38862a0db1252d2d72ecfc Mon Sep 17 00:00:00 2001 From: eimexdev <130890337+eimexdev@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:43:47 -0700 Subject: [PATCH 09/12] feat: add customizable soft-tint project monograms (#11845) --- .../Layers/ProjectionPipeline.test.ts | 39 ++++++ apps/web/src/components/ProjectFavicon.tsx | 53 ++------ apps/web/src/components/ProjectMonogram.tsx | 52 ++++++++ .../settings/ProjectIconPickerDialog.test.tsx | 8 +- .../settings/ProjectIconPickerDialog.tsx | 119 +++++++++++++----- .../settings/ProjectSettingsPanel.tsx | 3 +- apps/web/src/projectIdentity.test.ts | 13 +- apps/web/src/projectIdentity.ts | 18 +-- docs/user/project-settings.md | 8 +- packages/contracts/src/orchestration.test.ts | 72 +++++++++++ packages/contracts/src/orchestration.ts | 9 ++ 11 files changed, 303 insertions(+), 91 deletions(-) create mode 100644 apps/web/src/components/ProjectMonogram.tsx diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 21c95142929f..90f7470a8c5c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -4353,6 +4353,45 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { }), ); + it.effect("persists and clears a project monogram", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const sql = yield* SqlClient.SqlClient; + const projectId = ProjectId.make("project-monogram"); + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-monogram-create"), + projectId, + title: "Monogram", + workspaceRoot: "/tmp/project-monogram", + defaultModelSelection: null, + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* engine.dispatch({ + type: "project.meta.update", + commandId: CommandId.make("cmd-monogram-save"), + projectId, + projectIcon: { kind: "lucide", name: "folder-code", color: "violet", monogram: "T3" }, + }); + const saved = yield* sql<{ + readonly icon: string | null; + }>`SELECT project_icon_json AS icon FROM projection_projects WHERE project_id = ${projectId}`; + assert.deepEqual(saved, [ + { icon: '{"kind":"lucide","name":"folder-code","color":"violet","monogram":"T3"}' }, + ]); + yield* engine.dispatch({ + type: "project.meta.update", + commandId: CommandId.make("cmd-monogram-clear"), + projectId, + projectIcon: null, + }); + const cleared = yield* sql<{ + readonly icon: string | null; + }>`SELECT project_icon_json AS icon FROM projection_projects WHERE project_id = ${projectId}`; + assert.deepEqual(cleared, [{ icon: null }]); + }), + ); + it.effect("re-creating a deleted thread id starts from an empty projection", () => Effect.gen(function* () { const engine = yield* OrchestrationEngineService; diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 17006889fb9c..edf9f23e23a7 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -11,6 +11,7 @@ import { useAtomValue } from "@effect/atom-react"; import { projectFaviconUrlAtom } from "../state/assets"; import { deriveProjectIdentity } from "../projectIdentity"; import { projectIconColorClassName } from "../projectIconColors"; +import { ProjectMonogram } from "./ProjectMonogram"; import { cn } from "~/lib/utils"; const DynamicIcon = lazy(() => @@ -42,6 +43,15 @@ export function ProjectFavicon(input: { faviconPath: project.faviconPath, }), ); + if (project.projectIcon?.kind === "lucide" && project.projectIcon.monogram) { + return ( + + ); + } if (project.projectIcon?.kind === "emoji") { return ( 0) { const identity = deriveProjectIdentity(projectName); - // Wrapped like the emoji and Lucide branches so the monogram sits where an - // favicon would. Menu items, buttons and the like pull every bare svg - // in with [&_svg]:-mx-0.5 to trim the padding stroke icons carry, and this - // tile has no such padding. return ( - + ); } diff --git a/apps/web/src/components/ProjectMonogram.tsx b/apps/web/src/components/ProjectMonogram.tsx new file mode 100644 index 000000000000..04a44e166ea2 --- /dev/null +++ b/apps/web/src/components/ProjectMonogram.tsx @@ -0,0 +1,52 @@ +import type { ProjectIconColor } from "@t3tools/contracts"; +import { projectIconColorClassName } from "../projectIconColors"; +import { cn } from "~/lib/utils"; + +const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +export function ProjectMonogram({ + text, + color, + className, +}: { + readonly text: string; + readonly color: ProjectIconColor; + readonly className?: string | undefined; +}) { + // Wrapped like the emoji and Lucide branches so the monogram sits where an + // favicon would. Menu items, buttons and the like pull every bare svg + // in with [&_svg]:-mx-0.5 to trim the padding stroke icons carry, and this + // tile has no such padding. + return ( + + ); +} diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx index 2280395ecf40..e1c08dfbabb4 100644 --- a/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.test.tsx @@ -41,7 +41,13 @@ import { ProjectIconPickerDialog } from "./ProjectIconPickerDialog"; describe("ProjectIconPickerDialog", () => { it("shows icons first and selects them for an automatic project", () => { const markup = renderToStaticMarkup( - {}} onSelect={() => {}} />, + {}} + onSelect={() => {}} + />, ); expect(markup).toContain('data-current="lucide"'); diff --git a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx index 7fce7a4fbb5b..8bcdcc617ef7 100644 --- a/apps/web/src/components/settings/ProjectIconPickerDialog.tsx +++ b/apps/web/src/components/settings/ProjectIconPickerDialog.tsx @@ -1,4 +1,11 @@ -import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { deriveProjectIdentity } from "../../projectIdentity"; +import { ProjectMonogram } from "../ProjectMonogram"; +import { + ProjectMonogramText, + type ProjectIconColor, + type ProjectIconOverride, +} from "@t3tools/contracts"; import { DynamicIcon, type IconName } from "lucide-react/dynamic"; import { useEffect, useMemo, useRef, useState } from "react"; import { @@ -24,7 +31,7 @@ import { ScrollArea } from "../ui/scroll-area"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; const DEFAULT_ICON: IconName = "folder-code"; -const DEFAULT_COLOR: ProjectIconColor = "blue"; +const isMonogramText = Schema.is(ProjectMonogramText); function iconLabel(name: string): string { return name @@ -35,23 +42,29 @@ function iconLabel(name: string): string { export function ProjectIconPickerDialog({ current, + projectName, open, onOpenChange, onSelect, }: { readonly current: ProjectIconOverride | null; + readonly projectName: string; readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly onSelect: (icon: ProjectIconOverride) => void; }) { - const [mode, setMode] = useState<"lucide" | "emoji">( - current?.kind === "emoji" ? "emoji" : "lucide", + const automatic = deriveProjectIdentity(projectName); + const [mode, setMode] = useState( + current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"), ); const [iconName, setIconName] = useState( current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON, ); const [color, setColor] = useState( - current?.kind === "lucide" ? current.color : DEFAULT_COLOR, + current && current.kind !== "emoji" ? current.color : automatic.color, + ); + const [letters, setLetters] = useState( + current?.kind === "lucide" && current.monogram ? current.monogram : automatic.monogram, ); const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻"); const [query, setQuery] = useState(""); @@ -60,21 +73,33 @@ export function ProjectIconPickerDialog({ useEffect(() => { if (open && !previousOpenRef.current) { - setMode(current?.kind === "emoji" ? "emoji" : "lucide"); + setMode( + current?.kind === "lucide" && current.monogram ? "monogram" : (current?.kind ?? "lucide"), + ); setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON); - setColor(current?.kind === "lucide" ? current.color : DEFAULT_COLOR); + setColor(current && current.kind !== "emoji" ? current.color : automatic.color); + setLetters( + current?.kind === "lucide" && current.monogram ? current.monogram : automatic.monogram, + ); setEmoji(current?.kind === "emoji" ? current.emoji : "💻"); setQuery(""); setCustomEmoji(""); } previousOpenRef.current = open; - }, [current, open]); + }, [current, open, automatic.color, automatic.monogram]); const icons = useMemo(() => filterProjectIconNames(query), [query]); const selectedColorClassName = projectIconColorClassName(color); + const monogram = letters.normalize("NFKC").trim().toUpperCase(); + const validMonogram = isMonogramText(monogram); const save = () => { + if (mode === "monogram" && !validMonogram) return; onSelect( - mode === "lucide" ? { kind: "lucide", name: iconName, color } : { kind: "emoji", emoji }, + mode === "monogram" + ? { kind: "lucide", name: DEFAULT_ICON, monogram, color } + : mode === "lucide" + ? { kind: "lucide", name: iconName, color } + : { kind: "emoji", emoji }, ); onOpenChange(false); }; @@ -84,7 +109,7 @@ export function ProjectIconPickerDialog({ Choose project icon - Pick any Lucide icon and color, or use an emoji. + Choose an icon, emoji, or monogram. { const value = next[0]; - if (value === "lucide" || value === "emoji") setMode(value); + if (value === "lucide" || value === "emoji" || value === "monogram") setMode(value); }} > Icons Emoji + Monogram + {mode !== "emoji" ? ( +
+
Color
+
+ {PROJECT_ICON_COLORS.map((option) => ( + + ))} +
+
+ ) : null} + {mode === "lucide" ? ( <> -
-
Color
-
- {PROJECT_ICON_COLORS.map((option) => ( - - ))} -
-
No icons found.

) : null} + ) : mode === "monogram" ? ( +
+ +
+ + setLetters(event.currentTarget.value)} + aria-describedby="project-monogram-hint" + aria-invalid={!validMonogram} + autoComplete="off" + /> +

+ One or two letters or numbers. +

+
+
) : ( <> @@ -197,7 +250,9 @@ export function ProjectIconPickerDialog({ - +
diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 1a0f31d77836..3e4cfd22a937 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -435,7 +435,7 @@ function ProjectDetail({ title="Project icon" description={ projectIcon?.kind === "lucide" - ? `${projectIcon.name} · ${projectIcon.color}` + ? `${projectIcon.monogram ?? projectIcon.name} · ${projectIcon.color}` : projectIcon?.kind === "emoji" ? projectIcon.emoji : (faviconPath ?? "Automatic") @@ -530,6 +530,7 @@ function ProjectDetail({ void setProjectIcon({ faviconPath: null, projectIcon: icon })} diff --git a/apps/web/src/projectIdentity.test.ts b/apps/web/src/projectIdentity.test.ts index 942e76fc91b8..250fce32810a 100644 --- a/apps/web/src/projectIdentity.test.ts +++ b/apps/web/src/projectIdentity.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; +import { PROJECT_ICON_COLORS } from "./projectIconColors"; import { deriveProjectIdentity } from "./projectIdentity"; describe("deriveProjectIdentity", () => { @@ -18,14 +19,20 @@ describe("deriveProjectIdentity", () => { const canonical = deriveProjectIdentity("Nebula"); const equivalent = deriveProjectIdentity(" NEBULA "); - expect(equivalent.background).toBe(canonical.background); - expect(equivalent.highlight).toBe(canonical.highlight); + expect(equivalent.color).toBe(canonical.color); + }); + + it("uses only colors available in the icon picker", () => { + const palette = PROJECT_ICON_COLORS.map(({ value }) => value); + for (const name of ["Jobs", "Scripts and Extractors", "T3", "文書", "", "---"]) { + expect(palette).toContain(deriveProjectIdentity(name).color); + } }); it("generates different hues for different project names", () => { const colors = new Set( ["Nebula", "M7 Forge", "Silver Orchard", "Blue Harbor", "Copper Finch", "Juniper Vale"].map( - (projectName) => deriveProjectIdentity(projectName).background, + (projectName) => deriveProjectIdentity(projectName).color, ), ); diff --git a/apps/web/src/projectIdentity.ts b/apps/web/src/projectIdentity.ts index 661a07553a65..840856aa403d 100644 --- a/apps/web/src/projectIdentity.ts +++ b/apps/web/src/projectIdentity.ts @@ -1,8 +1,10 @@ +import { PROJECT_ICON_COLORS } from "./projectIconColors"; +import type { ProjectIconColor } from "@t3tools/contracts"; + /** Visual identity tokens for a generated project badge. */ export interface ProjectIdentity { readonly monogram: string; - readonly background: string; - readonly highlight: string; + readonly color: ProjectIconColor; } function normalizeProjectName(projectName: string): string { @@ -23,21 +25,19 @@ function projectMonogram(projectName: string): string { return Array.from(`${first}${second}`.toUpperCase()).slice(0, 2).join(""); } -function projectHue(projectName: string): number { +function projectColor(projectName: string): ProjectIconColor { const seed = normalizeProjectName(projectName).toLocaleLowerCase("en-US") || "project"; - let hue = 0; + let index = 0; for (const glyph of seed) { - hue = (hue * 31 + (glyph.codePointAt(0) ?? 0)) % 360; + index = (index * 31 + (glyph.codePointAt(0) ?? 0)) % PROJECT_ICON_COLORS.length; } - return hue; + return PROJECT_ICON_COLORS[index]?.value ?? "blue"; } /** Derives the stable monogram and generated colors used when a project has no icon. */ export function deriveProjectIdentity(projectName: string): ProjectIdentity { - const hue = projectHue(projectName); return { monogram: projectMonogram(projectName), - background: `hsl(${hue} 48% 36%)`, - highlight: `hsl(${(hue + 24) % 360} 58% 48%)`, + color: projectColor(projectName), }; } diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 2985c6c011c9..372e92b9a10a 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -43,12 +43,14 @@ Browser access changes apply when an agent session next starts. ## Project icons -Select the project and open Project to choose an icon, emoji, or image. The choice applies to +Select the project and open Project to choose an icon, emoji, monogram, or image. The choice applies to every checkout in the project group and appears on connected clients. Choose **Automatic** to let T3 Code detect an icon again. -When no image is found, web and desktop show a two-character monogram with colors -derived from the saved project name. For example, `Nebula` becomes `NA`, +Choose **Monogram** in the icon picker to set one or two letters or numbers and a color. + +When no image is found, web and desktop show a two-character monogram with a color +from the icon palette, derived from the saved project name. For example, `Nebula` becomes `NA`, `Silver Orchard` becomes `SO`, and `M7 Forge` becomes `M7`. ## Keep the default branch current diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index b8c8358813d4..5228e296d68f 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -17,6 +17,8 @@ import { OrchestrationGetTurnDiffInput, OrchestrationLatestTurn, ProjectCreatedPayload, + OrchestrationProjectShell, + ProjectIconColor, ProjectMetaUpdatedPayload, OrchestrationProposedPlan, OrchestrationSession, @@ -40,6 +42,18 @@ import { ProviderInstanceId } from "./providerInstance.ts"; const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); const decodeFullThreadDiffInput = Schema.decodeUnknownEffect(OrchestrationGetFullThreadDiffInput); const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); +// The icon shape understood by clients released before monograms. +const legacyProjectIcon = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("lucide"), name: Schema.String, color: ProjectIconColor }), + Schema.Struct({ kind: Schema.Literal("emoji"), emoji: Schema.String }), +]); +const decodeLegacyProjectShell = Schema.decodeUnknownEffect( + Schema.Struct({ + ...OrchestrationProjectShell.fields, + projectIcon: Schema.optional(Schema.NullOr(legacyProjectIcon)), + }), +); +const encodeProjectShell = Schema.encodeEffect(OrchestrationProjectShell); const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand); const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload); const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload); @@ -1494,6 +1508,64 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () => }), ); +it.effect("older clients decode monogram projects as their fallback icon", () => + Effect.gen(function* () { + const encoded = yield* encodeProjectShell({ + id: ProjectId.make("project-monogram"), + title: "Monogram", + workspaceRoot: "/tmp/monogram", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + projectIcon: { kind: "lucide", name: "folder-code", color: "violet", monogram: "T3" }, + }); + const decoded = yield* decodeLegacyProjectShell(encoded); + assert.deepEqual(decoded.projectIcon, { kind: "lucide", name: "folder-code", color: "violet" }); + }), +); + +it.effect("project monograms validate text and palette colors", () => + Effect.gen(function* () { + for (const text of ["A", "T3", "É", "文書", "कि", "किखि", "e\u0301"]) { + const projectIcon = { + kind: "lucide", + name: "folder-code", + color: "violet", + monogram: text, + } as const; + const command = yield* decodeOrchestrationCommand({ + type: "project.meta.update", + commandId: "cmd-monogram", + projectId: "project-1", + projectIcon, + }); + assert.strictEqual(command.type, "project.meta.update"); + if (command.type === "project.meta.update") + assert.deepEqual(command.projectIcon, projectIcon); + } + for (const projectIcon of [ + { kind: "lucide", name: "folder-code", monogram: "", color: "blue" }, + { kind: "lucide", name: "folder-code", monogram: "ABC", color: "blue" }, + { kind: "lucide", name: "folder-code", monogram: "किखिगि", color: "blue" }, + { kind: "lucide", name: "folder-code", monogram: "\u0301", color: "blue" }, + { kind: "lucide", name: "folder-code", monogram: "A B", color: "blue" }, + { kind: "lucide", name: "folder-code", monogram: "🚀", color: "blue" }, + { kind: "lucide", name: "folder-code", monogram: "T3", color: "ultraviolet" }, + ]) { + const result = yield* Effect.exit( + decodeOrchestrationCommand({ + type: "project.meta.update", + commandId: "cmd-monogram-invalid", + projectId: "project-1", + projectIcon, + }), + ); + assert.strictEqual(result._tag, "Failure"); + } + }), +); + it.effect("rejects thread history imports without messages", () => Effect.gen(function* () { const result = yield* Effect.exit( diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index aa2dd54fecb1..dc8a0732198c 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -459,11 +459,20 @@ const ProjectLucideIconName = TrimmedNonEmptyString.check( const ProjectEmoji = TrimmedNonEmptyString.check(Schema.isMaxLength(32)); +const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +export const ProjectMonogramText = TrimmedNonEmptyString.check( + Schema.isMaxLength(32), + Schema.isPattern(/^[\p{L}\p{N}][\p{L}\p{N}\p{M}\u200c\u200d]*$/u), + Schema.makeFilter((text) => Array.from(monogramSegmenter.segment(text)).length <= 2), +); + export const ProjectIconOverride = Schema.Union([ Schema.Struct({ kind: Schema.Literal("lucide"), name: ProjectLucideIconName, color: ProjectIconColor, + // Older clients ignore this field and render the named Lucide icon instead. + monogram: Schema.optional(ProjectMonogramText), }), Schema.Struct({ kind: Schema.Literal("emoji"), From 24b711b7f9ccba03518551991e466e2e4f8ace5a Mon Sep 17 00:00:00 2001 From: Kriday Dave Date: Tue, 15 Sep 2026 23:16:03 +0530 Subject: [PATCH 10/12] fix: multiple UI and server bug fixes (#11593) --- .../t3-terminal/ios/T3TerminalView.swift | 1 + apps/server/src/sourceControl/ForgejoCli.ts | 42 ++++++++++--------- .../components/chat/ModelPickerSidebar.tsx | 2 +- .../components/chat/ProviderInstanceIcon.tsx | 2 +- .../components/chat/ProviderModelPicker.tsx | 10 ++++- apps/web/src/components/ui/dialog-styles.ts | 2 +- 6 files changed, 36 insertions(+), 23 deletions(-) diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 262cc8a8a74d..69f8a6d4af16 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -410,6 +410,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { return false } + emitInput("\u{7F}") return false } diff --git a/apps/server/src/sourceControl/ForgejoCli.ts b/apps/server/src/sourceControl/ForgejoCli.ts index 9fdb4149c8b6..0eacba0759cf 100644 --- a/apps/server/src/sourceControl/ForgejoCli.ts +++ b/apps/server/src/sourceControl/ForgejoCli.ts @@ -366,25 +366,6 @@ export const make = Effect.gen(function* () { .execute(request) .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); const status = response.status; - if (status < 200 || status >= 300) - return yield* new ForgejoCliError({ - command: "fj", - cwd: input.cwd, - httpStatus: status, - ...(status === 401 - ? { reason: "authentication" as const } - : status === 403 - ? { reason: "forbidden" as const } - : status === 404 - ? { reason: "not-found" as const } - : status === 429 - ? { reason: "rate-limit" as const } - : {}), - detail: - status === 404 - ? "Forgejo repository or pull request was not found." - : `Forgejo API request failed (HTTP ${status}). Check this server's fj credentials and permissions.`, - }); const body = status === 204 || status === 205 ? { text: "", truncated: false, invalidUtf8: false } @@ -399,6 +380,29 @@ export const make = Effect.gen(function* () { reason: "invalid-response", detail: "Forgejo returned an oversized or invalid response.", }); + if (status < 200 || status >= 300) { + const detail = + status === 404 + ? "Forgejo repository or pull request was not found." + : body.text + ? `Forgejo API request failed (HTTP ${status}): ${body.text}` + : `Forgejo API request failed (HTTP ${status}). Check this server's fj credentials and permissions.`; + return yield* new ForgejoCliError({ + command: "fj", + cwd: input.cwd, + httpStatus: status, + ...(status === 401 + ? { reason: "authentication" as const } + : status === 403 + ? { reason: "forbidden" as const } + : status === 404 + ? { reason: "not-found" as const } + : status === 429 + ? { reason: "rate-limit" as const } + : {}), + detail, + }); + } return { exitCode: ChildProcessSpawner.ExitCode(0), stdout: body.text, diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 30f9e3ce809b..19a542d2aa2f 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -203,7 +203,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { displayName={entry.displayName} accentColor={entry.accentColor} showBadge={showInstanceBadge} - className="size-6" + className="size-6 z-30" iconClassName="size-5" indicatorBackground={ isHovered && !isDisabled diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index 4a40ed15bcb3..b9f7853cd8ec 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -29,7 +29,7 @@ export const ProviderInstanceIcon = memo(function ProviderInstanceIcon(props: { return ( {props.triggerLabel ?? triggerTitle} - {props.triggerLabel ?? triggerLabel} + {triggerTooltipContent} {selectedModel?.isUnavailable && props.triggerLabel === undefined ? ( diff --git a/apps/web/src/components/ui/dialog-styles.ts b/apps/web/src/components/ui/dialog-styles.ts index 4dda3fe93924..f2eeae928e99 100644 --- a/apps/web/src/components/ui/dialog-styles.ts +++ b/apps/web/src/components/ui/dialog-styles.ts @@ -5,7 +5,7 @@ const DIALOG_BACKDROP_CLASS = `dialog-backdrop ${DIALOG_BACKDROP_BASE_CLASS}`; const DIALOG_MEDIA_BACKDROP_CLASS = `${DIALOG_BACKDROP_BASE_CLASS} bg-black/75 backdrop-blur-none`; const DIALOG_POPUP_BASE_CLASS = - "-translate-y-[calc(1.25rem*var(--nested-dialogs))] relative flex min-h-0 w-full min-w-0 scale-[calc(1-0.1*var(--nested-dialogs))] flex-col opacity-[calc(1-0.1*var(--nested-dialogs))] outline-none transition-[scale,opacity,translate] duration-200 ease-in-out will-change-transform data-nested:data-ending-style:translate-y-8 data-nested:data-starting-style:translate-y-8 data-nested-dialog-open:origin-top data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0"; + "-translate-y-[calc(1.25rem*var(--nested-dialogs))] relative flex min-h-0 w-full min-w-0 scale-[calc(1-0.1*var(--nested-dialogs))] flex-col opacity-[calc(1-0.1*var(--nested-dialogs))] outline-none transition-[scale,opacity,translate] duration-200 ease-in-out will-change-transform data-nested:data-ending-style:translate-y-8 data-nested:data-starting-style:translate-y-8 data-nested-dialog-open:origin-top data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 [-webkit-app-region:no-drag]"; const DIALOG_POPUP_CLASS = `dialog-glass ${DIALOG_POPUP_BASE_CLASS} rounded-2xl border`; const DIALOG_MEDIA_POPUP_CLASS = `${DIALOG_POPUP_BASE_CLASS} max-h-[92vh] w-auto max-w-[92vw] overflow-visible rounded-none border border-transparent bg-transparent p-0 shadow-none`; From 7235701de052658cd78cb029b0df71af4b8dc21f Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:18:31 +0530 Subject: [PATCH 11/12] fix(server): release preview hosts after unanswered requests (#11381) Co-authored-by: yashranaway --- .../src/mcp/PreviewAutomationBroker.test.ts | 132 ++++++++++++++++++ .../server/src/mcp/PreviewAutomationBroker.ts | 8 +- 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 27557d43b701..0371c97c5fd9 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -15,10 +15,13 @@ import { type PreviewAutomationStreamEvent, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; @@ -1105,3 +1108,132 @@ it.effect("accepts responses only from the host that received the request", () = }), ), ); + +it.effect("evicts an unanswered host and lets later calls use a healthy runtime", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const connected = yield* Deferred.make(); + const received = yield* Deferred.make(); + const otherReceived = yield* Deferred.make(); + const otherCompleted = yield* Deferred.make(); + const oldTab = PreviewTabId.make("tab-on-frozen-host"); + const events = yield* broker.connect(makeHost()); + const consumer = yield* Stream.runForEach(events, (event) => { + if (event.type === "connected") return Deferred.succeed(connected, event.connectionId); + const request = { ...event.request, connectionId: event.connectionId }; + if (request.operation === "open") { + return broker.respond({ + clientId: "client-1", + connectionId: event.connectionId, + requestId: request.requestId, + ok: true, + result: { tabId: oldTab }, + }); + } + return request.operation === "snapshot" + ? Deferred.succeed(received, request) + : Deferred.succeed(otherReceived, undefined); + }).pipe(Effect.forkScoped); + const connectionId = yield* Deferred.await(connected); + yield* broker.invoke({ scope, operation: "open", input: {} }); + + const healthyConnected = yield* Deferred.make(); + const healthyRequests: RoutedRequest[] = []; + const healthy = yield* broker.connect(makeHost({ clientId: "healthy" })); + yield* Stream.runForEach(healthy, (event) => { + if (event.type === "connected") return Deferred.succeed(healthyConnected, undefined); + healthyRequests.push({ ...event.request, connectionId: event.connectionId }); + return broker.respond({ + clientId: "healthy", + connectionId: event.connectionId, + requestId: event.request.requestId, + ok: true, + result: "healthy", + }); + }).pipe(Effect.forkScoped); + yield* Deferred.await(healthyConnected); + + const timedOut = yield* broker + .invoke({ + scope, + operation: "snapshot", + input: {}, + timeoutMs: 1_000, + }) + .pipe(Effect.flip, Effect.forkScoped); + const lateRequest = yield* Deferred.await(received); + const other = yield* broker + .invoke({ + scope, + operation: "evaluate", + input: {}, + timeoutMs: 10_000, + }) + .pipe( + Effect.flip, + Effect.tap(() => Deferred.succeed(otherCompleted, undefined)), + Effect.forkScoped, + ); + yield* Deferred.await(otherReceived); + yield* TestClock.adjust(1_000); + expect(yield* Fiber.join(timedOut)).toMatchObject({ _tag: "PreviewAutomationTimeoutError" }); + expect(yield* Deferred.isDone(otherCompleted)).toBe(true); + expect(yield* Fiber.join(other)).toMatchObject({ + _tag: "PreviewAutomationClientDisconnectedError", + }); + const consumerExit = yield* Fiber.await(consumer); + expect(Exit.isFailure(consumerExit)).toBe(true); + if (Exit.isFailure(consumerExit)) { + expect(Cause.hasInterruptsOnly(consumerExit.cause)).toBe(true); + } + + // Late traffic from the evicted connection cannot restore its assignment. + yield* broker.respond({ + clientId: "client-1", + connectionId, + requestId: lateRequest.requestId, + ok: true, + result: { tabId: oldTab }, + }); + yield* broker.focusHost({ + clientId: "client-1", + connectionId, + environmentId: scope.environmentId, + focused: true, + }); + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("healthy"); + expect(healthyRequests).toHaveLength(1); + expect(healthyRequests[0]?.tabId).toBeUndefined(); + }), + ), +); + +it.effect("keeps a host that responds with an operation timeout", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const connected = yield* Deferred.make(); + const events = yield* broker.connect(makeHost()); + yield* Stream.runForEach(events, (event) => { + if (event.type === "connected") return Deferred.succeed(connected, undefined); + return broker.respond({ + clientId: "client-1", + connectionId: event.connectionId, + requestId: event.request.requestId, + ...(event.request.operation === "waitFor" + ? { + ok: false, + error: { _tag: "PreviewAutomationTimeoutError", message: "Selector timed out" }, + } + : { ok: true, result: "responsive" }), + }); + }).pipe(Effect.forkScoped); + yield* Deferred.await(connected); + expect( + yield* broker.invoke({ scope, operation: "waitFor", input: {} }).pipe(Effect.flip), + ).toMatchObject({ _tag: "PreviewAutomationTimeoutError" }); + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe("responsive"); + }), + ), +); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 8d92059bde8a..418835ae81d9 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -575,7 +575,13 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { } const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); return yield* Option.match(result, { - onNone: () => Effect.fail(new PreviewAutomationTimeoutError(requestContext)), + onNone: () => + Effect.gen(function* () { + // An unanswered request invalidates this connection. Do not replay + // actions: the client may have applied them before becoming unreachable. + yield* disconnect(connection.clientId, connection.queue); + return yield* new PreviewAutomationTimeoutError(requestContext); + }), onSome: (value) => Effect.succeed(value as A), }); }); From 3efdcc5296f1754e0f3bf7fee5fc2ada510e0438 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 15 Sep 2026 11:08:06 -0700 Subject: [PATCH 12/12] Preserve diff tree order and collapsed folders (#11931) --- .../components/diffs/DiffFileTree.test.tsx | 17 ++++++++++ .../web/src/components/diffs/DiffFileTree.tsx | 33 +++++++++++++++++-- .../diffs/diffFileTree.logic.test.ts | 31 +++++++++++++++++ .../components/diffs/diffFileTree.logic.ts | 30 ++++++++++++++++- 4 files changed, 107 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/diffs/DiffFileTree.test.tsx b/apps/web/src/components/diffs/DiffFileTree.test.tsx index 3a97c60254ab..d7767ab94258 100644 --- a/apps/web/src/components/diffs/DiffFileTree.test.tsx +++ b/apps/web/src/components/diffs/DiffFileTree.test.tsx @@ -178,6 +178,23 @@ describe("diff tree file activation", () => { expect(targets).toEqual([]); }); + it("reorders refreshed files without reopening a collapsed folder", async () => { + const files: DiffFileTreeEntry[] = [ + { path: "src/state/shell.ts", status: "modified" }, + { path: "src/features/route.ts", status: "added" }, + ]; + await mount({ files }); + const initialFolder = model().getItem("src/features/")!; + if (!("collapse" in initialFolder)) throw new Error("Expected the directory handle"); + await act(async () => initialFolder.collapse()); + await act(async () => { + renderer!.update(); + }); + const folder = model().getItem("src/features/")!; + if (!("isExpanded" in folder)) throw new Error("Expected the directory handle"); + expect(folder.isExpanded()).toBe(false); + }); + it("does not echo controlled selection, but lets the reader activate it", async () => { await mount({ selectedPath: "02-short.ts" }); expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); diff --git a/apps/web/src/components/diffs/DiffFileTree.tsx b/apps/web/src/components/diffs/DiffFileTree.tsx index 0d200853bcb4..9108c0556cc9 100644 --- a/apps/web/src/components/diffs/DiffFileTree.tsx +++ b/apps/web/src/components/diffs/DiffFileTree.tsx @@ -1,7 +1,7 @@ import type { GitStatusEntry } from "@pierre/trees"; import { FileTree, useFileTree, useFileTreeSelector } from "@pierre/trees/react"; import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; -import { useEffect, useMemo, useRef, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useTheme } from "~/hooks/useTheme"; import { cn } from "~/lib/utils"; @@ -13,7 +13,9 @@ import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { buildDiffFileTreeUpdates, + compareDiffFileTreeEntries, collectDirectoryPaths, + diffFileTreePositions, type DiffFileTreeEntry, } from "./diffFileTree.logic"; @@ -54,6 +56,16 @@ export function DiffFileTree({ const { resolvedTheme } = useTheme(); const paths = useMemo(() => entries.map((entry) => entry.path), [entries]); const directoryPaths = useMemo(() => collectDirectoryPaths(paths), [paths]); + const positions = useMemo(() => diffFileTreePositions(paths), [paths]); + const [ordering] = useState(() => { + let currentPositions: ReadonlyMap = new Map(); + return { + sort: compareDiffFileTreeEntries(() => currentPositions), + update: (nextPositions: ReadonlyMap) => { + currentPositions = nextPositions; + }, + }; + }); const gitStatus = useMemo>( () => entries.map((entry) => ({ path: entry.path, status: entry.status })), [entries], @@ -83,6 +95,7 @@ export function DiffFileTree({ }, paths: [], search: false, + sort: ordering.sort, unsafeCSS: PIERRE_TREE_UNSAFE_CSS, }); const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => @@ -90,17 +103,31 @@ export function DiffFileTree({ ); useEffect(() => { + ordering.update(positions); const mountedPaths = mountedPathsRef.current; if (mountedPaths === paths) return; mountedPathsRef.current = paths; if (mountedPaths === null) { model.resetPaths(paths); - } else { + } else if (mountedPaths.every((path, index) => paths[index] === path)) { + // PR slices only append files, so keep the existing tree and its open folders. const updates = buildDiffFileTreeUpdates(mountedPaths, paths); if (updates.length > 0) model.batch(updates); + } else { + // A refreshed diff can change the rank of existing siblings. Mutations do not reorder + // those rows, so rebuild while carrying the reader's folder expansion forward. + const collapsedDirectories = directoryPaths.filter((path) => { + const directory = model.getItem(path); + return directory !== null && "isExpanded" in directory && !directory.isExpanded(); + }); + model.resetPaths(paths); + for (const path of collapsedDirectories) { + const directory = model.getItem(path); + if (directory !== null && "collapse" in directory) directory.collapse(); + } } model.setGitStatus(gitStatus); - }, [gitStatus, model, paths]); + }, [directoryPaths, gitStatus, model, ordering, paths, positions]); useEffect(() => { if (selectedPath === null) { diff --git a/apps/web/src/components/diffs/diffFileTree.logic.test.ts b/apps/web/src/components/diffs/diffFileTree.logic.test.ts index d8e24968dcea..b21deedb8e54 100644 --- a/apps/web/src/components/diffs/diffFileTree.logic.test.ts +++ b/apps/web/src/components/diffs/diffFileTree.logic.test.ts @@ -1,9 +1,12 @@ import type { FileDiffMetadata } from "@pierre/diffs"; +import { preloadFileTree } from "@pierre/trees"; import { describe, expect, it } from "vite-plus/test"; import { buildDiffFileTreeUpdates, + compareDiffFileTreeEntries, collectDirectoryPaths, + diffFileTreePositions, diffFileTreeEntries, } from "./diffFileTree.logic"; @@ -41,6 +44,34 @@ describe("collectDirectoryPaths", () => { }); }); +describe("diff tree reading order", () => { + it("places folders and files where their first diff appears", () => { + const paths = [ + "apps/mobile/src/state/shell.ts", + "apps/mobile/src/features/threads/route.ts", + "apps/mobile/src/features/threads/screen.tsx", + ]; + const positions = diffFileTreePositions(paths); + const tree = preloadFileTree({ + paths, + initialExpansion: "open", + flattenEmptyDirectories: true, + sort: compareDiffFileTreeEntries(() => positions), + }); + const rows = [...tree.shadowHtml.matchAll(/data-item-path="([^"]+)"/g)].map( + (match) => match[1], + ); + expect(rows).toEqual([ + "apps/mobile/src/", + "apps/mobile/src/state/", + "apps/mobile/src/state/shell.ts", + "apps/mobile/src/features/threads/", + "apps/mobile/src/features/threads/route.ts", + "apps/mobile/src/features/threads/screen.tsx", + ]); + }); +}); + describe("buildDiffFileTreeUpdates", () => { it("adds a new file's directories before the file", () => { expect(buildDiffFileTreeUpdates(["README.md"], ["README.md", "src/lib/a.ts"])).toEqual([ diff --git a/apps/web/src/components/diffs/diffFileTree.logic.ts b/apps/web/src/components/diffs/diffFileTree.logic.ts index 4535ece8b143..7331fb150e05 100644 --- a/apps/web/src/components/diffs/diffFileTree.logic.ts +++ b/apps/web/src/components/diffs/diffFileTree.logic.ts @@ -1,5 +1,5 @@ import type { FileDiffMetadata } from "@pierre/diffs"; -import type { FileTreeBatchOperation, GitStatus } from "@pierre/trees"; +import type { FileTreeBatchOperation, FileTreeSortComparator, GitStatus } from "@pierre/trees"; import { resolveFileDiffPath } from "~/lib/diffRendering"; @@ -47,6 +47,34 @@ export function collectDirectoryPaths(paths: ReadonlyArray): ReadonlyArr return [...directories]; } +/** A folder takes the position of its first file in the diff. */ +export function diffFileTreePositions(paths: ReadonlyArray): ReadonlyMap { + const positions = new Map(); + paths.forEach((path, index) => { + positions.set(path, index); + let directory = ""; + for (const segment of path.split("/").slice(0, -1)) { + directory += `${segment}/`; + if (!positions.has(directory)) positions.set(directory, index); + } + }); + return positions; +} + +export function compareDiffFileTreeEntries( + getPositions: () => ReadonlyMap, +): FileTreeSortComparator { + return (left, right) => { + const positions = getPositions(); + return ( + (positions.get(left.path) ?? Number.MAX_SAFE_INTEGER) - + (positions.get(right.path) ?? Number.MAX_SAFE_INTEGER) || + left.depth - right.depth || + left.path.localeCompare(right.path) + ); + }; +} + function pathDepth(path: string): number { return path.split("/").filter(Boolean).length; }