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/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/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/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: {
/>
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-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-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/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 };
+}
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(),
+ }));
+}
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),
});
});
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/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));
}),
);
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/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/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/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 (
-
-
-
- {identity.monogram}
-
-
-
-
+
);
}
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 (
+
+
+
+ {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/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;
}
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) => (
+ setColor(option.value)}
+ >
+
+
+ ))}
+
+
+ ) : null}
+
{mode === "lucide" ? (
<>
-
-
Color
-
- {PROJECT_ICON_COLORS.map((option) => (
- setColor(option.value)}
- >
-
-
- ))}
-
-
No icons found.
) : null}
>
+ ) : mode === "monogram" ? (
+
+
+
+
+ Letters
+
+
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({
onOpenChange(false)}>
Cancel
- Save icon
+
+ Save icon
+
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/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. */}
+
+
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}
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`;
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/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/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 (
}),
);
+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"),