diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index c1eba805c2b6..6f9bac7333f4 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,6 +1,7 @@ import * as Effect from "effect/Effect"; import * as DesktopIpc from "./DesktopIpc.ts"; +import { installNotificationBadge } from "./methods/notificationBadge.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; import { clearConnectionCatalog, @@ -68,6 +69,7 @@ import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./m export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; + yield* installNotificationBadge(); yield* PreviewIpc.installPreviewEventForwarding(); yield* ipc.handle(AppActivationIpc.setReady); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index ca6bbd30b3e4..7106c45af8e8 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,4 +1,5 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge"; export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon"; export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; diff --git a/apps/desktop/src/ipc/methods/notificationBadge.test.ts b/apps/desktop/src/ipc/methods/notificationBadge.test.ts new file mode 100644 index 000000000000..a47732550f64 --- /dev/null +++ b/apps/desktop/src/ipc/methods/notificationBadge.test.ts @@ -0,0 +1,144 @@ +import * as Effect from "effect/Effect"; +import { beforeEach, expect, vi } from "vite-plus/test"; +import { it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +const native = vi.hoisted(() => ({ + setBadgeCount: vi.fn(), + setOverlayIcon: vi.fn(), + isDestroyed: vi.fn(() => false), + getFocusedWindow: vi.fn(() => null as object | null), + image: { isEmpty: vi.fn(() => false) }, + createFromDataURL: vi.fn(), + webContents: { send: vi.fn() }, + listeners: new Map void>(), +})); +vi.mock("electron", () => ({ + app: { + setBadgeCount: native.setBadgeCount, + on: (event: string, listener: () => void) => native.listeners.set(event, listener), + removeListener: (event: string) => native.listeners.delete(event), + }, + BrowserWindow: { + getFocusedWindow: native.getFocusedWindow, + getAllWindows: () => [native], + }, + nativeImage: { createFromDataURL: native.createFromDataURL }, +})); + +import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; +import { applyNotificationBadge, installNotificationBadge } from "./notificationBadge.ts"; + +const badge = { count: 2, image: "data:image/png;base64,aGVsbG8=" }; + +beforeEach(() => { + vi.clearAllMocks(); + native.getFocusedWindow.mockReturnValue(null); + native.isDestroyed.mockReturnValue(false); + native.image.isEmpty.mockReturnValue(false); + native.createFromDataURL.mockReturnValue(native.image); + native.setBadgeCount.mockImplementation(() => true); + native.listeners.clear(); +}); + +it.each(["darwin", "linux"] as const)("sets and clears the native %s count", (platform) => { + applyNotificationBadge(platform, badge); + applyNotificationBadge(platform, { count: 0, image: null }); + expect(native.setBadgeCount.mock.calls).toEqual([[2], [0]]); + expect(native.createFromDataURL).not.toHaveBeenCalled(); +}); + +it("sets and clears the Windows taskbar overlay", () => { + applyNotificationBadge("win32", badge); + expect(native.setOverlayIcon).toHaveBeenLastCalledWith( + native.image, + "2 threads with new notifications", + ); + applyNotificationBadge("win32", { count: 0, image: null }); + expect(native.setOverlayIcon).toHaveBeenLastCalledWith(null, ""); +}); + +it.each(["win32", "darwin", "linux"] as const)( + "rejects a late positive count while %s is focused", + (platform) => { + native.getFocusedWindow.mockReturnValue({}); + applyNotificationBadge(platform, badge); + if (platform === "win32") expect(native.setOverlayIcon).toHaveBeenCalledWith(null, ""); + else expect(native.setBadgeCount).toHaveBeenCalledWith(0); + expect(native.createFromDataURL).not.toHaveBeenCalled(); + }, +); + +it("ignores destroyed windows and clears invalid images", () => { + native.isDestroyed.mockReturnValue(true); + applyNotificationBadge("win32", badge); + expect(native.setOverlayIcon).not.toHaveBeenCalled(); + native.isDestroyed.mockReturnValue(false); + native.image.isEmpty.mockReturnValue(true); + applyNotificationBadge("win32", badge); + expect(native.setOverlayIcon.mock.calls[0]?.[0]).toBeNull(); +}); + +it("keeps notifications working when the native badge API fails", () => { + native.setBadgeCount.mockImplementation(() => { + throw new Error("Unavailable"); + }); + expect(() => applyNotificationBadge("linux", badge)).not.toThrow(); +}); + +it.effect("validates IPC and clears on native focus, quit, and disposal", () => + Effect.gen(function* () { + const handlers = new Map(); + yield* Effect.scoped( + Effect.gen(function* () { + yield* installNotificationBadge(); + const handler = handlers.get("desktop:set-notification-badge")!; + const event = { sender: { id: 1 } }; + for (const invalid of [ + { ...badge, count: -1 }, + { ...badge, count: 0.5 }, + { ...badge, count: Infinity }, + { ...badge, image: "https://example.com/icon.png" }, + { ...badge, image: `data:image/png;base64,${"a".repeat(16_384)}` }, + ]) { + yield* Effect.promise(() => expect(handler(event, invalid)).rejects.toBeDefined()); + } + expect(native.setBadgeCount).not.toHaveBeenCalled(); + yield* Effect.promise(() => Promise.resolve(handler(event, badge))); + expect(native.setBadgeCount).toHaveBeenLastCalledWith(2); + native.listeners.get("browser-window-focus")!(); + expect(native.setBadgeCount).toHaveBeenLastCalledWith(0); + expect(native.webContents.send).toHaveBeenCalledWith("desktop:set-notification-badge"); + native.getFocusedWindow.mockReturnValue({}); + yield* Effect.promise(() => Promise.resolve(handler(event, badge))); + expect(native.setBadgeCount).toHaveBeenLastCalledWith(0); + expect(native.webContents.send).toHaveBeenCalledTimes(2); + yield* Effect.promise(() => Promise.resolve(handler(event, { count: 0, image: null }))); + expect(native.webContents.send).toHaveBeenCalledTimes(2); + native.getFocusedWindow.mockReturnValue(null); + yield* Effect.promise(() => Promise.resolve(handler(event, badge))); + native.listeners.get("before-quit")!(); + expect(native.setBadgeCount).toHaveBeenLastCalledWith(0); + }), + ).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provide([ + ElectronApp.layer, + DesktopIpc.layer({ + handle: (channel, handler) => { + handlers.set(channel, handler); + }, + removeHandler: (channel) => { + handlers.delete(channel); + }, + on: vi.fn(), + removeAllListeners: vi.fn(), + }), + ]), + ); + expect(native.setBadgeCount).toHaveBeenLastCalledWith(0); + expect(native.listeners.size).toBe(0); + expect(handlers.size).toBe(0); + }), +); diff --git a/apps/desktop/src/ipc/methods/notificationBadge.ts b/apps/desktop/src/ipc/methods/notificationBadge.ts new file mode 100644 index 000000000000..40e4549138f2 --- /dev/null +++ b/apps/desktop/src/ipc/methods/notificationBadge.ts @@ -0,0 +1,71 @@ +import * as Electron from "electron"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; +import { SET_NOTIFICATION_BADGE_CHANNEL } from "../channels.ts"; + +const NotificationBadge = Schema.Struct({ + count: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 2_147_483_647 })), + image: Schema.NullOr( + Schema.String.check( + Schema.isMaxLength(16_384), + Schema.isPattern(/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i), + ), + ), +}); + +export function applyNotificationBadge( + platform: NodeJS.Platform, + { count, image }: typeof NotificationBadge.Type, +): void { + try { + if (Electron.BrowserWindow.getFocusedWindow()) count = 0; + if (platform === "win32") { + const overlay = count > 0 && image ? Electron.nativeImage.createFromDataURL(image) : null; + for (const window of Electron.BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.setOverlayIcon( + overlay?.isEmpty() ? null : overlay, + count > 0 ? `${count} threads with new notifications` : "", + ); + } + } + } else if (platform === "darwin" || platform === "linux") { + Electron.app.setBadgeCount(count); + } + } catch (error) { + Effect.runSync(Effect.logWarning("Could not update notification badge", error)); + } +} + +export const installNotificationBadge = Effect.fn("desktop.ipc.installNotificationBadge")( + function* () { + const ipc = yield* DesktopIpc.DesktopIpc; + const app = yield* ElectronApp.ElectronApp; + const platform = yield* HostProcessPlatform; + const clear = () => { + applyNotificationBadge(platform, { count: 0, image: null }); + for (const window of Electron.BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) window.webContents.send(SET_NOTIFICATION_BADGE_CHANNEL); + } + }; + yield* ipc.handle( + DesktopIpc.makeIpcMethod({ + channel: SET_NOTIFICATION_BADGE_CHANNEL, + payload: NotificationBadge, + result: Schema.Void, + handler: (badge) => + Effect.sync(() => { + if (badge.count > 0 && Electron.BrowserWindow.getFocusedWindow()) clear(); + else applyNotificationBadge(platform, badge); + }), + }), + ); + yield* app.on("browser-window-focus", clear); + yield* app.on("before-quit", clear); + yield* Effect.addFinalizer(() => Effect.sync(clear)); + }, +); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index d4edb7818180..4c9a8199de68 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -57,6 +57,13 @@ contextBridge.exposeInMainWorld("desktopBridge", { return result as ReturnType; }, getClientPlatform: () => clientPlatform, + setNotificationBadge: (badge) => + ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge), + onNotificationBadgeClear: (listener) => { + const handler = () => listener(); + ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); + return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); + }, getSystemLocale: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 5c1e01cb1471..5ad23ee70582 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -19,6 +19,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { ...DEFAULT_CLIENT_SETTINGS, notificationMode: "notifications-and-sound", + inAppNotificationsEnabled: true, appearanceContrast: 100, browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" }, browserDefaultZoomFactor: 1.25, @@ -56,6 +57,7 @@ const clientSettings: ClientSettings = { proactivePanelsEnabled: true, showSkillsInSlashMenu: false, providerModelPreferences: {}, + sidebarCompactThreadRows: false, sidebarProjectGroupingMode: "repository_path", sidebarProjectGroupingOverrides: { "environment-1:/tmp/project-a": "separate", diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 2ea14b951fe2..5e7545eafdf8 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -1,6 +1,8 @@ import { assert, describe, expect, it, vi } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { VcsRepositoryDetectionError } from "@t3tools/contracts"; @@ -24,6 +26,35 @@ function makeLayer(input: { } describe("GitWorkflowService", () => { + it.effect("reports a non-Git VCS repository as not a Git repository", () => + Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const isRepository = yield* workflow.isRepository("/jj-repo"); + + assert.equal(isRepository, false); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => + Effect.succeed({ + kind: "jj", + repository: { + kind: "jj", + rootPath: "/jj-repo", + metadataPath: "/jj-repo/.jj", + freshness: { + source: "live-local", + observedAt: DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"), + expiresAt: Option.none(), + }, + }, + driver: {} as VcsDriverRegistry.VcsDriverHandle["driver"], + }), + }), + ), + ), + ); + it.effect("returns an empty local status when no VCS repository is detected", () => Effect.gen(function* () { const workflow = yield* GitWorkflowService.GitWorkflowService; diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index e90a59fae8f1..5e3e5b0420f2 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -35,6 +35,11 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; export class GitWorkflowService extends Context.Service< GitWorkflowService, { + readonly isRepository: (cwd: string) => Effect.Effect; + readonly hasCommit: (input: { + readonly cwd: string; + readonly refName: string; + }) => Effect.Effect; readonly status: ( input: VcsStatusInput, ) => Effect.Effect; @@ -263,6 +268,31 @@ export const make = Effect.gen(function* () { ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input))); return GitWorkflowService.of({ + isRepository: (cwd) => + registry.detect({ cwd }).pipe( + Effect.map((handle) => handle?.kind === "git"), + Effect.mapError( + (cause) => + new GitManagerError({ + operation: "GitWorkflowService.isRepository", + cwd, + detail: "Failed to detect a VCS repository for this Git workflow.", + cause, + }), + ), + ), + hasCommit: (input) => + ensureGitCommand("GitWorkflowService.hasCommit", input.cwd).pipe( + Effect.andThen( + git.execute({ + operation: "GitWorkflowService.hasCommit", + cwd: input.cwd, + args: ["rev-parse", "--verify", `${input.refName}^{commit}`], + allowNonZeroExit: true, + }), + ), + Effect.map((result) => result.exitCode === 0), + ), status: (input) => detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe( Effect.flatMap((isGitRepository) => diff --git a/apps/server/src/provider/acp/CursorTransportFailure.test.ts b/apps/server/src/provider/acp/CursorTransportFailure.test.ts index dcd4f9996bdf..fdde19b934d2 100644 --- a/apps/server/src/provider/acp/CursorTransportFailure.test.ts +++ b/apps/server/src/provider/acp/CursorTransportFailure.test.ts @@ -38,6 +38,8 @@ describe("CursorTransportFailure", () => { "Error: ConnectError: [unauthenticated] sign in", "Error: ConnectError: [permission_denied] subscription required", "Error: HTTP 500 from the application being debugged", + "Error: RetriableError: [internal] Failed to run step, exceeded max retries", + "Error: RetriableError: [internal] Failed to run step, exceeded max retries\n at step (cli.js:1:2)", ])("preserves prose, code and non-transport errors: %s", (message) => { expect(failureFor([...message])).toBeUndefined(); }); diff --git a/apps/server/src/provider/acp/CursorTransportFailure.ts b/apps/server/src/provider/acp/CursorTransportFailure.ts index 99765c74eb07..e6cadb23dc0d 100644 --- a/apps/server/src/provider/acp/CursorTransportFailure.ts +++ b/apps/server/src/provider/acp/CursorTransportFailure.ts @@ -1,6 +1,7 @@ const maxLineLength = 4096; +// Cursor also uses RetriableError for agent-loop failures; preserve those diagnostics. const transportError = - /^Error: (?:RetriableError: .+|ConnectError: \[(?:unavailable|aborted|deadline_exceeded)\].*)$/; + /^Error: (?:RetriableError: (?!\[internal\]).+|ConnectError: \[(?:unavailable|aborted|deadline_exceeded)\].*)$/; const serverError = "Something went wrong communicating with the server. Please try again."; interface ReplyState { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 81399ce61d6f..af934c59d480 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -88,6 +88,13 @@ import * as Socket from "effect/unstable/socket/Socket"; import { vi } from "vite-plus/test"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); +const SUCCESSFUL_GIT_EXECUTION = { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}; const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationThreadDetailSnapshot), ); @@ -10558,7 +10565,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), remoteExists, fetchRemote, remoteBranchExists, @@ -10722,7 +10733,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), remoteExists, fetchRemote, remoteBranchExists, @@ -10804,6 +10819,167 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("falls back to the project checkout when worktree mode targets a non-repository", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.die(new Error("createWorktree must not run for a non-repository")), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-turn-start-non-repo"), + threadId: ThreadId.make("thread-bootstrap-non-repo"), + message: { + messageId: MessageId.make("msg-bootstrap-non-repo"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + }, + runSetupScript: true, + }, + createdAt, + }), + ), + ); + + assert.equal(response.sequence, 2); + assert.equal(createWorktree.mock.calls.length, 0); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.turn.start"], + ); + const finalCommand = dispatchedCommands[1]; + assertTrue(finalCommand?.type === "thread.turn.start"); + if (finalCommand?.type === "thread.turn.start") { + assert.equal(finalCommand.bootstrap, undefined); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("falls back to the project checkout when the worktree base has no commit", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.die(new Error("createWorktree must not run without a base commit")), + ); + + yield* buildAppUnderTest({ + layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, + gitVcsDriver: { + execute: () => + Effect.succeed({ + ...SUCCESSFUL_GIT_EXECUTION, + exitCode: ChildProcessSpawner.ExitCode(128), + stderr: "fatal: Needed a single revision", + }), + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-turn-start-unborn-head"), + threadId: ThreadId.make("thread-bootstrap-unborn-head"), + message: { + messageId: MessageId.make("msg-bootstrap-unborn-head"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + }, + runSetupScript: true, + }, + createdAt, + }), + ), + ); + + assert.equal(response.sequence, 2); + assert.equal(createWorktree.mock.calls.length, 0); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.turn.start"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("records setup-script failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; @@ -10834,7 +11010,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), createWorktree, }, orchestrationEngine: { @@ -10939,7 +11119,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), createWorktree, }, orchestrationEngine: { @@ -11044,7 +11228,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const config = yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), createWorktree, }, orchestrationEngine: { @@ -11253,7 +11441,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitVcsDriver: { + execute: () => Effect.succeed(SUCCESSFUL_GIT_EXECUTION), createWorktree, }, orchestrationEngine: { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a4898ce5ef19..1f960c488d8a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1122,6 +1122,49 @@ const makeWsRpcLayer = ( }); const bootstrapProgram = Effect.gen(function* () { + const prepareWorktree = bootstrap?.prepareWorktree; + let shouldPrepareWorktree = prepareWorktree + ? yield* gitWorkflow.isRepository(prepareWorktree.projectCwd) + : false; + let worktreeBaseRef = prepareWorktree?.baseBranch ?? null; + + if (prepareWorktree && shouldPrepareWorktree) { + // "Start from origin" is a stored default; repos without the + // requested remote branch fall back to the local base branch. + const startFromOrigin = + prepareWorktree.startFromOrigin === true && + (yield* gitWorkflow.remoteExists({ + cwd: prepareWorktree.projectCwd, + remoteName: "origin", + })); + if (startFromOrigin) { + yield* gitWorkflow.fetchRemote({ + cwd: prepareWorktree.projectCwd, + remoteName: "origin", + }); + const remoteBaseExists = yield* gitWorkflow.remoteBranchExists({ + cwd: prepareWorktree.projectCwd, + refName: prepareWorktree.baseBranch, + remoteName: "origin", + }); + if (remoteBaseExists) { + const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ + cwd: prepareWorktree.projectCwd, + refName: prepareWorktree.baseBranch, + fallbackRemoteName: "origin", + }); + worktreeBaseRef = resolvedRemoteBase.commitSha; + } + } + + const resolvedWorktreeBaseRef = worktreeBaseRef ?? prepareWorktree.baseBranch; + shouldPrepareWorktree = yield* gitWorkflow.hasCommit({ + cwd: prepareWorktree.projectCwd, + refName: resolvedWorktreeBaseRef, + }); + worktreeBaseRef = resolvedWorktreeBaseRef; + } + if (bootstrap?.createThread) { const created = yield* dispatchFromClient({ type: "thread.create", @@ -1144,40 +1187,12 @@ const makeWsRpcLayer = ( createdThread = true; } - if (bootstrap?.prepareWorktree) { - let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; - // "Start from origin" is a stored default; repos without the - // requested remote branch fall back to the local base branch. - const startFromOrigin = - bootstrap.prepareWorktree.startFromOrigin === true && - (yield* gitWorkflow.remoteExists({ - cwd: bootstrap.prepareWorktree.projectCwd, - remoteName: "origin", - })); - if (startFromOrigin) { - yield* gitWorkflow.fetchRemote({ - cwd: bootstrap.prepareWorktree.projectCwd, - remoteName: "origin", - }); - const remoteBaseExists = yield* gitWorkflow.remoteBranchExists({ - cwd: bootstrap.prepareWorktree.projectCwd, - refName: bootstrap.prepareWorktree.baseBranch, - remoteName: "origin", - }); - if (remoteBaseExists) { - const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ - cwd: bootstrap.prepareWorktree.projectCwd, - refName: bootstrap.prepareWorktree.baseBranch, - fallbackRemoteName: "origin", - }); - worktreeBaseRef = resolvedRemoteBase.commitSha; - } - } + if (prepareWorktree && shouldPrepareWorktree && worktreeBaseRef) { const worktree = yield* gitWorkflow.createWorktree({ - cwd: bootstrap.prepareWorktree.projectCwd, + cwd: prepareWorktree.projectCwd, refName: worktreeBaseRef, - newRefName: bootstrap.prepareWorktree.branch, - baseRefName: bootstrap.prepareWorktree.baseBranch, + newRefName: prepareWorktree.branch, + baseRefName: prepareWorktree.baseBranch, path: null, }); targetWorktreePath = worktree.worktree.path; diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 6769586f7fa8..6497df68c8bd 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -14,7 +14,11 @@ import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalSt import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; -import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; +import { + useCompactSidebarEnabled, + useEnvironmentIdentificationMode, + useLegacySidebarEnabled, +} from "../hooks/useSettings"; import { PanelAnimationSuppressionProvider, usePanelAnimationSettings, @@ -144,6 +148,7 @@ function ProjectProjectionRetention() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const legacySidebarEnabled = useLegacySidebarEnabled(); + const compactSidebarEnabled = useCompactSidebarEnabled(); const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); // Settings routes show the settings nav in place of whichever thread @@ -229,7 +234,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { s.queuePendingFileDrop); const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); - const { isMobile, setOpenMobile } = useSidebar(); + const { isMobile, setOpenMobile, state, setOpen } = useSidebar(); + const compactSidebarEnabled = useCompactSidebarEnabled(); + const isCompact = compactSidebarEnabled && !isMobile && state === "collapsed"; const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded); const toggleThreadSelection = useThreadSelectionStore((state) => state.toggleThread); @@ -1445,10 +1451,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (useThreadSelectionStore.getState().hasSelection()) { clearSelection(); } - setProjectExpanded(projectPreferenceKeys, !projectExpanded); + setProjectExpanded(projectPreferenceKeys, isCompact || !projectExpanded); + if (isCompact) setOpen(true); }, [ clearSelection, + isCompact, + setOpen, dragInProgressRef, projectExpanded, projectPreferenceKeys, @@ -1465,9 +1474,17 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (dragInProgressRef.current) { return; } - setProjectExpanded(projectPreferenceKeys, !projectExpanded); + setProjectExpanded(projectPreferenceKeys, isCompact || !projectExpanded); + if (isCompact) setOpen(true); }, - [dragInProgressRef, projectExpanded, projectPreferenceKeys, setProjectExpanded], + [ + dragInProgressRef, + isCompact, + projectExpanded, + projectPreferenceKeys, + setOpen, + setProjectExpanded, + ], ); const handleProjectButtonPointerDownCapture = useCallback( @@ -2359,8 +2376,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec <>
- {!projectExpanded && projectStatus ? ( + {isCompact ? null : !projectExpanded && projectStatus ? ( - + {project.displayName} @@ -2415,7 +2434,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec {/* Environment badge โ€“ visible by default, crossfades with the "new thread" button on hover using the same pointer-events + opacity pattern as the thread row archive/timestamp swap. */} - {project.environmentPresence === "remote-only" && ( + {!isCompact && project.environmentPresence === "remote-only" && ( +
+ + + } + > + {content} + + {props.label} + ); } @@ -704,6 +754,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { onDiscard: (draftId: DraftId) => void; }) { const { composer, draftId, onDiscard, onNavigate, session } = props; + const compactEnabled = useCompactSidebarEnabled(); + const { state, isMobile } = useSidebar(); + const compact = compactEnabled && state === "collapsed" && !isMobile; const promptPreview = replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label) .trim() @@ -742,6 +795,34 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { }, [draftId, onDiscard], ); + if (compact) { + return ( +
  • + + + } + > + + + +
    {props.projectDisplayName}
    +
    {preview}
    +
    +
    +
  • + ); + } return (
  • = { const SidebarThreadRow = memo(function SidebarThreadRow(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; + compact: boolean; // Slim rows are either settled (action: un-settle) or merely quiet // (seen Ready threads โ€” action: settle). variantAction: "settle" | "unsettle" | "unsnooze"; @@ -1031,6 +1113,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { variant, variantAction, } = props; + const compactEnabled = useCompactSidebarEnabled(); + const { state, isMobile } = useSidebar(); + const compact = compactEnabled && state === "collapsed" && !isMobile; const threadRef = useMemo( () => scopeThreadRef(thread.environmentId, thread.id), [thread.environmentId, thread.id], @@ -1208,6 +1293,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { branchMismatch={branchMismatch} terminalStatus={terminalStatus} terminalProcessCount={terminalProcessCount} + compactStatus={ + compact || (props.compact && variant === "card") + ? (topStatus?.label ?? + (variantAction === "unsnooze" + ? "Snoozed" + : variantAction === "unsettle" + ? "Settled" + : "Ready")) + : undefined + } /> ); @@ -1254,6 +1349,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { [isRenaming, onStartRename, thread.title, threadRef], ); const [isFileDragOver, setIsFileDragOver] = useState(false); + const [tooltipOpen, setTooltipOpen] = useState(false); const fileDropHandlers = useMemo( () => onFileDropThreads @@ -1416,7 +1512,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // A zero-height boundary also makes dnd-kit scale the source to // zero. Only projected peers use scaleY as a visibility sentinel. visibility: - !sortable.isDragging && sortable.transform?.scaleY === 0 + sortable.hidden || (!sortable.isDragging && sortable.transform?.scaleY === 0) ? ("hidden" as const) : undefined, }, @@ -1557,6 +1653,77 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) ) : null; + if (compact) { + return ( +
  • + + + } + > + {props.project ? ( + + ) : driverKind ? ( + + ) : ( + + )} + {topStatus ? ( + + ) : hasUnsentDraft ? ( + + ) : null} + {props.jumpLabel ? : null} + + {sortable?.isDragging ? ( + {dragDestination} + ) : ( + detailsTooltip + )} + +
  • + ); + } + if (variant === "slim") { return (
  • - + - + } > -
    -
    +
    +
    {draftIndicator} {props.project ? ( ) : null} - {props.projectDisplayName ? ( + {compactRows ? ( + title + ) : props.projectDisplayName ? ( )} {pinIndicator} + {compactRows ? ( + <> + {isRemote ? ( + + + + ) : null} + {terminalStatusIcon} + {topStatus && CompactStatusIcon ? ( + isWokeStatus ? ( + + ) : ( + + + {topStatus.label} + + ) + ) : null} + {prBadge} + + ) : 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 @@ -1765,20 +2011,33 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {sortable?.isDragging ? ( dragDestination ) : ( - + {/* Read-only status labels yield to the hover actions. Woke is itself an action, so it stays pointer-enabled and visible while the other controls appear beside it. */} - {topStatus ? ( + {compactRows ? ( + status === "working" ? ( + + ) : compactCompletedAt ? ( + + ) : ( + threadTimeLabel(thread) + ) + ) : topStatus ? ( isWokeStatus ? ( - Settle + {compactRows ? null : "Settle"} Settle thread @@ -1894,68 +2153,70 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )}
    -
    - {title} - {isRegeneratingTitle ? ( - - Regenerating title - - ) : null} -
    -
    - {/* Always the branch. The plan step used to take this slot while + {isRegeneratingTitle ? ( + + Regenerating title + + ) : null} + {compactRows ? null : ( + <> +
    {title}
    +
    + {/* Always the branch. The plan step used to take this slot while working, but it truncated to a half-sentence and dropped the branch, so the row lost its most stable identifier. */} - {thread.branch ? ( - <> - - - {thread.branch} - - - ) : ( - - )} - {terminalStatusIcon} - {prBadge} - {diff ? ( - - +{diff.insertions}{" "} - โˆ’{diff.deletions} - - ) : null} - - {isRemote ? ( - - - - ) : null} - {driverKind ? ( - - + {thread.branch ? ( + <> + + + {thread.branch} + + + ) : ( + + )} + {terminalStatusIcon} + {prBadge} + {diff ? ( + + +{diff.insertions}{" "} + โˆ’{diff.deletions} + + ) : null} + + {isRemote ? ( + + + + ) : null} + {driverKind ? ( + + + + ) : null} - ) : null} - -
    +
    + + )}
    {props.jumpLabel ? : null} @@ -1989,6 +2250,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; }) { const { thread } = props; + const compactEnabled = useCompactSidebarEnabled(); + const { state, isMobile } = useSidebar(); + const compact = compactEnabled && state === "collapsed" && !isMobile; const threadRef = useMemo( () => scopeThreadRef(thread.environmentId, thread.id), [thread.environmentId, thread.id], @@ -2074,6 +2338,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { onClick={props.onSelect} className={cn( "flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm outline-none", + compact && "justify-center px-0", props.isHighlighted || props.isRouteActive ? "bg-sidebar-row-active text-sidebar-foreground" : "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground", @@ -2085,9 +2350,15 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { > {props.project ? ( + ) : compact ? ( + ) : null} - {thread.title} - + {thread.title} + {threadTimeLabel(thread)} @@ -2115,8 +2386,12 @@ export default function Sidebar() { const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const router = useRouter(); - const { isMobile, setOpenMobile } = useSidebar(); + const { isMobile, setOpenMobile, setOpen, state: sidebarState } = useSidebar(); + const compactEnabled = useCompactSidebarEnabled(); + const compact = compactEnabled && sidebarState === "collapsed" && !isMobile; + const [snoozedFooter, setSnoozedFooter] = useState(null); const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const compactThreadRows = useClientSettings((s) => s.sidebarCompactThreadRows); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); @@ -2687,6 +2962,7 @@ export default function Sidebar() { [setSettledShelfExpanded], ); const renderedSettledThreads = useMemo(() => { + if (compact) return EMPTY_THREADS; if (settledShelfExpanded) return visibleSettledThreads; if (routeThreadKey === null) return EMPTY_THREADS; const routeThread = visibleSettledThreads.find( @@ -2694,7 +2970,7 @@ export default function Sidebar() { scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === routeThreadKey, ); return routeThread === undefined ? EMPTY_THREADS : [routeThread]; - }, [routeThreadKey, settledShelfExpanded, visibleSettledThreads]); + }, [compact, routeThreadKey, settledShelfExpanded, visibleSettledThreads]); // The snoozed shelf is collapsed by default: out of the way, never gone. // Collapsed threads don't render (and so don't participate in jump @@ -2922,10 +3198,14 @@ export default function Sidebar() { const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); - const startThreadRename = useCallback((threadRef: ScopedThreadRef, title: string) => { - setRenamingThreadKey(scopedThreadKey(threadRef)); - setRenamingTitle(title); - }, []); + const startThreadRename = useCallback( + (threadRef: ScopedThreadRef, title: string) => { + if (compact) setOpen(true); + setRenamingThreadKey(scopedThreadKey(threadRef)); + setRenamingTitle(title); + }, + [compact, setOpen], + ); const cancelThreadRename = useCallback(() => setRenamingThreadKey(null), []); const commitThreadRename = useCallback( (threadRef: ScopedThreadRef, title: string, originalTitle: string) => { @@ -3087,8 +3367,18 @@ export default function Sidebar() { const threadListRef = useRef(null); const dragLabelOffsetRef = useRef(0); const restrictBelowPins = useCallback( - (args) => restrictBelowSidebarLabel(args, dragLabelOffsetRef.current), - [], + (args) => + restrictBelowSidebarLabel( + { + ...args, + // The fixed snoozed shelf shares the main list's drag boundary. + containerNodeRect: compact + ? (threadListRef.current?.getBoundingClientRect() ?? args.containerNodeRect) + : args.containerNodeRect, + }, + dragLabelOffsetRef.current, + ), + [compact], ); const listMotionRef = useRef | null>(null); const attachListMotionRef = useCallback((node: HTMLUListElement | null) => { @@ -3310,7 +3600,7 @@ export default function Sidebar() { pinnedThreads.length + activeThreads.length + snoozedThreads.length + - settledThreads.length === + (compact ? 0 : settledThreads.length) === 0 ) { return []; @@ -3326,13 +3616,15 @@ export default function Sidebar() { items.push({ kind: "marker", marker: "snoozed-header" }); items.push(...rowsOf(visibleSnoozedThreads, "snoozed")); } - items.push({ kind: "marker", marker: "settled-header" }); - const settledRows = rowsOf(renderedSettledThreads, "settled"); - items.push({ kind: "marker", marker: "settled-placeholder" }); - items.push(...settledRows); + if (!compact) { + items.push({ kind: "marker", marker: "settled-header" }); + items.push({ kind: "marker", marker: "settled-placeholder" }); + items.push(...rowsOf(renderedSettledThreads, "settled")); + } return items; }, [ activeThreads, + compact, pinnedThreads, renderedSettledThreads, settledThreads.length, @@ -3402,6 +3694,7 @@ export default function Sidebar() { () => createSidebarSortingStrategy({ items: sidebarListItems, + compact, boundaryLabelHeight: SIDEBAR_DRAG_LABEL_HEIGHT, settledOrder: draggedSettledOrder, settledExpanded: settledShelfExpanded, @@ -3410,6 +3703,7 @@ export default function Sidebar() { snoozedThreadCount: snoozedThreads.length, }), [ + compact, draggedSettledOrder, routeThreadKey, settledShelfExpanded, @@ -3418,6 +3712,22 @@ export default function Sidebar() { snoozedThreads.length, ], ); + const draggingCompactSnoozed = compact && dragState?.activeSection === "snoozed"; + const compactSnoozedDragThread = + draggingCompactSnoozed && dragState ? threadByKey.get(dragState.activeKey) : undefined; + const compactSidebarSortingStrategy = useCallback( + (args) => { + const item = sidebarListItems[args.index]; + // Footer rows stay anchored while the main list previews a reorder. + if ( + item?.kind === "thread" ? item.section === "snoozed" : item?.marker === "snoozed-header" + ) { + return null; + } + return sidebarSortingStrategy(args); + }, + [sidebarListItems, sidebarSortingStrategy], + ); // Hidden and filtered threads keep their keys. Reserve those slots without // including the rows in the visible drop order or writing to them. const { pinnedKeysById, activeKeysById } = useMemo( @@ -4321,7 +4631,19 @@ export default function Sidebar() { <> 0 ? ( +
      + ) : null + } fixedHeader={ // Lifted above the stage backdrop, whose fade bleeds below the // header and would otherwise paint across the search row's outline. @@ -4381,8 +4703,12 @@ export default function Sidebar() { // popup opens under the field, is at least as wide as it, // and grows to fit project names up to a cap, past which // the rows truncate. - anchor={headerSearchRef} - className="max-w-[min(18rem,var(--available-width))] overflow-hidden" + anchor={compact ? undefined : headerSearchRef} + side={compact ? "right" : "bottom"} + className={cn( + "max-w-[min(18rem,var(--available-width))] overflow-hidden", + compact && "min-w-56", + )} > } > - + {isSearchingThreads ? ( threadSearchResults.length > 0 ? ( No threads found

      @@ -4556,18 +4890,24 @@ export default function Sidebar() { modifiers={[ restrictToVerticalAxis, restrictBelowPins, - restrictToFirstScrollableAncestor, + ...(compact ? [] : [restrictToFirstScrollableAncestor]), ]} onDragStart={handleThreadDragStart} onDragOver={handleThreadDragOver} onDragEnd={handleThreadDragEnd} > - +
        0 && "flex-1", + )} > {(() => { const renderThreadRowInner = ( @@ -4578,10 +4918,9 @@ export default function Sidebar() { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); - // Settled and snoozed are the ONLY things that collapse a - // 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. + // Settled and snoozed always use slim rows. Active and + // pinned threads use cards unless the user has explicitly + // enabled the compact thread-list preference. const isCard = section === "active" || section === "pinned"; const rowVariant = isCard ? "card" : "slim"; return ( @@ -4591,6 +4930,7 @@ export default function Sidebar() { key={`${threadKey}:${rowVariant}`} thread={thread} variant={rowVariant} + compact={compactThreadRows} // Snoozed rows wake, settled rows un-settle, and cards settle. variantAction={ section === "snoozed" @@ -4693,11 +5033,25 @@ export default function Sidebar() { !draggableThreadKeys.has(threadKey) || optimisticDrop !== null } > - {(bag) => renderThreadRowInner(thread, section, bag)} + {(bag) => + renderThreadRowInner( + thread, + section, + draggingCompactSnoozed && bag.isDragging + ? { ...bag, hidden: true } + : bag, + ) + } ); }; const from = dragState?.activeSection ?? null; + const showDragLabels = + from !== null && + (!compact || + dragTargetSection === "active" || + dragTargetSection === "pinned"); + const snoozedItems: ReactNode[] = []; const items: ReactNode[] = [ , ]; for (const item of sidebarListItems) { + const destination = + compact && + (item.kind === "thread" + ? item.section === "snoozed" + : item.marker === "snoozed-header") + ? snoozedItems + : items; if (item.kind === "thread") { - items.push(renderThreadRow(threadByKey.get(item.key)!, item.section)); + destination.push( + renderThreadRow(threadByKey.get(item.key)!, item.section), + ); continue; } switch (item.marker) { @@ -4720,7 +5083,7 @@ export default function Sidebar() { key="pinned-header" marker="pinned-header" label="Pinned" - visible={from !== null} + visible={showDragLabels} isDropTarget={dragTargetSection === "pinned"} />, ); @@ -4731,7 +5094,7 @@ export default function Sidebar() { key="pinned-divider" marker="pinned-divider" label="Active" - visible={from !== null} + visible={showDragLabels} isDropTarget={dragTargetSection === "active"} />, ); @@ -4755,10 +5118,11 @@ export default function Sidebar() { ); break; case "snoozed-header": - items.push( + destination.push( +
          + {renderThreadRowInner(compactSnoozedDragThread, "snoozed", { + isDragging: true, + listeners: undefined, + setNodeRef: () => {}, + transform: null, + transition: undefined, + })} +
        + , + document.body, + "compact-snoozed-drag", + ) + : null, + ]; })()} - {settledShelfExpanded && hiddenSettledCount > 0 ? ( + {!compact && settledShelfExpanded && hiddenSettledCount > 0 ? (
      • - + + + } + > + + + Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more + + + + Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more + +
      • ) : null}
      @@ -4836,20 +5239,29 @@ export default function Sidebar() { snoozedThreads.length + settledThreads.length === 0 ? ( -
      +
      {projects.length === 0 ? ( <> - No projects yet + No projects yet - ) : scopedProjectGroup ? ( + ) : compact ? null : scopedProjectGroup ? ( `No threads in ${scopedProjectGroup.displayName} yet` ) : ( "No threads yet" diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx new file mode 100644 index 000000000000..3ce4b8d6f176 --- /dev/null +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx @@ -0,0 +1,259 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + mode: "notifications", + inApp: false, + toast: vi.fn(), + shells: new Map(), + navigate: vi.fn(), + sound: vi.fn(), + badge: vi.fn(), + environmentIds: ["one", "two"], +})); +vi.mock("@effect/atom-react", () => ({ useAtomValue: (id: string) => state.shells.get(id) })); +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => state.navigate, + useParams: () => ({}), +})); +vi.mock("./ui/toast", () => ({ toastManager: { add: state.toast } })); +vi.mock("../state/shell", () => ({ environmentShell: { stateValueAtom: (id: string) => id } })); +vi.mock("../state/environments", () => ({ + useEnvironments: () => ({ + environments: state.environmentIds.map((environmentId) => ({ environmentId })), + }), +})); +vi.mock("../hooks/useSettings", () => ({ + useClientSettings: ( + select: (settings: { notificationMode: string; inAppNotificationsEnabled: boolean }) => unknown, + ) => select({ notificationMode: state.mode, inAppNotificationsEnabled: state.inApp }), + getClientSettings: () => ({ notificationMode: state.mode }), +})); +vi.mock("../threadNotifications", async (importOriginal) => ({ + ...(await importOriginal()), + playNotificationSound: state.sound, + unlockNotificationAudio: vi.fn(), + setNotificationBadge: state.badge, +})); + +import { ThreadNotificationCoordinator } from "./ThreadNotificationCoordinator"; + +class TestNotification extends EventTarget { + static permission = "granted"; + static sent: TestNotification[] = []; + close = vi.fn(); + get tag() { + return this.options.tag ?? ""; + } + constructor( + readonly title: string, + readonly options: NotificationOptions, + ) { + super(); + TestNotification.sent.push(this); + } +} + +const thread = { + id: "thread", + title: "Test thread", + archivedAt: null as string | null, + hasPendingApprovals: false, + hasPendingUserInput: false, + session: null, + latestTurn: { turnId: "turn", state: "running", completedAt: null as string | null }, +}; +let renderer: ReactTestRenderer | undefined; +let focused = false; +let visibility = "visible"; + +function shell(overrides: Partial = {}) { + return { status: "live", snapshot: Option.some({ threads: [{ ...thread, ...overrides }] }) }; +} +function complete(environment = "one", completedAt = "2026-09-13T08:00:00Z") { + state.shells.set( + environment, + shell({ latestTurn: { turnId: "turn", state: "completed", completedAt } }), + ); +} +async function render() { + await act(async () => { + if (renderer) renderer.update(); + else renderer = create(); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + state.mode = "notifications"; + state.inApp = false; + state.environmentIds = ["one", "two"]; + state.shells.set("one", shell()); + state.shells.set("two", shell()); + focused = false; + visibility = "visible"; + TestNotification.permission = "granted"; + TestNotification.sent = []; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("Notification", TestNotification); + vi.stubGlobal("window", Object.assign(new EventTarget(), { focus: vi.fn() })); + vi.stubGlobal( + "document", + Object.assign(new EventTarget(), { + hasFocus: () => focused, + get visibilityState() { + return visibility; + }, + }), + ); +}); + +afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +it("counts notifying threads across environments, replaces repeat alerts, and clears on focus", async () => { + await render(); + complete(); + await render(); + expect(state.badge).toHaveBeenLastCalledWith(1); + complete("one", "2026-09-13T08:01:00Z"); + complete("two"); + await render(); + expect(state.badge).toHaveBeenLastCalledWith(2); + expect(TestNotification.sent[0]!.close).toHaveBeenCalledOnce(); + focused = true; + window.dispatchEvent(new Event("focus")); + expect(state.badge).toHaveBeenLastCalledWith(0); + expect( + TestNotification.sent.every((notification) => notification.close.mock.calls.length > 0), + ).toBe(true); + focused = false; + complete("two", "2026-09-13T08:02:00Z"); + await render(); + expect(state.badge).toHaveBeenLastCalledWith(1); +}); + +it("does not badge old completions on first load or reconnect", async () => { + complete(); + await render(); + state.shells.set("one", { status: "connecting", snapshot: Option.none() }); + await render(); + complete("one", "2026-09-13T08:01:00Z"); + await render(); + expect(TestNotification.sent).toHaveLength(0); + expect(state.badge.mock.calls.every(([count]) => count === 0)).toBe(true); +}); + +it("removes alerts only from environments that leave the client", async () => { + await render(); + complete("one"); + complete("two"); + await render(); + expect(state.badge).toHaveBeenLastCalledWith(2); + const [removed, retained] = TestNotification.sent; + state.environmentIds = ["two"]; + await render(); + expect(state.badge).toHaveBeenLastCalledWith(1); + expect(removed!.close).toHaveBeenCalledOnce(); + expect(retained!.close).not.toHaveBeenCalled(); + await render(); + expect(removed!.close).toHaveBeenCalledOnce(); + state.environmentIds = []; + await render(); + expect(state.badge).toHaveBeenLastCalledWith(0); + expect(retained!.close).toHaveBeenCalledOnce(); +}); + +it("starts a fresh count after another native app window gains focus", async () => { + let clear: (() => void) | undefined; + const unsubscribe = vi.fn(); + Object.assign(window, { + desktopBridge: { + onNotificationBadgeClear: (listener: () => void) => { + clear = listener; + return unsubscribe; + }, + }, + }); + await render(); + complete(); + await render(); + clear!(); + expect(state.badge).toHaveBeenLastCalledWith(0); + complete("two"); + await render(); + expect(state.badge).toHaveBeenLastCalledWith(1); + await act(async () => renderer!.unmount()); + renderer = undefined; + expect(unsubscribe).toHaveBeenCalledOnce(); + expect(state.badge).toHaveBeenLastCalledWith(0); +}); + +it.each(["off", "sound", "focused", "denied", "archived"])( + "does not show visual alerts when %s", + async (condition) => { + if (condition === "off" || condition === "sound") state.mode = condition; + if (condition === "focused") focused = true; + if (condition === "denied") TestNotification.permission = "denied"; + await render(); + complete(); + if (condition === "archived") + state.shells.set( + "one", + shell({ + archivedAt: "2026-09-13T08:00:00Z", + hasPendingApprovals: true, + }), + ); + await render(); + expect(TestNotification.sent).toHaveLength(0); + expect(state.badge.mock.calls.every(([count]) => count === 0)).toBe(true); + }, +); + +it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( + "badges %s and clears when notifications are disabled", + async (flag) => { + await render(); + state.shells.set("one", shell({ [flag]: true })); + await render(); + expect(state.badge).toHaveBeenLastCalledWith(1); + const notification = TestNotification.sent[0]!; + notification.dispatchEvent(new Event("click")); + expect(state.navigate).toHaveBeenCalledWith({ + to: "/$environmentId/$threadId", + params: { environmentId: EnvironmentId.make("one"), threadId: "thread" }, + }); + state.mode = "sound"; + await render(); + expect(state.badge).toHaveBeenLastCalledWith(0); + expect(notification.close).toHaveBeenCalled(); + }, +); + +it("shows in-app alerts without adding a badge while focused", async () => { + state.inApp = true; + focused = true; + await render(); + complete(); + await render(); + expect(state.toast).toHaveBeenCalledOnce(); + expect(TestNotification.sent).toHaveLength(0); + expect(state.badge.mock.calls.every(([count]) => count === 0)).toBe(true); +}); + +it("badges background failures with in-app notifications enabled", async () => { + state.inApp = true; + await render(); + state.shells.set("one", shell({ latestTurn: { ...thread.latestTurn, state: "error" } })); + await render(); + expect(TestNotification.sent[0]?.title).toBe("Thread failed"); + expect(state.badge).toHaveBeenLastCalledWith(1); + expect(state.toast).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx new file mode 100644 index 000000000000..860b6389dc12 --- /dev/null +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -0,0 +1,254 @@ +import type { ClientSettings } from "@t3tools/contracts/settings"; +import * as Option from "effect/Option"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + mode: "off" as ClientSettings["notificationMode"], + inApp: true, + active: { environmentId: "env-1", threadId: "other-thread" }, + focused: true, + visible: "visible", + live: true, + completedAt: null as string | null, + archivedAt: null as string | null, + input: false, + approval: false, + sessionError: false, + turnError: false, + add: vi.fn( + (_toast: { title: string; description: string; actionProps: { onClick: () => void } }) => + "toast-1", + ), + close: vi.fn(), + navigate: vi.fn(), + sound: vi.fn(), + notification: vi.fn(function (_title: string, options: NotificationOptions) { + return Object.assign(new EventTarget(), { tag: options.tag, close: vi.fn() }); + }), +})); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => ({ + status: state.live ? "live" : "disconnected", + snapshot: Option.some({ + threads: [ + { + id: "thread-1", + title: "Fix the login form", + archivedAt: state.archivedAt, + hasPendingUserInput: state.input, + hasPendingApprovals: state.approval, + session: state.sessionError ? { status: "error" } : null, + latestTurn: { + turnId: "turn-1", + state: state.turnError ? "error" : state.completedAt ? "completed" : "running", + completedAt: state.completedAt, + }, + }, + ], + }), + }), +})); +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => state.navigate, + useParams: () => state.active, +})); +vi.mock("../hooks/useSettings", () => ({ + useClientSettings: ( + select: ( + settings: Pick, + ) => unknown, + ) => select({ notificationMode: state.mode, inAppNotificationsEnabled: state.inApp }), + getClientSettings: () => ({ notificationMode: state.mode }), +})); +vi.mock("../state/environments", () => ({ + useEnvironments: () => ({ environments: [{ environmentId: "env-1" }] }), +})); +vi.mock("../state/shell", () => ({ + environmentShell: { stateValueAtom: vi.fn() }, +})); +vi.mock("../threadNotifications", async (importOriginal) => ({ + ...(await importOriginal()), + playNotificationSound: state.sound, + setNotificationBadge: vi.fn(), +})); +vi.mock("./ui/toast", () => ({ + toastManager: { add: state.add, close: state.close }, +})); + +import { ThreadNotificationCoordinator } from "./ThreadNotificationCoordinator"; + +let renderer: ReactTestRenderer | undefined; + +async function render() { + await act(() => { + if (renderer) renderer.update(); + else renderer = create(); + }); +} + +async function complete() { + state.completedAt = "2026-09-13T10:00:00.000Z"; + await render(); +} + +beforeEach(() => { + vi.clearAllMocks(); + Object.assign(state, { + mode: "off", + inApp: true, + active: { environmentId: "env-1", threadId: "other-thread" }, + focused: true, + visible: "visible", + live: true, + completedAt: null, + archivedAt: null, + input: false, + approval: false, + sessionError: false, + turnError: false, + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", new EventTarget()); + vi.stubGlobal("document", { + get visibilityState() { + return state.visible; + }, + hasFocus: () => state.focused, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + vi.stubGlobal("Notification", Object.assign(state.notification, { permission: "granted" })); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +describe("thread notifications", () => { + it("alerts once with system alerts off and opens the completed thread", async () => { + await render(); + await complete(); + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + const toast = state.add.mock.calls[0]?.[0]; + expect(toast?.title).toBe("Thread completed"); + expect(toast?.description).toBe("Fix the login form"); + toast?.actionProps.onClick(); + expect(state.close).toHaveBeenCalledWith("toast-1"); + expect(state.navigate).toHaveBeenCalledWith({ + to: "/$environmentId/$threadId", + params: { environmentId: "env-1", threadId: "thread-1" }, + }); + expect(state.notification).not.toHaveBeenCalled(); + }); + + it.each(["active", "blurred", "hidden", "archived", "disabled"])( + "does not show a completion toast for %s threads", + async (condition) => { + await render(); + if (condition === "active") state.active.threadId = "thread-1"; + if (condition === "blurred") state.focused = false; + if (condition === "hidden") state.visible = "hidden"; + if (condition === "archived") state.archivedAt = "2026-09-13T09:00:00.000Z"; + if (condition === "disabled") state.inApp = false; + await complete(); + expect(state.add).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ["input", "Input needed"], + ["approval", "Approval needed"], + ["sessionError", "Thread failed"], + ["turnError", "Thread failed"], + ] as const)("uses the same %s event for in-app and desktop alerts", async (event, title) => { + state.mode = "notifications-and-sound"; + await render(); + state[event] = true; + await render(); + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + expect(state.add).toHaveBeenLastCalledWith(expect.objectContaining({ title })); + expect(state.sound).toHaveBeenCalledWith("input", expect.any(Function)); + expect(state.notification).not.toHaveBeenCalled(); + + state[event] = false; + await render(); + state.focused = false; + state[event] = true; + await render(); + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + expect(state.notification).toHaveBeenCalledTimes(1); + expect(state.notification).toHaveBeenCalledWith(title, { + body: "Fix the login form", + tag: "env-1:thread-1", + silent: true, + }); + }); + + it("keeps background desktop alerts when in-app notifications are disabled", async () => { + state.focused = false; + state.inApp = false; + state.mode = "notifications"; + await render(); + await complete(); + expect(state.add).not.toHaveBeenCalled(); + expect(state.notification).toHaveBeenCalledTimes(1); + state.inApp = true; + await render(); + expect(state.add).not.toHaveBeenCalled(); + }); + + it("does not replay a completion when opting in from all alerts off", async () => { + state.inApp = false; + await render(); + await complete(); + state.inApp = true; + await render(); + expect(state.add).not.toHaveBeenCalled(); + }); + + it("compares the environment as well as the thread", async () => { + state.active = { environmentId: "env-2", threadId: "thread-1" }; + await render(); + await complete(); + expect(state.add).toHaveBeenCalledTimes(1); + }); + + it("does not replay completed threads on first load or reconnect", async () => { + await complete(); + state.live = false; + await render(); + state.live = true; + await render(); + expect(state.add).not.toHaveBeenCalled(); + }); + + it("keeps sound but replaces the system popup when showing a toast", async () => { + state.mode = "notifications-and-sound"; + await render(); + await complete(); + expect(state.sound).toHaveBeenCalledWith("completion", expect.any(Function)); + expect(state.add).toHaveBeenCalledTimes(1); + expect(state.notification).not.toHaveBeenCalled(); + }); + + it("keeps system alerts when the app is in the background", async () => { + state.mode = "notifications"; + state.focused = false; + await render(); + await complete(); + expect(state.add).not.toHaveBeenCalled(); + expect(state.notification).toHaveBeenCalledWith("Thread completed", { + body: "Fix the login form", + tag: "env-1:thread-1", + silent: true, + }); + }); +}); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index e89175a77808..5feda71ea8f3 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -1,8 +1,8 @@ import { useAtomValue } from "@effect/atom-react"; -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useParams } from "@tanstack/react-router"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; import { useEnvironments } from "../state/environments"; @@ -11,13 +11,54 @@ import { hasDesktopNotifications, hasNotificationSound, playNotificationSound, + setNotificationBadge, unlockNotificationAudio, } from "../threadNotifications"; import { resolveSidebarThreadStatus } from "./Sidebar.logic"; +import { toastManager } from "./ui/toast"; export function ThreadNotificationCoordinator() { const { environments } = useEnvironments(); const mode = useClientSettings((settings) => settings.notificationMode); + const inAppNotificationsEnabled = useClientSettings( + (settings) => settings.inAppNotificationsEnabled, + ); + const pending = useRef( + new Map(), + ); + const onNotification = useCallback((environmentId: EnvironmentId, notification: Notification) => { + pending.current.get(notification.tag)?.notification.close(); + pending.current.set(notification.tag, { environmentId, notification }); + setNotificationBadge(pending.current.size); + }, []); + + useEffect(() => { + const activeIds = new Set(environments.map(({ environmentId }) => environmentId)); + const count = pending.current.size; + for (const [tag, { environmentId, notification }] of pending.current) { + if (activeIds.has(environmentId)) continue; + notification.close(); + pending.current.delete(tag); + } + if (count !== pending.current.size) setNotificationBadge(pending.current.size); + }, [environments]); + + useEffect(() => { + const clear = () => { + for (const { notification } of pending.current.values()) notification.close(); + pending.current.clear(); + setNotificationBadge(0); + }; + clear(); + if (!hasDesktopNotifications(mode)) return; + const unsubscribe = window.desktopBridge?.onNotificationBadgeClear?.(clear); + window.addEventListener("focus", clear); + return () => { + unsubscribe?.(); + window.removeEventListener("focus", clear); + clear(); + }; + }, [mode]); useEffect(() => { if (!hasNotificationSound(mode)) return; @@ -29,33 +70,49 @@ export function ThreadNotificationCoordinator() { }; }, [mode]); - if (mode === "off") return null; + if (mode === "off" && !inAppNotificationsEnabled) return null; return environments.map((environment) => ( )); } -function EnvironmentNotifications({ environmentId }: { environmentId: EnvironmentId }) { +function EnvironmentNotifications({ + environmentId, + onNotification, +}: { + environmentId: EnvironmentId; + onNotification: (environmentId: EnvironmentId, notification: Notification) => void; +}) { const shell = useAtomValue(environmentShell.stateValueAtom(environmentId)); const mode = useClientSettings((settings) => settings.notificationMode); + const inAppNotificationsEnabled = useClientSettings( + (settings) => settings.inAppNotificationsEnabled, + ); const navigate = useNavigate(); - const previous = useRef(new Map()); + const { environmentId: activeEnvironmentId, threadId: activeThreadId } = useParams({ + strict: false, + }); + const previous = useRef( + new Map(), + ); useEffect(() => { if (shell.status !== "live" || Option.isNone(shell.snapshot)) { previous.current.clear(); return; } - const next = new Map(); + const next = new Map(); for (const thread of shell.snapshot.value.threads) { - const status = resolveSidebarThreadStatus(thread); + let status = resolveSidebarThreadStatus(thread); + if (status === "ready" && thread.latestTurn?.state === "error") status = "failed"; const prior = previous.current.get(thread.id); - const input = - status === "input" || status === "approval" + const attention = + status === "input" || status === "approval" || status === "failed" ? `${thread.latestTurn?.turnId ?? ""}:${status}` : null; const completedAt = Date.parse(thread.latestTurn?.completedAt ?? ""); @@ -65,35 +122,66 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen Number.isFinite(completedAt) ? completedAt : (prior?.completion ?? null); - next.set(thread.id, { input, completion }); - if (!prior || mode === "off" || thread.archivedAt !== null) continue; + next.set(thread.id, { attention, completion }); + if (!prior || thread.archivedAt !== null) continue; const kind = - input && input !== prior.input + attention && attention !== prior.attention ? "input" : completion !== null && (prior.completion === null || completion > prior.completion) ? "completion" : null; if (!kind) continue; + const title = + kind === "completion" + ? "Thread completed" + : status === "approval" + ? "Approval needed" + : status === "failed" + ? "Thread failed" + : "Input needed"; if (hasNotificationSound(mode)) { void playNotificationSound(kind, () => hasNotificationSound(getClientSettings().notificationMode), ); } + if ( + inAppNotificationsEnabled && + document.visibilityState === "visible" && + document.hasFocus() && + (activeEnvironmentId !== environmentId || activeThreadId !== thread.id) + ) { + const toastId = toastManager.add({ + type: kind === "completion" ? "success" : status === "failed" ? "error" : "warning", + title, + description: thread.title, + data: { hideCopyButton: true }, + actionProps: { + children: "Open thread", + onClick: () => { + toastManager.close(toastId); + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId: thread.id }, + }); + }, + }, + }); + continue; + } if ( !hasDesktopNotifications(mode) || + (document.visibilityState === "visible" && document.hasFocus()) || typeof Notification === "undefined" || Notification.permission !== "granted" ) continue; try { - const notification = new Notification( - kind === "completion" - ? "Thread completed" - : status === "approval" - ? "Approval needed" - : "Input needed", - { body: thread.title, tag: `${environmentId}:${thread.id}`, silent: true }, - ); + const notification = new Notification(title, { + body: thread.title, + tag: `${environmentId}:${thread.id}`, + silent: true, + }); + onNotification(environmentId, notification); notification.addEventListener("click", () => { notification.close(); window.focus(); @@ -107,7 +195,16 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen } } previous.current = next; - }, [environmentId, mode, navigate, shell]); + }, [ + activeEnvironmentId, + activeThreadId, + environmentId, + inAppNotificationsEnabled, + mode, + navigate, + onNotification, + shell, + ]); return null; } diff --git a/apps/web/src/components/settings/CompactSidebarPreview.tsx b/apps/web/src/components/settings/CompactSidebarPreview.tsx new file mode 100644 index 000000000000..4d1e861d14e7 --- /dev/null +++ b/apps/web/src/components/settings/CompactSidebarPreview.tsx @@ -0,0 +1,82 @@ +import { useEffect, useRef, useState } from "react"; + +import { cn } from "~/lib/utils"; + +export function CompactSidebarPreview({ + railEnabled, + compactRows, +}: { + railEnabled: boolean; + compactRows: boolean; +}) { + const [collapsed, setCollapsed] = useState(false); + const sidebarRef = useRef(null); + + useEffect(() => { + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + const animation = sidebarRef.current?.animate( + [ + { width: "36px", offset: 0 }, + { width: railEnabled ? "12px" : "0px", offset: 0.45 }, + { width: railEnabled ? "12px" : "0px", offset: 0.6 }, + { width: "36px", offset: 1 }, + ], + { duration: 800, easing: "ease-in-out" }, + ); + return () => animation?.cancel(); + }, [railEnabled, compactRows]); + + return ( + + ); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index e8f1232b19bc..a6e29cf72312 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -6,6 +6,7 @@ import { TerminalIcon, } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; +import { useLocation } from "@tanstack/react-router"; import { Atom } from "effect/unstable/reactivity"; import { type KeyboardEvent, @@ -41,7 +42,7 @@ import { type EnvironmentId, resolveEnvironmentMachineKind, } from "@t3tools/contracts"; -import { connectionStatusText } from "@t3tools/client-runtime/connection"; +import { connectionStatusText, connectionStatusTitle } from "@t3tools/client-runtime/connection"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -67,7 +68,7 @@ import { } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; -import { LoadBalancingSettings } from "./LoadBalancingSettings"; +import { LoadBalancingPreference, LoadBalancingSettings } from "./LoadBalancingSettings"; import { GitHubRoutingSettings } from "./GitHubRoutingSettings"; import { Input } from "../ui/input"; import { CommandShortcut } from "../ui/command"; @@ -157,7 +158,7 @@ import { import { requestConfirmDialog } from "~/confirmDialog"; import { useAtomCommand } from "../../state/use-atom-command"; import { primaryServerKeybindingsAtom, serverEnvironment } from "~/state/server"; -import { ConnectionStatusDot } from "../ConnectionStatusDot"; +import { ConnectionStatusDot, connectionPhaseDotClassName } from "../ConnectionStatusDot"; import { ServerUpdateAction, ServerUpdateProgress, @@ -1498,37 +1499,40 @@ function SavedBackendListRow({
      -
      - - -

      - {environment.label} -

      +
      +
      + + +

      + {environment.label} +

      +
      + {isConnected ? ( +
      + +
      + ) : null}
      {metadataBits.length > 0 ? (

      {metadataBits.join(" ยท ")}

      ) : null} - {isConnected ? ( -
      - -
      - ) : null} {serverUpdateState.status !== "idle" ? (
      @@ -1586,12 +1590,12 @@ function SavedBackendListRow({ - Managed above + Managed locally } /> - The WSL backend is managed by the WSL setting above โ€” turn it on or off there. + Select the primary environment to turn the WSL backend on or off. ) : ( @@ -1813,6 +1817,19 @@ export function ConnectionsSettings() { const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); + const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); + const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") }); + const [handledSearchTargetId, setHandledSearchTargetId] = useState(null); + if (primaryEnvironment && handledSearchTargetId !== searchTargetId) { + setHandledSearchTargetId(searchTargetId); + if (["connections-environment", "wsl-backend"].includes(searchTargetId)) { + setSelectedEnvironmentId(primaryEnvironment.environmentId); + } + } + const selectedEnvironment = + environments.find((environment) => environment.environmentId === selectedEnvironmentId) ?? + primaryEnvironment ?? + environments[0]; const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, { reportFailure: false, @@ -3235,8 +3252,8 @@ export function ConnectionsSettings() { /> ); - return ( - + const primarySettings = ( + <> {canManageLocalBackend ? ( <> )} + + ); + return ( + {savedServerUpdateTargets.length > 0 ? ( @@ -3701,21 +3725,117 @@ export function ConnectionsSettings() {
      } > - {savedEnvironments.map((environment) => ( - - ))} +
      +
      + +
      + {(primaryEnvironment + ? [primaryEnvironment, ...savedEnvironments] + : savedEnvironments + ).map((environment) => { + const selected = environment.environmentId === selectedEnvironment?.environmentId; + const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; + return ( + + ); + })} +
      +
      +
      + + {selectedEnvironment ? ( +
      + {selectedEnvironment.entry.target._tag === "PrimaryConnectionTarget" ? ( + primarySettings + ) : ( + + + + )} + {selectedEnvironment.entry.enabled && loadBalancingEnvironments.length > 1 ? ( + + + + ) : null} + +
      + ) : null} +
      +
      - ); diff --git a/apps/web/src/components/settings/GitHubRoutingSettings.tsx b/apps/web/src/components/settings/GitHubRoutingSettings.tsx index 8f83817294ef..fad58b4f644b 100644 --- a/apps/web/src/components/settings/GitHubRoutingSettings.tsx +++ b/apps/web/src/components/settings/GitHubRoutingSettings.tsx @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; import { gitHubRoutingConnectionKey, gitHubRoutingPermissionFor, @@ -22,24 +23,30 @@ const options: ReadonlyArray<{ value: GitHubRoutingPermission; label: string }> export function GitHubRoutingSettings({ environments, + selectedEnvironmentId, }: { readonly environments: ReadonlyArray; + readonly selectedEnvironmentId: EnvironmentId; }) { const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom); const catalog = useAtomValue(environmentCatalog.catalogValueAtom); const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission); const [saving, setSaving] = useState(false); + const selectedEnvironments = environments.filter( + (environment) => environment.environmentId === selectedEnvironmentId, + ); return ( - {environments.map((environment) => ( + {selectedEnvironments.map((environment) => ( } /> - {environments.map((environment) => { - const weight = settings.loadBalancingWeights[environment.environmentId] ?? 50; - // Keep saved slider weights until the user chooses a different preference. - const preference = weight === 0 ? 0 : weight < 50 ? 25 : weight === 50 ? 50 : 100; + + ); +} - return ( - { - if (value !== null) { - updateSettings({ - loadBalancingWeights: { - ...settings.loadBalancingWeights, - [environment.environmentId]: value, - }, - }); - } - }} - > - - - - - {preferences.map(({ value, label }) => ( - - {label} - - ))} - - +export function LoadBalancingPreference({ environment }: { environment: EnvironmentPresentation }) { + const settings = useClientSettings(); + const settingsHydrated = useClientSettingsHydrated(); + const updateSettings = useUpdateClientSettings(); + const weight = settings.loadBalancingWeights[environment.environmentId] ?? 50; + // Keep saved slider weights until the user chooses a different preference. + const preference = weight === 0 ? 0 : weight < 50 ? 25 : weight === 50 ? 50 : 100; + + return ( + { + if (value !== null) { + updateSettings({ + loadBalancingWeights: { + ...settings.loadBalancingWeights, + [environment.environmentId]: value, + }, + }); } - /> - ); - })} - + }} + > + + + + + {preferences.map(({ value, label }) => ( + + {label} + + ))} + + + } + /> ); } diff --git a/apps/web/src/components/settings/NotificationSettings.tsx b/apps/web/src/components/settings/NotificationSettings.tsx index 22b4d2912302..5af5f0a85233 100644 --- a/apps/web/src/components/settings/NotificationSettings.tsx +++ b/apps/web/src/components/settings/NotificationSettings.tsx @@ -22,7 +22,7 @@ export function NotificationSettings() { {...searchableSetting("thread-notifications")} description={ permissionMessage ?? - "Alert when a thread finishes or needs input. Applies to this device while T3 Code is open." + "System alerts when a thread finishes, fails, or needs input or approval. Applies to this device while T3 Code is open." } control={ { + if ( + value !== "off" && + value !== "rail" && + value !== "threads" && + value !== "both" + ) + return; + updateSettings({ + compactSidebarEnabled: value === "rail" || value === "both", + sidebarCompactThreadRows: value === "threads" || value === "both", + }); + }} + > + + {compactSidebarModes[compactSidebarMode]} + + + {Object.entries(compactSidebarModes).map(([value, label]) => ( + + {label} + + ))} + + +
      + } + /> + + ); @@ -2250,6 +2335,17 @@ export function GeneralSettingsPanel() { + updateSettings({ inAppNotificationsEnabled: checked })} + aria-label="In-app notifications" + /> + } + /> item.to !== "/settings/projects" || isSettingsOverviewVisible(scopeSearch), ); const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); + const compactSidebarEnabled = useCompactSidebarEnabled(); const searchInputRef = useRef(null); const [query, setQuery] = useState(""); const [activeResultIndex, setActiveResultIndex] = useState(0); const searchableItems = useAvailableSettingsSearchItems(); const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]); - const isSearching = query.trim().length > 0; + const isSearching = query.trim().length > 0 && !(compactSidebarEnabled && !isMobile && !open); const hasResults = results.length > 0; useEffect(() => { @@ -233,7 +235,18 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { <> -
      + { + setOpen(true); + requestAnimationFrame(() => searchInputRef.current?.focus()); + }} + > + + +
      handleSectionClick(item.to)} > - {item.label} + + {item.label} + ); @@ -343,10 +360,12 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { - - - -
      +
      + + + +
      +
      diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index fefadbaaa9e8..373ab7885c42 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -159,6 +159,14 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Panel animations", to: "/settings/appearance", }, + { + id: "compact-sidebar", + title: "Compact sidebar", + to: "/settings/appearance", + searchTerms: [ + "collapsed icons rail hover navigation preview expanded dense density one line rows chats threads compact thread list", + ], + }, { id: "environment-identification", title: "Environment identification", @@ -241,6 +249,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["notification sound alert completion input approval desktop"], }, + { + id: "in-app-notifications", + title: "In-app notifications", + to: "/settings/general", + searchTerms: ["notification toast popup completion input approval failure"], + }, { id: "time-format", title: "Time format", diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index afbbf7671dfc..2f65cf8c3697 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -86,7 +86,7 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { + {currentFooterPage ? ( - + - Back + Back ) : ( @@ -224,8 +224,10 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { export const SidebarChromeFooter = memo(function SidebarChromeFooter() { return ( - - +
      + + +
      ); diff --git a/apps/web/src/components/sidebar/SidebarCompletedTime.test.tsx b/apps/web/src/components/sidebar/SidebarCompletedTime.test.tsx new file mode 100644 index 000000000000..7327af79e794 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarCompletedTime.test.tsx @@ -0,0 +1,56 @@ +import { act, memo } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +import { SidebarCompletedTime } from "./SidebarCompletedTime"; + +let renderer: ReactTestRenderer | undefined; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-07T01:01:00Z")); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { + setTimeout, + clearTimeout, + setInterval, + clearInterval, + }); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +it("advances visible and accessible completion times without rerendering its memoized row", async () => { + const rowRender = vi.fn(); + const Row = memo(function Row() { + rowRender(); + return ; + }); + await act(() => { + renderer = create(); + }); + expect(renderer!.root.findByType("time").props.dateTime).toBe("2026-09-07T01:00:00Z"); + expect(renderer!.root.findByProps({ className: "sr-only" }).children).toEqual(["Completed "]); + expect( + renderer!.root.findAll((node) => node.props.role === "status" || node.props["aria-live"]), + ).toHaveLength(0); + expect(renderer!.root.findByProps({ className: "text-secondary-label" }).children).toEqual([ + "1m", + ]); + + await act(() => vi.advanceTimersByTime(60_000)); + + expect(renderer!.root.findByProps({ className: "sr-only" }).children).toEqual(["Completed "]); + expect(renderer!.root.findByProps({ className: "text-secondary-label" }).children).toEqual([ + "2m", + ]); + expect(rowRender).toHaveBeenCalledTimes(1); + await act(() => renderer!.unmount()); + renderer = undefined; + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/apps/web/src/components/sidebar/SidebarCompletedTime.tsx b/apps/web/src/components/sidebar/SidebarCompletedTime.tsx new file mode 100644 index 000000000000..785b8d9459d2 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarCompletedTime.tsx @@ -0,0 +1,16 @@ +import { useNowMinute } from "../../hooks/useNowMinute"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; + +export function SidebarCompletedTime({ completedAt }: { completedAt: string }) { + // Subscribe inside the label so time advances even when the row is memoized. + const nowMinute = useNowMinute(); + const relativeTime = formatRelativeTimeLabel(completedAt, Date.parse(`${nowMinute}:00Z`)); + const label = relativeTime === "just now" ? "now" : relativeTime.replace(/ ago$/, ""); + + return ( + + ); +} diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index 878235615b39..d0718d9856f8 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -20,9 +20,10 @@ import { } from "react"; import { cn } from "~/lib/utils"; +import { useCompactSidebarEnabled } from "../../hooks/useSettings"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; -import { SidebarMenuButton } from "../ui/sidebar"; +import { SidebarMenuButton, useSidebar } from "../ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export interface SidebarThreadHeaderProps { @@ -69,6 +70,9 @@ export function SidebarThreadHeader({ activeSearchResultIndex, onClearSearch, }: SidebarThreadHeaderProps) { + const compactEnabled = useCompactSidebarEnabled(); + const { state, isMobile, setOpen } = useSidebar(); + const compact = compactEnabled && state === "collapsed" && !isMobile; const resultsVisible = isSearching && searchResultCount > 0; // Results shrink as the query narrows, so the active index can outrun the // list; pointing aria-activedescendant at a removed option strands the @@ -79,10 +83,24 @@ export function SidebarThreadHeader({ : "New thread"; return ( -
      +
      + {compact ? ( + { + setOpen(true); + requestAnimationFrame(() => searchInputRef.current?.focus()); + }} + > + + + ) : null}
      {/* Segmented well: the icons read as one control instead of three loose buttons competing with the search field beside them. */} -
      +
      {hasProjects ? ( <> {projectScope} diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index a94b7801ecfd..8c04eec6fe7d 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -348,7 +348,7 @@ function SidebarUpdateControl() { ); return ( - + { diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 307feda7abfc..404295f5f5c1 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -591,9 +591,11 @@ function SidebarSeparator({ className, ...props }: React.ComponentProps & { fixedHeader?: React.ReactNode; + fixedFooter?: React.ReactNode; }) { return ( <> @@ -617,6 +619,7 @@ function SidebarContent({ {...props} /> + {fixedFooter ?
      {fixedFooter}
      : null} ); } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 194cc36c55f4..fa4b8bc8fd31 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -379,6 +379,13 @@ export function useLegacySidebarEnabled(): boolean { return settingsHydrated && legacySidebarEnabled; } +/** Keep the default collapsed sidebar until persisted client settings hydrate. */ +export function useCompactSidebarEnabled(): boolean { + const settingsHydrated = useClientSettingsHydrated(); + const compactSidebarEnabled = useClientSettingsValue().compactSidebarEnabled; + return settingsHydrated && compactSidebarEnabled; +} + /** Read current settings for one environment, merged with client-local preferences. */ export function useEnvironmentSettings( environmentId: EnvironmentId, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 0831a922325e..982d81420445 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -3,11 +3,13 @@ import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/envir import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { Outlet, + Link, redirect, createRootRoute, type ErrorComponentProps, useLocation, useNavigate, + useRouter, } from "@tanstack/react-router"; import { CheckIcon, CopyIcon } from "lucide-react"; import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; @@ -104,11 +106,27 @@ export const Route = createRootRoute({ }, component: RootRouteView, errorComponent: RootRouteErrorView, + notFoundComponent: RootRouteNotFoundView, head: () => ({ meta: [{ name: "title", content: APP_DISPLAY_NAME }], }), }); +function RootRouteNotFoundView() { + return ( +
      +
      +

      Page not found

      +

      + This link doesn't point to a page in {APP_DISPLAY_NAME}. Go home to choose a project or + start a thread. +

      + +
      +
      + ); +} + function RootRouteView() { useEffect(() => installDesktopPasteAsText(window.desktopBridge, window), []); const pathname = useLocation({ select: (location) => location.pathname }); @@ -336,7 +354,8 @@ function HostedStaticEnvironmentBootstrap() { return null; } -function RootRouteErrorView({ error, reset }: ErrorComponentProps) { +function RootRouteErrorView({ error }: ErrorComponentProps) { + const router = useRouter(); const message = errorMessage(error); // Router pathname rather than window.location: desktop uses hash history, where the window path is always "/". const pathname = useLocation({ select: (location) => location.pathname }); @@ -359,7 +378,7 @@ function RootRouteErrorView({ error, reset }: ErrorComponentProps) {

      {message}

      -