Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ jobs:
cache: pnpm
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
# la-arch-check (run by the architecture tests) and its TypeScript twin, which is not on npm.
- run: uv tool install --no-build living-architecture==0.2.3
- run: uv tool install --no-build living-architecture==0.3.1
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- run: |
twin="$RUNNER_TEMP/living-architecture"
git init -q "$twin"
git -C "$twin" fetch -q --depth 1 https://github.com/MotleyAI/living-architecture.git 8b5cdc1e31d113435183a6459a252a843f5d7487 # v0.2.3
git -C "$twin" fetch -q --depth 1 https://github.com/MotleyAI/living-architecture.git 15ed4263ccce08513535aae077c8e7ba7363571d # v0.3.1
git -C "$twin" checkout -q FETCH_HEAD
cd "$twin/node"
npm ci --ignore-scripts
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Panes, terminal tabs, worktree rows and repo tabs show the most urgent mark of t

## Development

Requires Node 22 (`.nvmrc`), pnpm, and the `la-*` architecture tools on `PATH` (`uv tool install living-architecture==0.2.3`), which `pnpm test` runs.
Requires Node 22 (`.nvmrc`), pnpm, and the `la-*` architecture tools on `PATH` (`uv tool install living-architecture==0.3.1`), which `pnpm test` runs.

```sh
pnpm install
Expand Down
2 changes: 1 addition & 1 deletion architecture/model/typescript.c4 → architecture/model.c4
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@

specification {
element root
element node
element component
tag legacy
}

model {
typescript = root 'worktree-term' {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-12
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## Context

`WorktreeEntry` (`src/web/ui/app.tsx`) puts the select handler on the nested `.label` button, so the hit target is whatever box that child occupies; the `.worktree` row around it (padding, flex gaps, the strips above and below the label and the star, the mark) does nothing on click. Repo tabs already handle the click on the whole tab, with inner controls stopping propagation.

## Goals / Non-Goals

**Goals:** the row is the click target, so no future child layout can shrink it again.

**Non-Goals:** the 5px list padding outside the rows; the context menu, prunable and gone behaviour.

## Decisions

- **The row owns the click.** `.worktree` gets `onClick` → `view.selectWorktree(path)`; the label button loses its own `onClick`. Alternative rejected: stretching the label (CSS or a `::after` overlay) — still leaves the star column and gaps as special cases.
- **The label stays the keyboard control.** Enter/Space on the button fire a click that bubbles to the row; the row gets no role or `tabIndex`, avoiding a second tab stop. The row's handler carries `// NOSONAR(S6848) — a pointer shortcut; the label button is the keyboard control`, as at the pane container.
- **The star stops propagation.** Its `onClick` calls `event.stopPropagation()`, so toggling never selects. Clicks above or below the 18px star (29px row) select — a stray select is harmless, a stray star toggle is persisted and shared.
- **Cursor.** `cursor: pointer` moves from `.worktree .label` to `.worktree`.

## Risks / Trade-offs

- [A future in-row control forgets to stop propagation and also selects] → the checkbox e2e scenario shows the pattern; the repo-tab close button does the same.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## Why

A sidebar worktree switches only when the click lands on its branch-name text: the row is at least 29px tall, but only the text-tall label button inside it handles the click, so clicks above or below the text — between two names — do nothing.

## What Changes

- Web: a primary click anywhere in a worktree's row selects it; its checkbox still only toggles the star; a right-click still opens the worktree menu without selecting. The label stays the row's keyboard control (Enter or Space selects).
- The whole row shows the pointer cursor.

## Capabilities

### New Capabilities

### Modified Capabilities
- `web`: the worktree sidebar's click target is the whole row.

## Impact

- Code: `src/web/ui/app.tsx` (`WorktreeEntry`), `src/web/main/styles.css`.
- Architecture, wire, upgrade: no change.
- Tests: `test/e2e/sidebar.spec.ts`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
## MODIFIED Requirements

### Requirement: Worktree sidebar
The sidebar SHALL list the selected repo's worktrees in the daemon's order without bare entries, each labelled with its branch, or `<directory name> @ <first 7 digits of head>` when detached, or the directory name when it has no head. On hover a worktree SHALL show its path followed by one line `<preset> <id>: <status>` per terminal, or "no terminals", where the status is `waiting for input` (`state` `input`), `running` (`state` `working`), `done` (`state` `idle` and `unseen`), `idle` (`state` `idle`), `exited`, `exited with code <code>` or `killed by <signal>`, the exit taking precedence. A prunable worktree SHALL be shown greyed and SHALL offer no new terminal. A worktree no longer listed that still has live terminals SHALL be listed after the others, marked as gone, without a checkbox, until its last terminal closes. Each listed worktree SHALL have a checkbox reflecting the daemon's checked state, and toggling it SHALL send `setChecked`. A primary click anywhere in a worktree's row SHALL select it, except on its checkbox, which only toggles it; a right-click SHALL open the worktree menu without selecting. Enter or Space on a worktree's label SHALL select it. Worktree list changes SHALL show without user action.

#### Scenario: Labels
- **WHEN** worktrees are on branch `feature/x`, detached at `0123456789…` in directory `wt2`, and bare
- **THEN** the sidebar shows `feature/x` and `wt2 @ 0123456`, and no bare entry

#### Scenario: Status on hover
- **WHEN** a worktree has a terminal `probe` 3 printing continuously and a terminal `probe` 4 that exited with code 1
- **THEN** hovering it shows its path, `probe 3: running` and `probe 4: exited with code 1`

#### Scenario: Vanished worktree keeps its terminals
- **WHEN** a worktree with a running terminal is removed with `git worktree remove --force`
- **THEN** it stays listed as gone, its terminal remains usable, and it disappears after the terminal is closed

#### Scenario: Checkbox shared
- **WHEN** a worktree is checked in one page
- **THEN** it shows as checked in another page connected to the same hub

#### Scenario: The whole row selects
- **WHEN** the user clicks an unselected worktree's row at its top edge, at its bottom edge, in the space right of its label, or just above its checkbox
- **THEN** that worktree is selected

#### Scenario: The checkbox only toggles
- **WHEN** the user clicks an unselected worktree's checkbox
- **THEN** the checkbox toggles and the selected worktree does not change

#### Scenario: Right-click does not select
- **WHEN** the user right-clicks an unselected worktree's row
- **THEN** the worktree menu opens and the selected worktree does not change

#### Scenario: Keyboard selects
- **WHEN** the user focuses an unselected worktree's label and presses Enter, or another's and presses Space
- **THEN** that worktree is selected

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
## 1. Corpus

- [x] 1.1 Archive the merged `worktree-name-search` change in its own commit; `openspec validate --specs --strict` passes

## 2. Tests (e2e, `test/e2e/sidebar.spec.ts`)

- [x] 2.1 The whole row selects: `click({ position })` on an unselected worktree's row at y=1, y=height−2, right of the label, and above the star each select it (assert `aria-selected`); fails before the fix
- [x] 2.2 The checkbox only toggles: clicking an unselected worktree's checkbox checks it and leaves the selection unchanged
- [x] 2.3 Right-click does not select: right-clicking an unselected row opens the worktree menu and leaves the selection unchanged
- [x] 2.4 Keyboard selects: extend `a worktree is selected from the keyboard` to press Enter on one label and Space on another's

## 3. Implementation

- [x] 3.1 `WorktreeEntry` in `src/web/ui/app.tsx`: row `onClick` selects (with the `NOSONAR(S6848)` comment from design.md), the label button has no `onClick`, the star's `onClick` stops propagation; tests 2.1–2.4 pass
- [x] 3.2 `src/web/main/styles.css`: `cursor: pointer` on `.worktree`, removed from `.worktree .label`

## 4. Final gates

- [x] 4.1 `pnpm test` green (incl. Playwright); `pnpm lint` exits 0; `tsc -b` clean; `openspec validate dev-2080-worktree-term-clicking-anywhere-on-a-sidebar-worktree-row --strict` passes
45 changes: 44 additions & 1 deletion openspec/specs/web/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ The page SHALL show one tab per repo of each host, in the order the hub lists th
- **THEN** the tab labelled `box:app` shows the status `reconnecting`

### Requirement: Worktree sidebar
The sidebar SHALL list the selected repo's worktrees in the daemon's order without bare entries, each labelled with its branch, or `<directory name> @ <first 7 digits of head>` when detached, or the directory name when it has no head. On hover a worktree SHALL show its path followed by one line `<preset> <id>: <status>` per terminal, or "no terminals", where the status is `waiting for input` (`state` `input`), `running` (`state` `working`), `done` (`state` `idle` and `unseen`), `idle` (`state` `idle`), `exited`, `exited with code <code>` or `killed by <signal>`, the exit taking precedence. A prunable worktree SHALL be shown greyed and SHALL offer no new terminal. A worktree no longer listed that still has live terminals SHALL be listed after the others, marked as gone, without a checkbox, until its last terminal closes. Each listed worktree SHALL have a checkbox reflecting the daemon's checked state, and toggling it SHALL send `setChecked`. Worktree list changes SHALL show without user action.
The sidebar SHALL list the selected repo's worktrees in the daemon's order without bare entries, each labelled with its branch, or `<directory name> @ <first 7 digits of head>` when detached, or the directory name when it has no head. On hover a worktree SHALL show its path followed by one line `<preset> <id>: <status>` per terminal, or "no terminals", where the status is `waiting for input` (`state` `input`), `running` (`state` `working`), `done` (`state` `idle` and `unseen`), `idle` (`state` `idle`), `exited`, `exited with code <code>` or `killed by <signal>`, the exit taking precedence. A prunable worktree SHALL be shown greyed and SHALL offer no new terminal. A worktree no longer listed that still has live terminals SHALL be listed after the others, marked as gone, without a checkbox, until its last terminal closes. Each listed worktree SHALL have a checkbox reflecting the daemon's checked state, and toggling it SHALL send `setChecked`. A primary click anywhere in a worktree's row SHALL select it, except on its checkbox, which only toggles it; a right-click SHALL open the worktree menu without selecting. Enter or Space on a worktree's label SHALL select it. Worktree list changes SHALL show without user action.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#### Scenario: Labels
- **WHEN** worktrees are on branch `feature/x`, detached at `0123456789…` in directory `wt2`, and bare
Expand All @@ -76,6 +76,22 @@ The sidebar SHALL list the selected repo's worktrees in the daemon's order witho
- **WHEN** a worktree is checked in one page
- **THEN** it shows as checked in another page connected to the same hub

#### Scenario: The whole row selects
- **WHEN** the user clicks an unselected worktree's row at its top edge, at its bottom edge, in the space right of its label, or just above its checkbox
- **THEN** that worktree is selected

#### Scenario: The checkbox only toggles
- **WHEN** the user clicks an unselected worktree's checkbox
- **THEN** the checkbox toggles and the selected worktree does not change

#### Scenario: Right-click does not select
- **WHEN** the user right-clicks an unselected worktree's row
- **THEN** the worktree menu opens and the selected worktree does not change

#### Scenario: Keyboard selects
- **WHEN** the user focuses an unselected worktree's label and presses Enter, or another's and presses Space
- **THEN** that worktree is selected

### Requirement: Checked filter
The page SHALL offer, for the selected repo, a toggle between showing all worktrees and only checked ones; each repo SHALL keep its own setting, stored per browser across reloads, and a repo without one SHALL show all. With "checked only", the sidebar SHALL list the checked worktrees and the selected worktree. Selecting another repo SHALL show that repo's setting without animating the toggle; only the user's toggling SHALL animate it.

Expand Down Expand Up @@ -486,3 +502,30 @@ Only a page that has the focus SHALL send `resize`. A page that gains the focus
#### Scenario: Refocus takes it back
- **WHEN** the first page gains the focus again
- **THEN** the PTY takes the first page's current size

### Requirement: Worktree name search
The sidebar header SHALL hold, between its title and the checked filter's toggle, a text box for the selected repo: while it holds text, the sidebar SHALL list only the worktrees whose label contains that text, trimmed and ignoring case, that the checked filter also lists, and the selected worktree. While it holds text, a control beside it SHALL empty it. Each repo SHALL keep its own text, stored per browser across reloads; a repo without one SHALL list every worktree the checked filter lists. The text SHALL play no part in which worktree is selected without a stored selection. When the browser refuses to store a setting of the page, the change SHALL still apply and the page SHALL show that saving failed.

#### Scenario: Search narrows the list
- **WHEN** a repo has worktrees on `feature/login`, `fix/Logout` and `docs`, `feature/login` is selected and the user types `LOG`
- **THEN** the sidebar lists `feature/login` and `fix/Logout` only

#### Scenario: Selected worktree stays listed
- **WHEN** the selected worktree's label does not contain the typed text
- **THEN** it stays listed and selected

#### Scenario: Search belongs to its repo and persists
- **WHEN** text is typed in one repo, another repo is selected and the page is reloaded
- **THEN** the other repo shows an empty box and every worktree its checked filter lists, and the first repo shows its text and lists only the matching worktrees and the selected one

#### Scenario: Clear control empties the search
- **WHEN** the user activates the clear control
- **THEN** the box is empty and focused, the clear control is gone, and every worktree the checked filter lists is listed, with the selected one, also after a reload

#### Scenario: Search with "checked only"
- **WHEN** the search is `feat` and the checked filter is "checked only"
- **THEN** only checked worktrees whose label contains `feat`, and the selected worktree, are listed

#### Scenario: Refused storage write
- **WHEN** the browser refuses to store the search and the user types text
- **THEN** the list is narrowed by the text and the page shows that saving its settings failed
57 changes: 55 additions & 2 deletions src/daemon/server/bind.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import { mkdtempSync, readdirSync, rmSync } from 'node:fs';
import { existsSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import * as files from '../../platform/files/index.js';
import { bindSocket } from './bind.js';

const hooks = vi.hoisted(() => ({ failRename: false }));
interface Hooks {
failRename: boolean;
replaceAfterRename: boolean;
lockHeld: boolean;
lockPath: string;
/** Each removeFile call, and whether the start lock file existed at that moment. */
removals: { path: string; locked: boolean }[];
}

const hooks = vi.hoisted(
(): Hooks => ({ failRename: false, replaceAfterRename: false, lockHeld: false, lockPath: '', removals: [] }),
);

vi.mock('../../platform/files/index.js', async (importOriginal) => {
const real = await importOriginal<typeof files>();
Expand All @@ -14,6 +25,18 @@ vi.mock('../../platform/files/index.js', async (importOriginal) => {
renameFile: async (from: string, to: string): Promise<void> => {
if (hooks.failRename) throw Object.assign(new Error('EXDEV: rename failed'), { code: 'EXDEV' });
await real.renameFile(from, to);
if (hooks.replaceAfterRename) {
renameSync(to, `${to}.moved`);
writeFileSync(to, 'someone else');
}
},
acquireStartLock: async (path: string): Promise<files.StartLock> => {
if (hooks.lockHeld) throw new real.StartLockHeldError(`start lock ${path} is held`);
return real.acquireStartLock(path);
},
removeFile: async (path: string): Promise<void> => {
hooks.removals.push({ path, locked: existsSync(hooks.lockPath) });
await real.removeFile(path);
},
};
});
Expand All @@ -23,6 +46,10 @@ let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'wtd-bind-'));
hooks.failRename = false;
hooks.replaceAfterRename = false;
hooks.lockHeld = false;
hooks.lockPath = '';
hooks.removals = [];
});

afterEach(() => {
Expand All @@ -46,3 +73,29 @@ it('binds the socket when setup succeeds', async () => {
expect(readdirSync(paths.runDir)).toEqual(['box.sock']);
await listener.close();
});

it('keeps a socket replaced the moment it was renamed into place', async () => {
const paths = files.hostPaths({ env: { XDG_STATE_HOME: dir }, home: dir, host: 'box' });
hooks.replaceAfterRename = true;
const listener = await bindSocket(paths);
await listener.close();
expect(readFileSync(paths.socket, 'utf8')).toBe('someone else');
});

it('removes its socket on close only while holding the start lock, then releases the lock', async () => {
const paths = files.hostPaths({ env: { XDG_STATE_HOME: dir }, home: dir, host: 'box' });
hooks.lockPath = paths.lock;
const listener = await bindSocket(paths);
await listener.close();
expect(hooks.removals.filter((r) => r.path === paths.socket)).toEqual([{ path: paths.socket, locked: true }]);
expect(existsSync(paths.socket)).toBe(false);
expect(existsSync(paths.lock)).toBe(false);
});

it('leaves its socket on close while another process holds the start lock', async () => {
const paths = files.hostPaths({ env: { XDG_STATE_HOME: dir }, home: dir, host: 'box' });
const listener = await bindSocket(paths);
hooks.lockHeld = true;
await listener.close();
expect(existsSync(paths.socket)).toBe(true);
});
Loading
Loading