diff --git a/CHANGELOG.md b/CHANGELOG.md index e799a04d..a3bdac0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ User-facing release notes. The release workflow's `promote` job publishes the platform-limitations header), so each section is written for users, reviewed in the release PR, and frozen at the tag — never an auto-generated commit list. +## 2.1.0 + +This update tiles quick-launched agents evenly, gives plain folders a sidebar +card, and fixes agents that stopped drawing in narrow panes. + +### Sidebar + +- **Plain folders get a card like a repository.** A workspace that is not a git + repository now shows the same [sidebar card](src/ui/worktree-card.tsx) as a + repository checkout, labelled `Folder`, instead of loose agent rows. A folder + opened inside a larger repository shows its own name, not the repository's. + +- Removed the `Needs me` count button above the + [sidebar project list](src/ui/agent-rail.tsx). Each agent still shows its own + status, and the Agent Board's Needs me filter is unchanged. + +- **Claude Code limits show in every copy of Deck.** The + [limit collector](electron/agent-limits/claude-reader.ts) is now shared, so a + second Deck install no longer shows a dash while another one owns Claude's + status line. Your own status line command is still preserved. + +### Agents + +- **Quick Launch tiles instead of stacking columns.** A launched agent now splits the + [roomiest pane](src/lib/pane-tiling.ts) of the tab along its longer side, so the second, + third and fourth agent fill the tab evenly instead of halving one pane into ever + narrower strips. A divider you dragged yourself still decides where the next pane lands. + +- **Agents keep drawing in narrow panes.** A terminal never shrinks below 24 + columns; a narrower pane [clips its right edge](src/terminal/pane.ts) + instead. OpenCode stopped drawing for good once its pane reached 20 columns + or fewer, even after the pane grew back. + +- **Trackpad scrolling in terminals no longer stutters.** Terminal scrolling + is now instant, instead of switching between animated and instant scrolling + in the middle of a gesture. + ## 2.0.0 ### Feedback @@ -24,11 +61,6 @@ the release PR, and frozen at the tag — never an auto-generated commit list. ### Sidebar -- **Plain folders get a card like a repository.** A workspace that is not a git - repository now shows the same [sidebar card](src/ui/worktree-card.tsx) as a - repository checkout, labelled `Folder`, instead of loose agent rows. A folder - opened inside a larger repository shows its own name, not the repository's. - - **Workspace favicons in the sidebar.** [Project headers](src/ui/agent-rail.tsx) show the workspace favicon when available, falling back to the folder icon when the image is missing or cannot be displayed. @@ -47,11 +79,6 @@ the release PR, and frozen at the tag — never an auto-generated commit list. ### Agents -- **Quick Launch tiles instead of stacking columns.** A launched agent now splits the - [roomiest pane](src/lib/pane-tiling.ts) of the tab along its longer side, so the second, - third and fourth agent fill the tab evenly instead of halving one pane into ever - narrower strips. A divider you dragged yourself still decides where the next pane lands. - - **Removed yellow lines above terminal panes.** The [pane overlays](src/styles/06-stage-panes.css) no longer animate while agents work or when selecting an agent from the sidebar. Agent status indicators and pane navigation keep their existing behavior. diff --git a/docs/DESIGN-LANGUAGE.md b/docs/DESIGN-LANGUAGE.md index ee7970cf..a337b3e3 100644 --- a/docs/DESIGN-LANGUAGE.md +++ b/docs/DESIGN-LANGUAGE.md @@ -2924,50 +2924,21 @@ a 1.5s effect. The ping is the inset hairline DL-1.3 explicitly permits. ([head styles](../src/styles/04c-rail-worktree-card.css)); the bare row keeps 132px. -- **DL-27.26** **One `Needs me N` line above the clusters (2026-09-18, design - review L5).** The rail owns the question "which agent needs me" and answers - it once, at the top: a count of the panes in `asked` or `failed` across every - card, folded or not, drawn only while it is above zero. Pressing it runs the - same preflight as ⌘⇧A (`focus-next-attention`) and lands on the loudest - pane. It is a flat pill in the Board bar's chip vocabulary (DL-34.11): a - `--status-unread` wash inside DL-1.3's inset hairline, the count at - `--text-primary` 620 and the label at `--text-muted`, sharing the cluster - header's 7px inset. Strip glyphs keep their corner marks (DL-27.3) and never - carry a count; the Board bar's own `Needs me` filter (DL-34.11) is the same - figure on the other surface. Rendered as a still `` where nothing - wires the focus (DL-19.7). [Rail styles](../src/styles/04a-agent-rail.css), - [`AgentRail`](../src/ui/agent-rail.tsx). - - **Amended 2026-09-02 (owner, `openspec/changes/rail-create-consolidation`): - every checkout carries exactly ONE create control, and it opens the agent - list.** The open card's `New agent` row and the bare row of a checkout with - nothing open had spawned a plain SHELL through `onNewTabIn` — a process - started without a word about its agent or its place, under a label that said - "agent". Both open the checkout's actions menu now, anchored to the card or - row, exactly as the closed strip's `+` and a right-click do - ([`useActionsMenu`](../src/ui/worktree-card.tsx) `current` is the one state - all three shapes share); a press starts nothing, and selecting an agent - starts it. Agent rows show only the agent name, without a `Run` prefix. A folder git does not know (flat entries) ends with the same - `New agent` row, and its menu drops every git-backed row. A REMEMBERED - project prints its remembered checkouts as rowless groups - ([`rememberedWorktrees`](../src/ui/agent-rail-model.ts) `current`) so their - bare rows are its way back in, since the header's `+` (DL-27.18, retired) is - gone. `Open shell` opens a new shell tab even in a busy checkout; - `New split here` opens a pane beside the existing tab and materializes a tab - when the checkout has none. Both use the checkout named by the menu - ([menu callbacks](../src/ui/app.tsx), [tab creation](../src/terminal/tab-manager.ts)). - **The menu's heading returns for ONE placement:** raised by `⌘T` with no card - beside it, the same `CardActionsMenu` stands free under the stage strip - (DL-13.7, amended) and prints the composed destination - ([`subjectWhere`](../src/ui/agent-rail-card-model.ts) `current` — `whereOf`'s - own words) as one line, `.asr-act__where`, in title ink on the title rung; - never the `Actions for` / `Runs in` pair that came off on 2026-08-30. That - placement alone also carries `Open another project…`, because with the tab - strip's `+` gone, top-tab mode and a hidden sidebar have no other route to - the Open board. The menu reads a - [`MenuSubject`](../src/ui/agent-rail-card-model.ts) `current` rather than a - card's group, so the chord and the card build it from different inputs and - name one checkout with one set of words. +- **DL-27.26** **No aggregate attention button in the sidebar (2026-09-21).** + The owner removed the `Needs me N` line above the project clusters from + [`AgentRail`](../src/ui/agent-rail.tsx). Per-agent status indicators and the + [Board bar](../src/ui/agent-board-bar.tsx) remain available. + + **Checkout creation (2026-09-18, DECK-27):** the open `New agent` row, + collapsed strip `+`, bare checkout and flat folder row open the compact + full-page agent launcher on Electron. Opening creates nothing; `Run` splits + beside its captured target or creates a first pane when none exists. + [Create controls](../src/ui/worktree-card.tsx) omit menu ARIA on this route. + Right-click retains the checkout actions menu and its new-tab agent launches, + `Open shell` and `New split here`. Tauri retains the menu fallback. + Cmd/Ctrl+T uses the page when it has workspace context and the Open board + otherwise ([entry routing](../src/ui/app.tsx)). The legacy free-standing menu + still states its destination through [MenuSubject](../src/ui/agent-rail-card-model.ts). ## 28. The rail's action footer diff --git a/docs/internals/traps.md b/docs/internals/traps.md index 404b615c..8c2f0442 100644 --- a/docs/internals/traps.md +++ b/docs/internals/traps.md @@ -37,6 +37,12 @@ constants that currently switch behaviour off and are meant to be flipped back. - **Props on the element `DesktopChrome` returns are applied on mount and never updated.** The sidebar's live width and collapsed flag are written to `:root` imperatively to sidestep it. +- **A pane's terminal never goes below 24 columns, even when its box does.** opencode 1.18.31 stops + painting for good once its pty drops to 20 columns or fewer, also under tmux, and Deck + panes get that narrow easily. [`fit()`](../../src/terminal/pane.ts) clamps the size there, + so the extra columns clip at the pane's right edge. Rows follow the box: a row floor would + clip the bottom, where agents keep their prompt. Replacing it with `fitAddon.fit()` brings + the agent death back. ## Hosts and evidence diff --git a/electron/agent-limits/claude-reader.test.ts b/electron/agent-limits/claude-reader.test.ts index 0bcc5e1c..20238d08 100644 --- a/electron/agent-limits/claude-reader.test.ts +++ b/electron/agent-limits/claude-reader.test.ts @@ -112,6 +112,21 @@ describe.skipIf(process.platform === "win32")("Claude status-line collector", () await fs.unlink(path.join(options().directory, "claude-statusline.cjs")); expect(await run((await document()).statusLine.command, "still works")).toBe("still works"); }); + it("takes over another Deck install's wrapper and keeps the user's own command", async () => { + const original = { type: "command", command: `cat; printf %s "it's mine"`, refreshInterval: 5 }; + await fs.writeFile(options().settingsPath, JSON.stringify({ statusLine: original })); + const other = { ...options(), directory: path.join(root, "other-install") }; + await installClaudeLimitCollector(other); + await fs.rm(other.directory, { recursive: true }); + await installClaudeLimitCollector(options()); + const command = (await document()).statusLine.command; + expect(command).toContain(path.join(options().directory, "claude-statusline.cjs")); + expect(command).not.toContain("other-install"); + expect(await run(command, "passes through")).toBe("passes throughit's mine"); + await installClaudeLimitCollector(options()); + await restoreClaudeLimitCollector(options()); + expect((await document()).statusLine).toEqual(original); + }); it("refuses malformed settings without overwriting them", async () => { await fs.writeFile(options().settingsPath, "broken json"); await expect(installClaudeLimitCollector(options())).rejects.toThrow(SyntaxError); diff --git a/electron/agent-limits/claude-reader.ts b/electron/agent-limits/claude-reader.ts index 2665bdc1..487a9a06 100644 --- a/electron/agent-limits/claude-reader.ts +++ b/electron/agent-limits/claude-reader.ts @@ -34,7 +34,31 @@ async function optionalJson(file: string): Promise { } } -/** Wrap exactly the current command and retain all other status-line options/settings. */ +// The tail every wrapper below has carried since the first release: the user's +// own command, shell-quoted, or `:` when there was none. +const WRAPPER_FALLBACK = /; else sh -c ((?:'[^']*'|\\')+); fi$/; + +/** + * The command a Deck wrapper stands in front of: `null` when the user had no + * status line, `undefined` when `command` is not a Deck wrapper. Read from + * the wrapper itself rather than a manifest because the wrapper may belong to + * another Deck install (a dev build, an older release keyed to its own + * userData) whose manifest this one cannot find. + */ +function wrappedCommand(command: unknown): string | null | undefined { + if (typeof command !== "string" || !command.startsWith("if [ -x ") || !command.includes(SCRIPT)) + return undefined; + const quoted = WRAPPER_FALLBACK.exec(command)?.[1]; + if (quoted === undefined) return undefined; + const unquoted = quoted.replace(/'([^']*)'|\\'/g, (_match, inner?: string) => inner ?? "'"); + return unquoted === ":" ? null : unquoted; +} + +/** + * Wrap the user's command and retain all other status-line options/settings. + * The last Deck install to start owns the wrapper: it re-wraps any Deck + * wrapper with its own executable, and every install reads the same captures. + */ export async function installClaudeLimitCollector(options: ClaudeLimitOptions): Promise { const script = path.join(options.directory, SCRIPT); const manifestFile = path.join(options.directory, MANIFEST); @@ -42,10 +66,14 @@ export async function installClaudeLimitCollector(options: ClaudeLimitOptions): await updateClaudeUserSettings( options.settingsPath ?? claudeUserSettingsPath(), async (document) => { - const saved = limitRecord(await optionalJson(manifestFile)); const current = limitRecord(document.statusLine); - const owned = saved && current?.command === saved.installedCommand; - const original = owned ? saved.original : (document.statusLine ?? null); + const wrapped = wrappedCommand(current?.command); + const original = + wrapped === undefined + ? (document.statusLine ?? null) + : wrapped === null + ? null + : { ...current, command: wrapped }; const status = limitRecord(original); if ( original !== null && @@ -53,9 +81,6 @@ export async function installClaudeLimitCollector(options: ClaudeLimitOptions): ) { throw new Error("Unsupported Claude status line; settings were not changed."); } - if (!owned && typeof current?.command === "string" && current.command.includes(SCRIPT)) { - throw new Error("Another Deck installation owns this Claude status line."); - } const executable = shellQuote(options.executable); const run = `ELECTRON_RUN_AS_NODE=1 ${executable} ${shellQuote(script)} ${shellQuote(String(status?.command ?? ""))}`; const fallback = `sh -c ${shellQuote(String(status?.command ?? ":"))}`; @@ -67,7 +92,7 @@ export async function installClaudeLimitCollector(options: ClaudeLimitOptions): return { ...document, statusLine: { - ...((owned ? current : status) ?? {}), + ...(current ?? {}), type: "command", command: installedCommand, }, diff --git a/electron/agent-limits/service.test.ts b/electron/agent-limits/service.test.ts index 5d7589e7..c2c53ba8 100644 --- a/electron/agent-limits/service.test.ts +++ b/electron/agent-limits/service.test.ts @@ -9,7 +9,7 @@ describe("shared agent limit service", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); const secret = "synthetic-sensitive-setting"; const service = createAgentLimitsService({ - userData: "/unused", + appData: "/unused", executable: "/unused", readCodex: async () => absentLimits("codex"), connectClaude: async () => { @@ -26,7 +26,7 @@ describe("shared agent limit service", () => { const readCodex = vi.fn(async () => absentLimits("codex")); const connectClaude = vi.fn(async () => undefined); const service = createAgentLimitsService({ - userData: "/unused", + appData: "/unused", executable: "/unused", now: () => now, readCodex, @@ -46,7 +46,7 @@ describe("shared agent limit service", () => { it("fails closed on a failed account refresh and leaves the other provider readable", async () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); const service = createAgentLimitsService({ - userData: "/unused", + appData: "/unused", executable: "/unused", readCodex: async () => { throw new Error("signed out"); diff --git a/electron/agent-limits/service.ts b/electron/agent-limits/service.ts index 41ca59d5..592c32b6 100644 --- a/electron/agent-limits/service.ts +++ b/electron/agent-limits/service.ts @@ -10,7 +10,8 @@ import { installClaudeLimitCollector, readClaudeLimits } from "./claude-reader"; import { readCodexLimits } from "./codex-reader"; interface LimitsServiceOptions { - readonly userData: string; + /** The per-user app data root (`app.getPath("appData")`), not one install's userData. */ + readonly appData: string; readonly executable: string; readonly now?: () => number; readonly readCodex?: (signal: AbortSignal) => Promise; @@ -18,11 +19,16 @@ interface LimitsServiceOptions { readonly connectClaude?: () => Promise; } +const SHARED_LIMITS_DIRECTORY = path.join("SpaceVibe", "agent-limits"); + /** One Codex request per minute across windows; Claude consumes local status-line reports. */ export function createAgentLimitsService(options: LimitsServiceOptions) { const now = options.now ?? Date.now; const controller = new AbortController(); - const directory = path.join(options.userData, "agent-limits"); + // Shared by every Deck install on the account (release, dev build, a second + // copy): Claude has one user-level status line, so a collector keyed to one + // install's userData left every other install unable to read limits. + const directory = path.join(options.appData, SHARED_LIMITS_DIRECTORY); let codex = absentLimits("codex"); let lastCodexAttempt = -Infinity; let codexFlight: Promise | null = null; diff --git a/electron/ipc/register-services.ts b/electron/ipc/register-services.ts index 697ba9c0..1168cf19 100644 --- a/electron/ipc/register-services.ts +++ b/electron/ipc/register-services.ts @@ -33,7 +33,7 @@ export interface RegisterServicesDeps { export function registerServices(deps: RegisterServicesDeps): void { const limits = createAgentLimitsService({ - userData: app.getPath("userData"), + appData: app.getPath("appData"), executable: process.execPath, }); ipcMain.handle(CHANNELS.agentLimitsSnapshot, () => limits.snapshot()); diff --git a/package-lock.json b/package-lock.json index b93cf230..b3df90de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "spacevibe-deck", - "version": "2.0.0", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "spacevibe-deck", - "version": "2.0.0", + "version": "2.1.0", "hasInstallScript": true, "dependencies": { "@sentry/electron": "^7.18.0", diff --git a/package.json b/package.json index 1548613f..32226af6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "spacevibe-deck", "private": true, - "version": "2.0.0", + "version": "2.1.0", "type": "module", "scripts": { "generate:menu": "tsx scripts/generate-menu.ts", diff --git a/src/styles/04a-agent-rail.css b/src/styles/04a-agent-rail.css index 57cc1462..7845682c 100644 --- a/src/styles/04a-agent-rail.css +++ b/src/styles/04a-agent-rail.css @@ -969,15 +969,8 @@ pointer-events: none; } -/* DL-27.26 (2026-09-18): the `Needs me N` line above the clusters. One count - of the panes in `asked` or `failed`, drawn only while it is above zero, and - pressing it runs ⌘⇧A. It shares the cluster header's 7px inset so the chip - sits on the checkout dots' edge, and takes the same flat pill vocabulary as - the Board bar's filter chip (DL-34.11): a `--tone` wash inside DL-1.3's - inset hairline, no shadow, no motion. The count is the loud word — primary - text at 620 — and the label stays muted, so the line reads as a figure - with a caption rather than a button shouting. `` when nothing wires - the press (the gallery), ` - )} - - )}
{view.stream.map((group) => { const collapsed = collapsedGroupKeys.value.has(group.key);