Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@t3tools/desktop",
"version": "0.0.30",
"version": "0.0.32",
"private": true,
"main": "dist-electron/main.js",
"scripts": {
Expand Down
66 changes: 54 additions & 12 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -358,6 +360,7 @@ let updaterConfigured = false;
let updateState: DesktopUpdateState = initialUpdateState();
let updateBackgroundedAtMs: number | null = null;
let updateBackgroundBlurTimer: ReturnType<typeof setTimeout> | null = null;
let updateCheckTimeoutTimer: ReturnType<typeof setTimeout> | null = null;

function resolveUpdaterErrorContext(): DesktopUpdateErrorContext {
if (updateDownloadInFlight) return "download";
Expand Down Expand Up @@ -928,22 +931,41 @@ 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);
updateBackgroundBlurTimer = null;
}
}

// 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(),
Expand All @@ -965,28 +987,42 @@ 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<void> {
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}.`,
);
return;
}
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()),
Expand Down Expand Up @@ -1087,6 +1123,7 @@ function configureAutoUpdater(): void {
console.info("[desktop-updater] Looking for updates...");
});
autoUpdater.on("update-available", (info) => {
clearUpdateCheckTimeoutTimer();
setUpdateState(
reduceDesktopUpdateStateOnUpdateAvailable(
updateState,
Expand All @@ -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({
Expand Down Expand Up @@ -1763,6 +1802,7 @@ app.on("before-quit", () => {
isQuitting = true;
writeDesktopLogHeader("before-quit received");
clearUpdateBackgroundBlurTimer();
clearUpdateCheckTimeoutTimer();
clearUpdatePollTimer();
cancelBackendReadinessWait();
stopBackend();
Expand Down Expand Up @@ -1814,6 +1854,7 @@ if (process.platform !== "win32") {
isQuitting = true;
writeDesktopLogHeader("SIGINT received");
clearUpdateBackgroundBlurTimer();
clearUpdateCheckTimeoutTimer();
clearUpdatePollTimer();
cancelBackendReadinessWait();
stopBackend();
Expand All @@ -1825,6 +1866,7 @@ if (process.platform !== "win32") {
if (isQuitting) return;
isQuitting = true;
writeDesktopLogHeader("SIGTERM received");
clearUpdateCheckTimeoutTimer();
clearUpdatePollTimer();
cancelBackendReadinessWait();
stopBackend();
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src/updateState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
9 changes: 8 additions & 1 deletion apps/desktop/src/updateState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "t3",
"version": "0.0.30",
"version": "0.0.32",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
44 changes: 44 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }]);
}),
);
});
3 changes: 3 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -746,6 +747,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
subagentNickname: null,
subagentRole: null,
forkSourceThreadId: null,
lastKnownPr: null,
latestTurn: {
turnId: asTurnId("turn-shell"),
state: "completed",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
ProviderSkillReference,
ThreadId,
ThreadEnvironmentMode,
TurnDispatchMode,
TurnId,
type OrchestrationCheckpointSummary,
type OrchestrationLatestTurn,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration/projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ describe("orchestration projector", () => {
subagentNickname: null,
subagentRole: null,
forkSourceThreadId: null,
lastKnownPr: null,
latestTurn: null,
createdAt: now,
updatedAt: now,
Expand Down
Loading