Skip to content
Closed
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
30 changes: 30 additions & 0 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,36 @@ The upstream draft hero remains the empty-state headline. The fork adds `On <mac

**Last updated:** 2026-09-01

### DL007 — Opt-in next-prompt suggestions

Once a turn settles, the web composer can ask a model what the user would plausibly send next, with the model acting as a senior QA expert that prioritizes concrete validation and release-risk reduction. By default the request uses the thread's own model; a dedicated "QA suggestion model" (Settings → General) locks suggestions to one configurable model regardless of the thread — a nullable `composerSuggestionModelSelection` setting mirroring `sourceControlWriterModelSelection`. The feature is also renamable (`composerSuggestionLabel`) and its focus is rewritable (`composerSuggestionInstructions`), so it can act as a docs, security, or UX reviewer instead; custom instructions replace only the QA persona and focus rules, while the JSON output contract, the one-line length limit, and the untrusted-transcript guard are always appended after them. The suggestion renders as faded text in the empty composer; Tab accepts it. The server reads an end-preserving tail of the requested thread through the existing projection query and text-generation boundary, so no thread turn, event, message, or provider session is created. Requests are bound to the latest completed assistant message, rejected before project lookup when stale or active, and checked again immediately before provider launch; worktree-backed threads skip the project lookup entirely. Suggestions clear as soon as the user types, remain dismissed for that turn even if the draft is erased, and reset only when the model, thread, or latest message changes. Mobile has no toggle or request path.

**Implementation evidence:** `packages/contracts/src/composerSuggestion.ts`, `packages/contracts/src/settings.ts` (`composerSuggestionModelSelection`), `apps/server/src/textGeneration/`, `apps/server/src/ws.ts`, `apps/web/src/components/chat/useComposerSuggestion.ts`, `apps/web/src/components/chat/ChatComposer.tsx`, and `apps/web/src/components/settings/SettingsPanels.tsx`.

**Recorded validation:** focused web gating, RPC stale-turn/worktree/recheck, text-generation prompt, authorization, and command single-flight tests; settings round-trip tests for the dedicated-model setting; repository-wide `vp check` and `vp run typecheck`; integrated web pass against an isolated environment seeded from a real database snapshot, covering opt-in, ghost text after a settled Codex reply, Tab acceptance, whitespace drafts, and no repeat request after typing then erasing.

**Last updated:** 2026-09-07

### DL028 — Local-source desktop update action

The desktop sidebar and Settings → General → About update actions use a local-source update flow. About shows the build timestamp beside the app version. The source checkout comes from the running app root during development or the `t3codeLocalUpdateSource` path stamped into a packaged build.

A hidden PowerShell process fetches the tracked default branch and checks whether its commit is already contained in the commit stamped into the installed app. A match reports "Already up to date" without rebuilding. Otherwise, it merges, runs `vp check` and `vp run typecheck`, and invokes `vp run update:local` to build and open the installer. Progress markers become a `DesktopLocalUpdateState` and reach the client over `LOCAL_UPDATE_STATE_CHANNEL`.

Uncommitted work, including untracked files, is parked in a temporary stash keyed by exact object id and restored with its index state. A failed merge is aborted before the restore. A failed or ambiguous restore leaves the working tree untouched and reports the stash id for manual recovery. The separate "Build from this checkout" action packages the checkout as-is without fetching, merging, stashing, or verifying it.

The Windows build preflight requires MSVC Spectre-mitigated libraries only when `T3CODE_DESKTOP_REQUIRE_SPECTRE` is set. The update source is `upstream` when that remote exists and `origin` otherwise. A missing remote HEAD is repaired with `git remote set-head --auto`. The updater never pushes to a remote repository.

The same local-source action drives the legacy sidebar's update entry point (its Intel-on-Apple-Silicon build warning now offers "Update local Type-Delta" and shows the same progress label), so both sidebars share one update flow.

**Implementation evidence:** `apps/desktop/src/updates/LocalDesktopUpdate.ts`, `apps/desktop/src/ipc/{channels.ts,methods/updates.ts,DesktopIpcHandlers.ts}`, `apps/desktop/src/preload.ts`, `packages/contracts/src/ipc.ts` (`DesktopLocalUpdateState`), `apps/web/src/state/desktopLocalUpdate.ts`, `apps/web/src/components/sidebar/SidebarUpdatePill.tsx`, `apps/web/src/components/settings/SettingsPanels.tsx`, `apps/web/src/components/LegacySidebar.tsx`, and `scripts/{build-desktop-artifact.ts,install-local-desktop.ts}`.

**Recorded validation:** focused local-update service, installer-script, settings, and progress-label tests; repository-wide `vp check` and `vp run typecheck`. A follow-up will add AI-assisted repair (a headless provider CLI) at the merge/check/build steps that fail.

**Last updated:** 2026-09-09

**Last updated:** 2026-09-03

### DL003 — Compact Claude and Codex subscription meters

The fork retains session and weekly subscription meters in the active thread header and provider settings cards. A separate best-effort `usage` snapshot supplies these compact meters from Claude and Codex CLI-managed credentials; missing credentials, scopes, network access, or endpoint failures leave it absent without affecting provider health.
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ import {
import {
checkForUpdate,
downloadUpdate,
getLocalUpdateState,
getUpdateState,
installUpdate,
setUpdateChannel,
startLocalBuild,
startLocalUpdate,
} from "./methods/updates.ts";
import {
getAppBranding,
Expand Down Expand Up @@ -102,6 +105,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(downloadUpdate);
yield* ipc.handle(installUpdate);
yield* ipc.handle(checkForUpdate);
yield* ipc.handle(getLocalUpdateState);
yield* ipc.handle(startLocalUpdate);
yield* ipc.handle(startLocalBuild);
for (const previewMethod of PreviewIpc.methods) {
yield* ipc.handle(previewMethod);
}
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
export const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
export const UPDATE_CHECK_CHANNEL = "desktop:update-check";
export const LOCAL_UPDATE_START_CHANNEL = "desktop:local-update-start";
export const LOCAL_BUILD_START_CHANNEL = "desktop:local-build-start";
export const LOCAL_UPDATE_STATE_CHANNEL = "desktop:local-update-state";
export const LOCAL_UPDATE_GET_STATE_CHANNEL = "desktop:local-update-get-state";
export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale";
export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps";
Expand Down
32 changes: 32 additions & 0 deletions apps/desktop/src/ipc/methods/updates.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
DesktopUpdateActionResultSchema,
DesktopLocalUpdateStateSchema,
DesktopUpdateChannelSchema,
DesktopUpdateCheckResultSchema,
DesktopUpdateStateSchema,
Expand All @@ -8,6 +9,7 @@ import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";

import * as DesktopUpdates from "../../updates/DesktopUpdates.ts";
import * as LocalDesktopUpdate from "../../updates/LocalDesktopUpdate.ts";
import * as IpcChannels from "../channels.ts";
import * as DesktopIpc from "../DesktopIpc.ts";

Expand Down Expand Up @@ -60,3 +62,33 @@ export const checkForUpdate = DesktopIpc.makeIpcMethod({
return yield* updates.check("web-ui");
}),
});

export const getLocalUpdateState = DesktopIpc.makeIpcMethod({
channel: IpcChannels.LOCAL_UPDATE_GET_STATE_CHANNEL,
payload: Schema.Void,
result: DesktopLocalUpdateStateSchema,
handler: Effect.fn("desktop.ipc.updates.getLocalState")(function* () {
const updates = yield* LocalDesktopUpdate.LocalDesktopUpdates;
return yield* updates.getState;
}),
});

export const startLocalUpdate = DesktopIpc.makeIpcMethod({
channel: IpcChannels.LOCAL_UPDATE_START_CHANNEL,
payload: Schema.Void,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.updates.startLocal")(function* () {
const updates = yield* LocalDesktopUpdate.LocalDesktopUpdates;
yield* updates.start;
}),
});

export const startLocalBuild = DesktopIpc.makeIpcMethod({
channel: IpcChannels.LOCAL_BUILD_START_CHANNEL,
payload: Schema.Void,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.updates.startLocalBuild")(function* () {
const updates = yield* LocalDesktopUpdate.LocalDesktopUpdates;
yield* updates.startBuild;
}),
});
2 changes: 2 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
import * as DesktopState from "./app/DesktopState.ts";
import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts";
import * as DesktopTray from "./app/DesktopTray.ts";
import * as LocalDesktopUpdate from "./updates/LocalDesktopUpdate.ts";
import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts";
import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts";
Expand Down Expand Up @@ -203,6 +204,7 @@ const desktopApplicationLayer = Layer.mergeAll(
desktopSshLayer,
).pipe(
Layer.provideMerge(DesktopUpdates.layer),
Layer.provideMerge(LocalDesktopUpdate.layer),
Layer.provideMerge(desktopWslBackendLayer),
Layer.provideMerge(desktopLocalEnvironmentAuthLayer),
);
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,20 @@ contextBridge.exposeInMainWorld("desktopBridge", {
checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL),
downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL),
installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL),
startLocalUpdate: () => ipcRenderer.invoke(IpcChannels.LOCAL_UPDATE_START_CHANNEL),
startLocalBuild: () => ipcRenderer.invoke(IpcChannels.LOCAL_BUILD_START_CHANNEL),
getLocalUpdateState: () => ipcRenderer.invoke(IpcChannels.LOCAL_UPDATE_GET_STATE_CHANNEL),
onLocalUpdateState: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => {
if (typeof state !== "object" || state === null) return;
listener(state as Parameters<typeof listener>[0]);
};

ipcRenderer.on(IpcChannels.LOCAL_UPDATE_STATE_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(IpcChannels.LOCAL_UPDATE_STATE_CHANNEL, wrappedListener);
};
},
onUpdateState: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => {
if (typeof state !== "object" || state === null) return;
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const clientSettings: ClientSettings = {
glassOpacity: 80,
panelAnimationDurationMs: 0,
planModeEnabled: false,
composerSuggestionEnabled: false,
proactivePanelsEnabled: true,
showSkillsInSlashMenu: false,
providerModelPreferences: {},
Expand Down
82 changes: 82 additions & 0 deletions apps/desktop/src/updates/LocalDesktopUpdate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vite-plus/test";

import {
getDesktopLocalUpdateStateForStep,
localDesktopBuildPowerShell,
localDesktopUpdatePowerShell,
} from "./LocalDesktopUpdate.ts";

describe("local desktop update", () => {
it("fetches and merges locally before the required checks and installer build", () => {
expect(localDesktopUpdatePowerShell).toContain("git fetch $remote");
expect(localDesktopUpdatePowerShell).toContain('git rev-parse --verify "$remoteHead^{commit}"');
expect(localDesktopUpdatePowerShell).toContain(
"git merge-base --is-ancestor $remoteCommit $installedCommit",
);
expect(localDesktopUpdatePowerShell).toContain("::t3-local-update::up-to-date");
expect(localDesktopUpdatePowerShell).toContain("git merge --no-edit $remoteHead");
expect(localDesktopUpdatePowerShell).toContain("vp check");
expect(localDesktopUpdatePowerShell).toContain("vp run typecheck");
expect(localDesktopUpdatePowerShell).toContain("vp run update:local");
expect(localDesktopUpdatePowerShell).toContain('Invoke-UpdateStep "fetch"');
expect(localDesktopUpdatePowerShell).toContain('Invoke-UpdateStep "typecheck"');
expect(localDesktopUpdatePowerShell).toContain("::t3-local-update::open-installer");
expect(localDesktopUpdatePowerShell).not.toContain("Read-Host");
expect(localDesktopUpdatePowerShell).not.toContain("git push");
});

it("parks uncommitted work in an exact temporary stash and restores it on every exit path", () => {
const script = localDesktopUpdatePowerShell;
const stashAt = script.indexOf("git stash push --include-untracked --message $StashMessage");
const fetchAt = script.indexOf("git fetch $remote");
const restoreAt = script.lastIndexOf("Restore-LocalChanges");
expect(script).toContain("git stash apply --index $StashObject");
expect(stashAt).toBeGreaterThan(-1);
expect(stashAt).toBeLessThan(fetchAt);
expect(restoreAt).toBeGreaterThan(script.indexOf("vp run update:local"));
expect(script).toContain("if ($changes) { Save-LocalChanges }");
expect(script).toContain("git rev-parse --verify --quiet refs/stash");
expect(script).toContain("if ((Get-StashHead) -ne $StashObject)");
expect(script).toContain("git rev-parse --verify --quiet MERGE_HEAD");
expect(script.indexOf("git merge --abort")).toBeLessThan(restoreAt);
expect(script).toContain("::t3-local-update-failed::");
expect(script).not.toContain("git stash pop");
expect(script).not.toContain("git reset --hard");
expect(script).not.toContain("git clean");
expect(script).not.toContain("uncommitted changes. Commit or stash");
});

it("reports milestone progress only after a real update step starts", () => {
expect(getDesktopLocalUpdateStateForStep("fetch")).toMatchObject({
status: "running",
step: "fetch",
progressPercent: 25,
});
expect(getDesktopLocalUpdateStateForStep("stash").progressPercent).toBeLessThan(25);
expect(getDesktopLocalUpdateStateForStep("restore").progressPercent).toBeGreaterThan(92);
expect(getDesktopLocalUpdateStateForStep("completed")).toMatchObject({
status: "completed",
step: "completed",
progressPercent: 100,
});
expect(getDesktopLocalUpdateStateForStep("up-to-date")).toMatchObject({
status: "up-to-date",
step: "up-to-date",
progressPercent: 100,
});
});

it("falls back to origin for forks that track their own remote", () => {
expect(localDesktopUpdatePowerShell).toContain('$remotes -contains "upstream"');
expect(localDesktopUpdatePowerShell).toContain('elseif ($remotes -contains "origin")');
expect(localDesktopUpdatePowerShell).toContain("git remote set-head $remote --auto");
});

it("builds from the checkout as-is without touching Git history or the working tree", () => {
expect(localDesktopBuildPowerShell).toContain("vp run update:local");
expect(localDesktopBuildPowerShell).toContain("::t3-local-update::build");
expect(localDesktopBuildPowerShell).not.toContain("git fetch");
expect(localDesktopBuildPowerShell).not.toContain("git merge");
expect(localDesktopBuildPowerShell).not.toContain("git stash");
});
});
Loading
Loading