diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 4d50ad8d665e..febdefa9825b 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -16,7 +16,7 @@ import { DesktopPreviewSetColorSchemeInputSchema, DesktopPreviewTabInputSchema, DesktopPreviewWebviewConfigSchema, - PreviewAnnotationPayloadSchema, + PreviewAnnotationSubmissionResultSchema, PreviewAutomationSnapshot, PreviewAutomationStatus, } from "@t3tools/contracts"; @@ -227,7 +227,7 @@ export const setAnnotationTheme = DesktopIpc.makeIpcMethod({ export const pickElement = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, payload: DesktopPreviewTabInputSchema, - result: Schema.NullOr(PreviewAnnotationPayloadSchema), + result: Schema.NullOr(PreviewAnnotationSubmissionResultSchema), handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId }) { const manager = yield* PreviewManager.PreviewManager; return yield* manager.pickElement(tabId); diff --git a/apps/desktop/src/preview/AnnotationKeyboard.test.ts b/apps/desktop/src/preview/AnnotationKeyboard.test.ts new file mode 100644 index 000000000000..f49c1cb79f1a --- /dev/null +++ b/apps/desktop/src/preview/AnnotationKeyboard.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts"; + +const keyboardEvent = ( + overrides: Partial[0]> = {}, +) => ({ + key: "Enter", + metaKey: false, + ctrlKey: false, + shiftKey: false, + isComposing: false, + ...overrides, +}); + +describe("resolveAnnotationSubmission", () => { + it("attaches on Enter and sends on Cmd/Ctrl+Enter", () => { + expect(resolveAnnotationSubmission(keyboardEvent())).toBe("attach"); + expect(resolveAnnotationSubmission(keyboardEvent({ metaKey: true }))).toBe("send"); + expect(resolveAnnotationSubmission(keyboardEvent({ ctrlKey: true }))).toBe("send"); + }); + + it("leaves Shift+Enter and composition events available for editing", () => { + expect(resolveAnnotationSubmission(keyboardEvent({ shiftKey: true }))).toBeNull(); + expect(resolveAnnotationSubmission(keyboardEvent({ isComposing: true }))).toBeNull(); + expect(resolveAnnotationSubmission(keyboardEvent({ key: " " }))).toBeNull(); + }); +}); diff --git a/apps/desktop/src/preview/AnnotationKeyboard.ts b/apps/desktop/src/preview/AnnotationKeyboard.ts new file mode 100644 index 000000000000..6c694ccd2ed5 --- /dev/null +++ b/apps/desktop/src/preview/AnnotationKeyboard.ts @@ -0,0 +1,16 @@ +import type { PreviewAnnotationSubmission } from "@t3tools/contracts"; + +interface AnnotationKeyboardEvent { + readonly key: string; + readonly metaKey: boolean; + readonly ctrlKey: boolean; + readonly shiftKey: boolean; + readonly isComposing: boolean; +} + +export function resolveAnnotationSubmission( + event: AnnotationKeyboardEvent, +): PreviewAnnotationSubmission | null { + if (event.key !== "Enter" || event.shiftKey || event.isComposing) return null; + return event.metaKey || event.ctrlKey ? "send" : "attach"; +} diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index aa0b0743e933..e11d25bbed77 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -23,6 +23,10 @@ const ALLOWED_PREVIEW_PERMISSIONS: ReadonlySet = new Set([ "clipboard-sanitized-write", "notifications", "geolocation", + // Deliberately NOT local-fonts: preview sessions run untrusted web content, + // and silently granting it would hand every page the user's installed-font + // fingerprint (and font file bytes via FontData.blob()). The app's own font + // picker runs in the main window session, which is unaffected by this list. ]); export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass()( diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 684d6655da5b..a6ef30c2742a 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -36,6 +36,28 @@ describe("fitPictureInPictureContentSize", () => { }); }); +describe("isPreviewRefreshShortcut", () => { + const input = (overrides: Partial = {}) => + ({ + type: "keyDown", + key: "r", + meta: true, + control: false, + shift: false, + alt: false, + ...overrides, + }) as Electron.Input; + + it("recognizes the platform refresh chord without matching modified variants", () => { + expect(PreviewManager.isPreviewRefreshShortcut(input())).toBe(true); + expect(PreviewManager.isPreviewRefreshShortcut(input({ meta: false, control: true }))).toBe( + true, + ); + expect(PreviewManager.isPreviewRefreshShortcut(input({ shift: true }))).toBe(false); + expect(PreviewManager.isPreviewRefreshShortcut(input({ type: "keyUp" }))).toBe(false); + }); +}); + const { browserWindowConstructor, createFromPath, diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 62d400fbda62..169fe2992dca 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -11,6 +11,7 @@ import type { DesktopPreviewPointerEvent, PreviewAnnotationPayload, PreviewAnnotationRect, + PreviewAnnotationSubmissionResult, DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, DesktopPreviewScreenshotArtifact, @@ -406,6 +407,13 @@ const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{ { key: "w", meta: true, shift: false, control: false }, ]); +export const isPreviewRefreshShortcut = (input: Electron.Input): boolean => + input.type === "keyDown" && + input.key.toLowerCase() === "r" && + (input.meta || input.control) && + !input.shift && + !input.alt; + const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => { if (typeof value !== "object" || value === null || !("kind" in value)) return false; if (value.kind === "pointer") { @@ -1365,6 +1373,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); }); const beforeInput = (event: Electron.Event, input: Electron.Input): void => { + if (isPreviewRefreshShortcut(input)) { + event.preventDefault(); + runFork( + attempt({ operation: "shortcut.refresh", tabId, webContentsId: wc.id }, () => + wc.reload(), + ).pipe(Effect.ignore), + ); + return; + } runFork(forwardShortcut(event, input)); }; yield* Scope.addFinalizer( @@ -1792,7 +1809,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const wc = yield* requireWebContents(tabId); yield* cancelPickElement(tabId); const annotationTheme = yield* Ref.get(annotationThemeRef); - return yield* Effect.callback( + return yield* Effect.callback( (resume) => { const cleanup = Effect.fn("PreviewManager.cleanupPickElement")(function* () { yield* attempt({ operation: "pickElement.cleanup", tabId, webContentsId: wc.id }, () => { @@ -1807,14 +1824,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); }); const settlePick = Effect.fn("PreviewManager.settlePickElement")(function* ( - payload: PreviewAnnotationPayload | null, + payload: PreviewAnnotationSubmissionResult | null, ) { const active = (yield* Ref.get(pickSessionsRef)).get(tabId); if (!active || active.cancel !== cancel) return; yield* cleanup(); resume(Effect.succeed(payload)); }); - const settle = (payload: PreviewAnnotationPayload | null) => { + const settle = (payload: PreviewAnnotationSubmissionResult | null) => { runFork(settlePick(payload)); }; const cancelPickSession = Effect.fn("PreviewManager.cancelPickSession")(function* () { @@ -1844,11 +1861,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } const cropRect = normalizeCaptureRect(args[1]); + const submission = args[2] === "send" ? "send" : "attach"; runFork( captureAnnotationScreenshot(tabId, wc, cropRect).pipe( Effect.matchEffect({ - onFailure: () => Effect.sync(() => settle(payload)), - onSuccess: (screenshot) => Effect.sync(() => settle({ ...payload, screenshot })), + onFailure: () => Effect.sync(() => settle({ annotation: payload, submission })), + onSuccess: (screenshot) => + Effect.sync(() => settle({ annotation: { ...payload, screenshot }, submission })), }), Effect.ensuring( attempt( @@ -3586,7 +3605,7 @@ export class PreviewManager extends Context.Service< ) => Effect.Effect; readonly pickElement: ( tabId: string, - ) => Effect.Effect; + ) => Effect.Effect; readonly cancelPickElement: (tabId: string) => Effect.Effect; readonly captureScreenshot: ( tabId: string, diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index 2654b8981021..d03673400ab5 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -11,8 +11,10 @@ import type { PreviewAnnotationRegionTarget, PreviewAnnotationStrokeTarget, PreviewAnnotationStyleChange, + PreviewAnnotationSubmission, } from "@t3tools/contracts"; +import { resolveAnnotationSubmission } from "./AnnotationKeyboard.ts"; import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts"; import { ANNOTATION_CAPTURED_CHANNEL, @@ -426,7 +428,7 @@ function startAnnotation(): void { "hidden h-8 w-6 shrink-0 cursor-grab select-none border-0 bg-transparent p-0 font-sans text-lg font-bold leading-5 text-muted-foreground"; composerRow.appendChild(dragHandle); - const submit = createButton("Attach", "Attach annotation and screenshot"); + const submit = createButton("Attach", "Attach annotation and screenshot (Enter)"); submit.className += " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90"; composerRow.appendChild(submit); @@ -1182,7 +1184,7 @@ function startAnnotation(): void { refreshToolButtons(); }; - submit.addEventListener("click", () => { + const submitAnnotation = (submission: PreviewAnnotationSubmission): void => { if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0)) return; pendingCapture = true; @@ -1223,13 +1225,18 @@ function startAnnotation(): void { ...regions.map((region) => region.rect), ...strokes.map((stroke) => stroke.bounds), ]); - ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect); + ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect, submission); }); - }); - comment.addEventListener("keydown", (event) => { - if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return; + }; + submit.addEventListener("click", () => submitAnnotation("attach")); + root.addEventListener("keydown", (event) => { + const submission = event.target === comment ? resolveAnnotationSubmission(event) : null; + // Keep this in the bubble phase so editor inputs receive the event before + // it is isolated from listeners installed by the inspected page. + event.stopImmediatePropagation(); + if (!submission) return; event.preventDefault(); - submit.click(); + submitAnnotation(submission); }); window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8d76ea83a33e..53ef74f21911 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -20,6 +20,15 @@ const clientSettings: ClientSettings = { diffIgnoreWhitespace: true, environmentIdentificationMode: "artwork", favorites: [], + fontFamilyCode: "", + fontFamilyComposer: "", + fontFamilySans: "", + fontFamilyTerminal: "", + fontSizeCode: 13, + fontSizeInterface: 16, + fontSizePrompt: 14, + fontSizeTerminal: 12, + fontSmoothing: true, glassOpacity: 80, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 0bb8165f9fb9..e6fcbd877d9e 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -47,6 +47,7 @@ import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsCl import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; +import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; import { @@ -177,6 +178,13 @@ const SettingsSheetStack = createNativeStackNavigator({ title: "Appearance", }, }), + SettingsProjectGrouping: createNativeStackScreen({ + screen: SettingsProjectGroupingRouteScreen, + linking: "project-grouping", + options: { + title: "Project Grouping", + }, + }), SettingsClientStorage: createNativeStackScreen({ screen: SettingsClientStorageRouteScreen, linking: "client-storage", diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index ef5319a1b6f7..7fe21fb44ff3 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -70,7 +70,7 @@ export function AndroidScreenHeader(props: { accessibilityRole="button" hitSlop={8} onPress={props.onBack} - className="size-11 items-center justify-center" + className="-mr-2 size-11 items-center justify-center" > > = { magnifyingglass: IconSearch, paintbrush: IconPalette, "person.crop.circle": IconUserCircle, + pin: IconPin, + "pin.slash": IconPinnedOff, play: IconPlayerPlay, plus: IconPlus, "qrcode.viewfinder": IconQrcode, diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 0aefde991002..16a3efde6dd3 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -42,6 +42,8 @@ export function HomeRouteScreen() { settleThread, snoozeThread, unsnoozeThread, + pinThread, + unpinThread, unsettleThread, } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); @@ -155,6 +157,8 @@ export function HomeRouteScreen() { onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} + onPinThread={pinThread} + onUnpinThread={unpinThread} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} onOpenEnvironments={() => diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 1fae00983360..887047366cd3 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -111,6 +111,8 @@ interface HomeScreenProps { ) => Promise; readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; + readonly onPinThread: (thread: EnvironmentThreadShell) => Promise; + readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; readonly onNewThreadInProject: (project: EnvironmentProject) => void; @@ -516,6 +518,18 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onUnsnoozeThread], ); + const handlePinThread = useCallback( + (thread: EnvironmentThreadShell) => { + void props.onPinThread(thread); + }, + [props.onPinThread], + ); + const handleUnpinThread = useCallback( + (thread: EnvironmentThreadShell) => { + void props.onUnpinThread(thread); + }, + [props.onUnpinThread], + ); const handleDeleteThread = props.onDeleteThread; const handleUnsettleThread = props.onUnsettleThread; // The settled tail renders in pages; expansion resets when the filter @@ -579,6 +593,15 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const pinningEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadPinning === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -722,6 +745,7 @@ export function HomeScreen(props: HomeScreenProps) { thread={thread} variant={item.item.variant} snoozed={item.item.snoozed} + pinned={item.item.pinned} snoozePresetMinute={nowMinute} snoozeWakeLabelText={item.snoozeWakeLabelText} project={ @@ -757,9 +781,12 @@ export function HomeScreen(props: HomeScreenProps) { settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} onSettleThread={handleSettleThread} snoozeSupported={snoozeEnvironmentIds?.has(thread.environmentId) ?? true} + pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} onSnoozeThread={handleSnoozeThread} onUnsnoozeThread={handleUnsnoozeThread} onUnsettleThread={handleUnsettleThread} + onPinThread={handlePinThread} + onUnpinThread={handleUnpinThread} onChangeRequestState={handleChangeRequestState} projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null @@ -772,12 +799,15 @@ export function HomeScreen(props: HomeScreenProps) { [ handleChangeRequestState, handleDeleteThread, + handlePinThread, handleSettleThread, handleSnoozeThread, + handleUnpinThread, handleUnsnoozeThread, handleSwipeableClose, handleSwipeableWillOpen, handleUnsettleThread, + pinningEnvironmentIds, projectByKey, projectCwdByKey, props.onArchiveThread, diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index d70e2537baec..14f842d73e2c 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -31,12 +31,6 @@ export interface ResolvedHomeListOptions extends HomeListOptions { readonly projectGroupingMode: SidebarProjectGroupingMode; } -export function resolveProjectGroupingMode( - projectGroupingEnabled: boolean | undefined, -): SidebarProjectGroupingMode { - return projectGroupingEnabled === false ? "separate" : "repository"; -} - export const PROJECT_SORT_OPTIONS: ReadonlyArray<{ readonly value: HomeProjectSortOrder; readonly label: string; @@ -76,7 +70,9 @@ const HomeListOptionsContext = createContext export function HomeListOptionsProvider({ children, projectGroupingMode, -}: PropsWithChildren<{ readonly projectGroupingMode: SidebarProjectGroupingMode }>) { +}: PropsWithChildren<{ + readonly projectGroupingMode: SidebarProjectGroupingMode; +}>) { const [options, setOptions] = useState(defaultHomeListOptions); const value = useMemo( () => ({ options, setOptions, projectGroupingMode }), diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 75964aa23d2e..60d3ab2c867a 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -375,7 +375,7 @@ describe("buildHomeThreadGroups", () => { ).toHaveLength(2); }); - it("uses the repository label for a singleton repository scope", () => { + it("uses the physical project title for a singleton scope", () => { const project = makeProject({ environmentId: EnvironmentId.make("environment-1"), id: ProjectId.make("project-1"), @@ -408,8 +408,8 @@ describe("buildHomeThreadGroups", () => { ], ); - expect(scopes[0]?.title).toBe("codething-mvp"); - expect(groups[0]?.title).toBe("codething-mvp"); + expect(scopes[0]?.title).toBe("local-worktree-name"); + expect(groups[0]?.title).toBe("local-worktree-name"); }); it("sorts the newest thread first regardless of snapshot order", () => { @@ -565,10 +565,16 @@ describe("buildHomeThreadGroups", () => { ); expect(buildGroups(projects, threads, { projectGroupingMode: "repository" })).toHaveLength(1); - expect(buildGroups(projects, threads, { projectGroupingMode: "repository_path" })).toHaveLength( - 2, - ); - expect(buildGroups(projects, threads, { projectGroupingMode: "separate" })).toHaveLength(2); + expect( + buildGroups(projects, threads, { projectGroupingMode: "repository_path" }).map( + (group) => group.title, + ), + ).toEqual(["Mobile", "Web"]); + expect( + buildGroups(projects, threads, { projectGroupingMode: "separate" }).map( + (group) => group.title, + ), + ).toEqual(["Mobile", "Web"]); }); it("default view shows only threads from the last 5 days", () => { diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 5bd14086e293..2a9e0ec2cb86 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -1,5 +1,5 @@ import { - deriveLogicalProjectKey, + buildProjectGroups, derivePhysicalProjectKey, deriveProjectGroupLabel, } from "@t3tools/client-runtime/state/project-grouping"; @@ -37,10 +37,6 @@ export interface HomeProjectScope { readonly projectRefs: ReadonlyArray; } -function getProjectFreshnessTimestamp(project: EnvironmentProject): number { - return toSortableTimestamp(project.updatedAt) ?? toSortableTimestamp(project.createdAt) ?? 0; -} - function getProjectSortTimestamp( project: EnvironmentProject, sortOrder: HomeProjectSortOrder, @@ -60,64 +56,19 @@ export function buildHomeProjectScopes(input: { const projects = input.projects.filter( (project) => input.environmentId === null || project.environmentId === input.environmentId, ); - const projectsByPhysicalKey = new Map(); - for (const project of projects) { - const physicalKey = derivePhysicalProjectKey(project); - const existing = projectsByPhysicalKey.get(physicalKey); - if (existing) existing.push(project); - else projectsByPhysicalKey.set(physicalKey, [project]); - } - - const winnersByPhysicalKey = new Map< - string, - { readonly key: string; readonly project: EnvironmentProject } - >(); - for (const [physicalKey, members] of projectsByPhysicalKey) { - const project = members.reduce((winner, candidate) => { - const freshnessDelta = - getProjectFreshnessTimestamp(candidate) - getProjectFreshnessTimestamp(winner); - return freshnessDelta > 0 || (freshnessDelta === 0 && candidate.id > winner.id) - ? candidate - : winner; - }); - const identitySource = members.find((member) => member.repositoryIdentity !== null) ?? project; - winnersByPhysicalKey.set(physicalKey, { - key: deriveLogicalProjectKey(identitySource, { groupingMode: input.projectGroupingMode }), - project, - }); - } - - const groups = new Map(); - for (const { key, project } of winnersByPhysicalKey.values()) { - const existing = groups.get(key); - if (existing) existing.push(project); - else groups.set(key, [project]); - } - - const projectRefsByGroup = new Map(); - const seenProjectRefs = new Set(); - for (const project of projects) { - const refKey = scopedProjectKey(project.environmentId, project.id); - if (seenProjectRefs.has(refKey)) continue; - seenProjectRefs.add(refKey); - - const key = - winnersByPhysicalKey.get(derivePhysicalProjectKey(project))?.key ?? - deriveLogicalProjectKey(project, { groupingMode: input.projectGroupingMode }); - const refs = projectRefsByGroup.get(key); - const projectRef = { environmentId: project.environmentId, projectId: project.id }; - if (refs) refs.push(projectRef); - else projectRefsByGroup.set(key, [projectRef]); - } - - return Array.from(groups, ([key, projects]) => { - const representative = projects[0]!; + return buildProjectGroups({ + projects, + settings: { + sidebarProjectGroupingMode: input.projectGroupingMode, + sidebarProjectGroupingOverrides: {}, + }, + }).map((group) => { return { - key, - title: deriveProjectGroupLabel({ representative, members: projects }), - representative, - projects, - projectRefs: projectRefsByGroup.get(key) ?? [], + key: group.key, + title: group.label, + representative: group.representative, + projects: group.members.map((member) => member.project), + projectRefs: group.memberProjectRefs, }; }); } @@ -264,9 +215,11 @@ export function buildHomeThreadGroups(input: { }): ReadonlyArray { const now = input.now ?? Date.now(); const groups = new Map(); + const groupTitleByKey = new Map(); const groupKeyByProjectKey = new Map(); for (const scope of buildHomeProjectScopes(input)) { + groupTitleByKey.set(scope.key, scope.title); groups.set(scope.key, { key: scope.key, projects: [...scope.projects], @@ -345,7 +298,9 @@ export function buildHomeThreadGroups(input: { continue; } - const title = deriveProjectGroupLabel({ representative, members: group.projects }); + const title = + groupTitleByKey.get(group.key) ?? + deriveProjectGroupLabel({ representative, members: group.projects }); const groupMatches = query.length === 0 || title.toLocaleLowerCase().includes(query) || diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 0c621a04e38b..dcea2b6791b0 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -29,6 +29,13 @@ function environmentSupportsSnooze(environmentId: EnvironmentThreadShell["enviro ); } +function environmentSupportsPinning(environmentId: EnvironmentThreadShell["environmentId"]) { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadPinning === true + ); +} + type ThreadListAction = "archive" | "unarchive" | "delete" | "settle" | "unsettle"; const ACTION_VERBS: Record = { @@ -202,10 +209,14 @@ export function useThreadListActions(): { readonly snoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => Promise; readonly unsnoozeThread: (thread: EnvironmentThreadShell) => Promise; readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; + readonly pinThread: (thread: EnvironmentThreadShell) => Promise; + readonly unpinThread: (thread: EnvironmentThreadShell) => Promise; } { const executeAction = useThreadActionExecutor(); const snoozeMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false }); const unsnoozeMutation = useAtomCommand(threadEnvironment.unsnooze, { reportFailure: false }); + const pinMutation = useAtomCommand(threadEnvironment.pin, { reportFailure: false }); + const unpinMutation = useAtomCommand(threadEnvironment.unpin, { reportFailure: false }); const snoozeInFlightThreadKeys = useRef(new Set()); const archiveThread = useCallback( @@ -310,6 +321,62 @@ export function useThreadListActions(): { async (thread: EnvironmentThreadShell) => (await executeAction("unsettle", thread)) === true, [executeAction], ); + const pinThread = useCallback( + async (thread: EnvironmentThreadShell) => { + if (!environmentSupportsPinning(thread.environmentId)) { + Alert.alert( + "Could not pin thread", + "This environment's server does not support pinning yet. Update the server to use Pin.", + ); + return false; + } + selectionHaptic(); + const result = await pinMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not pin thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread could not be pinned.", + ); + return false; + } + return true; + }, + [pinMutation], + ); + const unpinThread = useCallback( + async (thread: EnvironmentThreadShell) => { + if (!environmentSupportsPinning(thread.environmentId)) { + Alert.alert( + "Could not unpin thread", + "This environment's server does not support pinning yet. Update the server to use Pin.", + ); + return false; + } + selectionHaptic(); + const result = await unpinMutation({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not unpin thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread could not be unpinned.", + ); + return false; + } + return true; + }, + [unpinMutation], + ); const confirmDeleteThread = useConfirmDeleteThread(executeAction); @@ -320,6 +387,8 @@ export function useThreadListActions(): { snoozeThread, unsnoozeThread, unsettleThread, + pinThread, + unpinThread, }; } diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 9c068c6249c2..a93268d0da6d 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -37,11 +37,15 @@ import { import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { mobilePreferencesAtom } from "../../state/preferences"; +import { + DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS, + resolveMobileProjectGroupingSettings, +} from "../../state/project-grouping"; import { parseActiveThreadPath, useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; -import { HomeListOptionsProvider, resolveProjectGroupingMode } from "../home/home-list-options"; +import { HomeListOptionsProvider } from "../home/home-list-options"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; @@ -190,15 +194,17 @@ export function AdaptiveWorkspaceLayout(props: { const preferencesResult = useAtomValue(mobilePreferencesAtom); if (!AsyncResult.isSuccess(preferencesResult)) { return AsyncResult.isFailure(preferencesResult) ? ( - + ) : null; } + const groupingSettings = resolveMobileProjectGroupingSettings(preferencesResult.value); return ( ); } diff --git a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx index 21bcad35e20d..5b62942bb697 100644 --- a/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAppearanceRouteScreen.tsx @@ -1,15 +1,25 @@ -import { ScrollView, View } from "react-native"; +import { useNavigation } from "@react-navigation/native"; +import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; import { CodeAppearanceSection } from "./appearance/sections/CodeAppearanceSection"; import { TerminalAppearanceSection } from "./appearance/sections/TerminalAppearanceSection"; import { TextAppearanceSection } from "./appearance/sections/TextAppearanceSection"; export function SettingsAppearanceRouteScreen() { + const navigation = useNavigation(); const insets = useSafeAreaInsets(); return ( + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : null} = [ + { + mode: "repository", + label: "Group by repository", + description: "Matching repositories appear as one project.", + }, + { + mode: "repository_path", + label: "Group by repository path", + description: "Keep monorepo paths separate.", + }, + { + mode: "separate", + label: "Keep separate", + description: "Show every workspace as its own project.", + }, +]; + +export function SettingsProjectGroupingRouteScreen() { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const checkmarkColor = useThemeColor("--color-icon"); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const preferencesReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; + const selectedMode = AsyncResult.isSuccess(preferencesResult) + ? resolveMobileProjectGroupingSettings(preferencesResult.value).sidebarProjectGroupingMode + : null; + + return ( + + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : null} + + + {GROUPING_OPTIONS.map((option, index) => ( + savePreferences(mobileProjectGroupingModePatch(option.mode))} + className={ + index === 0 + ? "flex-row items-center gap-4 p-4" + : "flex-row items-center gap-4 border-t border-border-subtle p-4" + } + > + + {option.label} + + {option.description} + + + {selectedMode === option.mode ? ( + + ) : null} + + ))} + + + + ); +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 49adfe75cb23..8547859adde9 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -530,20 +530,9 @@ function ConfiguredSettingsRouteScreen() { } function GeneralSettingsSection() { - const preferencesResult = useAtomValue(mobilePreferencesAtom); - const savePreferences = useAtomSet(updateMobilePreferencesAtom); - const projectGroupingEnabled = AsyncResult.isSuccess(preferencesResult) - ? preferencesResult.value.projectGroupingEnabled !== false - : true; - return ( - savePreferences({ projectGroupingEnabled: value })} - /> + ); } diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts index 71c059bedb48..df012c903256 100644 --- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -2,6 +2,7 @@ export type SettingsSheetTarget = | "SettingsEnvironments" | "SettingsArchive" | "SettingsAppearance" + | "SettingsProjectGrouping" | "SettingsClientStorage"; export type SettingsLegalDocumentTarget = "SettingsLegal"; diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 6d204e3a8e64..6b121d851082 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -50,6 +50,7 @@ import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../sta import { useRemoteConnectionStatus } from "../../state/use-remote-environment-registry"; import { branchBadgeLabel, useNewTaskFlow } from "./new-task-flow-provider"; import { useCreateProjectThread } from "./use-project-actions"; +import { resolveDraftProjectSelection } from "./new-task-project-selection"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; function formatWorkspaceLabel(input: { @@ -89,7 +90,7 @@ export function NewTaskDraftScreen(props: { const colorScheme = useColorScheme(); const isKeyboardVisible = useKeyboardState((state) => state.isVisible); const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); - const { logicalProjects, selectedProject, setProject } = flow; + const { projectScopes, selectedProject, selectedProjectKey, setProject } = flow; const { connectedEnvironments } = useRemoteConnectionStatus(); const selectedEnvironmentServerConfig = useEnvironmentServerConfig( selectedProject?.environmentId ?? null, @@ -277,24 +278,25 @@ export function NewTaskDraftScreen(props: { return; } - if (selectedProject) { + const selection = resolveDraftProjectSelection(selectedProjectKey, projects, projectScopes); + if (selection.kind === "preserve") { return; } - - if (logicalProjects.length === 1) { - setProject(logicalProjects[0]!.project); + if (selection.kind === "select") { + setProject(selection.project); return; } navigation.dispatch(StackActions.replace("NewTask")); }, [ - logicalProjects, + projectScopes, projects, props.initialProjectRef, props.incomingShareId, props.pendingTaskId, navigation, selectedProject, + selectedProjectKey, setProject, ]); diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 3e5e528cc732..ed8ddbeb5a43 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -1,8 +1,8 @@ import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; -import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; -import { useEffect, useMemo, useRef } from "react"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { useEffect, useRef, useState } from "react"; import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -11,12 +11,13 @@ import { cn } from "../../lib/cn"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; import { ProjectFavicon } from "../../components/ProjectFavicon"; -import { useProjects, useThreadShells } from "../../state/entities"; +import { useProjects } from "../../state/entities"; import type { WorkspaceState } from "../../state/workspaceModel"; import { useWorkspaceState } from "../../state/workspace"; -import { groupProjectsByRepository } from "../../lib/repositoryGroups"; +import { scopedProjectKey } from "../../lib/scopedEntities"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; +import { useNewTaskFlow } from "./new-task-flow-provider"; type NewTaskRouteParams = { readonly incomingShareId?: string | string[]; @@ -79,7 +80,7 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): { export function NewTaskRouteScreen({ route }: StaticScreenProps) { const projects = useProjects(); - const threads = useThreadShells(); + const { projectScopes } = useNewTaskFlow(); const { state: catalogState } = useWorkspaceState(); const navigation = useNavigation(); const isFocused = useIsFocused(); @@ -87,6 +88,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps>(() => new Set()); const { getShare, releaseShareReservation } = useIncomingShare(); const routeShareId = Array.isArray(route.params?.incomingShareId) ? route.params.incomingShareId[0] @@ -100,33 +102,6 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps groupProjectsByRepository({ projects, threads }), - [projects, threads], - ); - const items = useMemo(() => { - const nextItems: Array<{ - readonly environmentId: EnvironmentId; - readonly id: ProjectId; - readonly key: string; - readonly title: string; - readonly workspaceRoot: string; - }> = []; - for (const group of repositoryGroups) { - const project = group.projects[0]?.project; - if (!project) { - continue; - } - nextItems.push({ - environmentId: project.environmentId, - id: project.id, - key: group.key, - title: project.title, - workspaceRoot: project.workspaceRoot, - }); - } - return nextItems; - }, [repositoryGroups]); const projectEmptyState = deriveProjectEmptyState(catalogState); const resumedDestinationKeyRef = useRef(null); const reservedDestinationProject = incomingShare?.destination @@ -137,7 +112,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps { + async function selectProject(project: EnvironmentProject): Promise { if (incomingShare?.destination && !reservedDestinationProject) { try { await releaseShareReservation(incomingShare.id, incomingShare.destination); @@ -154,14 +129,26 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps { + const next = new Set(current); + if (next.has(groupKey)) { + next.delete(groupKey); + } else { + next.add(groupKey); + } + return next; + }); + } + useEffect(() => { const destination = incomingShare?.destination; if (!destination) { @@ -260,11 +247,12 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps - {items.length === 0 ? ( + {projectScopes.length === 0 ? ( {projectEmptyState.loading ? : null} @@ -300,42 +288,89 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps ) : ( - {items.map((item, index) => { - const isFirst = index === 0; - const isLast = index === items.length - 1; - + {projectScopes.map((scope, scopeIndex) => { + const hasMultipleProjects = scope.projects.length > 1; + const expanded = expandedGroupKeys.has(scope.key); + const singleProject = hasMultipleProjects ? null : scope.projects[0]; return ( - void selectProject(item)} - className={cn( - "bg-card px-4 py-3.5", - !isFirst && "border-t border-border-subtle", - isFirst && "rounded-t-[24px]", - isLast && "rounded-b-[24px]", - )} + 0 && "border-t border-border-subtle")} > - + { + if (singleProject) { + void selectProject(singleProject); + } else { + toggleGroup(scope.key); + } + }} + className="flex-row items-center gap-3 bg-card px-4 py-3.5" + > - - {item.title} + + {scope.title} + + {hasMultipleProjects + ? `${scope.projects.length} workspaces` + : singleProject?.workspaceRoot} + - - + + {hasMultipleProjects && expanded + ? scope.projects.map((project) => ( + void selectProject(project)} + className="flex-row items-center gap-3 border-t border-border-subtle bg-card py-3 pr-4 pl-10" + > + + + + {project.title} + + + {project.workspaceRoot} + + + + + )) + : null} + ); })} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index feebf056eded..4bdfa368f66e 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -205,6 +205,8 @@ function ThreadNavigationSidebarPane( snoozeThread, unsnoozeThread, unsettleThread, + pinThread, + unpinThread, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); const pendingTasks = usePendingNewTasks(); @@ -482,6 +484,15 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const pinningEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadPinning === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -793,6 +804,7 @@ function ThreadNavigationSidebarPane( previous.item.thread === item.item.thread && previous.item.variant === item.item.variant && previous.item.snoozed === item.item.snoozed && + previous.item.pinned === item.item.pinned && previous.snoozeWakeLabelText === item.snoozeWakeLabelText ); } @@ -880,6 +892,7 @@ function ThreadNavigationSidebarPane( thread={thread} variant={item.item.variant} snoozed={item.item.snoozed} + pinned={item.item.pinned} snoozePresetMinute={nowMinute} snoozeWakeLabelText={item.snoozeWakeLabelText} project={projectByKey.get(scopeKey) ?? null} @@ -916,9 +929,12 @@ function ThreadNavigationSidebarPane( settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} onSettleThread={settleThread} snoozeSupported={snoozeEnvironmentIds?.has(thread.environmentId) ?? true} + pinningSupported={pinningEnvironmentIds.has(thread.environmentId)} onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} + onPinThread={pinThread} + onUnpinThread={unpinThread} onChangeRequestState={handleChangeRequestState} projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} @@ -1046,6 +1062,8 @@ function ThreadNavigationSidebarPane( handleSwipeableClose, handleSwipeableWillOpen, openPendingTask, + pinThread, + pinningEnvironmentIds, projectByKey, projectCwdByKey, projectTitleByProjectKey, @@ -1065,6 +1083,7 @@ function ThreadNavigationSidebarPane( nowMinute, toggleSettledShelf, toggleSnoozedShelf, + unpinThread, unsettleThread, unsnoozeThread, updateGroupDisplay, diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 8d4ce7a7feda..18bacd125775 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -27,7 +27,6 @@ import { groupByProvider, resolveSelectableModelSelection, } from "../../lib/modelOptions"; -import { groupProjectsByRepository } from "../../lib/repositoryGroups"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { appAtomRegistry } from "../../state/atom-registry"; import { @@ -59,6 +58,12 @@ import { } from "../../state/use-remote-environment-registry"; import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; +import { + buildHomeProjectScopes, + sortHomeProjectScopes, + type HomeProjectScope, +} from "../home/homeThreadList"; +import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; type WorkspaceMode = "local" | "worktree"; @@ -109,10 +114,7 @@ export function branchBadgeLabel(input: { } type NewTaskFlowContextValue = { - readonly logicalProjects: ReadonlyArray<{ - readonly key: string; - readonly project: EnvironmentProject; - }>; + readonly projectScopes: ReadonlyArray; readonly selectedEnvironmentId: EnvironmentId | null; readonly selectedProjectKey: string | null; readonly selectedModelKey: string | null; @@ -175,32 +177,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const projects = useProjects(); const threads = useThreadShells(); const { savedConnectionsById } = useSavedRemoteConnections(); - - const repositoryGroups = useMemo( - () => groupProjectsByRepository({ projects, threads }), - [projects, threads], - ); - const logicalProjects = useMemo( + const groupingSettings = useMobileProjectGroupingSettings(); + const projectScopes = useMemo( () => - pipe( - repositoryGroups, - Arr.map((group) => { - const primaryProject = group.projects[0]?.project; - if (!primaryProject) { - return null; - } - return { key: group.key, project: primaryProject }; + sortHomeProjectScopes({ + scopes: buildHomeProjectScopes({ + projects, + environmentId: null, + projectGroupingMode: groupingSettings.sidebarProjectGroupingMode, }), - Arr.filter( - ( - entry, - ): entry is { - readonly key: string; - readonly project: EnvironmentProject; - } => entry !== null, - ), - ), - [repositoryGroups], + threads, + pendingTasks: [], + projectSortOrder: "updated_at", + }), + [groupingSettings.sidebarProjectGroupingMode, projects, threads], ); const [selectedEnvironmentIdOverride, setSelectedEnvironmentId] = useState( @@ -846,7 +836,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const value = useMemo( () => ({ - logicalProjects, + projectScopes, selectedEnvironmentId, selectedProjectKey, selectedModelKey, @@ -912,7 +902,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { finishEditingPendingTask, interactionMode, loadBranches, - logicalProjects, + projectScopes, modelOptions, prompt, providerGroups, diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts new file mode 100644 index 000000000000..d8ed12bcc73a --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -0,0 +1,82 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import type { HomeProjectScope } from "../home/homeThreadList"; +import { + getOnlySelectableProject, + resolveDraftProjectSelection, +} from "./new-task-project-selection"; + +function makeProject(id: string): EnvironmentProject { + return { + environmentId: EnvironmentId.make("environment"), + id: ProjectId.make(id), + title: id, + workspaceRoot: `/work/${id}`, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-01T00:00:00.000Z", + }; +} + +function makeScope(projects: ReadonlyArray): HomeProjectScope { + return { + key: "github.com/t3tools/t3code", + title: "T3 Code", + representative: projects[0]!, + projects, + projectRefs: projects.map((project) => ({ + environmentId: project.environmentId, + projectId: project.id, + })), + }; +} + +describe("getOnlySelectableProject", () => { + it("auto-selects when there is exactly one physical project", () => { + const project = makeProject("t3code"); + expect(getOnlySelectableProject([makeScope([project])])).toBe(project); + }); + + it("does not auto-select a representative when one group has multiple clones", () => { + const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")]; + expect(getOnlySelectableProject([makeScope(projects)])).toBeNull(); + }); +}); + +describe("resolveDraftProjectSelection", () => { + it("preserves an explicit project selection", () => { + const project = makeProject("t3code"); + expect( + resolveDraftProjectSelection("environment:t3code", [project], [makeScope([project])]), + ).toEqual({ kind: "preserve" }); + }); + + it("selects the only physical project when no project was explicitly selected", () => { + const project = makeProject("t3code"); + expect(resolveDraftProjectSelection(null, [project], [makeScope([project])])).toEqual({ + kind: "select", + project, + }); + }); + + it("opens the picker for multiple physical projects in one logical group", () => { + const projects = [makeProject("t3code"), makeProject("t3code-2"), makeProject("t3code-3")]; + expect(resolveDraftProjectSelection(null, projects, [makeScope(projects)])).toEqual({ + kind: "pick", + }); + }); + + it("does not preserve a project key that is missing from the catalog", () => { + const project = makeProject("t3code"); + expect( + resolveDraftProjectSelection("environment:removed", [project], [makeScope([project])]), + ).toEqual({ + kind: "select", + project, + }); + }); +}); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts new file mode 100644 index 000000000000..29ae3cf4f54f --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -0,0 +1,34 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; + +import { scopedProjectKey } from "../../lib/scopedEntities"; +import type { HomeProjectScope } from "../home/homeThreadList"; + +export type DraftProjectSelectionResolution = + | { readonly kind: "preserve" } + | { readonly kind: "select"; readonly project: EnvironmentProject } + | { readonly kind: "pick" }; + +export function getOnlySelectableProject( + projectScopes: ReadonlyArray, +): EnvironmentProject | null { + const onlyScope = projectScopes.length === 1 ? projectScopes[0] : null; + return onlyScope?.projects.length === 1 ? (onlyScope.projects[0] ?? null) : null; +} + +export function resolveDraftProjectSelection( + selectedProjectKey: string | null, + projects: ReadonlyArray, + projectScopes: ReadonlyArray, +): DraftProjectSelectionResolution { + const hasExplicitProjectSelection = + selectedProjectKey !== null && + projects.some( + (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey, + ); + if (hasExplicitProjectSelection) { + return { kind: "preserve" }; + } + + const onlyProject = getOnlySelectableProject(projectScopes); + return onlyProject ? { kind: "select", project: onlyProject } : { kind: "pick" }; +} 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 8d6874c78558..6b9dd52512ac 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -305,6 +305,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly variant: "card" | "slim"; /** Snoozed-shelf row: shows its wake time and offers Wake. */ readonly snoozed?: boolean; + /** Pinned-block row: shows the pin glyph and offers Unpin. */ + readonly pinned?: boolean; /** Preformatted against the parent minute tick so this memoized row's countdown keeps moving. */ readonly snoozeWakeLabelText?: string; @@ -337,11 +339,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onUnsnoozeThread: (thread: EnvironmentThreadShell) => void; readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; + readonly onPinThread: (thread: EnvironmentThreadShell) => void; + readonly onUnpinThread: (thread: EnvironmentThreadShell) => void; /** False on environments whose server predates thread.settle/unsettle: swipe + menu fall back to Archive instead of failing on use. */ readonly settlementSupported: boolean; /** False on servers that predate thread.snooze/unsnooze. */ readonly snoozeSupported: boolean; + /** False on servers that predate thread.pin/unpin. */ + readonly pinningSupported: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; /** Reports this row's live PR state up so the partition can auto-settle @@ -368,9 +374,12 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onUnsnoozeThread, onUnsettleThread, onArchiveThread, + onPinThread, + onUnpinThread, onChangeRequestState, } = props; const snoozedRow = props.snoozed === true; + const pinnedRow = props.pinned === true; const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); const prState = pr?.state ?? null; @@ -383,6 +392,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const drawerColor = useThemeColor("--color-drawer"); const pressedBackgroundColor = useThemeColor("--color-subtle"); const selectedBackgroundColor = useThemeColor("--color-user-bubble"); + const pinTintColor = useThemeColor("--color-foreground-muted"); const sidebarPane = props.pane === "sidebar"; const selected = props.selected === true; @@ -398,6 +408,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ); const handleUnsnooze = useCallback(() => onUnsnoozeThread(thread), [onUnsnoozeThread, thread]); const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); + const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]); + const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); // Swipe: the v2 primary action is the lifecycle transition. Every settled @@ -434,6 +446,20 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { })), [snoozePresets], ); + // Pinned cards keep the full lifecycle menu; only the pin item flips to + // Unpin. (Settling a pinned thread clears the pin server-side; snoozing + // hides the card until wake with the pin intact.) + const pinMenuItem = useMemo( + () => + props.pinningSupported + ? [ + pinnedRow + ? { id: "unpin", title: "Unpin", image: "pin.slash" } + : { id: "pin", title: "Pin", image: "pin" }, + ] + : [], + [pinnedRow, props.pinningSupported], + ); const snoozableCardMenuActions = useMemo( () => [ { id: "settle", title: "Settle", image: "checkmark" }, @@ -443,15 +469,22 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { image: "clock", subactions: snoozePresetActions, }, + ...pinMenuItem, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], - [snoozePresetActions], + [pinMenuItem, snoozePresetActions], + ); + const cardMenuActions = useMemo( + () => [CARD_MENU_ACTIONS[0]!, ...pinMenuItem, ...CARD_MENU_ACTIONS.slice(1)], + [pinMenuItem], ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "settle") handleSettle(); if (nativeEvent.event === "unsettle") handleUnsettle(); if (nativeEvent.event === "unsnooze") handleUnsnooze(); + if (nativeEvent.event === "pin") handlePin(); + if (nativeEvent.event === "unpin") handleUnpin(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "delete") handleDelete(); const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ @@ -468,8 +501,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { [ handleArchive, handleDelete, + handlePin, handleSettle, handleSnooze, + handleUnpin, handleUnsettle, handleUnsnooze, snoozePresets, @@ -560,6 +595,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { > {props.projectTitle ?? props.project?.title ?? ""} + {pinnedRow ? ( + + ) : null} { expect(layout.snoozedCount).toBe(1); }); + it("renders pinned threads first and exempts them from auto-settle — parity with web", () => { + const layout = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ + id: ThreadId.make("pinned-settled"), + title: "Pinned while settled", + pinnedAt: "2026-06-01T12:00:00.000Z", + // Stale settled state (the decider clears it on pin): the pin wins. + settledOverride: "settled", + settledAt: "2026-06-01T12:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + now: NOW, + }); + + expect(layout.items.map((item) => item.thread.id)).toEqual(["pinned-settled", "active"]); + expect(layout.items.map((item) => item.pinned)).toEqual([true, false]); + expect(layout.settledCount).toBe(0); + }); + + it("snooze hides a pinned thread and wake restores it to the pinned block", () => { + const snoozedInput = { + threads: [ + makeThread({ id: ThreadId.make("active"), title: "Active" }), + makeThread({ + id: ThreadId.make("pinned-snoozed"), + title: "Pinned and snoozed", + pinnedAt: "2026-06-01T12:00:00.000Z", + snoozedUntil: "2026-06-03T09:00:00.000Z", + snoozedAt: "2026-06-01T11:00:00.000Z", + }), + ], + environmentId: null, + searchQuery: "", + }; + + // Before the wake time: the snooze wins; the pin holds underneath. + const whileSnoozed = buildThreadListV2Items({ ...snoozedInput, now: NOW }); + expect(whileSnoozed.items.map((item) => item.thread.id)).toEqual(["active"]); + expect(whileSnoozed.snoozedCount).toBe(1); + + // After the wake time: the thread returns pinned, back on top. + const afterWake = buildThreadListV2Items({ ...snoozedInput, now: "2026-06-03T10:00:00.000Z" }); + expect(afterWake.items.map((item) => item.thread.id)).toEqual(["pinned-snoozed", "active"]); + expect(afterWake.items[0]?.pinned).toBe(true); + expect(afterWake.snoozedCount).toBe(0); + }); + it("classifies snooze with the second-precise clock and reports the next wake", () => { const layout = buildThreadListV2Items({ threads: [ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index c88aff4ec02d..fa5f58d5d0ee 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -180,6 +180,8 @@ export interface ThreadListV2Item { readonly variant: "card" | "slim"; /** Snoozed-shelf row: shows the wake countdown and offers Wake. */ readonly snoozed: boolean; + /** Pinned-block row: renders the pin glyph and offers Unpin. */ + readonly pinned: boolean; readonly isLast: boolean; } @@ -351,6 +353,7 @@ export function buildThreadListV2Items(input: { ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) : null; + const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; @@ -378,9 +381,10 @@ export function buildThreadListV2Items(input: { const supportsSnooze = input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true; const changeRequestState = input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null; - // Visibility parity with web: a snoozed thread leaves the list until it - // wakes (or raises its hand — effectiveSnoozed refuses blocked/failed - // work). Snooze outranks settled classification, same as web. + // Visibility parity with web: snooze outranks everything, including a + // pin — a snoozed thread leaves the list until it wakes (or raises its + // hand). The pin survives underneath, so a woken thread reappears at + // its original spot in the creation-ordered pinned block. if (supportsSnooze && effectiveSnoozed(thread, { now: snoozeNow })) { snoozed.push(thread); if ( @@ -392,6 +396,12 @@ export function buildThreadListV2Items(input: { } continue; } + // A pin otherwise overrides the lifecycle: pinned threads render above + // the inbox and never auto-settle out of sight. + if (thread.pinnedAt != null) { + pinned.push(thread); + continue; + } if ( supportsSettlement && effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) @@ -434,11 +444,21 @@ export function buildThreadListV2Items(input: { ); const items: ThreadListV2Item[] = []; + for (const thread of sortThreadsForListV2(pinned)) { + items.push({ + thread, + variant: "card", + snoozed: false, + pinned: true, + isLast: false, + }); + } for (const thread of orderedActive) { items.push({ thread, variant: "card", snoozed: false, + pinned: false, isLast: false, }); } @@ -448,6 +468,7 @@ export function buildThreadListV2Items(input: { thread, variant: "slim", snoozed: true, + pinned: false, isLast: false, }); } @@ -457,6 +478,7 @@ export function buildThreadListV2Items(input: { thread, variant: "slim", snoozed: false, + pinned: false, isLast: false, }); } diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts deleted file mode 100644 index ab4311524ce4..000000000000 --- a/apps/mobile/src/lib/repositoryGroups.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; - -import { groupProjectsByRepository } from "./repositoryGroups"; -import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; - -function makeProject( - input: Partial & Pick, -): EnvironmentProject { - return { - workspaceRoot: `/workspaces/${input.id}`, - repositoryIdentity: null, - defaultModelSelection: null, - scripts: [], - createdAt: "2026-04-01T00:00:00.000Z", - updatedAt: "2026-04-01T00:00:00.000Z", - ...input, - }; -} - -function makeThread( - input: Partial & - Pick, -): EnvironmentThreadShell { - return { - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - latestTurn: null, - createdAt: "2026-04-01T00:00:00.000Z", - updatedAt: "2026-04-01T00:00:00.000Z", - archivedAt: null, - session: null, - latestUserMessageAt: null, - hasPendingApprovals: false, - hasPendingUserInput: false, - hasActionableProposedPlan: false, - ...input, - settledOverride: input.settledOverride ?? null, - settledAt: input.settledAt ?? null, - }; -} - -describe("groupProjectsByRepository", () => { - it("groups projects across environments by repository identity", () => { - const repoIdentity = { - canonicalKey: "github.com/t3tools/t3code", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:t3tools/t3code.git", - }, - provider: "github", - owner: "t3tools", - name: "t3code", - displayName: "T3 Code", - }; - - const projects = [ - makeProject({ - environmentId: EnvironmentId.make("env-local"), - id: ProjectId.make("project-local"), - title: "T3 Code", - repositoryIdentity: repoIdentity, - }), - makeProject({ - environmentId: EnvironmentId.make("env-staging"), - id: ProjectId.make("project-staging"), - title: "T3 Code", - repositoryIdentity: repoIdentity, - }), - ]; - - const threads = [ - makeThread({ - environmentId: EnvironmentId.make("env-staging"), - id: ThreadId.make("thread-2"), - projectId: ProjectId.make("project-staging"), - title: "Fix reconnect flow", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - updatedAt: "2026-04-02T12:00:00.000Z", - }), - makeThread({ - environmentId: EnvironmentId.make("env-local"), - id: ThreadId.make("thread-1"), - projectId: ProjectId.make("project-local"), - title: "Polish mobile shell", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - updatedAt: "2026-04-03T12:00:00.000Z", - }), - ]; - - const groups = groupProjectsByRepository({ projects, threads }); - - expect(groups).toHaveLength(1); - expect(groups[0]).toMatchObject({ - key: "github.com/t3tools/t3code", - title: "T3 Code", - subtitle: "t3tools/t3code", - projectCount: 2, - threadCount: 2, - }); - expect( - groups[0]?.projects.map((entry) => ({ - environmentId: entry.project.environmentId, - latestActivityAt: entry.latestActivityAt, - threads: entry.threads.map((thread) => thread.id), - })), - ).toEqual([ - { - environmentId: "env-local", - latestActivityAt: "2026-04-03T12:00:00.000Z", - threads: ["thread-1"], - }, - { - environmentId: "env-staging", - latestActivityAt: "2026-04-02T12:00:00.000Z", - threads: ["thread-2"], - }, - ]); - expect(groups[0]?.latestActivityAt).toBe("2026-04-03T12:00:00.000Z"); - }); - - it("orders threads, projects, and repository groups by latest activity", () => { - const projects = [ - makeProject({ - environmentId: EnvironmentId.make("env-local"), - id: ProjectId.make("older-project"), - title: "Older", - }), - makeProject({ - environmentId: EnvironmentId.make("env-local"), - id: ProjectId.make("newer-project"), - title: "Newer", - }), - ]; - - const threads = [ - makeThread({ - environmentId: EnvironmentId.make("env-local"), - id: ThreadId.make("older-thread"), - projectId: ProjectId.make("older-project"), - title: "Older thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - updatedAt: "2026-04-02T12:00:00.000Z", - }), - makeThread({ - environmentId: EnvironmentId.make("env-local"), - id: ThreadId.make("newer-thread"), - projectId: ProjectId.make("older-project"), - title: "Newer thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - updatedAt: "2026-04-04T12:00:00.000Z", - }), - makeThread({ - environmentId: EnvironmentId.make("env-local"), - id: ThreadId.make("newest-thread"), - projectId: ProjectId.make("newer-project"), - title: "Newest thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - updatedAt: "2026-04-05T12:00:00.000Z", - }), - ]; - - const groups = groupProjectsByRepository({ projects, threads }); - - expect(groups.map((group) => group.title)).toEqual(["Newer", "Older"]); - expect(groups[1]?.projects[0]?.threads.map((thread) => thread.id)).toEqual([ - "newer-thread", - "older-thread", - ]); - }); - - it("falls back to a scoped project key when repository identity is unavailable", () => { - const projects = [ - makeProject({ - environmentId: EnvironmentId.make("env-local"), - id: ProjectId.make("project-local"), - title: "Scratchpad", - }), - ]; - - const groups = groupProjectsByRepository({ projects, threads: [] }); - - expect(groups).toHaveLength(1); - expect(groups[0]?.key).toBe("env-local:project-local"); - expect(groups[0]?.title).toBe("Scratchpad"); - expect(groups[0]?.subtitle).toBeNull(); - }); -}); diff --git a/apps/mobile/src/lib/repositoryGroups.ts b/apps/mobile/src/lib/repositoryGroups.ts deleted file mode 100644 index bf4c2f3fccd5..000000000000 --- a/apps/mobile/src/lib/repositoryGroups.ts +++ /dev/null @@ -1,131 +0,0 @@ -import * as Order from "effect/Order"; -import * as Arr from "effect/Array"; -import type { RepositoryIdentity } from "@t3tools/contracts"; - -import { scopedProjectKey } from "./scopedEntities"; -import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; - -const DateDescending = Order.flip(Order.Date); - -export interface RepositoryProjectGroup { - readonly key: string; - readonly project: EnvironmentProject; - readonly threads: ReadonlyArray; - readonly latestActivityAt: string; -} - -export interface RepositoryGroup { - readonly key: string; - readonly title: string; - readonly subtitle: string | null; - readonly repositoryIdentity: RepositoryIdentity | null; - readonly projectCount: number; - readonly threadCount: number; - readonly latestActivityAt: string; - readonly projects: ReadonlyArray; -} - -function compareIsoDateDescending(left: string, right: string): number { - return new Date(right).getTime() - new Date(left).getTime(); -} - -function deriveRepositoryGroupKey(project: EnvironmentProject): string { - return ( - project.repositoryIdentity?.canonicalKey ?? scopedProjectKey(project.environmentId, project.id) - ); -} - -function deriveRepositoryTitle(project: EnvironmentProject): string { - const identity = project.repositoryIdentity; - return identity?.displayName ?? identity?.name ?? project.title; -} - -function deriveRepositorySubtitle(identity: RepositoryIdentity | null | undefined): string | null { - if (!identity) { - return null; - } - if (identity.owner && identity.name) { - return `${identity.owner}/${identity.name}`; - } - return identity.canonicalKey; -} - -function deriveProjectLatestActivity( - project: EnvironmentProject, - threads: ReadonlyArray, -): string { - const latestThread = threads[0]; - return latestThread?.updatedAt ?? latestThread?.createdAt ?? project.updatedAt; -} - -export function groupProjectsByRepository(input: { - readonly projects: ReadonlyArray; - readonly threads: ReadonlyArray; -}): ReadonlyArray { - const threadsByProjectKey = new Map(); - - for (const thread of input.threads) { - const key = scopedProjectKey(thread.environmentId, thread.projectId); - const existing = threadsByProjectKey.get(key); - if (existing) { - existing.push(thread); - } else { - threadsByProjectKey.set(key, [thread]); - } - } - - const grouped = new Map(); - - for (const project of input.projects) { - const key = deriveRepositoryGroupKey(project); - const projectKey = scopedProjectKey(project.environmentId, project.id); - const threads = Arr.sortWith( - threadsByProjectKey.get(projectKey) ?? [], - (s) => new Date(s.updatedAt ?? s.createdAt), - DateDescending, - ); - - const latestActivityAt = deriveProjectLatestActivity(project, threads); - const projectGroup: RepositoryProjectGroup = { - key: projectKey, - project, - threads, - latestActivityAt, - }; - - const existing = grouped.get(key); - if (!existing) { - grouped.set(key, { - key, - title: deriveRepositoryTitle(project), - subtitle: deriveRepositorySubtitle(project.repositoryIdentity), - repositoryIdentity: project.repositoryIdentity ?? null, - projectCount: 1, - threadCount: threads.length, - latestActivityAt, - projects: [projectGroup], - }); - continue; - } - - grouped.set(key, { - ...existing, - title: existing.repositoryIdentity ? existing.title : deriveRepositoryTitle(project), - subtitle: existing.subtitle ?? deriveRepositorySubtitle(project.repositoryIdentity), - repositoryIdentity: existing.repositoryIdentity ?? project.repositoryIdentity ?? null, - projectCount: existing.projectCount + 1, - threadCount: existing.threadCount + threads.length, - latestActivityAt: - compareIsoDateDescending(existing.latestActivityAt, latestActivityAt) > 0 - ? latestActivityAt - : existing.latestActivityAt, - projects: Arr.sortWith( - [...existing.projects, projectGroup], - (s) => new Date(s.latestActivityAt), - DateDescending, - ), - }); - } - - return Arr.sortWith(grouped.values(), (s) => new Date(s.latestActivityAt), DateDescending); -} diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 6b1018e2a0ad..9a5ed82b3b8a 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -5,6 +5,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; import * as MobileDatabase from "./mobile-database"; import * as MobileSecureStorage from "./mobile-secure-storage"; @@ -22,7 +23,9 @@ export interface Preferences { readonly codeWordBreak?: boolean; readonly connectOnboardingOptOutAccounts?: ReadonlyArray; readonly collapsedProjectGroups?: readonly string[]; + /** @deprecated Kept temporarily so older OTA bundles retain the selected mode. */ readonly projectGroupingEnabled?: boolean; + readonly projectGroupingMode?: SidebarProjectGroupingMode; /** * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no * client-settings sync, so the flat v2 thread list is opted out of per @@ -80,6 +83,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { connectOnboardingOptOutAccounts?: ReadonlyArray; collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; + projectGroupingMode?: SidebarProjectGroupingMode; threadListV2Enabled?: boolean; } = {}; @@ -110,6 +114,13 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.projectGroupingEnabled === "boolean") { preferences.projectGroupingEnabled = parsed.projectGroupingEnabled; } + if ( + parsed.projectGroupingMode === "repository" || + parsed.projectGroupingMode === "repository_path" || + parsed.projectGroupingMode === "separate" + ) { + preferences.projectGroupingMode = parsed.projectGroupingMode; + } if (typeof parsed.threadListV2Enabled === "boolean") { preferences.threadListV2Enabled = parsed.threadListV2Enabled; } diff --git a/apps/mobile/src/state/project-grouping.logic.ts b/apps/mobile/src/state/project-grouping.logic.ts new file mode 100644 index 000000000000..3cd01174b5dd --- /dev/null +++ b/apps/mobile/src/state/project-grouping.logic.ts @@ -0,0 +1,33 @@ +import type { ProjectGroupingSettings } from "@t3tools/client-runtime/state/project-grouping"; +import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; + +import type { Preferences } from "../persistence/mobile-preferences"; + +export const DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS: ProjectGroupingSettings = { + sidebarProjectGroupingMode: "repository", + sidebarProjectGroupingOverrides: {}, +}; + +export function resolveMobileProjectGroupingSettings( + preferences: Preferences, +): ProjectGroupingSettings { + return { + sidebarProjectGroupingMode: + preferences.projectGroupingMode ?? + (preferences.projectGroupingEnabled === false ? "separate" : "repository"), + sidebarProjectGroupingOverrides: {}, + }; +} + +/** + * Dual-writes the legacy boolean for one release so an OTA rollback to an + * older mobile bundle preserves the user's grouping choice. + */ +export function mobileProjectGroupingModePatch( + mode: SidebarProjectGroupingMode, +): Partial { + return { + projectGroupingMode: mode, + projectGroupingEnabled: mode !== "separate", + }; +} diff --git a/apps/mobile/src/state/project-grouping.test.ts b/apps/mobile/src/state/project-grouping.test.ts new file mode 100644 index 000000000000..6995ea463ad0 --- /dev/null +++ b/apps/mobile/src/state/project-grouping.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + mobileProjectGroupingModePatch, + resolveMobileProjectGroupingSettings, +} from "./project-grouping.logic"; + +describe("mobile project grouping preferences", () => { + it("maps the legacy boolean while preferring the new mode", () => { + expect(resolveMobileProjectGroupingSettings({}).sidebarProjectGroupingMode).toBe("repository"); + expect( + resolveMobileProjectGroupingSettings({ projectGroupingEnabled: false }) + .sidebarProjectGroupingMode, + ).toBe("separate"); + expect( + resolveMobileProjectGroupingSettings({ + projectGroupingEnabled: false, + projectGroupingMode: "repository_path", + }).sidebarProjectGroupingMode, + ).toBe("repository_path"); + }); + + it("dual-writes the legacy boolean for rollback compatibility", () => { + expect(mobileProjectGroupingModePatch("separate")).toEqual({ + projectGroupingMode: "separate", + projectGroupingEnabled: false, + }); + expect(mobileProjectGroupingModePatch("repository_path")).toEqual({ + projectGroupingMode: "repository_path", + projectGroupingEnabled: true, + }); + }); +}); diff --git a/apps/mobile/src/state/project-grouping.ts b/apps/mobile/src/state/project-grouping.ts new file mode 100644 index 000000000000..c012c59d5b64 --- /dev/null +++ b/apps/mobile/src/state/project-grouping.ts @@ -0,0 +1,17 @@ +import { useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; + +import { mobilePreferencesAtom } from "./preferences"; +import { + DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS, + resolveMobileProjectGroupingSettings, +} from "./project-grouping.logic"; + +export * from "./project-grouping.logic"; + +export function useMobileProjectGroupingSettings() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + return AsyncResult.isSuccess(preferencesResult) + ? resolveMobileProjectGroupingSettings(preferencesResult.value) + : DEFAULT_MOBILE_PROJECT_GROUPING_SETTINGS; +} diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index cc52a50fe362..1e0c88c24e84 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -10,6 +10,7 @@ import * as Option from "effect/Option"; import * as References from "effect/References"; import * as Terminal from "effect/Terminal"; +import * as BootService from "../cloud/bootService.ts"; import { acquireRelayClientForLink, formatHeadlessAuthorizationPrompt, @@ -63,6 +64,15 @@ it.effect("treats cancelling optional background setup as a successful skip", () }), ); +it.effect("keeps a successful connection when a remote service update is pending", () => + Effect.gen(function* () { + const result = yield* recoverServiceOnboardingOffer( + Effect.fail(new BootService.BootServiceUpdatePendingError()), + ); + assert.isFalse(result); + }), +); + it.effect("does not install the relay client when the user declines the managed download", () => Effect.gen(function* () { let installCalls = 0; diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index bd846eeee345..d55b270f1831 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -185,6 +185,8 @@ export const recoverServiceOnboardingOffer = ( Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), BootServiceInstallError: (error) => Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + BootServiceUpdatePendingError: (error) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), }), ); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 9af69eb17926..a9ae3f49f70b 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -15,7 +15,11 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import * as ProcessRunner from "../processRunner.ts"; import * as BootService from "./bootService.ts"; import { pinnedRuntimePaths } from "./pinnedRuntime.ts"; -import { parseServiceState } from "./serviceProtocol.ts"; +import { + parseServiceState, + SERVICE_LAUNCHER_PROTOCOL, + serviceStateHasPendingUpdate, +} from "./serviceProtocol.ts"; it("keeps systemd pinned to the stable launcher rather than a versioned server", () => { const unit = BootService.renderBootServiceUnit({ @@ -97,15 +101,24 @@ it.layer(NodeServices.layer)("boot service install", (it) => { const plan = yield* service.install; expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ - protocol: 1, + protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.2.3", }); expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n"); expect((yield* service.status).current).toBe(true); - yield* fs.writeFileString( - statePath, - '{"protocol":1,"activeVersion":"1.2.3","update":{"id":"u","fromVersion":"1.2.3","targetVersion":"1.2.4","status":"pending"}}', - ); + // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document. + const pendingState = JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.2.3", + update: { + id: "u", + fromVersion: "1.2.3", + targetVersion: "1.2.4", + dbPath: "/tmp/state.sqlite", + status: "pending", + }, + }); + yield* fs.writeFileString(statePath, pendingState); expect((yield* service.status).current).toBe(false); expect(yield* service.uninstall).toBe(true); expect((yield* service.status).installed).toBe(false); @@ -141,6 +154,33 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + it.effect("restarts without overwriting a pending remote update", () => + Effect.gen(function* () { + const { service, fs, statePath, commands } = yield* makeHarness(); + yield* service.install; + // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document. + const pendingState = JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL - 1, + activeVersion: "1.2.3", + update: { + id: "remote-update", + fromVersion: "1.2.3", + targetVersion: "1.2.4", + status: "pending", + }, + }); + yield* fs.writeFileString(statePath, pendingState); + commands.length = 0; + + expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUpdatePendingError"); + expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true); + expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([ + "systemctl --user stop t3code.service", + "systemctl --user restart t3code.service", + ]); + }), + ); + it.effect("fails closed off Linux", () => Effect.gen(function* () { const { service } = yield* makeHarness("darwin"); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 9a8481b11b51..ec110cb8b6a8 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -21,6 +21,7 @@ import { SERVICE_LAUNCHER_PROTOCOL, SERVICE_STATE_FILE, parseServiceState, + serviceStateHasPendingUpdate, type ServiceState, } from "./serviceProtocol.ts"; @@ -110,10 +111,20 @@ export class BootServiceInstallError extends Schema.TaggedErrorClass()( + "BootServiceUpdatePendingError", + {}, +) { + override get message(): string { + return "A remote server update is still pending. Wait for it to finish, then retry."; + } +} + export type BootServiceError = | BootServiceUnsupportedError | BootServiceCommandError - | BootServiceInstallError; + | BootServiceInstallError + | BootServiceUpdatePendingError; export interface BootServiceStatus { readonly supported: boolean; @@ -288,6 +299,15 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { } yield* Effect.gen(function* () { + if (installed) { + const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option); + if ( + Option.isSome(previousStateText) && + serviceStateHasPendingUpdate(previousStateText.value) + ) { + return yield* new BootServiceUpdatePendingError(); + } + } yield* fs .makeDirectory(unitDir, { recursive: true }) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index 6fe1d5a4a3c3..276ee037773c 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -11,6 +11,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ServiceLauncherClient from "./serviceLauncherClient.ts"; +import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; import * as ServerSelfUpdate from "./selfUpdate.ts"; interface HarnessOptions { @@ -50,7 +51,11 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( const result = options.preflight === "blocked" ? { status: "blocked", version: "1.1.0", reason: "local update required" } - : { status: "ready", version: "1.1.0", launcherProtocol: 1 }; + : { + status: "ready", + version: "1.1.0", + launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, + }; return { // @effect-diagnostics-next-line preferSchemaOverJson:off - fake child-process stdout. stdout: JSON.stringify(result), @@ -64,7 +69,6 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( }); const launcher = ServiceLauncherClient.ServiceLauncherClient.of({ managed: options.managed ?? true, - trial: false, requestUpdate: options.requestUpdate ?? (() => diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 58bb84117309..015fd557d3bd 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -170,7 +170,7 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { yield* reportProgress("installing"); const updateId = yield* launcher - .requestUpdate({ targetVersion }) + .requestUpdate({ targetVersion, dbPath: serverConfig.dbPath }) .pipe( Effect.mapError((error) => failWith( diff --git a/apps/server/src/cloud/serviceLauncherClient.test.ts b/apps/server/src/cloud/serviceLauncherClient.test.ts index 6b9e926ff8db..eab0935419a0 100644 --- a/apps/server/src/cloud/serviceLauncherClient.test.ts +++ b/apps/server/src/cloud/serviceLauncherClient.test.ts @@ -5,6 +5,7 @@ import * as Fiber from "effect/Fiber"; import { SERVICE_LAUNCHER_CONTEXT_ENV, + SERVICE_LAUNCHER_PROTOCOL, type ServiceLauncherChildMessage, type ServiceLauncherParentMessage, } from "./serviceProtocol.ts"; @@ -53,10 +54,11 @@ it.effect("waits for the launcher to durably commit the trial update ID", () => id: "update-1", fromVersion: "1.0.0", targetVersion: "1.1.0", + dbPath: "/tmp/state.sqlite", status: "pending" as const, }; const host = new FakeLauncherProcess({ - protocol: 1, + protocol: SERVICE_LAUNCHER_PROTOCOL, childVersion: "1.1.0", update: pending, }); @@ -79,13 +81,14 @@ it.effect("waits for the launcher to durably commit the trial update ID", () => it.effect("returns the launcher-generated ID only after update acceptance", () => Effect.gen(function* () { const host = new FakeLauncherProcess({ - protocol: 1, + protocol: SERVICE_LAUNCHER_PROTOCOL, childVersion: "1.0.0", }); const client = yield* makeClient(host, "1.0.0"); - const requested = yield* Effect.forkChild(client.requestUpdate({ targetVersion: "1.1.0" }), { - startImmediately: true, - }); + const requested = yield* Effect.forkChild( + client.requestUpdate({ targetVersion: "1.1.0", dbPath: "/tmp/state.sqlite" }), + { startImmediately: true }, + ); yield* Effect.yieldNow; host.emit({ type: "update-accepted", @@ -97,11 +100,15 @@ it.effect("returns the launcher-generated ID only after update acceptance", () = it.effect("preserves a launcher rejection as a distinct error", () => Effect.gen(function* () { - const host = new FakeLauncherProcess({ protocol: 1, childVersion: "1.0.0" }); - const client = yield* makeClient(host, "1.0.0"); - const requested = yield* Effect.forkChild(client.requestUpdate({ targetVersion: "1.1.0" }), { - startImmediately: true, + const host = new FakeLauncherProcess({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + childVersion: "1.0.0", }); + const client = yield* makeClient(host, "1.0.0"); + const requested = yield* Effect.forkChild( + client.requestUpdate({ targetVersion: "1.1.0", dbPath: "/tmp/state.sqlite" }), + { startImmediately: true }, + ); yield* Effect.yieldNow; host.emit({ type: "update-rejected", reason: "requires local update" }); expect(yield* Fiber.join(requested).pipe(Effect.flip)).toMatchObject({ @@ -115,12 +122,13 @@ it.effect("preserves a launcher rejection as a distinct error", () => it.effect("rejects contradictory trial context instead of leaving activation closed", () => Effect.gen(function* () { const host = new FakeLauncherProcess({ - protocol: 1, + protocol: SERVICE_LAUNCHER_PROTOCOL, childVersion: "1.1.0", update: { id: "update-1", fromVersion: "1.0.0", targetVersion: "1.2.0", + dbPath: "/tmp/state.sqlite", status: "pending", }, }); diff --git a/apps/server/src/cloud/serviceLauncherClient.ts b/apps/server/src/cloud/serviceLauncherClient.ts index 760642c29f59..970c72cee721 100644 --- a/apps/server/src/cloud/serviceLauncherClient.ts +++ b/apps/server/src/cloud/serviceLauncherClient.ts @@ -100,9 +100,9 @@ export class ServiceLauncherClient extends Context.Service< ServiceLauncherClient, { readonly managed: boolean; - readonly trial: boolean; readonly requestUpdate: (input: { readonly targetVersion: string; + readonly dbPath: string; }) => Effect.Effect; readonly prepareTrial: Effect.Effect< ServerSelfUpdateOutcome | undefined, @@ -137,8 +137,8 @@ const resolveStartup = Effect.fn("cloud.service_launcher_client.resolve_startup" export const resolveServiceLauncherMode = Effect.fn("cloud.service_launcher_client.resolve_mode")( function* () { - const { context, managed } = yield* resolveStartup(); - return { managed, trial: context?.update?.status === "pending" }; + const { managed } = yield* resolveStartup(); + return { managed }; }, ); @@ -199,7 +199,7 @@ export const make = Effect.fn("cloud.service_launcher_client.make")(function* (o }), ); - const requestUpdate = (input: { readonly targetVersion: string }) => + const requestUpdate = (input: { readonly targetVersion: string; readonly dbPath: string }) => exchange( { type: "request-update", ...input }, (reply) => reply.type === "update-accepted" || reply.type === "update-rejected", @@ -233,14 +233,18 @@ export const make = Effect.fn("cloud.service_launcher_client.make")(function* (o if (reply.type !== "committed") { return Effect.die("service launcher returned an impossible prepared response"); } - return Effect.succeed({ ...pending, status: "committed" as const }); + return Effect.succeed({ + id: pending.id, + fromVersion: pending.fromVersion, + targetVersion: pending.targetVersion, + status: "committed" as const, + }); }), ) : Effect.succeed(outcome); return ServiceLauncherClient.of({ managed, - trial: pending !== undefined, requestUpdate, prepareTrial, }); diff --git a/apps/server/src/cloud/servicePreflight.test.ts b/apps/server/src/cloud/servicePreflight.test.ts index d2ce6db8de84..2eb2e02015d0 100644 --- a/apps/server/src/cloud/servicePreflight.test.ts +++ b/apps/server/src/cloud/servicePreflight.test.ts @@ -1,47 +1,26 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as NodeSqlite from "node:sqlite"; -import { migrationManifest } from "../persistence/Migrations.ts"; import { runServicePreflight } from "./servicePreflight.ts"; +import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; -it.layer(NodeServices.layer)("service update preflight", (it) => { - it.effect("requires exact migration-manifest equality without mutating the database", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-preflight-test-" }); - const databasePath = path.join(root, "state.sqlite"); - const database = new NodeSqlite.DatabaseSync(databasePath); - database.exec("CREATE TABLE effect_sql_migrations (migration_id INTEGER, name TEXT)"); - const insert = database.prepare( - "INSERT INTO effect_sql_migrations (migration_id, name) VALUES (?, ?)", - ); - for (const [id, name] of migrationManifest) insert.run(id, name); - database.close(); - - expect(runServicePreflight({ databasePath, launcherProtocol: 1, version: "1.2.3" })).toEqual({ - status: "ready", - version: "1.2.3", - launcherProtocol: 1, - }); +it("requires the database-snapshot launcher protocol", () => { + expect( + runServicePreflight({ + databasePath: "/missing/state.sqlite", + launcherProtocol: SERVICE_LAUNCHER_PROTOCOL - 1, + version: "1.2.3", + }), + ).toMatchObject({ status: "blocked", version: "1.2.3" }); - const changed = new NodeSqlite.DatabaseSync(databasePath); - changed.exec("DELETE FROM effect_sql_migrations WHERE migration_id = 35"); - changed.close(); - const blocked = runServicePreflight({ - databasePath, - launcherProtocol: 1, - version: "1.2.3", - }); - expect(blocked.status).toBe("blocked"); - if (blocked.status === "blocked") { - expect(blocked.reason).toContain("npx t3@1.2.3 service update"); - } + expect( + runServicePreflight({ + databasePath: "/missing/state.sqlite", + launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, + version: "1.2.3", }), - ); + ).toEqual({ + status: "ready", + version: "1.2.3", + launcherProtocol: SERVICE_LAUNCHER_PROTOCOL, + }); }); diff --git a/apps/server/src/cloud/servicePreflight.ts b/apps/server/src/cloud/servicePreflight.ts index 1843e1638817..ee0f972baa3f 100644 --- a/apps/server/src/cloud/servicePreflight.ts +++ b/apps/server/src/cloud/servicePreflight.ts @@ -1,7 +1,4 @@ -import * as NodeSqlite from "node:sqlite"; - import packageJson from "../../package.json" with { type: "json" }; -import { migrationManifest } from "../persistence/Migrations.ts"; import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; export type ServicePreflightResult = @@ -16,20 +13,8 @@ export type ServicePreflightResult = readonly reason: string; }; -const localUpdateReason = (version: string) => - `This version includes a database update and cannot be installed remotely. Run \`npx t3@${version} service update\` on the server machine.`; - -const isMigrationRow = ( - value: unknown, -): value is { readonly migration_id: number; readonly name: string } => - typeof value === "object" && - value !== null && - "migration_id" in value && - typeof value.migration_id === "number" && - "name" in value && - typeof value.name === "string"; - export function runServicePreflight(input: { + /** Older servers always pass this flag when invoking a staged preflight. */ readonly databasePath: string; readonly launcherProtocol: number; readonly version?: string; @@ -44,28 +29,6 @@ export function runServicePreflight(input: { }; } - try { - const database = new NodeSqlite.DatabaseSync(input.databasePath, { readOnly: true }); - try { - const rows: ReadonlyArray = database - .prepare("SELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id") - .all(); - const exact = - rows.length === migrationManifest.length && - rows.every((row, index) => { - const expected = migrationManifest[index]; - return ( - isMigrationRow(row) && row.migration_id === expected?.[0] && row.name === expected?.[1] - ); - }); - if (!exact) return { status: "blocked", version, reason: localUpdateReason(version) }; - } finally { - database.close(); - } - } catch { - return { status: "blocked", version, reason: localUpdateReason(version) }; - } - return { status: "ready", version, launcherProtocol: SERVICE_LAUNCHER_PROTOCOL }; } diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts index 921bc1447ed2..ebc5d15d54f0 100644 --- a/apps/server/src/cloud/serviceProtocol.ts +++ b/apps/server/src/cloud/serviceProtocol.ts @@ -1,6 +1,7 @@ import type { ServerSelfUpdateOutcome } from "@t3tools/contracts"; -export const SERVICE_LAUNCHER_PROTOCOL = 1 as const; +/** Protocol 2 snapshots SQLite before trials so migrations can be rolled back safely. */ +export const SERVICE_LAUNCHER_PROTOCOL = 2 as const; export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT"; export const SERVICE_LAUNCHER_FILE = "service-launcher.mjs"; export const SERVICE_STATE_FILE = "service-state.json"; @@ -9,6 +10,7 @@ export interface PendingServiceUpdate { readonly id: string; readonly fromVersion: string; readonly targetVersion: string; + readonly dbPath: string; readonly status: "pending"; } @@ -31,6 +33,7 @@ export type ServiceLauncherChildMessage = | { readonly type: "request-update"; readonly targetVersion: string; + readonly dbPath: string; } | { readonly type: "prepared"; @@ -78,7 +81,9 @@ export function decodeServiceUpdate(value: unknown): ServiceUpdateRecord | undef return undefined; } if (status === "pending") { - return { id, fromVersion, targetVersion, status }; + return typeof value.dbPath === "string" && value.dbPath.trim() !== "" + ? { id, fromVersion, targetVersion, dbPath: value.dbPath, status } + : undefined; } if ( (status === "committed" || status === "rolled-back" || status === "failed") && @@ -165,6 +170,16 @@ export function parseServiceState(value: string): ServiceState | undefined { } } +/** Detects an in-flight update across launcher protocol versions before replacing its state. */ +export function serviceStateHasPendingUpdate(value: string): boolean { + try { + const parsed: unknown = JSON.parse(value); + return isRecord(parsed) && isRecord(parsed.update) && parsed.update.status === "pending"; + } catch { + return false; + } +} + export function decodeServiceLauncherContext(value: string): ServiceLauncherContext | undefined { let parsed: unknown; try { @@ -202,8 +217,12 @@ export function decodeServiceLauncherChildMessage( value: unknown, ): ServiceLauncherChildMessage | undefined { if (!isRecord(value)) return undefined; - if (value.type === "request-update" && typeof value.targetVersion === "string") { - return { type: value.type, targetVersion: value.targetVersion }; + if ( + value.type === "request-update" && + typeof value.targetVersion === "string" && + typeof value.dbPath === "string" + ) { + return { type: value.type, targetVersion: value.targetVersion, dbPath: value.dbPath }; } return value.type === "prepared" && typeof value.updateId === "string" ? { type: value.type, updateId: value.updateId } diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index a14f89fd0315..b6eedb87e667 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -145,6 +145,7 @@ export const make = Effect.gen(function* () { connectionProbe: true, threadSettlement: true, threadSnooze: true, + threadPinning: true, threadTitleRegeneration: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), diff --git a/apps/server/src/mcp/toolkits/preview/tools.test.ts b/apps/server/src/mcp/toolkits/preview/tools.test.ts index d00ff459b9df..652c20e6ac0d 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.test.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.test.ts @@ -12,6 +12,19 @@ const schemaHasDescription = (schema: unknown): boolean => { .some((members) => members.some(schemaHasDescription)); }; +const schemaHasMultipleAllOfDescriptions = (schema: unknown): boolean => { + if (!schema || typeof schema !== "object") return false; + const record = schema as Record; + const allOf = Array.isArray(record.allOf) ? record.allOf : []; + const descriptionCount = allOf.filter( + (member) => + member !== null && + typeof member === "object" && + typeof (member as Record).description === "string", + ).length; + return descriptionCount > 1 || Object.values(record).some(schemaHasMultipleAllOfDescriptions); +}; + it("exports provider-compatible object schemas with described parameters", () => { for (const tool of Object.values(PreviewToolkit.tools)) { const schema = Tool.getJsonSchema(tool) as { @@ -27,6 +40,9 @@ it("exports provider-compatible object schemas with described parameters", () => expect(schema.type, `${tool.name} must export a top-level object schema`).toBe("object"); expect(schema.anyOf, `${tool.name} must not export a root anyOf`).toBeUndefined(); expect(schema.oneOf, `${tool.name} must not export a root oneOf`).toBeUndefined(); + if (tool.name === "preview_navigate") { + expect(schemaHasMultipleAllOfDescriptions(schema)).toBe(false); + } expect( schema.properties?.tabId, `${tool.name} must allow an explicit collaborative browser tab target`, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 4f4fb61d6985..67b672271e0b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -611,6 +611,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti settledAt: null, snoozedUntil: null, snoozedAt: null, + pinnedAt: null, titleRegenerationRequestId: null, titleRegenerationStartedAt: null, latestUserMessageAt: null, @@ -717,6 +718,36 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.pinned": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + pinnedAt: event.payload.pinnedAt, + updatedAt: event.payload.updatedAt, + }); + return; + } + + case "thread.unpinned": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + pinnedAt: null, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.meta-updated": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index e804c4049a4d..b08f29c642fc 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -312,6 +312,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, + pinnedAt: null, titleRegeneration: null, titleRegenerationFailure: null, deletedAt: null, @@ -428,6 +429,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, + pinnedAt: null, titleRegeneration: null, titleRegenerationFailure: null, session: { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3aaa36243250..97153bcde4de 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -396,6 +396,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + pinned_at AS "pinnedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId", @@ -433,6 +434,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + pinned_at AS "pinnedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId", @@ -472,6 +474,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + pinned_at AS "pinnedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId", @@ -911,6 +914,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + pinned_at AS "pinnedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId", @@ -1352,6 +1356,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: row.settledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, titleRegeneration: mapTitleRegeneration(row), titleRegenerationFailure: mapTitleRegenerationFailure(row), deletedAt: row.deletedAt, @@ -1556,6 +1561,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: row.settledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, titleRegeneration: mapTitleRegeneration(row), titleRegenerationFailure: mapTitleRegenerationFailure(row), deletedAt: row.deletedAt, @@ -1691,6 +1697,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: row.settledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, titleRegeneration: mapTitleRegeneration(row), titleRegenerationFailure: mapTitleRegenerationFailure(row), session: sessionByThread.get(row.threadId) ?? null, @@ -1831,6 +1838,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: row.settledAt, snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, + pinnedAt: row.pinnedAt, titleRegeneration: mapTitleRegeneration(row), titleRegenerationFailure: mapTitleRegenerationFailure(row), session: sessionByThread.get(row.threadId) ?? null, @@ -2103,6 +2111,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: threadRow.value.settledAt, snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, + pinnedAt: threadRow.value.pinnedAt, titleRegeneration: mapTitleRegeneration(threadRow.value), titleRegenerationFailure: mapTitleRegenerationFailure(threadRow.value), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, @@ -2203,6 +2212,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { settledAt: threadRow.value.settledAt, snoozedUntil: threadRow.value.snoozedUntil, snoozedAt: threadRow.value.snoozedAt, + pinnedAt: threadRow.value.pinnedAt, titleRegeneration: mapTitleRegeneration(threadRow.value), titleRegenerationFailure: mapTitleRegenerationFailure(threadRow.value), deletedAt: null, diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 3b558d24739e..ee96e422945a 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -13,6 +13,8 @@ import { ThreadUnsettledPayload as ContractsThreadUnsettledPayloadSchema, ThreadSnoozedPayload as ContractsThreadSnoozedPayloadSchema, ThreadUnsnoozedPayload as ContractsThreadUnsnoozedPayloadSchema, + ThreadPinnedPayload as ContractsThreadPinnedPayloadSchema, + ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, @@ -42,6 +44,8 @@ export const ThreadUnarchivedPayload = ContractsThreadUnarchivedPayloadSchema; export const ThreadUnsettledPayload = ContractsThreadUnsettledPayloadSchema; export const ThreadSnoozedPayload = ContractsThreadSnoozedPayloadSchema; export const ThreadUnsnoozedPayload = ContractsThreadUnsnoozedPayloadSchema; +export const ThreadPinnedPayload = ContractsThreadPinnedPayloadSchema; +export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.pinned.test.ts b/apps/server/src/orchestration/decider.pinned.test.ts new file mode 100644 index 000000000000..bed41e13a17e --- /dev/null +++ b/apps/server/src/orchestration/decider.pinned.test.ts @@ -0,0 +1,226 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +const PINNED_AT = "1969-12-30T00:00:00.000Z"; + +function makeReadModel(input: { + readonly pinnedAt?: string | null; + readonly archivedAt?: string | null; + readonly settledOverride?: "settled" | "active" | null; + readonly settledAt?: string | null; + readonly snoozedUntil?: string | null; + readonly snoozedAt?: string | null; +}): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: input.archivedAt ?? null, + settledOverride: input.settledOverride ?? null, + settledAt: input.settledAt ?? (input.settledOverride === "settled" ? NOW : null), + snoozedUntil: input.snoozedUntil ?? null, + snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? PINNED_AT : null), + pinnedAt: input.pinnedAt ?? null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +it.layer(NodeServices.layer)("pinned thread decider", (it) => { + it.effect("pins a thread, stamping pinnedAt and updatedAt together", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.pinned"); + if (events[0]?.type === "thread.pinned") { + expect(events[0].payload.pinnedAt).toBe(events[0].payload.updatedAt); + } + }), + ); + + it.effect("re-pinning preserves the original pinnedAt and updatedAt", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-again"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.pinned"); + if (events[0]?.type === "thread.pinned") { + expect(events[0].payload.pinnedAt).toBe(PINNED_AT); + expect(events[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("unpins a pinned thread", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.unpin", + commandId: CommandId.make("cmd-unpin"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.unpinned"); + if (events[0]?.type === "thread.unpinned") { + expect(events[0].payload.updatedAt).not.toBe(NOW); + } + }), + ); + + it.effect("unpinning an unpinned thread preserves updatedAt", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.unpin", + commandId: CommandId.make("cmd-unpin-noop"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.unpinned"); + if (events[0]?.type === "thread.unpinned") { + expect(events[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("pinning a settled thread also un-settles it", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-settled"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({ settledOverride: "settled" }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events.map((entry) => entry.type)).toEqual(["thread.pinned", "thread.unsettled"]); + const unsettled = events.find((entry) => entry.type === "thread.unsettled"); + if (unsettled?.type === "thread.unsettled") { + expect(unsettled.payload.reason).toBe("user"); + } + }), + ); + + it.effect("pinning a snoozed thread also wakes it", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-snoozed"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({ snoozedUntil: "1970-01-02T09:00:00.000Z" }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events.map((entry) => entry.type)).toEqual(["thread.pinned", "thread.unsnoozed"]); + }), + ); + + it.effect("pinning an unparked thread emits only thread.pinned", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-plain"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events.map((entry) => entry.type)).toEqual(["thread.pinned"]); + }), + ); + + it.effect("settling a pinned thread also unpins it", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-pinned"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({ pinnedAt: PINNED_AT }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events.map((entry) => entry.type)).toEqual(["thread.settled", "thread.unpinned"]); + }), + ); + + it.effect("settling an unpinned thread emits no unpin event", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-unpinned"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events.map((entry) => entry.type)).toEqual(["thread.settled"]); + }), + ); + + it.effect("rejects pinning an archived thread", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.pin", + commandId: CommandId.make("cmd-pin-archived"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel({ archivedAt: NOW }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index e5007adb8e4a..3347fd617902 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -487,14 +487,14 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // settledAt: the engine rejects zero-event commands, and bulk-settle / // double-click must stay silent no-ops rather than surface errors. const alreadySettled = thread.settledOverride === "settled" && thread.settledAt !== null; - return { + const settledEvent = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, })), - type: "thread.settled", + type: "thread.settled" as const, payload: { threadId: command.threadId, settledAt: alreadySettled ? thread.settledAt : occurredAt, @@ -504,6 +504,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" updatedAt: alreadySettled ? thread.updatedAt : occurredAt, }, }; + // Settling is "I'm done with this": it clears a pin the same way it + // parks the thread. Without this, settling a pinned thread would only + // stamp invisible state — the pin would hold the card in place until + // a separate unpin. + if (thread.pinnedAt == null) { + return settledEvent; + } + return [ + settledEvent, + { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unpinned" as const, + payload: { + threadId: command.threadId, + updatedAt: occurredAt, + }, + }, + ]; } case "thread.unsettle": { @@ -630,6 +653,98 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.pin": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = yield* nowIso; + // Re-pinning an already-pinned thread is a duplicate (double-click, + // raced clients): re-emit with the original timestamps so the + // projection is a no-op. Pinning has no lifecycle invariants — a pin + // only ever promotes visibility, so it can never hide pending work. + const existingPinnedAt = thread.pinnedAt ?? null; + const pinnedEvent = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.pinned" as const, + payload: { + threadId: command.threadId, + pinnedAt: existingPinnedAt ?? occurredAt, + updatedAt: existingPinnedAt !== null ? thread.updatedAt : occurredAt, + }, + }; + // Pinning is a promotion: it clears the parked states rather than + // silently outranking them. An explicit settle un-settles (reason + // "user", same override the un-settle button stamps), and a snooze's + // return ticket is spent — the thread is on top NOW, not on Tuesday. + const promotionEvents: Array> = []; + if (thread.settledOverride === "settled") { + promotionEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsettled", + payload: { + threadId: command.threadId, + reason: "user", + updatedAt: occurredAt, + }, + }); + } + if (thread.snoozedUntil != null) { + promotionEvents.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unsnoozed", + payload: { + threadId: command.threadId, + reason: "user", + updatedAt: occurredAt, + }, + }); + } + return promotionEvents.length > 0 ? [pinnedEvent, ...promotionEvents] : pinnedEvent; + } + + case "thread.unpin": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + // Idempotent by re-emission (see thread.settle): unpinning a thread + // that is not pinned lands on the same null state without churning + // updatedAt. + const alreadyUnpinned = thread.pinnedAt == null; + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.unpinned", + payload: { + threadId: command.threadId, + updatedAt: alreadyUnpinned ? thread.updatedAt : occurredAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.pinned.test.ts b/apps/server/src/orchestration/projector.pinned.test.ts new file mode 100644 index 000000000000..35bd063667a8 --- /dev/null +++ b/apps/server/src/orchestration/projector.pinned.test.ts @@ -0,0 +1,77 @@ +import { + CommandId, + EventId, + ProjectId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +function makeEvent(input: { + readonly sequence: number; + readonly type: OrchestrationEvent["type"]; + readonly payload: unknown; +}): OrchestrationEvent { + return { + sequence: input.sequence, + eventId: EventId.make(`event-${input.sequence}`), + type: input.type, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: CommandId.make(`command-${input.sequence}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: input.payload as never, + } as OrchestrationEvent; +} + +it.effect("projects pin lifecycle events", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const created = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + sequence: 1, + type: "thread.created", + payload: { + threadId: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { provider: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + expect(created.threads[0]?.pinnedAt ?? null).toBeNull(); + + const pinned = yield* projectEvent( + created, + makeEvent({ + sequence: 2, + type: "thread.pinned", + payload: { threadId: ThreadId.make("thread-1"), pinnedAt: now, updatedAt: now }, + }), + ); + expect(pinned.threads[0]?.pinnedAt).toBe(now); + + const unpinned = yield* projectEvent( + pinned, + makeEvent({ + sequence: 3, + type: "thread.unpinned", + payload: { threadId: ThreadId.make("thread-1"), updatedAt: now }, + }), + ); + expect(unpinned.threads[0]?.pinnedAt).toBeNull(); + }), +); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index a6bc35fd7d6e..fb82fd767346 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -23,7 +23,9 @@ import { ThreadProposedPlanUpsertedPayload, ThreadRuntimeModeSetPayload, ThreadSettledPayload, + ThreadPinnedPayload, ThreadSnoozedPayload, + ThreadUnpinnedPayload, ThreadUnarchivedPayload, ThreadUnsettledPayload, ThreadUnsnoozedPayload, @@ -394,6 +396,28 @@ export function projectEvent( })), ); + case "thread.pinned": + return decodeForEvent(ThreadPinnedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + pinnedAt: payload.pinnedAt, + updatedAt: payload.updatedAt, + }), + })), + ); + + case "thread.unpinned": + return decodeForEvent(ThreadUnpinnedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + pinnedAt: null, + updatedAt: payload.updatedAt, + }), + })), + ); + case "thread.meta-updated": return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 4763f5656538..71d7df566fd2 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -95,6 +95,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, + pinnedAt: null, latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -157,6 +158,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { settledAt: "2026-03-25T00:00:00.000Z", snoozedUntil: "2026-03-26T09:00:00.000Z", snoozedAt: "2026-03-25T00:00:00.000Z", + pinnedAt: "2026-03-25T00:00:00.000Z", latestUserMessageAt: null, pendingApprovalCount: 0, pendingUserInputCount: 0, @@ -175,6 +177,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { assert.strictEqual(row.settledAt, "2026-03-25T00:00:00.000Z"); assert.strictEqual(row.snoozedUntil, "2026-03-26T09:00:00.000Z"); assert.strictEqual(row.snoozedAt, "2026-03-25T00:00:00.000Z"); + assert.strictEqual(row.pinnedAt, "2026-03-25T00:00:00.000Z"); // Un-settle to the keep-active pin and wake the snooze; confirm the // flips persist. @@ -184,6 +187,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { settledAt: null, snoozedUntil: null, snoozedAt: null, + pinnedAt: null, }); const repersisted = yield* threads.getById({ threadId: ThreadId.make("thread-settled"), @@ -193,6 +197,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { assert.strictEqual(updated?.settledAt, null); assert.strictEqual(updated?.snoozedUntil, null); assert.strictEqual(updated?.snoozedAt, null); + assert.strictEqual(updated?.pinnedAt, null); }), ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 1b1f5651cbd6..9480bc43d083 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -47,6 +47,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settled_at, snoozed_until, snoozed_at, + pinned_at, title_regeneration_request_id, title_regeneration_started_at, title_regeneration_failure_request_id, @@ -75,6 +76,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.settledAt}, ${row.snoozedUntil}, ${row.snoozedAt}, + ${row.pinnedAt}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, ${row.titleRegenerationFailureRequestId ?? null}, @@ -103,6 +105,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settled_at = excluded.settled_at, snoozed_until = excluded.snoozed_until, snoozed_at = excluded.snoozed_at, + pinned_at = excluded.pinned_at, title_regeneration_request_id = excluded.title_regeneration_request_id, title_regeneration_started_at = excluded.title_regeneration_started_at, title_regeneration_failure_request_id = excluded.title_regeneration_failure_request_id, @@ -138,6 +141,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + pinned_at AS "pinnedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId", @@ -175,6 +179,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { settled_at AS "settledAt", snoozed_until AS "snoozedUntil", snoozed_at AS "snoozedAt", + pinned_at AS "pinnedAt", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", title_regeneration_failure_request_id AS "titleRegenerationFailureRequestId", diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 3cfdb5c5c761..d1e002501263 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -7,7 +7,6 @@ import type { SqlError } from "effect/unstable/sql/SqlError"; import { runMigrations } from "../Migrations.ts"; import { ServerConfig } from "../../config.ts"; -import * as ServiceLauncherClient from "../../cloud/serviceLauncherClient.ts"; type RuntimeSqliteLayerConfig = { readonly filename: string; @@ -31,28 +30,24 @@ const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* ( return clientModule.layer(config); }, Layer.unwrap); -const setup = (trial: boolean) => - Layer.effectDiscard( - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`PRAGMA foreign_keys = ON;`; - if (!trial) { - yield* sql`PRAGMA journal_mode = WAL;`; - yield* runMigrations(); - } - }), - ); +const setup = Layer.effectDiscard( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`PRAGMA foreign_keys = ON;`; + yield* sql`PRAGMA journal_mode = WAL;`; + yield* runMigrations(); + }), +); export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")(function* ( dbPath: string, - options?: { readonly trial?: boolean }, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); return Layer.provideMerge( - setup(options?.trial === true), + setup, makeRuntimeSqliteLayer({ filename: dbPath, spanAttributes: { @@ -64,14 +59,13 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")( }, Layer.unwrap); export const SqlitePersistenceMemory = Layer.provideMerge( - setup(false), + setup, makeRuntimeSqliteLayer({ filename: ":memory:" }), ); export const layerConfig = Layer.unwrap( Effect.gen(function* () { const { dbPath } = yield* ServerConfig; - const launcher = yield* ServiceLauncherClient.resolveServiceLauncherMode(); - return makeSqlitePersistenceLive(dbPath, { trial: launcher.trial }); + return makeSqlitePersistenceLive(dbPath); }), ); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index a52a430cf518..d5a0f660a3b0 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -60,6 +60,9 @@ import Migration0037 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; import Migration0038 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; import Migration0039 from "./Migrations/036_ProjectionThreadTitleRegenerationFailure.ts"; import Migration0040 from "./Migrations/037_RepairProjectionThreadTitleRegenerationFailure.ts"; +// Upstream ProjectionThreadsPinned (upstream file 036 / runtime 36) renumbered +// past fork titleRegenerationFailure filenames 036/037 and runtime ids 39/40. +import Migration0041 from "./Migrations/038_ProjectionThreadsPinned.ts"; /** * Migration loader with all migrations defined inline. @@ -112,6 +115,7 @@ export const migrationEntries = [ [38, "ProjectionThreadTitleRegeneration", Migration0038], [39, "ProjectionThreadTitleRegenerationFailure", Migration0039], [40, "RepairProjectionThreadTitleRegenerationFailure", Migration0040], + [41, "ProjectionThreadsPinned", Migration0041], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinned.ts b/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinned.ts new file mode 100644 index 000000000000..c96e4693fc57 --- /dev/null +++ b/apps/server/src/persistence/Migrations/038_ProjectionThreadsPinned.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "pinned_at")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN pinned_at TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 50ab80c101c5..d18d8f1ca705 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -41,6 +41,7 @@ export const ProjectionThread = Schema.Struct({ settledAt: Schema.NullOr(IsoDateTime), snoozedUntil: Schema.NullOr(IsoDateTime), snoozedAt: Schema.NullOr(IsoDateTime), + pinnedAt: Schema.NullOr(IsoDateTime), titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), titleRegenerationFailureRequestId: Schema.optional(Schema.NullOr(CommandId)), diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index 21f3618d5124..4562c6a6de8e 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -9,6 +9,7 @@ import { compareExactServiceVersions, decodeServiceState, isExactServiceVersion, + SERVICE_LAUNCHER_PROTOCOL, } from "./cloud/serviceProtocol.ts"; it("accepts only exact semantic versions", () => { @@ -33,12 +34,13 @@ it("orders exact semantic versions without treating build metadata as precedence it("rejects contradictory service state", () => { assert.isUndefined( decodeServiceState({ - protocol: 1, + protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "0.0.31", update: { id: "update-1", fromVersion: "0.0.30", targetVersion: "0.0.32", + dbPath: "/tmp/state.sqlite", status: "pending", }, }), @@ -46,12 +48,26 @@ it("rejects contradictory service state", () => { assert.isUndefined( decodeServiceState({ - protocol: 1, + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + update: { + id: "update-3", + fromVersion: "1.0.0", + targetVersion: "1.1.0", + status: "pending", + }, + }), + ); + + assert.isUndefined( + decodeServiceState({ + protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.0.0", update: { id: "update-2", fromVersion: "1.0.0", targetVersion: "0.9.0", + dbPath: "/tmp/state.sqlite", status: "pending", }, }), @@ -66,7 +82,7 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-test-" }); const statePath = path.join(root, "runtime", "service-state.json"); const state = { - protocol: 1, + protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "0.0.31", } as const; @@ -88,7 +104,7 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { yield* fs.writeFileString(path.join(versionDir, ".install-complete"), "1.0.0\n"); yield* Effect.promise(() => writeServiceState(statePath, { - protocol: 1, + protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.0.0", }), ); @@ -106,6 +122,11 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-flow-" }); const statePath = path.join(root, "runtime", "service-state.json"); + const databasePath = path.join(root, "userdata", "state.sqlite"); + yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); + yield* fs.writeFileString(databasePath, "before trial"); + // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in fake child source. + const encodedDatabasePath = JSON.stringify(databasePath); const childSource = ` const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); if (context.update?.status === "pending") { @@ -114,7 +135,7 @@ if (context.update?.status === "pending") { if (message.type === "committed") process.exit(0); }); } else if (context.update === undefined) { - process.send({ type: "request-update", targetVersion: "1.1.0" }); + process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${encodedDatabasePath} }); setInterval(() => {}, 1_000); } else { process.exit(0); @@ -129,7 +150,7 @@ if (context.update?.status === "pending") { } yield* Effect.promise(() => writeServiceState(statePath, { - protocol: 1, + protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.0.0", }), ); @@ -154,12 +175,17 @@ if (context.update?.status === "pending") { const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-rollback-" }); const statePath = path.join(root, "runtime", "service-state.json"); + const databasePath = path.join(root, "userdata", "state.sqlite"); + yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); + yield* fs.writeFileString(databasePath, "before trial"); + // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in fake child source. + const encodedDatabasePath = JSON.stringify(databasePath); const childSource = ` const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); if (context.update?.status === "pending") { process.send({ type: "prepared", updateId: "wrong-update" }); } else if (context.update === undefined) { - process.send({ type: "request-update", targetVersion: "1.1.0" }); + process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${encodedDatabasePath} }); setInterval(() => {}, 1_000); } else { process.exit(0); @@ -174,7 +200,7 @@ if (context.update?.status === "pending") { } yield* Effect.promise(() => writeServiceState(statePath, { - protocol: 1, + protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.0.0", }), ); @@ -196,4 +222,65 @@ if (context.update?.status === "pending") { ); }), ); + + it.effect("restores the database when a migrating trial exits", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-db-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const databasePath = path.join(root, "userdata", "state.sqlite"); + const original = "database before migration"; + yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); + yield* fs.writeFileString(databasePath, original); + // @effect-diagnostics-next-line preferSchemaOverJson:off - embeds a path in fake child source. + const encodedDatabasePath = JSON.stringify(databasePath); + const childSource = ` +import { writeFileSync } from "node:fs"; +const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); +if (context.update?.status === "pending") { + writeFileSync(context.update.dbPath, "database after migration"); + writeFileSync(context.update.dbPath + "-wal", "trial wal"); + writeFileSync(context.update.dbPath + "-shm", "trial shm"); + process.exit(1); +} else if (context.update === undefined) { + process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${encodedDatabasePath} }); + setInterval(() => {}, 1_000); +} else { + process.exit(0); +} +`; + for (const version of ["1.0.0", "1.1.0"]) { + const versionDir = path.join(root, "runtime", "versions", version); + const entryPath = path.join(versionDir, "node_modules", "t3", "dist", "bin.mjs"); + yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true }); + yield* fs.writeFileString(entryPath, childSource); + yield* fs.writeFileString(path.join(versionDir, ".install-complete"), `${version}\n`); + } + yield* Effect.promise(() => + writeServiceState(statePath, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + }), + ); + + const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath))); + yield* Effect.promise(() => + launcher.run().then( + () => Promise.reject(new Error("launcher unexpectedly completed")), + () => Promise.resolve(), + ), + ); + + const state = yield* Effect.promise(() => readServiceState(statePath)); + assert.equal(state.activeVersion, "1.0.0"); + assert.equal(state.update?.status, "rolled-back"); + assert.equal(yield* fs.readFileString(databasePath), original); + assert.isFalse(yield* fs.exists(`${databasePath}-wal`)); + assert.isFalse(yield* fs.exists(`${databasePath}-shm`)); + const updateId = state.update?.id; + assert.isDefined(updateId); + assert.isFalse(yield* fs.exists(path.join(root, "runtime", "db-backup", updateId))); + }), + ); }); diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index d7ca33578023..211c0138bc4b 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -48,6 +48,121 @@ const runtimePaths = (baseDir: string, version: string) => { }; }; +/** SQLite persists across the main file plus its WAL and shared-memory sidecars. */ +const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const; +const RESTORE_MARKER = ".restore-pending"; + +const databaseBackupDir = (baseDir: string, updateId: string) => + NodePath.join(baseDir, "runtime", "db-backup", updateId); + +const databaseBackupFile = (backupDir: string, suffix: (typeof DB_FILE_SUFFIXES)[number]) => + NodePath.join(backupDir, suffix === "" ? "database" : `database${suffix}`); + +async function pathExists(target: string): Promise { + try { + await NodeFSP.access(target); + return true; + } catch (cause) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return false; + throw cause; + } +} + +async function syncFile(filePath: string): Promise { + const handle = await NodeFSP.open(filePath, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function syncDirectory(directory: string): Promise { + const handle = await NodeFSP.open(directory, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +/** + * Snapshots the database once per update before the first trial. A completed + * backup is never overwritten because a restarted launcher may be looking at + * database writes from an earlier attempt by the same trial. + */ +async function backupDatabaseOnce(baseDir: string, pending: PendingServiceUpdate): Promise { + const backupDir = databaseBackupDir(baseDir, pending.id); + if (await pathExists(backupDir)) return; + + const stagingDir = `${backupDir}.staging`; + await NodeFSP.rm(stagingDir, { recursive: true, force: true }); + await NodeFSP.mkdir(stagingDir, { recursive: true, mode: 0o700 }); + try { + for (const suffix of DB_FILE_SUFFIXES) { + const source = `${pending.dbPath}${suffix}`; + if (suffix !== "" && !(await pathExists(source))) continue; + const destination = databaseBackupFile(stagingDir, suffix); + await NodeFSP.copyFile(source, destination); + await syncFile(destination); + } + await NodeFSP.rename(stagingDir, backupDir); + await syncDirectory(NodePath.dirname(backupDir)); + } catch (cause) { + await NodeFSP.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined); + throw cause; + } +} + +const restoreMarkerPath = (baseDir: string, updateId: string) => + NodePath.join(databaseBackupDir(baseDir, updateId), RESTORE_MARKER); + +const databaseRestorePending = (baseDir: string, pending: PendingServiceUpdate) => + pathExists(restoreMarkerPath(baseDir, pending.id)); + +/** Mark rollback before changing live files so launcher recovery cannot boot a partial restore. */ +async function markDatabaseRestorePending(backupDir: string): Promise { + const markerPath = NodePath.join(backupDir, RESTORE_MARKER); + if (!(await pathExists(markerPath))) { + const handle = await NodeFSP.open(markerPath, "wx", 0o600); + try { + await handle.sync(); + } finally { + await handle.close(); + } + await syncDirectory(backupDir); + } +} + +/** Restore is retryable after any process crash while the backup directory remains. */ +async function restoreDatabaseBackup( + baseDir: string, + pending: PendingServiceUpdate, +): Promise { + const backupDir = databaseBackupDir(baseDir, pending.id); + if (!(await pathExists(backupDir))) return; + + await markDatabaseRestorePending(backupDir); + for (const suffix of DB_FILE_SUFFIXES) { + const target = `${pending.dbPath}${suffix}`; + const source = databaseBackupFile(backupDir, suffix); + if (await pathExists(source)) { + await NodeFSP.copyFile(source, target); + await syncFile(target); + } else { + await NodeFSP.rm(target, { force: true }); + } + } + await syncDirectory(NodePath.dirname(pending.dbPath)); +} + +async function discardDatabaseBackup(baseDir: string, updateId: string): Promise { + const backupDir = databaseBackupDir(baseDir, updateId); + if (!(await pathExists(backupDir))) return; + await NodeFSP.rm(backupDir, { recursive: true, force: true }); + await syncDirectory(NodePath.dirname(backupDir)); +} + export async function readServiceState(filePath: string): Promise { const contents = await NodeFSP.readFile(filePath, "utf8"); const state = parseServiceState(contents); @@ -217,9 +332,16 @@ export class Launcher { async #recover(): Promise { const update = this.#state.update; if (update?.status !== "pending") { + if (update !== undefined) { + await discardDatabaseBackup(this.#baseDir, update.id).catch(() => undefined); + } await this.#startChild(this.#state.activeVersion, "active", update); return; } + if (await databaseRestorePending(this.#baseDir, update)) { + await this.#returnToPrevious(update, "failed", "rollback-interrupted"); + return; + } if (!(await runtimeExists(this.#baseDir, update.targetVersion))) { await this.#returnToPrevious(update, "failed", "target-runtime-missing"); return; @@ -228,6 +350,13 @@ export class Launcher { } async #startTrial(pending: PendingServiceUpdate): Promise { + // The previous child is dead here, so all three SQLite files are quiescent. + try { + await backupDatabaseOnce(this.#baseDir, pending); + } catch { + await this.#returnToPrevious(pending, "failed", "db-backup-failed"); + return; + } try { await this.#startChild(pending.targetVersion, "trial", pending); } catch { @@ -322,6 +451,10 @@ export class Launcher { await reject("Remote updates must select a newer server version."); return; } + if (!NodePath.isAbsolute(message.dbPath)) { + await reject("The requested database path is not absolute."); + return; + } if (!(await runtimeExists(this.#baseDir, message.targetVersion))) { await reject("The requested target runtime is missing or incomplete."); return; @@ -331,6 +464,7 @@ export class Launcher { id: NodeCrypto.randomUUID(), fromVersion: child.version, targetVersion: message.targetVersion, + dbPath: message.dbPath, status: "pending", }; const next: ServiceState = { ...this.#state, update: pending }; @@ -375,6 +509,7 @@ export class Launcher { await writeServiceState(this.#statePath, next); this.#state = next; child.role = "active"; + await discardDatabaseBackup(this.#baseDir, committed.id).catch(() => undefined); await sendMessage(child.process, { type: "committed", updateId: committed.id }); } @@ -423,6 +558,11 @@ export class Launcher { reason: string, child?: ManagedChild, ): Promise { + if (child !== undefined) { + this.#child = null; + await terminateChild(child.process); + } + await restoreDatabaseBackup(this.#baseDir, pending); const outcome = terminalUpdate({ pending, status, reason }); const next: ServiceState = { ...this.#state, @@ -431,10 +571,7 @@ export class Launcher { }; await writeServiceState(this.#statePath, next); this.#state = next; - if (child !== undefined) { - this.#child = null; - await terminateChild(child.process); - } + await discardDatabaseBackup(this.#baseDir, pending.id).catch(() => undefined); await this.#startChild(next.activeVersion, "active", outcome); } } diff --git a/apps/web/index.html b/apps/web/index.html index eccee92878b3..021bcb4156ce 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -47,14 +47,7 @@ body { background: #ffffff; color: #262626; - font-family: - "DM Sans Variable", - "DM Sans", - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - system-ui, - sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; } html.dark body { diff --git a/apps/web/package.json b/apps/web/package.json index 764f05561e7c..fcc535a384c8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,8 +21,6 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", - "@fontsource-variable/dm-sans": "^5.2.8", - "@fontsource/jetbrains-mono": "^5.2.8", "@formkit/auto-animate": "^0.9.0", "@legendapp/list": "3.2.0", "@lexical/react": "^0.41.0", diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts new file mode 100644 index 000000000000..8467c13c2cef --- /dev/null +++ b/apps/web/src/appearanceFonts.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + clampCodeFontSize, + clampInterfaceFontSize, + clampPromptFontSize, + DEFAULT_CODE_FONT_STACK, + DEFAULT_SANS_FONT_STACK, + appearanceFontStack, + cssFontFamilies, + resolveDefaultFamilyLabel, +} from "./appearanceFonts"; + +describe("cssFontFamilies", () => { + it("returns null for effectively empty input", () => { + expect(cssFontFamilies("")).toBeNull(); + expect(cssFontFamilies(" ")).toBeNull(); + expect(cssFontFamilies(" , , ")).toBeNull(); + }); + + it("quotes names with spaces and keeps single idents bare", () => { + expect(cssFontFamilies("Fira Code")).toBe('"Fira Code"'); + expect(cssFontFamilies("monospace")).toBe("monospace"); + expect(cssFontFamilies('"Comic Mono"')).toBe('"Comic Mono"'); + }); + + it("normalizes comma-separated lists and strips embedded quotes", () => { + expect(cssFontFamilies(" Fira Code , Menlo ")).toBe('"Fira Code", Menlo'); + expect(cssFontFamilies('Bad"Name')).toBe('"BadName"'); + }); + + it("quotes names that are not single CSS idents", () => { + expect(cssFontFamilies("3270 Nerd Font")).toBe('"3270 Nerd Font"'); + expect(cssFontFamilies("M+ 1m")).toBe('"M+ 1m"'); + }); +}); + +describe("resolveDefaultFamilyLabel", () => { + it("skips generic keywords and returns null for a stack of only generics", () => { + expect(resolveDefaultFamilyLabel("system-ui, sans-serif")).toBeNull(); + expect(resolveDefaultFamilyLabel("ui-monospace, monospace")).toBeNull(); + }); +}); + +describe("appearanceFontStack", () => { + it("prepends the custom family to the default stack", () => { + expect(appearanceFontStack("Fira Code", DEFAULT_CODE_FONT_STACK)).toBe( + `"Fira Code", ${DEFAULT_CODE_FONT_STACK}`, + ); + }); + + it("falls back to the default stack when unset", () => { + expect(appearanceFontStack("", DEFAULT_SANS_FONT_STACK)).toBe(DEFAULT_SANS_FONT_STACK); + }); +}); + +describe("font size clamping", () => { + it("keeps sizes inside the ranges the UI can absorb", () => { + expect(clampInterfaceFontSize(16)).toBe(16); + expect(clampInterfaceFontSize(2)).toBe(12); + expect(clampInterfaceFontSize(96)).toBe(20); + expect(clampPromptFontSize(40)).toBe(20); + expect(clampCodeFontSize(1)).toBe(10); + }); + + it("rounds fractional values and falls back for unusable input", () => { + expect(clampCodeFontSize(13.4)).toBe(13); + expect(clampInterfaceFontSize(Number.NaN)).toBe(16); + expect(clampPromptFontSize(Number.POSITIVE_INFINITY)).toBe(14); + }); +}); diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts new file mode 100644 index 000000000000..3fb6c821a1b1 --- /dev/null +++ b/apps/web/src/appearanceFonts.ts @@ -0,0 +1,352 @@ +/** + * Font preferences from Settings → Appearance, applied as CSS custom + * properties. The default stacks mirror the `--font-sans` / `--font-mono` + * definitions in `index.css`; a custom family is always prepended to the + * matching default stack so glyph coverage never regresses. + */ + +import { + DEFAULT_CODE_FONT_SIZE, + DEFAULT_INTERFACE_FONT_SIZE, + DEFAULT_PROMPT_FONT_SIZE, + MAX_CODE_FONT_SIZE, + MAX_INTERFACE_FONT_SIZE, + MAX_PROMPT_FONT_SIZE, + MIN_CODE_FONT_SIZE, + MIN_INTERFACE_FONT_SIZE, + MIN_PROMPT_FONT_SIZE, +} from "@t3tools/contracts"; + +export const DEFAULT_SANS_FONT_STACK = + '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif'; + +// Concrete names first: some engines alias `ui-monospace` to the +// proportional system UI font, which would break every code surface. +export const DEFAULT_CODE_FONT_STACK = + '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace'; + +function quoteFontFamilyName(name: string): string { + const bare = name.trim(); + if (bare.length === 0) return ""; + // Already quoted, or a single ident that needs no quoting. + if (/^(['"]).*\1$/.test(bare)) return bare; + if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare; + return `"${bare.replaceAll('"', "")}"`; +} + +/** + * Normalize a user-entered family (single name or comma-separated list) into a + * safe CSS font-family list, or null when the input is effectively empty. + */ +export function cssFontFamilies(input: string): string | null { + const families = input + .split(",") + .map(quoteFontFamilyName) + .filter((name) => name.length > 0); + return families.length > 0 ? families.join(", ") : null; +} + +/** The full stack a preference resolves to: custom families before the default. */ +export function appearanceFontStack(custom: string, defaultStack: string): string { + const families = cssFontFamilies(custom); + return families === null ? defaultStack : `${families}, ${defaultStack}`; +} + +export interface AppearanceFontPreferences { + readonly sans: string; + readonly code: string; + readonly composer: string; + readonly sizeInterface: number; + readonly sizePrompt: number; + readonly sizeCode: number; + /** Grayscale `antialiased` rendering; false keeps the heavier platform default. */ + readonly smoothing: boolean; +} + +/** + * Apply the preferences to the root element. Unset families remove the + * override so the stylesheet defaults (and theme changes) stay in charge. + * + * Sizes are always written: the interface size drives the root font size (and + * with it every rem-based dimension), while the prompt and code sizes stay in + * absolute pixels so they do not scale twice. + */ +export function applyAppearanceFontVariables( + root: HTMLElement, + preferences: AppearanceFontPreferences, +): void { + const families: ReadonlyArray = [ + ["--font-sans", preferences.sans, DEFAULT_SANS_FONT_STACK], + ["--font-mono", preferences.code, DEFAULT_CODE_FONT_STACK], + // The composer falls back to whatever the sans preference resolves to. + ["--font-composer", preferences.composer, "var(--font-sans)"], + ]; + for (const [variable, custom, fallback] of families) { + const list = cssFontFamilies(custom); + if (list === null) { + root.style.removeProperty(variable); + } else { + root.style.setProperty(variable, `${list}, ${fallback}`); + } + } + + root.style.fontSize = `${clampInterfaceFontSize(preferences.sizeInterface)}px`; + root.style.setProperty("--font-size-prompt", `${clampPromptFontSize(preferences.sizePrompt)}px`); + const code = clampCodeFontSize(preferences.sizeCode); + root.style.setProperty("--font-size-code", `${code}px`); + // The @pierre/diffs surfaces read their own hook for code text. + root.style.setProperty("--diffs-font-size", `${code}px`); + + // Inherited from the root; only macOS engines honor the property, so no + // platform gate is needed here. Smoothing on means grayscale `antialiased` + // (thinner strokes); off restores the platform default, which macOS renders + // with heavier stem darkening. + if (preferences.smoothing) { + root.style.setProperty("-webkit-font-smoothing", "antialiased"); + } else { + root.style.removeProperty("-webkit-font-smoothing"); + } +} + +function clampFontSize(value: number, minimum: number, maximum: number, fallback: number): number { + if (!Number.isFinite(value)) return fallback; + return Math.min(maximum, Math.max(minimum, Math.round(value))); +} + +export function clampInterfaceFontSize(value: number): number { + return clampFontSize( + value, + MIN_INTERFACE_FONT_SIZE, + MAX_INTERFACE_FONT_SIZE, + DEFAULT_INTERFACE_FONT_SIZE, + ); +} + +export function clampPromptFontSize(value: number): number { + return clampFontSize(value, MIN_PROMPT_FONT_SIZE, MAX_PROMPT_FONT_SIZE, DEFAULT_PROMPT_FONT_SIZE); +} + +export function clampCodeFontSize(value: number): number { + return clampFontSize(value, MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE, DEFAULT_CODE_FONT_SIZE); +} + +const FONT_PROBE_TEXT = "mmmmmmmmMMWli1O0@# fjord"; +let fontProbeContext: CanvasRenderingContext2D | null | undefined; + +function probeWidth(fontList: string): number | null { + if (fontProbeContext === undefined) { + fontProbeContext = document.createElement("canvas").getContext("2d"); + } + if (fontProbeContext === null) return null; + fontProbeContext.font = `16px ${fontList}`; + return fontProbeContext.measureText(FONT_PROBE_TEXT).width; +} + +/** + * Canvas metric probing instead of document.fonts.check(): check() reports + * true for families that are not installed at all (nothing needs loading), so + * it cannot filter the dropdown. A family exists when falling back to at + * least one generic changes the measured advance. + */ +export function isFontFamilyAvailable(family: string): boolean { + const families = cssFontFamilies(family); + if (families === null) return false; + if (/^(system-ui|sans-serif|serif|monospace|ui-monospace)$/i.test(families)) return true; + try { + for (const generic of ["monospace", "serif", "sans-serif"]) { + const baseline = probeWidth(generic); + const candidate = probeWidth(`${families}, ${generic}`); + if (baseline === null || candidate === null) return false; + if (candidate !== baseline) return true; + } + return false; + } catch { + return false; + } +} + +/** + * Whether a family renders every character on the same advance. Cell-grid + * surfaces (the terminal) require this: a proportional face draws its text + * narrower than the lattice the cursor and selection are placed on, which + * reads as ragged gaps and a cursor stranded to the right of the text. + * + * Unmeasurable environments answer true, so a missing canvas never blocks a + * legitimate font. + */ +export function isMonospaceFamily(family: string): boolean { + const families = cssFontFamilies(family); + if (families === null) return true; + try { + if (fontProbeContext === undefined) { + fontProbeContext = document.createElement("canvas").getContext("2d"); + } + if (fontProbeContext === null) return true; + // Fall back to a generic mono so an absent face measures as monospace and + // is left for the normal fallback chain to resolve. + fontProbeContext.font = `32px ${families}, monospace`; + const narrow = fontProbeContext.measureText("i").width; + const wide = fontProbeContext.measureText("M").width; + if (!Number.isFinite(narrow) || !Number.isFinite(wide) || wide === 0) return true; + return Math.abs(wide - narrow) < 0.5; + } catch { + return true; + } +} + +// Nameable faces the platform generics commonly map to, likeliest first. +// Pixel-comparing a generic against these names the actual face; Apple's own +// UI fonts are deliberately not CSS-nameable, so a miss on an Apple platform +// identifies San Francisco itself. +const SANS_GENERIC_CANDIDATES = [ + "Segoe UI", + "Roboto", + "Noto Sans", + "Ubuntu", + "Cantarell", + "DejaVu Sans", + "Liberation Sans", + "Helvetica Neue", + "Arial", +] as const; +const MONO_GENERIC_CANDIDATES = [ + "Menlo", + "Consolas", + "Cascadia Mono", + "DejaVu Sans Mono", + "Ubuntu Mono", + "Liberation Mono", + "Noto Sans Mono", + "Roboto Mono", + "Monaco", + "Courier New", +] as const; + +const GENERIC_PROBE_TEXT = "RagIl10O@ fjord quiz"; + +/** + * Advance width of the probe text laid out by the DOM - not canvas, whose + * generic-family mapping diverges from real rendering (this engine draws + * `ui-monospace` as the proportional UI font on canvas but not in CSS). + * Identical widths at this size mean the same face for practical purposes. + */ +function measureDomProbeWidth(fontFamily: string): number | null { + try { + const body = document.body; + if (!body) return null; + const span = document.createElement("span"); + span.style.cssText = + "position:absolute;left:-9999px;top:0;visibility:hidden;white-space:pre;font-size:100px;"; + span.style.fontFamily = fontFamily; + span.textContent = GENERIC_PROBE_TEXT; + body.appendChild(span); + const width = span.getBoundingClientRect().width; + span.remove(); + return width > 0 ? width : null; + } catch { + return null; + } +} + +function widthsMatch(left: number, right: number): boolean { + return Math.abs(left - right) < 0.01; +} + +/** + * Name the concrete face a generic keyword renders as, by measuring the + * generic against nameable candidates. Null when the face cannot be + * identified (and the platform gives no definitional answer). + */ +function resolveGenericFamilyLabel(generic: string): string | null { + const lower = generic.toLowerCase(); + if (lower === "serif") return null; + const monoLike = lower === "ui-monospace" || lower === "monospace"; + const genericWidth = measureDomProbeWidth(generic); + if (genericWidth === null) return null; + for (const candidate of monoLike ? MONO_GENERIC_CANDIDATES : SANS_GENERIC_CANDIDATES) { + if (!isFontFamilyAvailable(candidate)) continue; + const candidateWidth = measureDomProbeWidth(`"${candidate}"`); + if (candidateWidth !== null && widthsMatch(genericWidth, candidateWidth)) { + return candidate; + } + } + // No nameable face matched; on Apple platforms that means one of the San + // Francisco faces, which CSS cannot name. Comparing against -apple-system + // tells the UI face apart from SF Mono. + if (/mac|iphone|ipad|ipod/i.test(navigator.platform)) { + const systemWidth = measureDomProbeWidth("-apple-system"); + if (systemWidth !== null && widthsMatch(genericWidth, systemWidth)) return "SF Pro"; + return monoLike ? "SF Mono" : "SF Pro"; + } + return null; +} + +/** + * The first family of a default stack that will actually render - what the + * "Default" choice means on this machine. Concrete names are probed for + * availability; generic keywords are resolved to the face they draw with + * where identifiable. Null when nothing can be named. + */ +export function resolveDefaultFamilyLabel(stack: string): string | null { + for (const raw of stack.split(",")) { + const family = raw.trim().replace(/^(['"])(.*)\1$/, "$2"); + if (family.length === 0) continue; + if ( + /^(system-ui|sans-serif|serif|monospace|ui-monospace|-apple-system|BlinkMacSystemFont)$/i.test( + family, + ) + ) { + const resolved = resolveGenericFamilyLabel(family); + if (resolved !== null) return resolved; + continue; + } + if (isFontFamilyAvailable(family)) return family; + } + return null; +} + +export interface InstalledFontFamiliesResult { + readonly families: readonly string[]; + /** + * "unsupported" - the engine has no Local Font Access API (Safari, + * Firefox); "denied" - the API exists but the user declined the permission + * prompt. Both fall back to the curated catalog. + */ + readonly status: "granted" | "denied" | "unsupported"; +} + +let installedFamiliesCache: InstalledFontFamiliesResult | null = null; + +/** + * Every installed family via the Local Font Access API (Chromium and + * Electron). Call from a user gesture: the first call raises the browser's + * local-fonts permission prompt. A denial is not cached, so reopening the + * picker can ask again after the user changes the site setting. + */ +export async function queryInstalledFontFamilies(): Promise { + if (installedFamiliesCache !== null) return installedFamiliesCache; + const query = ( + window as Window & { + queryLocalFonts?: () => Promise>; + } + ).queryLocalFonts; + if (typeof query !== "function") { + installedFamiliesCache = { families: [], status: "unsupported" }; + return installedFamiliesCache; + } + try { + const fonts = await query.call(window); + const families = [...new Set(fonts.map((font) => font.family))] + // Dot-prefixed families are macOS-internal UI faces; selecting one is + // never intended and most refuse to render for web content anyway. + .filter((family) => !family.startsWith(".")) + .sort((left, right) => left.localeCompare(right)); + // A denied permission check resolves with an empty list instead of + // throwing; no machine has zero fonts, so treat empty as denied. + if (families.length === 0) return { families: [], status: "denied" }; + installedFamiliesCache = { families, status: "granted" }; + return installedFamiliesCache; + } catch { + return { families: [], status: "denied" }; + } +} diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index f7d1856da039..a3f043c65368 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -9,7 +9,7 @@ import { HistoryIcon, MonitorIcon, } from "lucide-react"; -import { memo, useCallback, useMemo } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; @@ -214,6 +214,98 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ); }); +/** + * Collapse the strip's labels to icons only when the text no longer fits. + * + * Hidden labels stay measurable (they collapse to invisible absolute boxes, + * which keep their natural width), so the required width can be recomputed in + * either state on every pass - no remembered widths that could go stale or + * latch the strip compact. A small hysteresis keeps the boundary from + * flapping between states. + */ +const COMPACT_EXPAND_HYSTERESIS_PX = 16; + +function useLabelsOverflow(element: HTMLDivElement | null): boolean { + const [overflows, setOverflows] = useState(false); + // A render-synced mirror instead of useEffectEvent: the compiler memoizes + // the event callback, which left observers reading the first render's null + // element forever. + const stateRef = useRef({ element, overflows }); + stateRef.current = { element, overflows }; + + const measure = useCallback(() => { + const { element: current, overflows: compact } = stateRef.current; + if (!current) return; + const available = current.clientWidth; + if (available === 0) return; + // flex-1 stretches the groups to fill the strip, so their own boxes always + // measure "full". Sum the laid-out content instead, skipping hidden form + // artifacts and absolutely-positioned nodes (the compact-hidden labels). + const contentWidth = (parent: Element): number => { + const gap = Number.parseFloat(getComputedStyle(parent).columnGap) || 0; + let width = 0; + let counted = 0; + for (const child of parent.children) { + if (!(child instanceof HTMLElement)) continue; + if (child.offsetWidth <= 1) continue; + const position = getComputedStyle(child).position; + if (position === "absolute" || position === "fixed") continue; + width += child.offsetWidth; + counted += 1; + } + return width + gap * Math.max(0, counted - 1); + }; + const stripGap = Number.parseFloat(getComputedStyle(current).columnGap) || 0; + let needed = 0; + let groups = 0; + for (const child of current.children) { + if (!(child instanceof HTMLElement) || child.offsetWidth <= 1) continue; + needed += contentWidth(child); + groups += 1; + } + needed += stripGap * Math.max(0, groups - 1); + for (const label of current.querySelectorAll("[data-composer-label]")) { + // The clipping can happen below the marker (SelectValue truncates + // internally), where the outer span's scrollWidth matches its clipped + // box. The text's real width is the largest scrollWidth in the subtree. + let textWidth = label.scrollWidth; + for (const inner of label.querySelectorAll("*")) { + textWidth = Math.max(textWidth, inner.scrollWidth); + } + if (compact) { + // Compact: the label is squeezed to zero width but keeps reporting + // the full width it would need when expanded. + needed += textWidth; + } else { + // Expanded: the label is in flow; only the clipped remainder is + // missing from the content sum. + needed += Math.max(0, textWidth - label.clientWidth); + } + } + setOverflows(compact ? needed > available - COMPACT_EXPAND_HYSTERESIS_PX : needed > available); + }, []); + + // Label widths can change without the strip box moving (font family or + // size preferences), so re-measure on every render as well as on resize + // and font loads. + useEffect(() => { + measure(); + }); + + useEffect(() => { + if (!element) return; + const observer = new ResizeObserver(measure); + observer.observe(element); + document.fonts.addEventListener("loadingdone", measure); + return () => { + observer.disconnect(); + document.fonts.removeEventListener("loadingdone", measure); + }; + }, [element, measure]); + + return overflows; +} + export const BranchToolbar = memo(function BranchToolbar({ environmentId, threadId, @@ -300,11 +392,17 @@ export const BranchToolbar = memo(function BranchToolbar({ canPickEnvironment: showEnvironmentPicker, }); const isMobile = useIsMobile(); + const [stripElement, setStripElement] = useState(null); + const labelsOverflow = useLabelsOverflow(stripElement); if (!hasActiveThread || !activeProject) return null; return ( -
+
{isMobile ? ( - {triggerLabel} + + {triggerLabel} + diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index d300139d3cf5..ca778daad31c 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -82,7 +82,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe {effectiveEnvMode === "worktree" ? ( @@ -92,7 +92,12 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe ) : ( )} - + + + diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index e4ed54758ff4..2cf99547752a 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -49,7 +49,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir ) : ( )} - {activeEnvironment?.label ?? "Run on"} + + {activeEnvironment?.label ?? "Run on"} + ); } @@ -72,7 +77,12 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir ) : ( )} - + + + diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 45d0554bd019..1335e6bb05b2 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1363,6 +1363,9 @@ function ChatMarkdown({ }, [createAssetUrl, openPreview, preparedConnection, threadRef], ); + /* eslint-disable react/no-unstable-nested-components -- ReactMarkdown requires component + * renderers that close over this message's metadata. useMemo keeps them stable until that + * metadata changes. */ const markdownComponents = useMemo(() => { const fileLinkChip = ( fileLinkMeta: MarkdownFileLinkMeta, @@ -1593,6 +1596,7 @@ function ChatMarkdown({ text, threadRef, ]); + /* eslint-enable react/no-unstable-nested-components */ return (
void }) => { + const onSend = async ( + e?: { preventDefault: () => void }, + directAnnotation?: { + annotation: PreviewAnnotationPayload; + image: ComposerImageAttachment | null; + }, + ) => { e?.preventDefault(); + const notifyDirectAnnotationAttached = () => { + if (!directAnnotation) return; + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Annotation attached to draft", + description: "Sending is unavailable right now. Finish the current action, then send.", + }), + ); + }; if ( !activeThread || isSendBusy || @@ -4679,19 +4696,28 @@ function ChatViewContent(props: ChatViewProps) { threadDetailLoading || activeEnvironmentUnavailable || sendInFlightRef.current - ) + ) { + notifyDirectAnnotationAttached(); return; + } if (activePendingProgress) { + if (directAnnotation) { + notifyDirectAnnotationAttached(); + return; + } onAdvanceActivePendingUserInput(); return; } const sendCtx = composerRef.current?.getSendContext(); - if (!sendCtx?.providerAvailable) return; + if (!sendCtx?.providerAvailable) { + notifyDirectAnnotationAttached(); + return; + } const { - images: composerImages, + images: sendContextImages, terminalContexts: composerTerminalContexts, elementContexts: composerElementContexts, - previewAnnotations: composerPreviewAnnotations, + previewAnnotations: sendContextPreviewAnnotations, reviewComments: composerReviewComments, selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, @@ -4699,6 +4725,26 @@ function ChatViewContent(props: ChatViewProps) { selectedPromptEffort: ctxSelectedPromptEffort, selectedModelSelection: ctxSelectedModelSelection, } = sendCtx; + const composerImages = + directAnnotation?.image && + !sendContextImages.some((image) => image.id === directAnnotation.image?.id) + ? [...sendContextImages, directAnnotation.image] + : sendContextImages; + const composerPreviewAnnotations = + directAnnotation && + !sendContextPreviewAnnotations.some( + (annotation) => annotation.id === directAnnotation.annotation.id, + ) + ? [ + ...sendContextPreviewAnnotations, + { + ...directAnnotation.annotation, + screenshot: directAnnotation.annotation.screenshot + ? { ...directAnnotation.annotation.screenshot, dataUrl: "" } + : null, + }, + ] + : sendContextPreviewAnnotations; const promptForSend = promptRef.current; const { trimmedPrompt: trimmed, @@ -4714,7 +4760,7 @@ function ChatViewContent(props: ChatViewProps) { composerPreviewAnnotations.length + composerReviewComments.length, }); - if (showPlanFollowUpPrompt && activeProposedPlan) { + if (!directAnnotation && showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed, planMarkdown: activeProposedPlan.planMarkdown, @@ -5787,6 +5833,9 @@ function ChatViewContent(props: ChatViewProps) { tabId={activeRightPanelSurface.resourceId} configuredUrls={configuredPreviewUrls} visible + onSendAnnotation={(annotation, image) => { + void onSend(undefined, { annotation, image }); + }} /> ) : activeRightPanelSurface?.kind === "terminal" ? ( diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 107554dd87bc..f642bf877f38 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -138,9 +138,20 @@ import { buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, } from "../sidebarProjectGrouping"; +import type { Project } from "../types"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; +function projectFavicon(project: Project) { + return ( + + ); +} + function getLocalFileManagerName(platform: string): string { if (isMacPlatform(platform)) { return "Finder"; @@ -926,13 +937,7 @@ function OpenCommandPaletteDialog(props: { group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? [] ); }, - icon: (project) => ( - - ), + icon: projectFavicon, runProject: openProjectFromSearch, }), [openProjectFromSearch, pickerProjects, projectGroupByTargetKey], @@ -950,13 +955,7 @@ function OpenCommandPaletteDialog(props: { group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? [] ); }, - icon: (project) => ( - - ), + icon: projectFavicon, runProject: async (project) => { const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); const contextualRefBelongsToGroup = diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 169126788ae8..f64bdedaa59c 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1747,12 +1747,14 @@ function ComposerPromptEditorInner({ return ( -
+
Appearance + // can drive it; keep everything else here. + "block max-h-50 min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none", className, )} data-testid="composer-editor" @@ -1763,7 +1765,7 @@ function ComposerPromptEditorInner({ } placeholder={ terminalContexts.length > 0 ? null : ( -
+
{placeholder}
) diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index b39350cec107..8c641ce3e256 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -33,6 +33,7 @@ import { GitBranchIcon, EllipsisIcon, MessageSquareIcon, + PinIcon, PlusIcon, SearchIcon, ServerIcon, @@ -399,6 +400,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { settlementSupported: boolean; // Same contract for thread.snooze/unsnooze. snoozeSupported: boolean; + // Renders the pin glyph. Pinned cards keep the full settle/snooze quick + // actions: settling clears the pin server-side, and snoozing hides the + // card until wake with the pin intact underneath. Pin/unpin themselves + // live in the context menu only. + isPinned: boolean; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; // When a snooze ended (timer or early wake); drives the Woke pill until @@ -762,7 +768,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" onClick={handlePrClick} className={cn( - "shrink-0 font-mono text-xs hover:underline", + // Sidebar chrome follows the interface font; tabular digits keep the + // number from reflowing as PR states stream in. + "shrink-0 text-xs tabular-nums hover:underline", variant === "slim" && variantAction === "unsettle" ? props.isActive ? "text-muted-foreground/70" @@ -942,6 +950,13 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : ( )} + {props.isPinned ? ( + + ) : null} {/* The visible state owns this slot's width: status at rest, actions on hover/keyboard focus or while the popover is open. Keeping the hidden state out of flow lets the project label reclaim @@ -1231,8 +1246,15 @@ export default function SidebarV2() { const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const { settleThread, unsettleThread, snoozeThread, unsnoozeThread, deleteThread } = - useThreadActions(); + const { + settleThread, + unsettleThread, + snoozeThread, + unsnoozeThread, + pinThread, + unpinThread, + deleteThread, + } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -1609,76 +1631,91 @@ export default function SidebarV2() { // merging, no optimistic holds. Archived threads remain hidden here — // archive keeps its original "remove from sidebar" meaning. const serverConfigs = useAtomValue(environmentServerConfigsAtom); - const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { - const now = `${nowMinute}:00.000Z`; - // Snooze classification uses a REAL clock, not the quantized minute: - // wake times are second-precise and a woken thread must not linger on - // the shelf for the rest of the minute. snoozeWakeTick re-runs this - // memo exactly at the next wake boundary. - void snoozeWakeTick; - const preciseNow = new Date().toISOString(); - const visible = threads.filter( - (thread) => - thread.archivedAt === null && - (scopedProjectKeys === null || - scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), - ); - const active: EnvironmentThreadShell[] = []; - const snoozed: EnvironmentThreadShell[] = []; - const settled: EnvironmentThreadShell[] = []; - for (const thread of visible) { - // Threads on servers without the settlement capability (old server, - // or descriptor not loaded yet) never classify as settled: the user - // could neither un-settle nor pin them, so auto-settling them would - // strand rows in a tail with no working affordances. - const supportsSettlement = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; - const supportsSnooze = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; - // Snooze outranks settled classification: an explicitly snoozed thread - // belongs to the shelf even if it would also auto-settle (the shelf's - // wake time is a stronger statement about when it matters again). - if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { - snoozed.push(thread); - } else if ( - supportsSettlement && - effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) - ) { - settled.push(thread); - } else { - active.push(thread); + const { pinnedThreads, activeThreads, snoozedThreads, settledThreads, snoozeNow } = + useMemo(() => { + const now = `${nowMinute}:00.000Z`; + // Snooze classification uses a REAL clock, not the quantized minute: + // wake times are second-precise and a woken thread must not linger on + // the shelf for the rest of the minute. snoozeWakeTick re-runs this + // memo exactly at the next wake boundary. + void snoozeWakeTick; + const preciseNow = new Date().toISOString(); + const visible = threads.filter( + (thread) => + thread.archivedAt === null && + (scopedProjectKeys === null || + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), + ); + const pinned: EnvironmentThreadShell[] = []; + const active: EnvironmentThreadShell[] = []; + const snoozed: EnvironmentThreadShell[] = []; + const settled: EnvironmentThreadShell[] = []; + for (const thread of visible) { + // Threads on servers without the settlement capability (old server, + // or descriptor not loaded yet) never classify as settled: the user + // could neither un-settle nor pin them, so auto-settling them would + // strand rows in a tail with no working affordances. + const supportsSettlement = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === + true; + const supportsSnooze = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null; + // Snooze outranks everything, including a pin: "hide until Tuesday" + // temporarily suspends "keep on top". The pin survives underneath — + // pinned cards are creation-ordered, so on wake the thread reappears + // at its original spot in the pinned block. (For unpinned threads + // this is also the snooze-beats-auto-settle rule: the wake time is a + // stronger statement about when the thread matters again.) + if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { + snoozed.push(thread); + // A pin otherwise overrides the lifecycle: pinned threads never + // auto-settle out of sight. (The decider clears settled state on + // pin and the pin on settle, so pin-vs-settled conflicts only + // arise from stale or raced writes.) + } else if (thread.pinnedAt != null) { + pinned.push(thread); + } else if ( + supportsSettlement && + effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) + ) { + settled.push(thread); + } else { + active.push(thread); + } } - } - return { - activeThreads: sortThreadsForSidebarV2(active), - // Soonest wake first: "what comes back next" is the shelf's question. - snoozedThreads: snoozed.toSorted( - (left, right) => - firstValidTimestampMs(left.snoozedUntil ?? null) - - firstValidTimestampMs(right.snoozedUntil ?? null), - ), - settledThreads: sortSettledThreadsForSidebarV2(settled), - snoozeNow: preciseNow, - }; - }, [ - autoSettleAfterDays, - changeRequestStateByKey, - nowMinute, - scopedProjectKeys, - serverConfigs, - snoozeWakeTick, - threads, - ]); + return { + // Same static creation order as the inbox: a pin freezes prominence, + // it does not introduce a new ordering scheme. + pinnedThreads: sortThreadsForSidebarV2(pinned), + activeThreads: sortThreadsForSidebarV2(active), + // Soonest wake first: "what comes back next" is the shelf's question. + snoozedThreads: snoozed.toSorted( + (left, right) => + firstValidTimestampMs(left.snoozedUntil ?? null) - + firstValidTimestampMs(right.snoozedUntil ?? null), + ), + settledThreads: sortSettledThreadsForSidebarV2(settled), + snoozeNow: preciseNow, + }; + }, [ + autoSettleAfterDays, + changeRequestStateByKey, + nowMinute, + scopedProjectKeys, + serverConfigs, + snoozeWakeTick, + threads, + ]); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); const isSearchingThreads = threadSearchQuery.trim().length > 0; const searchableThreads = useMemo( - () => [...activeThreads, ...snoozedThreads, ...settledThreads], - [activeThreads, settledThreads, snoozedThreads], + () => [...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads], + [activeThreads, pinnedThreads, settledThreads, snoozedThreads], ); const threadSearchResults = useMemo( () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery), @@ -1782,8 +1819,8 @@ export default function SidebarV2() { }, [routeThreadKey, snoozedShelfExpanded, snoozedThreads]); const orderedThreads = useMemo( - () => [...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], - [activeThreads, visibleSnoozedThreads, renderedSettledThreads], + () => [...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads], + [pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads], ); const orderedThreadKeys = useMemo( () => @@ -2085,6 +2122,42 @@ export default function SidebarV2() { }, [unsnoozeThread], ); + const attemptPin = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + const result = await pinThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to pin thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [pinThread], + ); + const attemptUnpin = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + const result = await unpinThread(threadRef); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to unpin thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [unpinThread], + ); // One snooze per thread at a time — same double-dispatch guard as settle. const snoozingThreadKeysRef = useRef(new Set()); const attemptSnooze = useCallback( @@ -2248,7 +2321,8 @@ export default function SidebarV2() { // Post-settle navigation must skip threads settling in this same // batch — they are all leaving the card block together. Rows that // are already explicitly settled are skipped: nothing to do on a - // valid mixed selection. + // valid mixed selection. Pinned rows ARE included: the decider + // clears the pin as part of settling, so they park like the rest. const coSettlingKeys = new Set(threadKeys); for (const threadKey of threadKeys) { const thread = threadByKeyRef.current.get(threadKey); @@ -2345,6 +2419,8 @@ export default function SidebarV2() { true; const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; + const supportsPinning = + serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true; const supportsTitleRegeneration = serverConfigs.get(thread.environmentId)?.environment.capabilities .threadTitleRegeneration === true; @@ -2352,6 +2428,7 @@ export default function SidebarV2() { const lastTitleRegenerationError = titleRegenerationFailureReason(thread); const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); + const isPinned = thread.pinnedAt != null; // Presets resolve at menu-open time (same as the popover). const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => @@ -2365,6 +2442,16 @@ export default function SidebarV2() { }, ] : []), + ...(supportsPinning + ? [ + isPinned + ? { id: "unpin", label: "Unpin thread" } + : { id: "pin", label: "Pin thread" }, + ] + : []), + // Both lifecycle actions stay available on pinned threads: + // settling clears the pin ("done" beats "keep on top"), and + // snoozing hides the card until wake with the pin intact. ...(supportsSettlement ? [ isSettled @@ -2452,6 +2539,12 @@ export default function SidebarV2() { case "unsnooze": attemptUnsnooze(threadRef); return; + case "pin": + attemptPin(threadRef); + return; + case "unpin": + attemptUnpin(threadRef); + return; case "rename": startThreadRename(threadRef, thread.title); return; @@ -2526,8 +2619,10 @@ export default function SidebarV2() { })(); }, [ + attemptPin, attemptSettle, attemptSnooze, + attemptUnpin, attemptUnsettle, attemptUnsnooze, confirmThreadDelete, @@ -2882,7 +2977,7 @@ export default function SidebarV2() { {(() => { const renderThreadRow = ( thread: EnvironmentThreadShell, - section: "active" | "snoozed" | "settled", + section: "pinned" | "active" | "snoozed" | "settled", ) => { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), @@ -2891,7 +2986,7 @@ export default function SidebarV2() { // row: every other thread is a full card. Density comes // from users (or the auto rules) actually parking work, // not from the sidebar second-guessing what still matters. - const isCard = section === "active"; + const isCard = section === "active" || section === "pinned"; const rowVariant = isCard ? "card" : "slim"; return ( ); }; - const items: ReactNode[] = activeThreads.map((thread) => - renderThreadRow(thread, "active"), + // Pinned block: full cards above the inbox, closed by a + // thin divider (the pin glyphs carry the meaning, so no + // header text). Vanishes entirely at count 0. + const items: ReactNode[] = pinnedThreads.map((thread) => + renderThreadRow(thread, "pinned"), ); + if (pinnedThreads.length > 0) { + items.push( +
  • , + ); + } + for (const thread of activeThreads) { + items.push(renderThreadRow(thread, "active")); + } // Snoozed shelf: between the inbox and Settled — out of the // way, never gone. The header always renders while anything // is snoozed (the count is the whole footprint when @@ -3058,7 +3170,11 @@ export default function SidebarV2() { ) : null} {!isSearchingThreads && - activeThreads.length + snoozedThreads.length + settledThreads.length === 0 ? ( + pinnedThreads.length + + activeThreads.length + + snoozedThreads.length + + settledThreads.length === + 0 ? (
    {projects.length === 0 ? ( <> diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 5c7f6a774ee4..914e04b647dd 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -56,6 +56,7 @@ import { type ThreadTerminalGroup, } from "../types"; import { readLocalApi } from "~/localApi"; +import { useClientSettings } from "../hooks/useSettings"; import { useAttachedTerminalSession } from "../state/terminalSessions"; import { serverEnvironment } from "../state/server"; import { previewEnvironment } from "../state/preview"; @@ -131,7 +132,13 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri return value ?? fallback; } -function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { +/** The surface treats an omitted family or size as "use the built-in default". */ +function terminalFontOptions(family: string, size: number): { family?: string; size: number } { + const trimmed = family.trim(); + return trimmed.length > 0 ? { family: trimmed, size } : { size }; +} + +export function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { const isDark = document.documentElement.classList.contains("dark"); const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; @@ -305,6 +312,13 @@ export function TerminalViewport({ onAddTerminalContext(selection); }); const readTerminalLabel = useEffectEvent(() => terminalLabel); + // The terminal inherits the monospace (code) preference unless it has an + // override of its own, so one font choice drives every mono surface. + const terminalFontFamily = useClientSettings( + (settings) => settings.fontFamilyTerminal.trim() || settings.fontFamilyCode, + ); + const terminalFontSize = useClientSettings((settings) => settings.fontSizeTerminal); + const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize }); const terminalSession = useAttachedTerminalSession({ environmentId, terminal: { @@ -367,6 +381,13 @@ export function TerminalViewport({ keybindingsRef.current = keybindings; }, [keybindings]); + useEffect(() => { + const current = terminalFontRef.current; + if (current.family === terminalFontFamily && current.size === terminalFontSize) return; + terminalFontRef.current = { family: terminalFontFamily, size: terminalFontSize }; + void terminalRef.current?.setFont(terminalFontOptions(terminalFontFamily, terminalFontSize)); + }, [terminalFontFamily, terminalFontSize]); + useEffect(() => { const mount = containerRef.current; if (!mount) return; @@ -378,8 +399,10 @@ export function TerminalViewport({ let setupCleanups: Array<() => void> = []; const setup = async (): Promise<(() => void) | null> => { + const setupFont = terminalFontRef.current; const terminalOptions: GhosttyTerminalSurfaceOptions = { theme: terminalThemeFromApp(mount), + font: terminalFontOptions(setupFont.family, setupFont.size), onData: (data) => handleData(data), onResize: (cols, rows) => void resizeTerminal(cols, rows), onSelectionChange: () => handleSelectionChange(), @@ -397,6 +420,13 @@ export function TerminalViewport({ terminal.setTheme(terminalThemeFromApp(mount)); setupTerminal = terminal; terminalRef.current = terminal; + // Client settings hydrate asynchronously; a font preference that landed + // while the surface was loading found terminalRef null, so its setFont + // was dropped. Re-apply whatever is current once the terminal exists. + const currentFont = terminalFontRef.current; + if (currentFont.family !== setupFont.family || currentFont.size !== setupFont.size) { + void terminal.setFont(terminalFontOptions(currentFont.family, currentFont.size)); + } const latestSession = latestSessionRef.current; previousSessionRef.current = latestSession; if (latestSession.buffer.length > 0) terminal.resetAndWrite(latestSession.buffer); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index c58716d843cb..6371fc20558c 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -777,7 +777,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onLayout={updateModelListScrollFades} onScroll={updateModelListScrollFades} className={cn( - "model-picker-list h-full overflow-x-hidden overscroll-y-contain py-1.5 [--fade-size:1.5rem]", + "model-picker-list scrollbar-gutter-stable h-full overflow-x-hidden overscroll-y-contain py-1.5 [--fade-size:1.5rem]", showTopScrollFade && "model-picker-list-scroll-fade-top", showBottomScrollFade && "model-picker-list-scroll-fade-bottom", )} diff --git a/apps/web/src/components/composerInlineChip.ts b/apps/web/src/components/composerInlineChip.ts index b60b1678943f..f08f9285da94 100644 --- a/apps/web/src/components/composerInlineChip.ts +++ b/apps/web/src/components/composerInlineChip.ts @@ -1,20 +1,23 @@ +// Chip metrics are in em so the pills scale with the text they sit in (the +// composer honors the prompt font-size preference). The chat variant pins the +// original 12px, where every em value resolves to the same pixels as before. const INLINE_CHIP_CLASS_NAME = - "inline-flex max-w-full items-center gap-1 rounded-md border border-border/70 bg-accent/40 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-foreground align-middle"; + "inline-flex max-w-full items-center gap-[0.33em] rounded-[0.5em] border border-border/70 bg-accent/40 px-[0.5em] py-[0.08em] font-medium leading-[1.1] text-foreground align-middle"; -export const CHAT_INLINE_CHIP_CLASS_NAME = INLINE_CHIP_CLASS_NAME; +export const CHAT_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[12px]`; -export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} select-none`; +export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[0.86em] select-none`; -export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-3.5 shrink-0 opacity-85"; +export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-[1.17em] shrink-0 opacity-85"; export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight"; export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`; export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = - "inline-flex max-w-full select-none items-center gap-1 rounded-md border border-fuchsia-500/25 bg-fuchsia-500/12 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300"; + "inline-flex max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] py-[0.08em] font-medium text-[0.86em] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300"; -export const SKILL_CHIP_ICON_SVG = ``; +export const SKILL_CHIP_ICON_SVG = ``; export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME = - "ml-0.5 inline-flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/72 transition-colors hover:bg-foreground/6 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"; + "ml-[0.17em] inline-flex size-[1.17em] shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/72 transition-colors hover:bg-foreground/6 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"; diff --git a/apps/web/src/components/preview/PreviewChromeRow.test.tsx b/apps/web/src/components/preview/PreviewChromeRow.test.tsx new file mode 100644 index 000000000000..77e13fb421cd --- /dev/null +++ b/apps/web/src/components/preview/PreviewChromeRow.test.tsx @@ -0,0 +1,25 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { PreviewChromeRow } from "./PreviewChromeRow"; + +describe("PreviewChromeRow", () => { + it("shows the complete URL while the address bar is not focused", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('value="https://example.com/dashboard?mode=edit&tab=1#notes"'); + }); +}); diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index 1f4eb95d62be..958b30a47978 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -23,7 +23,6 @@ import { cn } from "~/lib/utils"; interface Props { url: string; - displayUrl?: string | undefined; loading: boolean; loadProgress: number; canGoBack: boolean; @@ -65,7 +64,6 @@ const NOOP = () => {}; export function PreviewChromeRow({ url, - displayUrl, loading, loadProgress, canGoBack, @@ -172,7 +170,7 @@ export function PreviewChromeRow({ render={ } /> - {!inputFocused && displayUrl ? {url} : null} {onOpenInBrowser && !inputFocused ? ( | undefined; visible: boolean; + onSendAnnotation?: ( + annotation: PreviewAnnotationPayload, + image: ComposerImageAttachment | null, + ) => void; } -export function PreviewPanel({ mode, threadRef, tabId, configuredUrls, visible }: Props) { +export function PreviewPanel({ + mode, + threadRef, + tabId, + configuredUrls, + visible, + onSendAnnotation, +}: Props) { if (!isPreviewSupportedInRuntime()) { return ( @@ -35,6 +47,7 @@ export function PreviewPanel({ mode, threadRef, tabId, configuredUrls, visible } {...(tabId !== undefined ? { tabId } : {})} configuredUrls={configuredUrls} visible={visible} + {...(onSendAnnotation ? { onSendAnnotation } : {})} /> ); diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 576c37d77b72..4121b72602f0 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -17,6 +17,11 @@ const mocks = vi.hoisted(() => ({ closeRightPanel: vi.fn(), openPictureInPicture: vi.fn(async (_tabId: string): Promise => undefined), closePictureInPicture: vi.fn(async (_tabId: string): Promise => undefined), + pickElement: vi.fn(), + previewAnnotationScreenshotFile: vi.fn(), + addPreviewAnnotation: vi.fn(), + addImage: vi.fn(), + toggleAnnotation: null as (() => void) | null, pictureInPicture: false, showEmptyState: false, })); @@ -28,11 +33,15 @@ vi.mock("~/state/session", () => ({ vi.mock("~/composerDraftStore", () => ({ useComposerDraftStore: ( select: (store: { addPreviewAnnotation: () => void; addImage: () => void }) => unknown, - ) => select({ addPreviewAnnotation: vi.fn(), addImage: vi.fn() }), + ) => + select({ + addPreviewAnnotation: mocks.addPreviewAnnotation, + addImage: mocks.addImage, + }), })); vi.mock("~/lib/previewAnnotation", () => ({ - previewAnnotationScreenshotFile: vi.fn(), + previewAnnotationScreenshotFile: mocks.previewAnnotationScreenshotFile, })); vi.mock("~/localApi", () => ({ @@ -144,6 +153,7 @@ vi.mock("~/components/ui/toast", () => ({ vi.mock("./previewBridge", () => ({ previewBridge: { navigate: mocks.navigate, + pickElement: mocks.pickElement, pictureInPicture: { open: mocks.openPictureInPicture, close: mocks.closePictureInPicture, @@ -154,6 +164,7 @@ vi.mock("./previewBridge", () => ({ vi.mock("./PreviewChromeRow", () => ({ PreviewChromeRow: (props: { onSubmit: (url: string) => void; + onPickElement?: () => void; onPictureInPicture?: () => void; pictureInPicture?: boolean; trailingActions?: { @@ -161,6 +172,7 @@ vi.mock("./PreviewChromeRow", () => ({ }; }) => { mocks.submittedUrl = props.onSubmit; + mocks.toggleAnnotation = props.onPickElement ?? null; mocks.togglePictureInPicture = props.onPictureInPicture ?? null; mocks.toggleNativePictureInPicture = props.trailingActions?.props.onNativePictureInPicture ?? null; @@ -213,6 +225,11 @@ describe("PreviewView navigation", () => { mocks.closeRightPanel.mockClear(); mocks.openPictureInPicture.mockClear(); mocks.closePictureInPicture.mockClear(); + mocks.pickElement.mockReset(); + mocks.previewAnnotationScreenshotFile.mockReset(); + mocks.addPreviewAnnotation.mockClear(); + mocks.addImage.mockClear(); + mocks.toggleAnnotation = null; mocks.pictureInPicture = false; mocks.showEmptyState = false; }); @@ -327,4 +344,70 @@ describe("PreviewView navigation", () => { expect(mocks.closePictureInPicture).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID), ); }); + + it("forwards Cmd/Ctrl+Enter annotations to the composer send path", async () => { + const annotation = { + id: "annotation-1", + pageUrl: "https://example.com/dashboard", + pageTitle: "Dashboard", + comment: "Tighten this spacing", + elements: [], + regions: [], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-07-27T00:00:00.000Z", + }; + const onSendAnnotation = vi.fn(); + mocks.pickElement.mockResolvedValue({ annotation, submission: "send" }); + + renderToStaticMarkup( + , + ); + mocks.toggleAnnotation?.(); + + await vi.waitFor(() => expect(onSendAnnotation).toHaveBeenCalledWith(annotation, null)); + expect(mocks.addPreviewAnnotation).toHaveBeenCalledWith(TEST_THREAD_REF, annotation); + }); + + it("still sends when screenshot attachment conversion fails", async () => { + const annotation = { + id: "annotation-2", + pageUrl: "https://example.com/dashboard", + pageTitle: "Dashboard", + comment: "Tighten this spacing", + elements: [], + regions: [], + strokes: [], + styleChanges: [], + screenshot: { + dataUrl: "data:image/png;base64,c2NyZWVuc2hvdA==", + width: 10, + height: 10, + cropRect: { x: 0, y: 0, width: 10, height: 10 }, + }, + createdAt: "2026-07-27T00:00:00.000Z", + }; + const onSendAnnotation = vi.fn(); + mocks.pickElement.mockResolvedValue({ annotation, submission: "send" }); + mocks.previewAnnotationScreenshotFile.mockRejectedValue(new Error("conversion failed")); + + renderToStaticMarkup( + , + ); + mocks.toggleAnnotation?.(); + + await vi.waitFor(() => expect(onSendAnnotation).toHaveBeenCalledWith(annotation, null)); + expect(mocks.addImage).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index a3e23c2c41c9..a2435627c626 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -4,13 +4,14 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { FILL_PREVIEW_VIEWPORT, + type PreviewAnnotationPayload, type PreviewViewportSetting, type ScopedThreadRef, } from "@t3tools/contracts"; import { normalizePreviewUrl } from "@t3tools/shared/preview"; import { useCallback, useEffect, useRef, useState } from "react"; -import { useComposerDraftStore } from "~/composerDraftStore"; +import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore"; import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation"; import { ensureLocalApi } from "~/localApi"; import { @@ -19,7 +20,6 @@ import { useThreadPreviewState, } from "~/previewStateStore"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; -import { useEnvironment, useEnvironmentHttpBaseUrl } from "~/state/environments"; import { previewEnvironment } from "~/state/preview"; import { useAtomCommand } from "~/state/use-atom-command"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; @@ -29,7 +29,6 @@ import { previewBridge } from "./previewBridge"; import { subscribePreviewAction } from "./previewActionBus"; import { openPreviewSession } from "./openPreviewSession"; import { PreviewChromeRow } from "./PreviewChromeRow"; -import { formatPreviewUrl } from "./previewUrlPresentation"; import { PreviewEmptyState } from "./PreviewEmptyState"; import { PreviewMoreMenu } from "./PreviewMoreMenu"; import { @@ -60,6 +59,10 @@ interface Props { tabId?: string | null; configuredUrls?: ReadonlyArray | undefined; visible: boolean; + onSendAnnotation?: ( + annotation: PreviewAnnotationPayload, + image: ComposerImageAttachment | null, + ) => void; } const localApi = typeof window === "undefined" ? null : ensureLocalApi(); @@ -68,7 +71,13 @@ const localApi = typeof window === "undefined" ? null : ensureLocalApi(); * Single-tab preview surface: chrome row on top, one webview below, empty * state when no session exists for the thread. */ -export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, visible }: Props) { +export function PreviewView({ + threadRef, + tabId: requestedTabId, + configuredUrls, + visible, + onSendAnnotation, +}: Props) { const [focusUrlNonce, setFocusUrlNonce] = useState(undefined); const [pickActive, setPickActive] = useState(false); const activeRecordingTabIds = useActiveBrowserRecordingTabIds(); @@ -80,8 +89,6 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, ); const addPreviewAnnotation = useComposerDraftStore((store) => store.addPreviewAnnotation); const addImage = useComposerDraftStore((store) => store.addImage); - const environment = useEnvironment(threadRef.environmentId); - const environmentHttpBaseUrl = useEnvironmentHttpBaseUrl(threadRef.environmentId); const open = useAtomCommand(previewEnvironment.open); const resize = useAtomCommand(previewEnvironment.resize, "preview viewport resize"); @@ -116,14 +123,6 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, const showEmptyState = shouldShowPreviewEmptyState(snapshot); const controller = desktopOverlay?.controller ?? "none"; const loadProgress = useLoadingProgress(loading); - const displayUrl = - url && environment && environmentHttpBaseUrl - ? (formatPreviewUrl({ - url, - environmentLabel: environment.label, - environmentHttpBaseUrl, - }) ?? undefined) - : undefined; const viewport = snapshot?.viewport ?? FILL_PREVIEW_VIEWPORT; const panelRect = useBrowserSurfaceStore((state) => runtimeTabId ? (state.byTabId[runtimeTabId]?.rect ?? null) : null, @@ -523,20 +522,34 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, setPickActive(true); void (async () => { try { - const annotation = await previewBridge.pickElement(runtimeTabId); - if (!annotation) return; + const result = await previewBridge.pickElement(runtimeTabId); + if (!result) return; + const { annotation, submission } = result; addPreviewAnnotation(threadRef, annotation); - const screenshotFile = await previewAnnotationScreenshotFile(annotation); - if (screenshotFile && annotation.screenshot) { - addImage(threadRef, { - type: "image", - id: annotation.id, - name: screenshotFile.name, - mimeType: screenshotFile.type, - sizeBytes: screenshotFile.size, - previewUrl: annotation.screenshot.dataUrl, - file: screenshotFile, - }); + let screenshotFile: File | null = null; + try { + screenshotFile = await previewAnnotationScreenshotFile(annotation); + } catch { + // The structured annotation is still sendable when converting its + // optional screenshot into a composer attachment fails. + } + const image = + screenshotFile && annotation.screenshot + ? ({ + type: "image", + id: annotation.id, + name: screenshotFile.name, + mimeType: screenshotFile.type, + sizeBytes: screenshotFile.size, + previewUrl: annotation.screenshot.dataUrl, + file: screenshotFile, + } satisfies ComposerImageAttachment) + : null; + if (image) { + addImage(threadRef, image); + } + if (submission === "send") { + onSendAnnotation?.(annotation, image); } } catch { // Picker failed (e.g. webview navigated). Treat as silent cancel. @@ -561,7 +574,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, } } })(); - }, [addImage, addPreviewAnnotation, runtimeTabId, threadRef]); + }, [addImage, addPreviewAnnotation, onSendAnnotation, runtimeTabId, threadRef]); // If the active tab changes mid-pick (close, thread switch, hot restart), // tell main to tear down the in-flight session AND reset our local toggle @@ -611,7 +624,6 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, > { - it("formats signed asset URLs with the environment label and decoded filename", () => { - expect( - formatPreviewUrl({ - url: "http://127.0.0.1:3773/api/assets/token/architecture%20brief.pdf", - environmentLabel: "Local environment", - environmentHttpBaseUrl: "http://127.0.0.1:3773", - }), - ).toBe("Local environment · architecture brief.pdf"); - }); - - it("does not alias assets from another origin", () => { - expect( - formatPreviewUrl({ - url: "https://example.com/api/assets/token/report.pdf", - environmentLabel: "Local environment", - environmentHttpBaseUrl: "http://127.0.0.1:3773", - }), - ).toBe("example.com"); - }); - - it("formats regular preview URLs as their exact host", () => { - expect( - formatPreviewUrl({ - url: "http://127.0.0.1:5173/dashboard", - environmentLabel: "Local environment", - environmentHttpBaseUrl: "http://127.0.0.1:3773", - }), - ).toBe("127.0.0.1:5173"); - }); - - it("does not compact non-http URLs", () => { - expect( - formatPreviewUrl({ - url: "file:///tmp/report.pdf", - environmentLabel: "Local environment", - environmentHttpBaseUrl: "http://127.0.0.1:3773", - }), - ).toBeNull(); - }); -}); diff --git a/apps/web/src/components/preview/previewUrlPresentation.ts b/apps/web/src/components/preview/previewUrlPresentation.ts deleted file mode 100644 index 0ae3c1900aac..000000000000 --- a/apps/web/src/components/preview/previewUrlPresentation.ts +++ /dev/null @@ -1,27 +0,0 @@ -interface PreviewUrlPresentationInput { - readonly url: string; - readonly environmentLabel: string; - readonly environmentHttpBaseUrl: string; -} - -export function formatPreviewUrl(input: PreviewUrlPresentationInput): string | null { - try { - const url = new URL(input.url); - const environmentUrl = new URL(input.environmentHttpBaseUrl); - if (url.origin === environmentUrl.origin && url.pathname.startsWith("/api/assets/")) { - const encodedFileName = url.pathname.split("/").at(-1); - if (!encodedFileName) { - return null; - } - const fileName = decodeURIComponent(encodedFileName); - if (!fileName || fileName === "." || fileName === "..") { - return null; - } - return `${input.environmentLabel} · ${fileName}`; - } - - return url.protocol === "http:" || url.protocol === "https:" ? url.host : null; - } catch { - return null; - } -} diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index c8f039e64282..290e2daa12b8 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -1,6 +1,10 @@ -import type { DesktopWslState } from "@t3tools/contracts"; +import type { AdvertisedEndpoint, DesktopWslState } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; +import { + applyWslEnableSelection, + isQrShareableEndpoint, + selectQrEndpointOption, +} from "./ConnectionsSettings.logic"; const baseWslState: DesktopWslState = { enabled: false, @@ -73,3 +77,89 @@ describe("applyWslEnableSelection", () => { expect(state).toMatchObject({ enabled: true, wslOnly: true }); }); }); + +function makeEndpoint(overrides: Partial): AdvertisedEndpoint { + return { + id: "desktop-lan:http://192.168.1.42:4780", + label: "Local network", + provider: { id: "desktop-core", label: "Desktop", kind: "core", isAddon: false }, + httpBaseUrl: "http://192.168.1.42:4780", + wsBaseUrl: "ws://192.168.1.42:4780", + reachability: "lan", + compatibility: { hostedHttpsApp: "unknown", desktopApp: "compatible" }, + source: "desktop-core", + status: "available", + ...overrides, + }; +} + +describe("isQrShareableEndpoint", () => { + it("excludes loopback endpoints so a scanned phone never dials itself", () => { + expect( + isQrShareableEndpoint( + makeEndpoint({ + id: "desktop-loopback:4780", + reachability: "loopback", + httpBaseUrl: "http://127.0.0.1:4780", + }), + ), + ).toBe(false); + }); + + it("excludes unavailable endpoints and keeps reachable ones", () => { + expect(isQrShareableEndpoint(makeEndpoint({ status: "unavailable" }))).toBe(false); + expect(isQrShareableEndpoint(makeEndpoint({}))).toBe(true); + expect( + isQrShareableEndpoint(makeEndpoint({ reachability: "private-network", status: "unknown" })), + ).toBe(true); + }); +}); + +describe("selectQrEndpointOption", () => { + const options = [ + { + id: "desktop-loopback:4780", + preferenceKey: "desktop-core:loopback:http", + qrShareable: false, + }, + { + id: "tailscale-ip:http://100.84.12.7:4780", + preferenceKey: "tailscale:ip:http", + qrShareable: true, + }, + { + id: "tailscale-ip:http://100.84.12.8:4780", + preferenceKey: "tailscale:ip:http", + qrShareable: true, + }, + { + id: "desktop-lan:http://192.168.1.42:4780", + preferenceKey: "desktop-core:lan:http", + qrShareable: true, + }, + ]; + + it("resolves an explicit selection by unique endpoint id, not the shared preference key", () => { + expect(selectQrEndpointOption(options, "tailscale-ip:http://100.84.12.8:4780", null)?.id).toBe( + "tailscale-ip:http://100.84.12.8:4780", + ); + }); + + it("falls back to the saved default preference key when nothing is selected", () => { + expect(selectQrEndpointOption(options, null, "desktop-core:lan:http")?.id).toBe( + "desktop-lan:http://192.168.1.42:4780", + ); + }); + + it("skips non-QR-shareable options in the fallback so the panel never opens on loopback", () => { + expect(selectQrEndpointOption(options, "tailscale-ip:gone", "nope")?.id).toBe( + "tailscale-ip:http://100.84.12.7:4780", + ); + }); + + it("returns the first option when nothing is QR-shareable, and null when empty", () => { + const loopbackOnly = options.slice(0, 1); + expect(selectQrEndpointOption(loopbackOnly, null, null)?.id).toBe("desktop-loopback:4780"); + expect(selectQrEndpointOption([], "anything", "anything")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index 362a24dd3ff2..faa0cb6c7543 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -1,7 +1,50 @@ -import type { DesktopBridge, DesktopWslState } from "@t3tools/contracts"; +import type { AdvertisedEndpoint, DesktopBridge, DesktopWslState } from "@t3tools/contracts"; type WslEnableBridge = Pick; +/** + * A QR code encoding a loopback URL makes the scanning device dial itself, so + * loopback endpoints stay copyable from the endpoint menu but are never + * offered as QR targets. + */ +export function isQrShareableEndpoint(endpoint: AdvertisedEndpoint): boolean { + return endpoint.status !== "unavailable" && endpoint.reachability !== "loopback"; +} + +export type QrEndpointOption = { + /** Unique per endpoint instance (AdvertisedEndpoint.id); safe as a React key. */ + readonly id: string; + /** + * Stable per endpoint *type* (endpointDefaultPreferenceKey). Multiple + * endpoints can share one, so it is only used to match the saved default. + */ + readonly preferenceKey: string; + /** False for endpoints that stay copyable but must never render as a QR. */ + readonly qrShareable: boolean; +}; + +/** + * Resolves which endpoint the share panel shows: the user's explicit pick, + * else the saved default endpoint, else the first QR-shareable option (so the + * panel never opens on a loopback QR), else the first option. A stale + * selectedId (endpoint disappeared) falls back rather than blanking the panel. + */ +export function selectQrEndpointOption( + options: ReadonlyArray, + selectedId: string | null, + defaultPreferenceKey: string | null, +): T | null { + return ( + (selectedId !== null ? options.find((option) => option.id === selectedId) : undefined) ?? + (defaultPreferenceKey !== null + ? options.find((option) => option.preferenceKey === defaultPreferenceKey) + : undefined) ?? + options.find((option) => option.qrShareable) ?? + options[0] ?? + null + ); +} + export async function applyWslEnableSelection(input: { readonly bridge: WslEnableBridge; readonly mode: "both" | "wsl-only"; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 527ba96bf2e4..8bbf35083ec2 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,5 +1,4 @@ import { - ChevronDownIcon, ChevronsLeftRightEllipsisIcon, PlusIcon, QrCodeIcon, @@ -8,7 +7,7 @@ import { TriangleAlertIcon, } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; -import { type ReactNode, memo, useCallback, useMemo, useState } from "react"; +import { type ReactNode, memo, useCallback, useId, useMemo, useState } from "react"; import { AuthAccessReadScope, AuthAccessWriteScope, @@ -42,7 +41,11 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { cn } from "../../lib/utils"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; -import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; +import { + applyWslEnableSelection, + isQrShareableEndpoint, + selectQrEndpointOption, +} from "./ConnectionsSettings.logic"; import { SettingsPageContainer, SettingsRow, @@ -82,17 +85,7 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; -import { Group, GroupSeparator } from "../ui/group"; import { AnimatedHeight } from "../AnimatedHeight"; -import { - Menu, - MenuGroup, - MenuGroupLabel, - MenuItem, - MenuPopup, - MenuSeparator, - MenuTrigger, -} from "../ui/menu"; import { Textarea } from "../ui/textarea"; import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; import { readHostedPairingRequest } from "../../hostedPairing"; @@ -506,6 +499,22 @@ function isHostedAppPairingUrl(value: string): boolean { } } +function endpointShareHint(endpoint: AdvertisedEndpoint, url: string): string { + if (isHostedAppPairingUrl(url)) { + return "Opens the hosted app, no install needed"; + } + switch (endpoint.reachability) { + case "lan": + return "Devices on the same network"; + case "private-network": + return "Devices on your private network"; + case "public": + return "Reachable from anywhere"; + case "loopback": + return "Clients on this machine"; + } +} + type PairingLinkListRowProps = { pairingLink: ServerPairingLinkRecord; endpointUrl: string | null | undefined; @@ -531,6 +540,11 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ [pairingLink.expiresAt], ); const [isRevealDialogOpen, setIsRevealDialogOpen] = useState(false); + const [isQrPanelOpen, setIsQrPanelOpen] = useState(false); + // Ephemeral per-row choice of which endpoint the QR encodes (AdvertisedEndpoint.id); + // null falls back to the saved default endpoint. + const [qrEndpointId, setQrEndpointId] = useState(null); + const qrPanelId = useId(); const currentOriginPairingUrl = useMemo( () => resolveCurrentOriginPairingUrl(pairingLink.credential), @@ -549,10 +563,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ }, [defaultEndpointKey, endpoints, pairingLink.credential]); const endpointCopyOptions = useMemo(() => { const options: Array<{ - readonly key: string; + readonly id: string; + readonly preferenceKey: string; readonly label: string; readonly url: string; readonly detail: string; + readonly qrShareable: boolean; }> = []; for (const endpoint of endpoints) { if (endpoint.status === "unavailable") { @@ -560,10 +576,12 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ } const url = resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential); options.push({ - key: endpointDefaultPreferenceKey(endpoint), + id: endpoint.id, + preferenceKey: endpointDefaultPreferenceKey(endpoint), label: endpoint.label, url, - detail: isHostedAppPairingUrl(url) ? "Hosted app link" : "Backend pairing URL", + detail: endpointShareHint(endpoint, url), + qrShareable: isQrShareableEndpoint(endpoint), }); } return options; @@ -575,16 +593,25 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ : isLoopbackHostname(window.location.hostname) ? null : currentOriginPairingUrl); - const revealValue = shareablePairingUrl ?? pairingLink.credential; - const isShareableHostedAppPairingUrl = - shareablePairingUrl !== null && isHostedAppPairingUrl(shareablePairingUrl); + // Value of the copy attempt that last failed. The clipboard-failure reveal + // dialog must show exactly what failed to copy, not the row's default URL. + const [failedCopyValue, setFailedCopyValue] = useState(null); + const revealValue = failedCopyValue ?? shareablePairingUrl ?? pairingLink.credential; + const isRevealValueUrl = revealValue !== pairingLink.credential; + const isRevealValueHostedAppPairingUrl = isRevealValueUrl && isHostedAppPairingUrl(revealValue); + // Never render a QR for a loopback URL, even in the manual-copy fallback. + const isRevealValueQrShareable = + endpointCopyOptions.find((option) => option.url === revealValue)?.qrShareable ?? true; const canCopyToClipboard = typeof window !== "undefined" && window.isSecureContext && navigator.clipboard?.writeText != null; - const { copyToClipboard } = useCopyToClipboard<"code" | "hosted-link" | "link">({ - onCopy: (kind) => { + const { copyToClipboard } = useCopyToClipboard<{ + value: string; + kind: "code" | "hosted-link" | "link"; + }>({ + onCopy: ({ kind }) => { toastManager.add({ type: "success", title: @@ -601,7 +628,10 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ : "Paste it into another client to finish pairing.", }); }, - onError: (error, kind) => { + onError: (error, { value, kind }) => { + // Captured per attempt so concurrent copies cannot make the dialog + // reveal a different value than the one that failed. + setFailedCopyValue(value); setIsRevealDialogOpen(true); toastManager.add( stackedThreadToast({ @@ -621,7 +651,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ const copyPairingValue = useCallback( (value: string, kind: "code" | "hosted-link" | "link") => { - copyToClipboard(value, kind); + copyToClipboard(value, { value, kind }); }, [copyToClipboard], ); @@ -635,97 +665,19 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ copyPairingValue(pairingLink.credential, "code"); }, [copyPairingValue, pairingLink.credential]); - const handleCopyDefaultLink = useCallback(() => { - if (!shareablePairingUrl) return; - copyPairingValue(shareablePairingUrl, copyKindForUrl(shareablePairingUrl)); - }, [copyKindForUrl, copyPairingValue, shareablePairingUrl]); - const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); const primaryLabel = pairingLink.label ?? "Pairing link"; - const defaultEndpointCopyOption = - endpointCopyOptions.find((option) => option.key === defaultEndpointKey) ?? - endpointCopyOptions[0] ?? - null; - const defaultEndpointCopyLabel = defaultEndpointCopyOption?.label ?? "URL"; - const backendEndpointCopyOptions = endpointCopyOptions.filter( - (option) => !isHostedAppPairingUrl(option.url), - ); - const hostedEndpointCopyOptions = endpointCopyOptions.filter((option) => - isHostedAppPairingUrl(option.url), - ); - const renderEndpointMenuItems = ( - options: typeof endpointCopyOptions = endpointCopyOptions, - renderDetail = true, - ) => - options.map((option) => ( - copyPairingValue(option.url, copyKindForUrl(option.url))} - > - - {option.label} - {renderDetail ? ( - - {option.detail} - - ) : null} - - - )); - const renderPairingCodeMenuItem = (renderDetail = true) => ( - - - Copy code - {renderDetail ? ( - Token only - ) : null} - - - ); - const renderCompactEndpointGroup = ( - label: string, - options: typeof endpointCopyOptions, - includeSeparator: boolean, - ) => - options.length > 0 ? ( - <> - {includeSeparator ? : null} - - {label} - {renderEndpointMenuItems(options, false)} - - - ) : null; - const renderGroupedCopyMenuItems = (options?: { codeFirst?: boolean }) => ( - <> - {options?.codeFirst ? ( - <> - - Pairing code - {renderPairingCodeMenuItem(false)} - - {endpointCopyOptions.length > 0 ? : null} - - ) : null} - {renderCompactEndpointGroup("Pairing URLs", backendEndpointCopyOptions, false)} - {renderCompactEndpointGroup( - "Hosted app link", - hostedEndpointCopyOptions, - backendEndpointCopyOptions.length > 0, - )} - {!options?.codeFirst ? ( - <> - {endpointCopyOptions.length > 0 ? : null} - - Pairing code - {renderPairingCodeMenuItem(false)} - - - ) : null} - - ); - + const selectedQrOption = selectQrEndpointOption( + endpointCopyOptions, + qrEndpointId, + defaultEndpointKey, + ); + const qrPairingUrl = selectedQrOption?.url ?? shareablePairingUrl; + // With no endpoint list the fallback is never loopback: selectPairingEndpoint + // skips loopback and the current-origin fallback is guarded by + // isLoopbackHostname, so only an explicit loopback selection hides the QR. + const canRenderQrForSelection = selectedQrOption?.qrShareable ?? true; if (expiresAtMs <= nowMs) { return null; } @@ -740,35 +692,6 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ dotClassName="bg-amber-400" />

    {primaryLabel}

    - - {shareablePairingUrl ? ( - <> - - } - > - - - - - - - ) : null} -

    {formatExpiresInLabel(pairingLink.expiresAt, nowMs)} @@ -782,46 +705,31 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ ) : null}

  • - + {shareablePairingUrl && canCopyToClipboard ? ( + + ) : null} + { + setIsRevealDialogOpen(open); + if (!open) setFailedCopyValue(null); + }} + > {canCopyToClipboard ? ( - <> - {shareablePairingUrl ? ( - - - - - - } - > - - - - {renderGroupedCopyMenuItems()} - - - - ) : ( - - )} - + shareablePairingUrl ? null : ( + + ) ) : ( }> {shareablePairingUrl ? "Show link" : "Show code"} @@ -830,15 +738,15 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ - {shareablePairingUrl - ? isShareableHostedAppPairingUrl + {isRevealValueUrl + ? isRevealValueHostedAppPairingUrl ? "Hosted app pairing link" : "Pairing link" : "Pairing code"} - {shareablePairingUrl - ? isShareableHostedAppPairingUrl + {isRevealValueUrl + ? isRevealValueHostedAppPairingUrl ? "Clipboard copy is unavailable here. Open or manually copy this hosted app link on the device you want to connect." : "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect." : "Clipboard copy is unavailable here. Manually copy this code into another client."} @@ -848,15 +756,15 @@ const PairingLinkListRow = memo(function PairingLinkListRow({