diff --git a/apps/desktop/package.json b/apps/desktop/package.json index afee12f7..24743778 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.0.30", + "version": "0.0.32", "private": true, "main": "dist-electron/main.js", "scripts": { diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c3129572..a13947c5 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -109,6 +109,8 @@ const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; const AUTO_UPDATE_FOREGROUND_RECHECK_MIN_INTERVAL_MS = 5 * 60 * 1000; +const AUTO_UPDATE_FOREGROUND_RECHECK_MIN_BACKGROUND_MS = 30 * 1000; +const AUTO_UPDATE_CHECK_TIMEOUT_MS = 45 * 1000; const DESKTOP_UPDATE_CHANNEL = "latest"; const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; @@ -358,6 +360,7 @@ let updaterConfigured = false; let updateState: DesktopUpdateState = initialUpdateState(); let updateBackgroundedAtMs: number | null = null; let updateBackgroundBlurTimer: ReturnType | null = null; +let updateCheckTimeoutTimer: ReturnType | null = null; function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { if (updateDownloadInFlight) return "download"; @@ -928,15 +931,6 @@ function shouldEnableAutoUpdates(): boolean { ); } -function shouldTriggerForegroundUpdateCheck(foregroundedAtMs: number): boolean { - return shouldCheckForUpdatesOnForeground({ - checkedAt: updateState.checkedAt, - backgroundedAtMs: updateBackgroundedAtMs, - foregroundedAtMs, - minIntervalMs: AUTO_UPDATE_FOREGROUND_RECHECK_MIN_INTERVAL_MS, - }); -} - function clearUpdateBackgroundBlurTimer(): void { if (updateBackgroundBlurTimer) { clearTimeout(updateBackgroundBlurTimer); @@ -944,6 +938,34 @@ function clearUpdateBackgroundBlurTimer(): void { } } +// Fail closed if electron-updater never emits a terminal check outcome. +function clearUpdateCheckTimeoutTimer(): void { + if (updateCheckTimeoutTimer) { + clearTimeout(updateCheckTimeoutTimer); + updateCheckTimeoutTimer = null; + } +} + +function armUpdateCheckTimeout(reason: string): void { + clearUpdateCheckTimeoutTimer(); + updateCheckTimeoutTimer = setTimeout(() => { + updateCheckTimeoutTimer = null; + if (updateState.status !== "checking") { + return; + } + updateCheckInFlight = false; + setUpdateState( + reduceDesktopUpdateStateOnCheckFailure( + updateState, + "Timed out while checking for updates. Try again.", + new Date().toISOString(), + ), + ); + console.error(`[desktop-updater] Update check timed out (${reason}).`); + }, AUTO_UPDATE_CHECK_TIMEOUT_MS); + updateCheckTimeoutTimer.unref(); +} + function isDesktopAppForegrounded(): boolean { return BrowserWindow.getAllWindows().some( (window) => !window.isDestroyed() && window.isFocused(), @@ -965,16 +987,28 @@ function handleDesktopAppForegrounded(): void { clearUpdateBackgroundBlurTimer(); clearUnreadNotificationBadge(); const foregroundedAtMs = Date.now(); - if (!shouldTriggerForegroundUpdateCheck(foregroundedAtMs)) { + const backgroundedAtMs = updateBackgroundedAtMs; + updateBackgroundedAtMs = null; + const shouldCheck = shouldCheckForUpdatesOnForeground({ + checkedAt: updateState.checkedAt, + backgroundedAtMs, + foregroundedAtMs, + minBackgroundDurationMs: AUTO_UPDATE_FOREGROUND_RECHECK_MIN_BACKGROUND_MS, + minIntervalMs: AUTO_UPDATE_FOREGROUND_RECHECK_MIN_INTERVAL_MS, + }); + if (!shouldCheck) { return; } - updateBackgroundedAtMs = null; void checkForUpdates("foreground"); } async function checkForUpdates(reason: string): Promise { if (isQuitting || !updaterConfigured || updateCheckInFlight) return; - if (updateState.status === "downloading" || updateState.status === "downloaded") { + if ( + updateState.status === "checking" || + updateState.status === "downloading" || + updateState.status === "downloaded" + ) { console.info( `[desktop-updater] Skipping update check (${reason}) while status=${updateState.status}.`, ); @@ -982,11 +1016,13 @@ async function checkForUpdates(reason: string): Promise { } updateCheckInFlight = true; setUpdateState(reduceDesktopUpdateStateOnCheckStart(updateState, new Date().toISOString())); + armUpdateCheckTimeout(reason); console.info(`[desktop-updater] Checking for updates (${reason})...`); try { await autoUpdater.checkForUpdates(); } catch (error: unknown) { + clearUpdateCheckTimeoutTimer(); const message = error instanceof Error ? error.message : String(error); setUpdateState( reduceDesktopUpdateStateOnCheckFailure(updateState, message, new Date().toISOString()), @@ -1087,6 +1123,7 @@ function configureAutoUpdater(): void { console.info("[desktop-updater] Looking for updates..."); }); autoUpdater.on("update-available", (info) => { + clearUpdateCheckTimeoutTimer(); setUpdateState( reduceDesktopUpdateStateOnUpdateAvailable( updateState, @@ -1098,11 +1135,13 @@ function configureAutoUpdater(): void { console.info(`[desktop-updater] Update available: ${info.version}`); }); autoUpdater.on("update-not-available", () => { + clearUpdateCheckTimeoutTimer(); setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString())); lastLoggedDownloadMilestone = -1; console.info("[desktop-updater] No updates available."); }); autoUpdater.on("error", (error) => { + clearUpdateCheckTimeoutTimer(); const message = formatErrorMessage(error); if (!updateCheckInFlight && !updateDownloadInFlight) { setUpdateState({ @@ -1763,6 +1802,7 @@ app.on("before-quit", () => { isQuitting = true; writeDesktopLogHeader("before-quit received"); clearUpdateBackgroundBlurTimer(); + clearUpdateCheckTimeoutTimer(); clearUpdatePollTimer(); cancelBackendReadinessWait(); stopBackend(); @@ -1814,6 +1854,7 @@ if (process.platform !== "win32") { isQuitting = true; writeDesktopLogHeader("SIGINT received"); clearUpdateBackgroundBlurTimer(); + clearUpdateCheckTimeoutTimer(); clearUpdatePollTimer(); cancelBackendReadinessWait(); stopBackend(); @@ -1825,6 +1866,7 @@ if (process.platform !== "win32") { if (isQuitting) return; isQuitting = true; writeDesktopLogHeader("SIGTERM received"); + clearUpdateCheckTimeoutTimer(); clearUpdatePollTimer(); cancelBackendReadinessWait(); stopBackend(); diff --git a/apps/desktop/src/updateState.test.ts b/apps/desktop/src/updateState.test.ts index a409e385..da0b206f 100644 --- a/apps/desktop/src/updateState.test.ts +++ b/apps/desktop/src/updateState.test.ts @@ -181,6 +181,7 @@ describe("shouldCheckForUpdatesOnForeground", () => { checkedAt: "2026-03-04T00:00:00.000Z", backgroundedAtMs: null, foregroundedAtMs: Date.parse("2026-03-04T00:05:00.000Z"), + minBackgroundDurationMs: 30_000, minIntervalMs: 5 * 60 * 1000, }), ).toBe(false); @@ -192,17 +193,31 @@ describe("shouldCheckForUpdatesOnForeground", () => { checkedAt: null, backgroundedAtMs: Date.parse("2026-03-04T00:00:00.000Z"), foregroundedAtMs: Date.parse("2026-03-04T00:05:00.000Z"), + minBackgroundDurationMs: 30_000, minIntervalMs: 5 * 60 * 1000, }), ).toBe(true); }); + it("returns false when the app was backgrounded too briefly", () => { + expect( + shouldCheckForUpdatesOnForeground({ + checkedAt: "2026-03-04T00:00:00.000Z", + backgroundedAtMs: Date.parse("2026-03-04T00:04:45.000Z"), + foregroundedAtMs: Date.parse("2026-03-04T00:05:00.000Z"), + minBackgroundDurationMs: 30_000, + minIntervalMs: 5 * 60 * 1000, + }), + ).toBe(false); + }); + it("returns false when the last check is still within the foreground cooldown", () => { expect( shouldCheckForUpdatesOnForeground({ checkedAt: "2026-03-04T00:03:00.000Z", backgroundedAtMs: Date.parse("2026-03-04T00:04:00.000Z"), foregroundedAtMs: Date.parse("2026-03-04T00:06:00.000Z"), + minBackgroundDurationMs: 30_000, minIntervalMs: 5 * 60 * 1000, }), ).toBe(false); @@ -214,6 +229,7 @@ describe("shouldCheckForUpdatesOnForeground", () => { checkedAt: "2026-03-04T00:00:00.000Z", backgroundedAtMs: Date.parse("2026-03-04T00:04:00.000Z"), foregroundedAtMs: Date.parse("2026-03-04T00:06:00.000Z"), + minBackgroundDurationMs: 30_000, minIntervalMs: 5 * 60 * 1000, }), ).toBe(true); diff --git a/apps/desktop/src/updateState.ts b/apps/desktop/src/updateState.ts index adcf4785..329136b7 100644 --- a/apps/desktop/src/updateState.ts +++ b/apps/desktop/src/updateState.ts @@ -32,13 +32,20 @@ export function shouldCheckForUpdatesOnForeground(args: { checkedAt: string | null; backgroundedAtMs: number | null; foregroundedAtMs: number; + minBackgroundDurationMs: number; minIntervalMs: number; }): boolean { - const { checkedAt, backgroundedAtMs, foregroundedAtMs, minIntervalMs } = args; + const { checkedAt, backgroundedAtMs, foregroundedAtMs, minBackgroundDurationMs, minIntervalMs } = + args; if (backgroundedAtMs === null || foregroundedAtMs <= backgroundedAtMs) { return false; } + // Ignore fleeting blur/focus churn from window transitions and native dialogs. + if (foregroundedAtMs - backgroundedAtMs < minBackgroundDurationMs) { + return false; + } + if (checkedAt === null) { return true; } diff --git a/apps/server/package.json b/apps/server/package.json index f40cd32b..a8a5d72a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.0.30", + "version": "0.0.32", "license": "MIT", "repository": { "type": "git", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 359ac458..e11ff1a4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2270,4 +2270,48 @@ it.layer( ]); }), ); + + it.effect("projects steer dispatch mode onto the triggering user message", () => + Effect.gen(function* () { + const eventStore = yield* OrchestrationEventStore; + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.makeUnsafe("thread-steer-chip"); + const messageId = MessageId.makeUnsafe("message-steer-chip"); + const createdAt = "2026-02-27T11:00:00.000Z"; + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-steer-chip-1"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.makeUnsafe("cmd-steer-chip-1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-steer-chip-1"), + metadata: {}, + payload: { + threadId, + messageId, + role: "user", + text: "hello", + dispatchMode: "steer", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }, + }); + + yield* projectionPipeline.bootstrap; + + const rows = yield* sql<{ readonly dispatchMode: string | null }>` + SELECT dispatch_mode AS "dispatchMode" + FROM projection_thread_messages + WHERE message_id = ${messageId} + `; + + assert.deepEqual(rows, [{ dispatchMode: "steer" }]); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 418b3aa6..e9ff8436 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -740,6 +740,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), ...(event.payload.skills !== undefined ? { skills: event.payload.skills } : {}), ...(event.payload.mentions !== undefined ? { mentions: event.payload.mentions } : {}), + ...(event.payload.dispatchMode !== undefined + ? { dispatchMode: event.payload.dispatchMode } + : {}), isStreaming: event.payload.streaming, source: event.payload.source, createdAt: existingMessage?.createdAt ?? event.payload.createdAt, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index f618d744..789e3bb3 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -307,6 +307,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { subagentNickname: null, subagentRole: null, forkSourceThreadId: null, + lastKnownPr: null, latestUserMessageAt: "2026-02-24T00:00:03.500Z", hasPendingApprovals: true, hasPendingUserInput: true, @@ -746,6 +747,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { subagentNickname: null, subagentRole: null, forkSourceThreadId: null, + lastKnownPr: null, latestTurn: { turnId: asTurnId("turn-shell"), state: "completed", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 6f9f05c2..ef346c30 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -15,6 +15,7 @@ import { ProviderSkillReference, ThreadId, ThreadEnvironmentMode, + TurnDispatchMode, TurnId, type OrchestrationCheckpointSummary, type OrchestrationLatestTurn, @@ -71,6 +72,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), skills: Schema.NullOr(Schema.fromJsonString(Schema.Array(ProviderSkillReference))), mentions: Schema.NullOr(Schema.fromJsonString(Schema.Array(ProviderMentionReference))), + dispatchMode: Schema.NullOr(TurnDispatchMode), }), ); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; @@ -165,6 +167,7 @@ function toProjectedMessage(row: ProjectionThreadMessageDbRow): OrchestrationMes ...(row.attachments !== null ? { attachments: row.attachments } : {}), ...(row.skills !== null ? { skills: row.skills } : {}), ...(row.mentions !== null ? { mentions: row.mentions } : {}), + ...(row.dispatchMode ? { dispatchMode: row.dispatchMode } : {}), turnId: row.turnId, streaming: row.isStreaming === 1, source: row.source, @@ -501,6 +504,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { attachments_json AS "attachments", skills_json AS "skills", mentions_json AS "mentions", + dispatch_mode AS "dispatchMode", is_streaming AS "isStreaming", source, created_at AS "createdAt", @@ -755,6 +759,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { attachments_json AS "attachments", skills_json AS "skills", mentions_json AS "mentions", + dispatch_mode AS "dispatchMode", is_streaming AS "isStreaming", source, created_at AS "createdAt", diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 7824e6dc..eb7a0a48 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -684,6 +684,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" attachments: command.message.attachments, ...(command.message.skills !== undefined ? { skills: command.message.skills } : {}), ...(command.message.mentions !== undefined ? { mentions: command.message.mentions } : {}), + dispatchMode, turnId: null, streaming: false, source: "native", diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index feea2d29..7967efcd 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -93,6 +93,7 @@ describe("orchestration projector", () => { subagentNickname: null, subagentRole: null, forkSourceThreadId: null, + lastKnownPr: null, latestTurn: null, createdAt: now, updatedAt: now, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index 598db067..c7c278a2 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -167,4 +167,42 @@ layer("ProjectionThreadMessageRepository", (it) => { ]); }), ); + + it.effect("preserves dispatch mode when later updates omit it", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const threadId = ThreadId.makeUnsafe("thread-preserve-dispatch-mode"); + const messageId = MessageId.makeUnsafe("message-preserve-dispatch-mode"); + const createdAt = "2026-02-28T19:30:00.000Z"; + + yield* repository.upsert({ + messageId, + threadId, + turnId: null, + role: "user", + text: "steer this", + dispatchMode: "steer", + isStreaming: false, + source: "native", + createdAt, + updatedAt: "2026-02-28T19:30:01.000Z", + }); + + yield* repository.upsert({ + messageId, + threadId, + turnId: null, + role: "user", + text: "steer this harder", + isStreaming: false, + source: "native", + createdAt, + updatedAt: "2026-02-28T19:30:02.000Z", + }); + + const rows = yield* repository.listByThreadId({ threadId }); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.dispatchMode, "steer"); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index a52c660a..f6c4b3ca 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -5,6 +5,7 @@ import { ChatAttachment, ProviderMentionReference, ProviderSkillReference, + TurnDispatchMode, } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; @@ -22,6 +23,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), skills: Schema.NullOr(Schema.fromJsonString(Schema.Array(ProviderSkillReference))), mentions: Schema.NullOr(Schema.fromJsonString(Schema.Array(ProviderMentionReference))), + dispatchMode: Schema.NullOr(TurnDispatchMode), }), ); @@ -45,6 +47,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { attachments_json, skills_json, mentions_json, + dispatch_mode, is_streaming, source, created_at, @@ -80,6 +83,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { WHERE message_id = ${row.messageId} ) ), + COALESCE( + ${row.dispatchMode ?? null}, + ( + SELECT dispatch_mode + FROM projection_thread_messages + WHERE message_id = ${row.messageId} + ) + ), ${row.isStreaming ? 1 : 0}, ${row.source}, ${row.createdAt}, @@ -103,6 +114,10 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { excluded.mentions_json, projection_thread_messages.mentions_json ), + dispatch_mode = COALESCE( + excluded.dispatch_mode, + projection_thread_messages.dispatch_mode + ), is_streaming = excluded.is_streaming, source = excluded.source, created_at = excluded.created_at, @@ -125,6 +140,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { attachments_json AS "attachments", skills_json AS "skills", mentions_json AS "mentions", + dispatch_mode AS "dispatchMode", is_streaming AS "isStreaming", source, created_at AS "createdAt", @@ -168,6 +184,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { ...(row.attachments !== null ? { attachments: row.attachments } : {}), ...(row.skills !== null ? { skills: row.skills } : {}), ...(row.mentions !== null ? { mentions: row.mentions } : {}), + ...(row.dispatchMode ? { dispatchMode: row.dispatchMode } : {}), })), ), ); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index b4c17bc8..5a5c9e52 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -42,6 +42,7 @@ import Migration0026 from "./Migrations/026_ProjectionThreadShellSummary.ts"; import Migration0027 from "./Migrations/027_BackfillProjectionThreadShellSummary.ts"; import Migration0028 from "./Migrations/028_ProjectionProjectsKind.ts"; import Migration0029 from "./Migrations/029_ProjectionThreadsLastKnownPr.ts"; +import Migration0030 from "./Migrations/030_ProjectionThreadMessagesDispatchMode.ts"; /** * Migration loader with all migrations defined inline. @@ -83,6 +84,7 @@ export const migrationEntries = [ [27, "BackfillProjectionThreadShellSummary", Migration0027], [28, "ProjectionProjectsKind", Migration0028], [29, "ProjectionThreadsLastKnownPr", Migration0029], + [30, "ProjectionThreadMessagesDispatchMode", Migration0030], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/030_ProjectionThreadMessagesDispatchMode.ts b/apps/server/src/persistence/Migrations/030_ProjectionThreadMessagesDispatchMode.ts new file mode 100644 index 00000000..4ca334cd --- /dev/null +++ b/apps/server/src/persistence/Migrations/030_ProjectionThreadMessagesDispatchMode.ts @@ -0,0 +1,27 @@ +// FILE: 030_ProjectionThreadMessagesDispatchMode.ts +// Purpose: Adds projected dispatch-mode metadata so user messages can render steer chips after reloads. +// Layer: Server persistence migration + +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const columns = yield* sql<{ + name: string; + }>` + SELECT name + FROM pragma_table_info('projection_thread_messages') + WHERE name = 'dispatch_mode' + `; + + if (columns.length > 0) { + return; + } + + yield* sql` + ALTER TABLE projection_thread_messages + ADD COLUMN dispatch_mode TEXT + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index f2923538..283c9e99 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -10,6 +10,7 @@ import { ChatAttachment, OrchestrationMessageRole, OrchestrationMessageSource, + TurnDispatchMode, MessageId, ProviderMentionReference, ProviderSkillReference, @@ -31,6 +32,7 @@ export const ProjectionThreadMessage = Schema.Struct({ attachments: Schema.optional(Schema.Array(ChatAttachment)), skills: Schema.optional(Schema.Array(ProviderSkillReference)), mentions: Schema.optional(Schema.Array(ProviderMentionReference)), + dispatchMode: Schema.optional(TurnDispatchMode), isStreaming: Schema.Boolean, source: OrchestrationMessageSource, createdAt: IsoDateTime, diff --git a/apps/web/package.json b/apps/web/package.json index 9ac5295d..2ba64f19 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.30", + "version": "0.0.32", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 9df71a52..6897be2f 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { AppSettingsSchema, DEFAULT_CHAT_FONT_SIZE_PX, + DEFAULT_THEME_APPEARANCE_SELECTION_ID, DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, DEFAULT_SIDEBAR_THREAD_SORT_ORDER, DEFAULT_TIMESTAMP_FORMAT, @@ -317,6 +318,10 @@ describe("AppSettingsSchema", () => { customCodexModels: [], customClaudeModels: [], customGeminiModels: [], + lightThemeAppearance: DEFAULT_THEME_APPEARANCE_SELECTION_ID, + darkThemeAppearance: DEFAULT_THEME_APPEARANCE_SELECTION_ID, + lightImportedThemeAppearance: null, + darkImportedThemeAppearance: null, }); }); }); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index f1a9a5ff..72194c5f 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -9,6 +9,11 @@ import { } from "@t3tools/shared/model"; import { useLocalStorage } from "./hooks/useLocalStorage"; import { EnvMode } from "./components/BranchToolbar.logic"; +import { + type ThemeAppearanceConfig, + ThemeAppearanceConfigSchema, + ThemeAppearanceSelectionId, +} from "./themeAppearance"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; @@ -29,6 +34,7 @@ export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "upda export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at"]); export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +export const DEFAULT_THEME_APPEARANCE_SELECTION_ID = "default" as const; type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels" | "customGeminiModels"; export type ProviderCustomModelConfig = { provider: ProviderKind; @@ -85,6 +91,18 @@ export const AppSettingsSchema = Schema.Struct({ customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customGeminiModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), + lightThemeAppearance: ThemeAppearanceSelectionId.pipe( + withDefaults(() => DEFAULT_THEME_APPEARANCE_SELECTION_ID), + ), + darkThemeAppearance: ThemeAppearanceSelectionId.pipe( + withDefaults(() => DEFAULT_THEME_APPEARANCE_SELECTION_ID), + ), + lightImportedThemeAppearance: Schema.NullOr(ThemeAppearanceConfigSchema).pipe( + withDefaults(() => null), + ), + darkImportedThemeAppearance: Schema.NullOr(ThemeAppearanceConfigSchema).pipe( + withDefaults(() => null), + ), textGenerationModel: Schema.optional(TrimmedNonEmptyString), uiFontFamily: Schema.String.check(Schema.isMaxLength(256)).pipe(withDefaults(() => "")), defaultProvider: ProviderKind.pipe(withDefaults(() => "codex" as const)), @@ -175,6 +193,24 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { }; } +export function patchThemeAppearanceSelection( + mode: "light" | "dark", + selection: AppSettings["lightThemeAppearance"], +): Partial> { + return mode === "light" + ? { lightThemeAppearance: selection } + : { darkThemeAppearance: selection }; +} + +export function patchImportedThemeAppearance( + mode: "light" | "dark", + config: ThemeAppearanceConfig | null, +): Partial> { + return mode === "light" + ? { lightImportedThemeAppearance: config } + : { darkImportedThemeAppearance: config }; +} + export function getCustomModelsForProvider( settings: Pick, provider: ProviderKind, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 21809f08..bc72eb9d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -173,6 +173,7 @@ import { import { Button } from "./ui/button"; import { Separator } from "./ui/separator"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; +import { terminalRuntimeRegistry } from "./terminal/terminalRuntimeRegistry"; import { cn, isMacPlatform, randomUUID } from "~/lib/utils"; import { toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -2529,6 +2530,7 @@ export default function ChatView({ if (!confirmed) { return; } + terminalRuntimeRegistry.disposeTerminal(activeThreadId, terminalId); const fallbackExitWrite = () => api.terminal .write({ threadId: activeThreadId, terminalId, data: "exit\n" }) @@ -4581,6 +4583,7 @@ export default function ChatView({ id: messageIdForSend, role: "user", text: outgoingMessageText, + dispatchMode, ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), createdAt: messageCreatedAt, streaming: false, @@ -5014,6 +5017,7 @@ export default function ChatView({ id: messageIdForSend, role: "user", text: outgoingMessageText, + dispatchMode, createdAt: messageCreatedAt, streaming: false, source: "native", diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 5e62cb5a..4804ce97 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -18,8 +18,10 @@ import { getProjectSortTimestamp, hasUnseenCompletion, isDuplicateProjectCreateError, + pruneExpandedProjectThreadListsForCollapsedProjects, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, + resolveSidebarRestorableThreadRoute, resolveThreadRowClassName, resolveThreadStatusPill, shouldPrunePinnedThreads, @@ -110,6 +112,63 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); }); +describe("resolveSidebarRestorableThreadRoute", () => { + it("returns the last thread route when the thread still exists", () => { + expect( + resolveSidebarRestorableThreadRoute({ + lastThreadRoute: { + threadId: "thread-123", + splitViewId: "split-456", + }, + availableThreadIds: new Set(["thread-123", "thread-789"]), + }), + ).toEqual({ + threadId: "thread-123", + splitViewId: "split-456", + }); + }); + + it("returns null when the remembered thread no longer exists", () => { + expect( + resolveSidebarRestorableThreadRoute({ + lastThreadRoute: { + threadId: "thread-123", + }, + availableThreadIds: new Set(["thread-789"]), + }), + ).toBeNull(); + }); +}); + +describe("pruneExpandedProjectThreadListsForCollapsedProjects", () => { + it("clears remembered show-more state when a project is collapsed", () => { + const current = new Set(["/Users/tester/Code/one", "/Users/tester/Code/two"]); + + const next = pruneExpandedProjectThreadListsForCollapsedProjects({ + expandedProjectThreadListCwds: current, + projects: [ + { cwd: "/Users/tester/Code/one", expanded: false }, + { cwd: "/Users/tester/Code/two", expanded: true }, + ], + normalizeProjectCwd: (cwd) => cwd.replace(/\/+$/, ""), + }); + + expect([...next]).toEqual(["/Users/tester/Code/two"]); + }); + + it("preserves the existing set when no collapsed project needs pruning", () => { + const current = new Set(["/Users/tester/Code/one"]); + + const next = pruneExpandedProjectThreadListsForCollapsedProjects({ + expandedProjectThreadListCwds: current, + projects: [{ cwd: "/Users/tester/Code/one", expanded: true }], + normalizeProjectCwd: (cwd) => cwd.replace(/\/+$/, ""), + }); + + expect(next).toBe(current); + }); +}); + describe("add-project error helpers", () => { it("finds an existing project by workspace root", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 680c6a7c..e5f5d969 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -23,6 +23,10 @@ export { export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const SIDEBAR_THREAD_PREWARM_LIMIT = 10; export type SidebarNewThreadEnvMode = "local" | "worktree"; +export type SidebarLastThreadRoute = { + threadId: string; + splitViewId?: string | undefined; +}; type SidebarProject = { id: string; name: string; @@ -102,6 +106,52 @@ export function resolveSidebarNewThreadEnvMode(input: { return input.requestedEnvMode ?? input.defaultEnvMode; } +// Reuses the last visited thread route when leaving special views like settings. +export function resolveSidebarRestorableThreadRoute(input: { + lastThreadRoute: SidebarLastThreadRoute | null; + availableThreadIds: ReadonlySet; +}): SidebarLastThreadRoute | null { + const { lastThreadRoute, availableThreadIds } = input; + if (!lastThreadRoute) { + return null; + } + + return availableThreadIds.has(lastThreadRoute.threadId) ? lastThreadRoute : null; +} + +// Drops remembered "show more" state for projects that are currently collapsed. +export function pruneExpandedProjectThreadListsForCollapsedProjects< + T extends Pick, +>(input: { + expandedProjectThreadListCwds: ReadonlySet; + projects: readonly T[]; + normalizeProjectCwd: (cwd: string) => string; +}): ReadonlySet { + const { expandedProjectThreadListCwds, normalizeProjectCwd, projects } = input; + const collapsedProjectCwds = new Set( + projects + .filter((project) => !project.expanded) + .map((project) => normalizeProjectCwd(project.cwd)) + .filter((cwd) => cwd.length > 0), + ); + + if (collapsedProjectCwds.size === 0) { + return expandedProjectThreadListCwds; + } + + let changed = false; + const nextExpandedProjectThreadListCwds = new Set(); + for (const cwd of expandedProjectThreadListCwds) { + if (collapsedProjectCwds.has(cwd)) { + changed = true; + continue; + } + nextExpandedProjectThreadListCwds.add(cwd); + } + + return changed ? nextExpandedProjectThreadListCwds : expandedProjectThreadListCwds; +} + export function resolveThreadRowClassName(input: { isActive: boolean; isSelected: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index af21607f..5f3b9e91 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -110,6 +110,7 @@ import { ProjectSidebarIcon } from "./ProjectSidebarIcon"; import { ThreadPinToggleButton } from "./ThreadPinToggleButton"; import { ThreadRunningSpinner } from "./ThreadRunningSpinner"; import { RenameThreadDialog } from "./RenameThreadDialog"; +import { terminalRuntimeRegistry } from "./terminal/terminalRuntimeRegistry"; import { SidebarSearchPalette } from "./SidebarSearchPalette"; import { useHandleNewChat } from "../hooks/useHandleNewChat"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -167,8 +168,10 @@ import { getSidebarThreadIdsToPrewarm, getVisibleSidebarEntriesForPreview, getUnpinnedThreadsForSidebar, + pruneExpandedProjectThreadListsForCollapsedProjects, resolveProjectStatusIndicator, resolveSidebarNewThreadEnvMode, + resolveSidebarRestorableThreadRoute, resolveThreadRowClassName, resolveThreadStatusPill, isDuplicateProjectCreateError, @@ -309,25 +312,6 @@ function ProviderGlyph({ provider, className }: { provider: ProviderKind; classN return