diff --git a/apps/web/src/sidebarPendingFileDropStore.test.ts b/apps/web/src/sidebarPendingFileDropStore.test.ts
new file mode 100644
index 000000000000..5965a5f1c9da
--- /dev/null
+++ b/apps/web/src/sidebarPendingFileDropStore.test.ts
@@ -0,0 +1,163 @@
+import { beforeEach, describe, expect, it } from "vite-plus/test";
+
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import { type EnvironmentId, ThreadId } from "@t3tools/contracts";
+
+import {
+ isSameSidebarThreadRef,
+ useSidebarPendingFileDropStore,
+ type SidebarPendingFileDrop,
+} from "./sidebarPendingFileDropStore";
+
+function makeFiles(...names: string[]): File[] {
+ return names.map((name) => new File(["x"], name));
+}
+
+function makeEntry(
+ environmentId: string,
+ threadId: string,
+ files: File[],
+): Omit {
+ return {
+ threadRef: scopeThreadRef(environmentId as EnvironmentId, ThreadId.make(threadId)),
+ files,
+ };
+}
+
+function fileNames(files: File[]): string[] {
+ return files.map((file) => file.name);
+}
+
+function refOf(environmentId: string, threadId: string) {
+ return makeEntry(environmentId, threadId, []).threadRef;
+}
+
+beforeEach(() => {
+ useSidebarPendingFileDropStore.setState({ pending: [] });
+});
+
+describe("sidebarPendingFileDropStore", () => {
+ it("starts empty", () => {
+ expect(useSidebarPendingFileDropStore.getState().pending).toEqual([]);
+ });
+
+ it("stashes and consumes a drop for the matching thread", () => {
+ const files = makeFiles("a.png", "b.png");
+ const store = useSidebarPendingFileDropStore.getState();
+ store.queuePendingFileDrop(makeEntry("env-1", "thread-1", files));
+
+ expect(
+ useSidebarPendingFileDropStore.getState().consumePendingFileDrop(refOf("env-1", "thread-1")),
+ ).toEqual(files);
+ expect(useSidebarPendingFileDropStore.getState().pending).toEqual([]);
+ });
+
+ it("accumulates repeat drops onto the same thread instead of replacing", () => {
+ const store = useSidebarPendingFileDropStore.getState();
+ store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("a.png")));
+ store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("b.png")));
+
+ expect(
+ fileNames(
+ useSidebarPendingFileDropStore
+ .getState()
+ .consumePendingFileDrop(refOf("env-1", "thread-1")) ?? [],
+ ),
+ ).toEqual(["a.png", "b.png"]);
+ expect(useSidebarPendingFileDropStore.getState().pending).toEqual([]);
+ });
+
+ it("keeps drops for other threads when consuming one thread", () => {
+ const store = useSidebarPendingFileDropStore.getState();
+ store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("a.png")));
+ store.queuePendingFileDrop(makeEntry("env-1", "thread-2", makeFiles("b.png")));
+
+ expect(
+ fileNames(
+ useSidebarPendingFileDropStore
+ .getState()
+ .consumePendingFileDrop(refOf("env-1", "thread-2")) ?? [],
+ ),
+ ).toEqual(["b.png"]);
+ expect(useSidebarPendingFileDropStore.getState().pending).toHaveLength(1);
+ });
+
+ it("clears only the drop matching a stale navigation's id", () => {
+ const store = useSidebarPendingFileDropStore.getState();
+ const firstId = store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("a.png")));
+ store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("b.png")));
+
+ useSidebarPendingFileDropStore.getState().clearPendingFileDrop(firstId);
+ expect(
+ fileNames(
+ useSidebarPendingFileDropStore
+ .getState()
+ .consumePendingFileDrop(refOf("env-1", "thread-1")) ?? [],
+ ),
+ ).toEqual(["b.png"]);
+ });
+
+ it("keeps a newer drop deliverable after the first navigation fails", () => {
+ // Mirrors handleThreadFileDrop: two drops queued for the same unopened
+ // thread, then the first navigation fails (or lands elsewhere) and cleans
+ // up by its own drop id. The newer drop must survive with its files
+ // intact so the thread opening still attaches them.
+ const store = useSidebarPendingFileDropStore.getState();
+ const firstId = store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("a.png")));
+ const secondId = store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("b.png")));
+
+ // First navigation fails: handler clears only its own drop.
+ useSidebarPendingFileDropStore.getState().clearPendingFileDrop(firstId);
+ const remaining = useSidebarPendingFileDropStore.getState().pending;
+ expect(remaining).toHaveLength(1);
+ expect(remaining[0]?.id).toBe(secondId);
+ expect(fileNames(remaining[0]?.files ?? [])).toEqual(["b.png"]);
+
+ // Thread opens: the surviving drop is still attached.
+ expect(
+ fileNames(
+ useSidebarPendingFileDropStore
+ .getState()
+ .consumePendingFileDrop(refOf("env-1", "thread-1")) ?? [],
+ ),
+ ).toEqual(["b.png"]);
+ expect(useSidebarPendingFileDropStore.getState().pending).toEqual([]);
+ });
+
+ it("clears every drop for a missing thread", () => {
+ const store = useSidebarPendingFileDropStore.getState();
+ store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("a.png")));
+ store.queuePendingFileDrop(makeEntry("env-1", "thread-1", makeFiles("b.png")));
+ store.queuePendingFileDrop(makeEntry("env-1", "thread-2", makeFiles("c.png")));
+
+ useSidebarPendingFileDropStore
+ .getState()
+ .clearPendingFileDropsForThread(refOf("env-1", "thread-1"));
+ expect(
+ fileNames(
+ useSidebarPendingFileDropStore
+ .getState()
+ .consumePendingFileDrop(refOf("env-1", "thread-2")) ?? [],
+ ),
+ ).toEqual(["c.png"]);
+ expect(useSidebarPendingFileDropStore.getState().pending).toEqual([]);
+ });
+
+ it("does not confuse refs whose joined keys collide on colons", () => {
+ const store = useSidebarPendingFileDropStore.getState();
+ store.queuePendingFileDrop(makeEntry("a", "b:c", makeFiles("a.png")));
+
+ expect(
+ useSidebarPendingFileDropStore.getState().consumePendingFileDrop(refOf("a:b", "c")),
+ ).toBeNull();
+ expect(useSidebarPendingFileDropStore.getState().pending).toHaveLength(1);
+ });
+});
+
+describe("isSameSidebarThreadRef", () => {
+ it("compares fields, not joined keys", () => {
+ expect(isSameSidebarThreadRef(refOf("a", "b:c"), refOf("a", "b:c"))).toBe(true);
+ expect(isSameSidebarThreadRef(refOf("a", "b:c"), refOf("a:b", "c"))).toBe(false);
+ expect(isSameSidebarThreadRef(refOf("a", "b"), refOf("a", "c"))).toBe(false);
+ });
+});
diff --git a/apps/web/src/sidebarPendingFileDropStore.ts b/apps/web/src/sidebarPendingFileDropStore.ts
new file mode 100644
index 000000000000..3c1fe328b2c1
--- /dev/null
+++ b/apps/web/src/sidebarPendingFileDropStore.ts
@@ -0,0 +1,74 @@
+import { create } from "zustand";
+
+import type { ScopedThreadRef } from "@t3tools/contracts";
+
+/**
+ * Field-wise ref equality. `scopedThreadKey` joins with `:`, so two distinct
+ * refs can collide when an id itself contains one; drops must never cross
+ * threads on that account.
+ */
+export function isSameSidebarThreadRef(a: ScopedThreadRef, b: ScopedThreadRef): boolean {
+ return a.environmentId === b.environmentId && a.threadId === b.threadId;
+}
+
+/**
+ * One sidebar row drop. Drops queue up instead of replacing each other, so a
+ * second drop onto the same thread before it opens keeps both files; each
+ * entry carries its own id so a stale navigation can only ever clear the drop
+ * that started it.
+ */
+export interface SidebarPendingFileDrop {
+ id: string;
+ threadRef: ScopedThreadRef;
+ files: File[];
+}
+
+interface SidebarPendingFileDropStoreState {
+ pending: SidebarPendingFileDrop[];
+ /**
+ * Appends a drop to the queue and returns its id, for later
+ * identity-checked cleanup.
+ */
+ queuePendingFileDrop: (entry: Omit) => string;
+ /** Removes the single drop with this id, leaving newer drops untouched. */
+ clearPendingFileDrop: (id: string) => void;
+ /** Removes every queued drop aimed at this thread (e.g. it went missing). */
+ clearPendingFileDropsForThread: (threadRef: ScopedThreadRef) => void;
+ /**
+ * Returns every queued drop's files for `threadRef`, oldest first, and
+ * removes them; returns null (leaving state untouched) when none match.
+ */
+ consumePendingFileDrop: (threadRef: ScopedThreadRef) => File[] | null;
+}
+
+let nextPendingFileDropId = 0;
+
+export const useSidebarPendingFileDropStore = create()(
+ (set, get) => ({
+ pending: [],
+ queuePendingFileDrop: (entry) => {
+ const id = `sidebar-file-drop-${(nextPendingFileDropId += 1)}`;
+ set((state) => ({ pending: [...state.pending, { ...entry, id }] }));
+ return id;
+ },
+ clearPendingFileDrop: (id) => {
+ set((state) => ({ pending: state.pending.filter((drop) => drop.id !== id) }));
+ },
+ clearPendingFileDropsForThread: (threadRef) => {
+ set((state) => ({
+ pending: state.pending.filter((drop) => !isSameSidebarThreadRef(drop.threadRef, threadRef)),
+ }));
+ },
+ consumePendingFileDrop: (threadRef) => {
+ const matches = get().pending.filter((drop) =>
+ isSameSidebarThreadRef(drop.threadRef, threadRef),
+ );
+ if (matches.length === 0) {
+ return null;
+ }
+ const matchedIds = new Set(matches.map((drop) => drop.id));
+ set((state) => ({ pending: state.pending.filter((drop) => !matchedIds.has(drop.id)) }));
+ return matches.flatMap((drop) => drop.files);
+ },
+ }),
+);
diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts
index ee3240b41b08..2ef7fe26a115 100644
--- a/apps/web/src/terminal/ghostty/surface.test.ts
+++ b/apps/web/src/terminal/ghostty/surface.test.ts
@@ -556,6 +556,20 @@ describe("isTerminalCopyShortcut", () => {
expect(isTerminalCopyShortcut(event({ key: "C", metaKey: true }), "MacIntel")).toBe(true);
expect(isTerminalCopyShortcut(event({ key: "j", metaKey: true }), "MacIntel")).toBe(false);
});
+
+ it("supports the conventional Ctrl+Insert copy shortcut", () => {
+ expect(isTerminalCopyShortcut(event({ key: "Insert", ctrlKey: true }), "Linux x86_64")).toBe(
+ true,
+ );
+ expect(isTerminalCopyShortcut(event({ key: "Insert" }), "Linux x86_64")).toBe(false);
+ expect(
+ isTerminalCopyShortcut(
+ event({ key: "Insert", ctrlKey: true, shiftKey: true }),
+ "Linux x86_64",
+ ),
+ ).toBe(false);
+ expect(isTerminalCopyShortcut(event({ key: "Insert", ctrlKey: true }), "MacIntel")).toBe(false);
+ });
});
describe("applyTerminalCopyEvent", () => {
diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts
index 29aaac6f6abd..ca1cd5a18eb2 100644
--- a/apps/web/src/terminal/ghostty/surface.ts
+++ b/apps/web/src/terminal/ghostty/surface.ts
@@ -321,7 +321,11 @@ export function isTerminalCopyShortcut(
event: Pick,
platform = navigator.platform,
) {
- if (event.key.toLowerCase() !== "c") return false;
+ const key = event.key.toLowerCase();
+ if (key === "insert" && !isMacPlatform(platform)) {
+ return event.ctrlKey && !event.shiftKey && !event.metaKey;
+ }
+ if (key !== "c") return false;
return isMacPlatform(platform) ? event.metaKey : event.ctrlKey;
}
@@ -1027,12 +1031,12 @@ export class GhosttyTerminalSurface {
// A plain Ctrl+C/Cmd+C fires the browser's native copy event, caught in
// onCopyEvent; not preventing the default keeps that path alive. WebKit
// omits the keyboard copy event without a DOM selection, so race the
- // clipboard write against it the same way paste races its read. The
- // Shift variant has no native event (Chrome binds Ctrl+Shift+C to
+ // clipboard write against it the same way paste races its read. Ctrl+Shift+C
+ // and Ctrl+Insert have no native copy event (Chrome binds the former to
// inspect), so synthesize one with execCommand("copy").
const selection = this.getSelection();
this.primeCopy(selection);
- if (event.shiftKey) {
+ if (event.shiftKey || event.key.toLowerCase() === "insert") {
event.preventDefault();
document.execCommand("copy");
} else {
diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts
index 1a95acbcfbf7..4d68a3231b31 100644
--- a/apps/web/src/uiStateStore.test.ts
+++ b/apps/web/src/uiStateStore.test.ts
@@ -26,6 +26,7 @@ function makeUiState(overrides: Partial = {}): UiState {
threadLastVisitedAtById: {},
threadChangedFilesExpandedById: {},
defaultAdvertisedEndpointKey: null,
+ pullRequestMergeMethod: "merge",
...overrides,
};
}
@@ -158,6 +159,18 @@ describe("uiStateStore pure functions", () => {
});
describe("parsePersistedState", () => {
+ it("hydrates the last selected pull request merge method", () => {
+ const parsed = parsePersistedState({
+ pullRequestMergeMethod: "squash",
+ });
+ const invalid = parsePersistedState({
+ pullRequestMergeMethod: "fast-forward",
+ });
+
+ expect(parsed.pullRequestMergeMethod).toBe("squash");
+ expect(invalid.pullRequestMergeMethod).toBe("merge");
+ });
+
it("hydrates raw UI-owned state without server entities", () => {
const parsed = parsePersistedState({
projectExpandedById: {
@@ -189,6 +202,7 @@ describe("parsePersistedState", () => {
},
defaultAdvertisedEndpointKey: "desktop-core:lan:http",
sidebarProjectScopeKey: null,
+ pullRequestMergeMethod: "merge",
threadChangedFilesExpandedById: {
"environment:thread-1": {
"turn-1": false,
@@ -317,6 +331,7 @@ describe("uiStateStore persistence", () => {
"turn-2": true,
},
},
+ pullRequestMergeMethod: "merge",
});
expect(parsePersistedState(persisted)).toEqual({
...state,
diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts
index b14ce917c861..e82c86f26404 100644
--- a/apps/web/src/uiStateStore.ts
+++ b/apps/web/src/uiStateStore.ts
@@ -1,4 +1,5 @@
import { Debouncer } from "@tanstack/react-pacer";
+import type { PullRequestMergeMethod } from "@t3tools/contracts";
import { create } from "zustand";
import { normalizeProjectPathForComparison } from "./lib/projectPaths";
@@ -29,6 +30,7 @@ export interface PersistedUiState {
sidebarProjectScopeKey?: string | null;
threadChangedFilesExpansionVersion?: number;
threadChangedFilesExpandedById?: Record>;
+ pullRequestMergeMethod?: string;
}
export interface UiProjectState {
@@ -49,7 +51,12 @@ export interface UiEndpointState {
defaultAdvertisedEndpointKey: string | null;
}
-export interface UiState extends UiProjectState, UiThreadState, UiEndpointState {}
+export interface UiPullRequestState {
+ pullRequestMergeMethod: PullRequestMergeMethod;
+}
+
+export interface UiState
+ extends UiProjectState, UiThreadState, UiEndpointState, UiPullRequestState {}
const initialState: UiState = {
projectExpandedById: {},
@@ -58,6 +65,7 @@ const initialState: UiState = {
threadLastVisitedAtById: {},
threadChangedFilesExpandedById: {},
defaultAdvertisedEndpointKey: null,
+ pullRequestMergeMethod: "merge",
};
const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:";
@@ -109,6 +117,10 @@ function sanitizeTimestampRecord(value: unknown): Record {
);
}
+function isPullRequestMergeMethod(value: unknown): value is PullRequestMergeMethod {
+ return value === "merge" || value === "squash" || value === "rebase";
+}
+
export function parsePersistedState(parsed: PersistedUiState): UiState {
const projectExpandedById =
parsed.projectExpandedById === undefined
@@ -143,6 +155,9 @@ export function parsePersistedState(parsed: PersistedUiState): UiState {
: {},
defaultAdvertisedEndpointKey: sanitizeOptionalKey(parsed.defaultAdvertisedEndpointKey),
sidebarProjectScopeKey: sanitizeOptionalKey(parsed.sidebarProjectScopeKey),
+ pullRequestMergeMethod: isPullRequestMergeMethod(parsed.pullRequestMergeMethod)
+ ? parsed.pullRequestMergeMethod
+ : initialState.pullRequestMergeMethod,
};
}
@@ -216,6 +231,7 @@ export function persistState(state: UiState): void {
sidebarProjectScopeKey: state.sidebarProjectScopeKey,
threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION,
threadChangedFilesExpandedById: state.threadChangedFilesExpandedById,
+ pullRequestMergeMethod: state.pullRequestMergeMethod,
} satisfies PersistedUiState),
);
if (!legacyKeysCleanedUp) {
@@ -324,6 +340,12 @@ export function setSidebarProjectScopeKey(state: UiState, projectKey: string | n
};
}
+function setPullRequestMergeMethod(state: UiState, method: PullRequestMergeMethod): UiState {
+ return state.pullRequestMergeMethod === method
+ ? state
+ : { ...state, pullRequestMergeMethod: method };
+}
+
export function resolveProjectExpanded(
projectExpandedById: Readonly>,
preferenceKeys: readonly string[],
@@ -407,6 +429,7 @@ interface UiStateStore extends UiState {
setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void;
setDefaultAdvertisedEndpointKey: (key: string | null) => void;
setSidebarProjectScopeKey: (projectKey: string | null) => void;
+ setPullRequestMergeMethod: (method: PullRequestMergeMethod) => void;
setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void;
reorderProjects: (
currentProjectOrder: readonly string[],
@@ -427,6 +450,7 @@ export const useUiStateStore = create((set) => ({
set((state) => setDefaultAdvertisedEndpointKey(state, key)),
setSidebarProjectScopeKey: (projectKey) =>
set((state) => setSidebarProjectScopeKey(state, projectKey)),
+ setPullRequestMergeMethod: (method) => set((state) => setPullRequestMergeMethod(state, method)),
setProjectExpanded: (projectIds, expanded) =>
set((state) => setProjectExpanded(state, projectIds, expanded)),
reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) =>
diff --git a/docs/operations/development.md b/docs/operations/development.md
index 94bab97ddedb..f913a8100e75 100644
--- a/docs/operations/development.md
+++ b/docs/operations/development.md
@@ -74,11 +74,12 @@ Windows investigation while that suite is not a required gate.
### Unused code
`vp run knip:check` checks unused files and dependencies across the repo, then
-unused runtime exports in `apps/desktop`, `apps/web`, and every internal package under
+unused runtime exports in `apps/server`, `apps/desktop`, `apps/web`, and every internal package under
`packages/`. CI enforces both checks.
Exported types and Effect schemas are allowed without consumers. The schema preprocessor
recognizes schema types, including aliases and schema classes; functions that create or decode
-schemas remain checked. Completely unused files remain checked too.
+schemas remain checked. Canonical Effect service construction APIs stay exported with an explicit
+`@public` annotation, which Knip recognizes. Completely unused files remain checked too.
Named exports in web UI component modules are kept as complete component sets. Knip ignores
unused exports in `apps/web/src/components/ui/*.tsx`, while still reporting an entire unused file.
Use `vp run knip --workspace apps/web` to audit one workspace, including exports,
diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md
index 8e34c9e75606..b2c244a6123e 100644
--- a/docs/user/thread-sidebar.md
+++ b/docs/user/thread-sidebar.md
@@ -24,6 +24,11 @@ worktree**, each background submission creates its own worktree.
Pin a thread from its menu to keep it above your active work.
+On web and desktop, you can also drag files from your computer onto any thread row:
+the thread opens and the files are attached in its composer, ready for
+your next message. The same per-message file limits apply as when attaching
+files directly; see [Attach files](./composer.md#attach-files).
+
Pinning does not prevent automatic settlement. Settling a thread removes its pin.
On web and desktop, drag a thread between sections to change its state. Drag a thread up into
diff --git a/docs/user/usage.md b/docs/user/usage.md
index 4be084ea299e..fba493156dc2 100644
--- a/docs/user/usage.md
+++ b/docs/user/usage.md
@@ -42,8 +42,10 @@ the dialog.
**Usage → Limits** pools every subscription account it can see per provider, so with several Codex
or Claude accounts across your environments and hubs you read one number per window rather than a
list. Each window card shows how much of the pool is left and a bar with one segment per account,
-ordered by which resets soonest; when the provider reports reset times, the card also says when
-the next reset lands and how much it hands back. The hatched
+kept in the same column across windows. Accounts are ordered by their 5-hour reset, soonest
+first, or by the first available window when no account reports a 5-hour limit. A gap means the
+account does not report that window. When the provider reports reset times, the card also says
+when the next reset lands and how much it hands back. The hatched
part of a segment is what that reset restores. Tap a segment or account row for the account's plan,
where it is signed in, and its reset time. On web, you can hover too. Codex accounts with banked
reset credits show a ticket count and the **Use reset** action in the account details. On narrow screens, numbered rows below
diff --git a/package.json b/package.json
index cfa0684359f1..dd1a106a0409 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,7 @@
"tc": "vp run -r --concurrency-limit 2 typecheck",
"lint": "vp lint --report-unused-disable-directives",
"knip": "knip --preprocessor ./scripts/knip-schemas.ts",
- "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",
+ "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",
"knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",
"lint:mobile": "node scripts/mobile-native-static-check.ts",
"test": "vp run -r test",
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index b0fc4031d6dc..7d3cd2ceafe7 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -455,6 +455,25 @@ describe("ServerSettings thread settlement", () => {
});
});
+describe("ClientSettings pull request merge methods", () => {
+ it("defaults to no project overrides and accepts supported methods", () => {
+ expect(decodeClientSettings({}).pullRequestMergeMethodOverrides).toEqual({});
+ expect(
+ decodeClientSettingsPatch({
+ pullRequestMergeMethodOverrides: { project: "squash" },
+ }).pullRequestMergeMethodOverrides,
+ ).toEqual({ project: "squash" });
+ });
+
+ it("rejects unsupported project merge methods", () => {
+ expect(() =>
+ decodeClientSettingsPatch({
+ pullRequestMergeMethodOverrides: { project: "fast-forward" },
+ }),
+ ).toThrow();
+ });
+});
+
describe("ServerSettings.providerInstances (slice-2 invariant)", () => {
it("defaults text generation to Luna at low reasoning effort", () => {
expect(DEFAULT_SERVER_SETTINGS.textGenerationModelSelection).toEqual({
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 3d24e488ce7a..3491103da94f 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -32,6 +32,7 @@ import {
ProviderInstanceId,
type ProviderDriverKind,
} from "./providerInstance.ts";
+import { PullRequestMergeMethod } from "./pullRequest.ts";
// ── Client Settings (local-only) ───────────────────────────────
@@ -391,6 +392,10 @@ export const ClientSettingsSchema = Schema.Struct({
modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))),
}),
).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
+ pullRequestMergeMethodOverrides: Schema.Record(
+ TrimmedNonEmptyString,
+ PullRequestMergeMethod,
+ ).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
// Legacy plan mode. The composer's Build/Plan toggle was removed from the
// default UI; this beta flag restores it (plus the /plan and /default slash
// commands) for users who still rely on the old workflow.
@@ -1341,6 +1346,9 @@ export const ClientSettingsPatch = Schema.Struct({
}),
),
),
+ pullRequestMergeMethodOverrides: Schema.optionalKey(
+ Schema.Record(TrimmedNonEmptyString, PullRequestMergeMethod),
+ ),
planModeEnabled: Schema.optionalKey(Schema.Boolean),
contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean),
composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean),
diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts
index 48eb87ce0c2d..b814e66da459 100644
--- a/packages/shared/src/usageLimits.test.ts
+++ b/packages/shared/src/usageLimits.test.ts
@@ -9,6 +9,7 @@ import {
import { describe, expect, it } from "vite-plus/test";
import {
+ type LimitAccount,
isUsageLimitsCommand,
collectProviderUsageLimits,
sameUsageLimitCommandCoverage,
@@ -777,12 +778,103 @@ describe("pools", () => {
["weekly", 1],
["monthly", 1],
]);
- // Segments read left to right as "who refills next", matching the reset list.
+ // Session resets determine the account order for every row.
expect(session?.members.map((member) => member.account.key)).toEqual(["hub:a", "hub:b"]);
expect(pools[0]?.accounts.map((account) => account.key)).toEqual(["hub:a", "hub:b"]);
});
});
+describe("pooled account columns", () => {
+ const weekly = {
+ ...window,
+ id: "seven_day",
+ kind: "weekly",
+ label: "Weekly",
+ windowDurationMins: 7 * 24 * 60,
+ } as const;
+ const account = (key: string, windows: LimitAccount["limits"]["windows"]): LimitAccount => ({
+ key,
+ driver: ProviderDriverKind.make("claudeAgent"),
+ displayName: key,
+ email: undefined,
+ plan: undefined,
+ accentColor: undefined,
+ environments: [],
+ sourceLabel: "Hub",
+ redeem: null,
+ limits: { checkedAt: "2026-09-03T11:00:00.000Z", windows },
+ });
+ const keys = (pool: ReturnType[number]) =>
+ pool.windows.map((row) =>
+ row.columns.map((member) => (member.window ? member.account.key : null)),
+ );
+
+ it("keeps session columns across rows with opposite reset and usage orders", () => {
+ const accounts = [
+ account("a", [
+ { ...weekly, usedPercent: 80, resetsAt: "2026-09-05T12:00:00.000Z" },
+ { ...window, usedPercent: 10, resetsAt: "2026-09-03T15:00:00.000Z" },
+ ]),
+ account("b", [
+ { ...weekly, usedPercent: 20, resetsAt: "2026-09-06T12:00:00.000Z" },
+ { ...window, usedPercent: 90, resetsAt: "2026-09-03T13:00:00.000Z" },
+ ]),
+ ];
+ const [pool] = collectLimitPools(accounts, now);
+ expect(pool!.accounts.map((account) => account.key)).toEqual(["b", "a"]);
+ expect(keys(pool!)).toEqual([
+ ["b", "a"],
+ ["b", "a"],
+ ]);
+ expect(pool!.windows[1]!.resets.map((reset) => reset.member.account.key)).toEqual(["a", "b"]);
+ expect(pool!.windows[1]!.remainingPercent).toBe(50);
+ expect(keys(collectLimitPools(accounts.toReversed(), now)[0]!)).toEqual(keys(pool!));
+ });
+
+ it("preserves gaps without counting missing windows toward pooled quota", () => {
+ const [pool] = collectLimitPools(
+ [
+ account("a", [window]),
+ account("b", [
+ { ...window, resetsAt: "2026-09-03T15:00:00.000Z" },
+ { ...weekly, usedPercent: 80 },
+ ]),
+ account("c", [weekly]),
+ ],
+ now,
+ );
+ expect(keys(pool!)).toEqual([
+ ["a", "b", null],
+ [null, "b", "c"],
+ ]);
+ expect(pool!.windows[1]!.members.map((member) => member.account.key)).toEqual(["b", "c"]);
+ expect(pool!.windows[1]!.remainingPercent).toBe(40);
+ expect(pool!.windows[1]!.resets.map((reset) => reset.restoresPercent)).toEqual([40, 20]);
+ });
+
+ it("falls back to weekly resets when no account reports a session", () => {
+ const [pool] = collectLimitPools(
+ [
+ account("a", [{ ...weekly, resetsAt: "2026-09-06T12:00:00.000Z" }]),
+ account("b", [{ ...weekly, resetsAt: "2026-09-05T12:00:00.000Z" }]),
+ ],
+ now,
+ );
+ expect(keys(pool!)).toEqual([["b", "a"]]);
+ });
+
+ it("sorts unknown resets last and breaks ties consistently", () => {
+ const accounts = [
+ account("z", [{ ...window, resetsAt: undefined }]),
+ account("b", [window]),
+ account("a", [window]),
+ account("y", [{ ...window, resetsAt: "invalid" }]),
+ ];
+ expect(keys(collectLimitPools(accounts, now)[0]!)).toEqual([["a", "b", "y", "z"]]);
+ expect(keys(collectLimitPools(accounts.toReversed(), now)[0]!)).toEqual([["a", "b", "y", "z"]]);
+ });
+});
+
describe("collectLimitNotices", () => {
const checkedAt = "2026-09-03T11:00:00.000Z";
const claude = ProviderDriverKind.make("claudeAgent");
diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts
index 784b1ada3e31..5c32cc0343b7 100644
--- a/packages/shared/src/usageLimits.ts
+++ b/packages/shared/src/usageLimits.ts
@@ -357,6 +357,11 @@ export interface LimitPoolWindow {
readonly kind: ServerProviderUsageWindow["kind"];
readonly label: string;
readonly members: readonly LimitPoolMember[];
+ /** Fixed account positions across rows; a null window leaves a gap. */
+ readonly columns: ReadonlyArray<{
+ readonly account: LimitAccount;
+ readonly window: ServerProviderUsageWindow | null;
+ }>;
readonly remainingPercent: number;
readonly usedPercent: number;
readonly pace: LimitPace | null;
@@ -389,10 +394,10 @@ const WINDOW_KIND_ORDER: Record = {
* a month on Free/Go), and a monthly allowance must not average into a
* five-hour pool. Pools order by kind, then first appearance.
*
- * `accounts` is the table order: instances the user can act on (native,
- * named) before hub-only accounts, each group alphabetical. Each window's
- * `members` sort by reset instead, soonest first, so a bar reads left to
- * right as "who refills next" and matches the reset list under it.
+ * Accounts and columns share the session reset order, soonest first. When
+ * no account reports a session window, use the first window by kind instead.
+ * Missing reset times sort last, with account names and keys breaking ties.
+ * Each window's reset list still follows its own clock.
*/
export function collectLimitPools(
accounts: readonly LimitAccount[],
@@ -405,10 +410,20 @@ export function collectLimitPools(
else byDriver.set(account.driver, [account]);
}
return [...byDriver].map(([driver, members]) => {
+ const orderWindow = members
+ .flatMap((account) => account.limits.windows)
+ .sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind])[0];
+ const orderReset = (account: LimitAccount) => {
+ const window = account.limits.windows.find(
+ (window) => window.kind === orderWindow?.kind && window.id === orderWindow.id,
+ );
+ return (window ? resetMillis(window) : null) ?? Number.POSITIVE_INFINITY;
+ };
const sorted = [...members].sort(
(left, right) =>
- Number(left.redeem === null) - Number(right.redeem === null) ||
- accountSortName(left).localeCompare(accountSortName(right)),
+ orderReset(left) - orderReset(right) ||
+ accountSortName(left).localeCompare(accountSortName(right)) ||
+ left.key.localeCompare(right.key),
);
return { driver, accounts: sorted, windows: poolWindows(sorted, now) };
});
@@ -428,12 +443,8 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L
else byKey.set(key, [{ account, window }]);
}
}
- const pools = [...byKey.values()].map((unordered): LimitPoolWindow => {
- const members = [...unordered].sort(
- (left, right) =>
- (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) -
- (resetMillis(right.window) ?? Number.POSITIVE_INFINITY),
- );
+ const pools = [...byKey.values()].map((members): LimitPoolWindow => {
+ const memberByAccount = new Map(members.map((member) => [member.account.key, member]));
const first = members[0]!.window;
const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length;
// Pace compares spend against the clock, so it is judged only over the
@@ -465,6 +476,9 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L
kind: first.kind,
label: first.label,
members,
+ columns: accounts.map(
+ (account) => memberByAccount.get(account.key) ?? { account, window: null },
+ ),
usedPercent: Math.round(usedPercent),
remainingPercent: Math.round(100 - usedPercent),
pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed),