From a6cb69aa75b10f8f9573bdb2bc4d5a48598d92ce Mon Sep 17 00:00:00 2001 From: Franco Conturso Date: Wed, 11 Nov 2026 17:27:42 -0300 Subject: [PATCH] Sidebar: an empty project can start an orchestrator or a terminal, and Close project replaces Hide project (#746, D291) --- cmd/contr/e2e_see_test.go | 30 +++++++ docs/DECISIONS.md | 9 +- docs/spec/ProjectRow.md | 27 +++--- docs/spec/ProjectsModal.md | 18 ++-- internal/app/attach.go | 1 + internal/app/frame.go | 7 ++ internal/app/projectsview/projectsmodal.go | 2 +- .../app/projectsview/projectsmodal_hide.go | 4 +- .../projectsview/projectsmodal_hide_test.go | 2 +- .../app/projectsview/projectsmodal_test.go | 6 +- .../app/projectsview/projectsmodal_view.go | 2 +- internal/app/projectsview_frame_test.go | 8 +- internal/app/sidebar/sidebar.go | 46 +++++----- internal/app/sidebar/sidebar_keys_test.go | 9 +- internal/app/sidebar/sidebar_test.go | 4 +- internal/app/sidebar/spaces.go | 28 +++++- internal/app/sidebar/spaces_projects_test.go | 7 +- internal/app/sidebar/stopmenus_test.go | 85 +++++++++++++++++-- internal/app/terminalempty.go | 39 ++++++++- internal/app/terminalempty_test.go | 48 ++++++++++- 20 files changed, 299 insertions(+), 83 deletions(-) diff --git a/cmd/contr/e2e_see_test.go b/cmd/contr/e2e_see_test.go index 84725568..fc124d5a 100644 --- a/cmd/contr/e2e_see_test.go +++ b/cmd/contr/e2e_see_test.go @@ -55,6 +55,14 @@ var seeFixtures = map[string]seeFixture{ }, ready: "Sidebar", }, + "empty-project": { + about: "the workers fixture and a stored project, site, with nothing open (D291); press its row, or right-click it for its menu", + setup: func(t *testing.T, h *harness) { + seeWorkers(t, h) + h.storedProject(t, "site") + }, + ready: "site, ", + }, "workers": { about: "project demo: an orchestrator and two builders, one working", setup: seeWorkers, @@ -668,6 +676,28 @@ func (h *harness) recentProject(t *testing.T, name string) { } } +// storedProject makes the git repo name in the scratch folder and stores it as an +// added folder with no terminal: an empty project (D116). +func (h *harness) storedProject(t *testing.T, name string) { + t.Helper() + dir := filepath.Join(h.home, "Library", "Application Support", "contr") + path := h.repo(t, name, "main") + if real, err := filepath.EvalSymlinks(path); err == nil { + path = real + } + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + doc, err := json.Marshal(map[string]any{"version": 1, "order": []string{filepath.Join(path, ".git")}, + "folders": map[string]string{filepath.Join(path, ".git"): path}}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "projects.json"), doc, 0o600); err != nil { + t.Fatal(err) + } +} + func seeWorkers(t *testing.T, h *harness) { seeWorkersRepo(t, h) } // seeNonGit is the workers fixture with `+ new` set to pick a folder outside diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 1e23747a..f6c33234 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -158,10 +158,10 @@ replaces it. | D168 | 2026-11-06 | Gaps are 8 pt and the title bar is 38 pt (#433). Title bar values replaced by D177. `theme.PanelGap` is `Space8`: between panels (4 + 1 + 3 around the `ui.Split` divider, so the drag area is as wide as the gap), from the window left, right and bottom edge to the panels, and from the title bar to the panels. `titlebar-height` is 38 (was 46): the 30 pt tab sits 4 pt under the window top and 4 pt above the bar's end, so the tab row is 12 pt from the panels; the traffic lights stay centered on the row (`TrafficLightPosition` reads `TitleBarHeight`). The tab keeps its spec (30 high, padding `0 10`, starts at the terminal panel's left edge). The divider line is transparent at rest and shows grey only on hover or drag (unchanged). Measured in the capture with `TestWindowGaps` (2x, Dark and Light): before, every gap 10 pt and the tab row 8 / 30 / 18 pt (rim to tab, tab, tab to panel); after, every gap 8 pt and 4 / 30 / 12. | The owner saw the gaps as unequal and too large, and the tab row as too spaced since the gap under the title bar (D167). In the capture the visible gaps were already equal (10 pt, pixel by pixel), so the change is size: 8 pt everywhere. Replaces the sizes of D167. | | D169 | 2026-11-06 | Agent rows as in the mock-up (#434). An orchestrator row sits 16 px in from its project row; its workers sit 17 px further in, on the rail. A worker row has the task on line 1 (the terminal title without its status glyph, when it is usable; else the worker name) and ` ` on line 2 (the name is left out when line 1 already shows it); the issue id is only in the screen-reader label. The model is the profile's `--model`, else the `--model` of the terminal's own command line, so an orchestrator contr did not start shows it when it was started with the flag. The age keeps its place at the right of line 2 whatever else line 2 holds. | | D170 | 2026-11-06 | Files panel as in the mock (#435). The head shows the project name (kit.Panel `Title`; the group keeps its name "Files"). The `filter` icon is three lines, not a funnel. The tree has no disclosure arrow: `ui.Tree` always builds one, so it runs inside `ui.Themed` with `TextMuted` clear and `SurfaceHover` = `hover`, and the row has a negative start margin of the arrow's width. A folder opens by click or ← →. | The mock shows the project name in the head, three lines for filter and no arrows; `ui.Tree` has no option to hide its arrow, so the theme and the margin hide it while MyGo keeps the folder click, the arrow keys and the screen-reader roles. | -| D171 | 2026-11-06 | Replaced by D268 for the ink tokens it names (five inks now); the rest still holds. Sidebar chrome as in the mock-up (#436). A project row has the `folder` outline icon and the name in `body` (regular), not `body-strong`. An empty project is dim (name and icon `ink-42`) and has no `+`; its menu has "New orchestrator in …" (D116). The Projects head has one `+` whose menu has "New orchestrator" and "Open projects" (the icon column keeps both buttons). Project groups are 12 px apart. The bottom has a hairline above the Settings button, which sits at the right. Row icons set their own ink: the This Mac icon was nearly invisible. | The mock shows projects quieter than their agents, one head button, and air between groups. A margin on a `ui.List` row does not move the next row, so the air is the padding of a wrapper. A menu keeps both actions without a second button. | +| D171 | 2026-11-06 | Partly replaced by D291 (the menu of an empty project). Replaced by D268 for the ink tokens it names (five inks now); the rest still holds. Sidebar chrome as in the mock-up (#436). A project row has the `folder` outline icon and the name in `body` (regular), not `body-strong`. An empty project is dim (name and icon `ink-42`) and has no `+`; its menu has "New orchestrator in …" (D116). The Projects head has one `+` whose menu has "New orchestrator" and "Open projects" (the icon column keeps both buttons). Project groups are 12 px apart. The bottom has a hairline above the Settings button, which sits at the right. Row icons set their own ink: the This Mac icon was nearly invisible. | The mock shows projects quieter than their agents, one head button, and air between groups. A margin on a `ui.List` row does not move the next row, so the air is the padding of a wrapper. A menu keeps both actions without a second button. | | D172 | 2026-11-06 | Title bar as in the mock (#437; D168). The sidebar toggle next to the window buttons is removed: the corner holds only the window buttons (78 px at least). The sidebar shows or hides with the new View > Toggle Sidebar menu item, `⌘B` (the menu had no such item before). A plain terminal tab shows the bare `>_` prompt icon (`terminal.svg` lost its box). The selected tab keeps its pill (`selected` fill and `shadow-selected` ring). `theme.SidebarToggleRadius` and `TitleGlyph*` are removed. | Issue #437. | | D173 | 2026-11-06 | Launch selects an agent (#438). Once per run, when nothing is selected, `SpacesList` selects the first live orchestrator of the first project in sidebar order, else the first live worker, else nothing. It waits for the stored project order (`HoldLaunch`, released when the store loads or fails to load), so the pick follows the order the owner sees. It never replaces a selection the owner made, and it does not run again after the owner clears the selection. Spec: `Terminal.md` edge cases. | -| D174 | 2026-11-06 | Issue titles, status-line model, empty projects last (#443). **Title:** line 1 of a worker row is the title of its issue, read with `gh issue view N --json title` (`github.Client.IssueTitle`) in the worker's folder, off the UI thread, by `IssueTitles` (like `WorkerPRs`, D104): a read starts only from an agents sync for a live worker whose issue has no fresh answer, so spawn reads it at once and an idle screen does nothing; a found title holds 10 minutes, a failed read (or none yet) is asked again after 5; a failed read is logged and keeps the title that was known; at most 2 reads run at once. Fallback: the task (D169), then the name. **Model:** an agent with no profile model and no `--model` in argv shows the model of Claude Code's status line: `contr statusline` saves the short name ("sonnet", "opus") in the context file of the terminal (D144), and the usage sampler's read gives it to the row. **Order:** a project with nothing open sorts after those with agents; the others keep the dragged order (D109; owner, 2026-11-06); a drag moves the project in the stored order, so an empty project keeps its stored place. **Keys:** with the project list focused, the menu key (or Shift+F10) opens the menu of the empty project under the key cursor, and Enter there starts nothing (right-click opens the same menu). The This Mac icon ink is a named token test, not a pixel test. | Owner brief and review of #446. | +| D174 | 2026-11-06 | Partly replaced by D291 (the menu item "New orchestrator in "). Issue titles, status-line model, empty projects last (#443). **Title:** line 1 of a worker row is the title of its issue, read with `gh issue view N --json title` (`github.Client.IssueTitle`) in the worker's folder, off the UI thread, by `IssueTitles` (like `WorkerPRs`, D104): a read starts only from an agents sync for a live worker whose issue has no fresh answer, so spawn reads it at once and an idle screen does nothing; a found title holds 10 minutes, a failed read (or none yet) is asked again after 5; a failed read is logged and keeps the title that was known; at most 2 reads run at once. Fallback: the task (D169), then the name. **Model:** an agent with no profile model and no `--model` in argv shows the model of Claude Code's status line: `contr statusline` saves the short name ("sonnet", "opus") in the context file of the terminal (D144), and the usage sampler's read gives it to the row. **Order:** a project with nothing open sorts after those with agents; the others keep the dragged order (D109; owner, 2026-11-06); a drag moves the project in the stored order, so an empty project keeps its stored place. **Keys:** with the project list focused, the menu key (or Shift+F10) opens the menu of the empty project under the key cursor, and Enter there starts nothing (right-click opens the same menu). The This Mac icon ink is a named token test, not a pixel test. | Owner brief and review of #446. | | D175 | 2026-11-06 | Selected tab in the panel color; long titles end in an ellipsis (#448; D172). The selected tab's pill is filled with the `panel` token, as the panels are, so it follows the Settings tint sliders (D162) and reads as part of the terminal below it; the `shadow-selected` ring stays. The tab title is `Text(...).SingleLine()`: with `NoWrap().Ellipsis("…")` MyGo did not cut a title wider than its tab (the text ran over the close button), so the title came out cut mid-letter. The tooltip is the tab's tip with the full title in front when the tip does not hold it. Title style stays `row-small` as in the spec. | Owner request on v0.2.27. | | D176 | 2026-11-06 | Update ready: background install, title-bar pill, one toast (#451; changes D115 and D146). When the scheduled check (`Settings.StartChecks`) finds a newer version, contr installs it off the UI thread (`Updates.Install`) and does not relaunch. MyGo's updater keeps the running app as it is: the new bundle replaces the app once it exits, and runs after `App.Relaunch` or at the next launch. A failed download is logged and shows in Settings ("download failed: …", with "Install and restart"), not as a toast, and shows no pill. When the install ends, the title bar shows the pill `Restart to update` (`download` icon) left of the diff summary until the restart; the Settings dot (D146) stays. The first time a version is ready, one toast drops under the pill (`ToastBase`, attached to the pill) with `Restart now`, `Later` and close; it does not show again for that version in this run. A click on the pill opens a panel under it (`PopoverBase`): version and `from` version, `What's new` (the list items of the release notes, at most 6), the agents note, `Downloaded N min ago`, `Later` and `Restart now ⏎` (Return presses it). `Restart now` calls `Settings.Relaunch`; Settings → Updates shows the same button when an update is ready. A manual "Check for updates" still asks before it installs. Spec: `docs/spec/UpdatePill.md`, `TitleBar.md`. | Issue #451: the owner wants to see and start an update without opening Settings. | | D177 | 2026-11-06 | Equal space above and below the tab row (#455; replaces the title bar values of D168). The space from the window top edge to the tab top and from the tab bottom to the panel top is 8 pt each. `titlebar-height` is 46 pt (8 + 30 pt tab + 8); the tabs, the right-side buttons and the diff summary center in it, and the traffic lights stay centered on the tab row (`TrafficLightPosition` Y is `(46 - 12) / 2`). The body row has no top padding, so the panels start where the title bar ends. Reason: the owner saw the tab close to the top and far from the panel (4 pt and 12 pt). | @@ -186,7 +186,7 @@ replaces it. | D204 | 2026-11-09 | The questions card body is selectable, and a backtick span shows as a code chip that copies (#498). `Selectable()` on the card body lets the owner drag, double-click, Cmd+C and use the Copy menu on its texts. In the question, the to-do and the Details source, a span in backticks shows in mono (`meta` size) on `hover-strong` with `radius-4`, without the backticks; an unclosed backtick or an empty span stays plain text. A click on a chip copies its text and shows the toast "Copied" (D126); its label is "Copy ". | The owner got a to-do with a command to run and could not copy it. MyGo has selectable text and inline elements in a RichText, so no hand-built layout. Texts inside buttons (choice labels and impacts) keep the button click: MyGo does not select text in controls. | | D205 | 2026-11-09 | The Context meter falls back to the Stop hook's labels (#499). When the sampler has no status line file for the selected terminal, `agents.Worker` gives `contr.context` (tokens) and `contr.context-limit` (the compaction window, D189), and the meter shows them in the format of the file ("90k of 160k 56%"). A label that is missing or not a positive integer is no data. The file wins when there is one. | A session that does not run `contr statusline` (started before #497, or by hand in an hc terminal) showed "—", but the Stop hook already labels each hc terminal with its context. The label limit is the compaction window, not the model window, so the spec says so. | | D206 | 2026-11-09 | `agents.DefaultConfig` has an `orchestrator` profile: `claude --model opus --effort medium --permission-mode auto` (#493). `+ new` works with no agents.json. A profile in the file still replaces the default. `Config.Save` still writes only profiles that the file had or that differ from `DefaultConfig`, so the default is not frozen in the file. Settings → Agents shows the default for the orchestrator; the `not set` state is removed. `LoadConfig` loads a file with a `presets` key and no `profiles` key (the old Swift format) with the defaults, and sets `Config.Notice`. The notice shows once in the toasts at start and in `contr agents help`. The file is not rewritten or deleted. Replaces the "no default orchestrator profile" part of D57 and the `not set` part of D186. | A new install failed on `+ new` with "no orchestrator profile", and an old-format file gave no message. | -| D207 | 2026-11-09 | The projects modal is one palette dialog (#494; layout and copy in `docs/spec/ProjectsModal.md`). One search field filters the list, and also takes `owner/name`, a GitHub URL or a path, which turn the top row into `Clone …` or `Open …`. One list has the sections `Add`, `Open · N` and `Recent`. Enter runs the selected row; `⌘⌫` hides; `⌘O` opens the folder dialog; Esc clears the field, and on an empty field closes the dialog. There is no `Done`: every change saves at once (D63). The surface is `glass-float` (surface replaced by D217: `float-solid`), 560 wide, radius `radius-14`, shadow `shadow-float`. **Hide** (was `close`, D70) shows a toast ` hidden from the sidebar` with `Undo`; the toast belongs to the frame, so Undo works after the dialog closes. A hidden project goes to the top of Recent; opening it shows it again. The clone shows an indeterminate progress line, with `Cancel` and `Try again`. A path that is not a folder shows a red line under its row. The open-row meta is ` · · ` (the `Hold` text of the main worktree row); zero counts never show. **Exception to the `blocked` rule:** the `N need you` part of that meta is `blocked` text (tokens.md amended). **Replaces:** in D110 the `close` button; in D116 the `+ open folder…` and `open` buttons; in D117 the separate clone field, `Choose folder…`, `Clone` and the `→ ` preview. The rest of D110, D116 and D117 stands. D70 stands: Hide hides in contr only. | The owner approved the mock-up. Three ways to add a project were spread over the top, middle and bottom of the modal, and a big button on every row made a hide as loud as an open. `Done` suggested that changes wait for a confirm, but they save at once. Red text for `N need you` is what the issue asks for, and it shows only above zero. `scrim` token: Dark rgba(8,8,8,.72), Light rgba(20,20,20,.18). | +| D207 | 2026-11-09 | Partly replaced by D291 (the word "hide" in the modal and the toast text). The projects modal is one palette dialog (#494; layout and copy in `docs/spec/ProjectsModal.md`). One search field filters the list, and also takes `owner/name`, a GitHub URL or a path, which turn the top row into `Clone …` or `Open …`. One list has the sections `Add`, `Open · N` and `Recent`. Enter runs the selected row; `⌘⌫` hides; `⌘O` opens the folder dialog; Esc clears the field, and on an empty field closes the dialog. There is no `Done`: every change saves at once (D63). The surface is `glass-float` (surface replaced by D217: `float-solid`), 560 wide, radius `radius-14`, shadow `shadow-float`. **Hide** (was `close`, D70) shows a toast ` hidden from the sidebar` with `Undo`; the toast belongs to the frame, so Undo works after the dialog closes. A hidden project goes to the top of Recent; opening it shows it again. The clone shows an indeterminate progress line, with `Cancel` and `Try again`. A path that is not a folder shows a red line under its row. The open-row meta is ` · · ` (the `Hold` text of the main worktree row); zero counts never show. **Exception to the `blocked` rule:** the `N need you` part of that meta is `blocked` text (tokens.md amended). **Replaces:** in D110 the `close` button; in D116 the `+ open folder…` and `open` buttons; in D117 the separate clone field, `Choose folder…`, `Clone` and the `→ ` preview. The rest of D110, D116 and D117 stands. D70 stands: Hide hides in contr only. | The owner approved the mock-up. Three ways to add a project were spread over the top, middle and bottom of the modal, and a big button on every row made a hide as loud as an open. `Done` suggested that changes wait for a confirm, but they save at once. Red text for `N need you` is what the issue asks for, and it shows only above zero. `scrim` token: Dark rgba(8,8,8,.72), Light rgba(20,20,20,.18). | | D212 | 2026-11-09 | The update panel, the What's new panel and the open questions card use the new token `float-solid` as their fill (#524; changes D176, D209): `glass-float` at full alpha, Dark `#222328`, Light `#fcfcfd`. `float-line` and `shadow-float` stay. The closed notch label, the outline card, the find bar and the toasts keep `glass-float`. | Owner request, 2026-11-09: on the owner's Mac (Light) the file tree behind the panel showed through, and its rows cut through the text. No blur shows behind the panel, so the translucent fill gave no glass look. The owner then asked for the same solid fill on the questions card. | | D210 | 2026-11-09 | contr reads the Codex plan limits from `codex app-server` (#508; owner, question D2): JSON-RPC over stdio, one JSON message per line. It sends `initialize` (`clientInfo` name `contr`), the `initialized` notification and `account/rateLimits/read`, takes the `codex` limit (`rateLimitsByLimitId.codex`, else `rateLimits`), maps `primary` and `secondary` (`usedPercent`, `resetsAt` in Unix seconds) to windows, closes stdin and waits for the process to end. A read has a 10 s limit; then the process group is killed. `codex` comes from PATH, else from the PATH of the owner's interactive login shell (`$SHELL -l -i`, read once per run), and runs with that PATH. A failure (no `codex`, no start, an error answer, a bad answer, a timeout, no limit window) gives no data and is logged once per kind until a read succeeds. The rollout file reader stays as the fallback. | Current Codex keeps sessions in SQLite and writes no rollout files, so the Codex meter showed "–". app-server is the API that the Codex IDE extension uses for the same data, and contr reads no Codex file for it. Spawn needs no PATH lookup (hc runs argv), so contr had none to reuse; version managers such as mise put `codex` and `node` on PATH only in the interactive rc file. | | D209 | 2026-11-09 | After an update, the update pill shows What's new once (#509; extends D176). When an install ends well (background or "Install and restart"), `Settings.RecordInstall` saves `{version, from, notes, installed_at, shown: false}` to `updated.json` in contr's state folder (`internal/updated`, temp file and rename), off the UI thread and before any relaunch. At start the frame reads it off the UI thread; when `version` is the running version and `shown` is false, the title bar shows `Updated to X` (`download` icon) with its panel open: the same panel as D176 (head, `from`, What's new) without the agents note, and a foot with `Updated N min ago` and a filled `Got it ⏎`. Got it, Return, Escape or a press outside closes it; the pill goes and the file is saved with `shown: true`, off the UI thread. A missing file, another version or `shown` shows nothing; a corrupt file is logged. A manual install outside contr writes no file, so it shows nothing. Spec: `docs/spec/UpdatePill.md`. | Owner request, 2026-11-09: after an update and restart, show the same component as the update panel, open, with What's new for the version that now runs. | @@ -198,7 +198,7 @@ replaces it. | D215 | 2026-11-09 | Floating surfaces blur what is behind them (#536). The projects modal card (fill and blur replaced by D217), the toasts, the find bar and the outline card fill with `glass-float` (alpha 0.72 Dark, 0.8 Light) and now also set `Material(glass.Blur{Radius: theme.GlassBlur})`, as the notch does. The terminal was not above the overlay: it is a normal draw of the same layer, and the modal and its scrim are above it. Without the blur, the 20-28 percent of the terminal under the card showed through. `TestGlassFloatSurfacesBlurWhatIsBehind` fails when a file fills with `glass-float` and has no `glass.Blur`. | Issue #536: the banner box and text of the terminal showed in the top-left of the modal in both themes. Settings fills with `ground` and is opaque, so it needs no blur. | | D217 | 2026-11-09 | Modals are never transparent (#546). A modal or dialog (`DialogBase`, `Modal`, `AlertDialog`) and a floating card with text to read have an opaque fill: `float-solid` or `ground`. The projects modal panel now fills with `float-solid` (`projectsModalFill`); its `scrim` backdrop, `float-line` border and `shadow-float` stay. The panel has no `glass.Blur` material any more: with it, the panel drew the blurred backdrop under the scrim in place of the fill (Light `#d2d2d1`, not `#fcfcfd`), and `float-solid` has no blur. `glass-float` stays only for the closed questions notch, the outline card, the find bar and toasts. The rule is in `docs/spec/README.md` (Color), `ProjectsModal.md` and the `float-solid` row of `tokens.md`. **Replaces:** the `glass-float` surface of D207 and the projects modal fill and blur of D215. | Owner request (2026-11-09): "modals shouldn't be transparent". Under `glass-float` the terminal text behind the projects modal showed through its rows, even with the blur of D215. Settings already fills with the opaque `ground`; the update panel, What's new and the open questions card already use `float-solid` (D212). | | D218 | 2026-11-09 | The Claude glyph is the Claude Code mascot (owner, #549); it replaces D123's mark for the status glyph. `assets/icons/claude-mascot.svg` is an MIT icon that NOTICE credits: one path (evenodd), its viewBox cropped to the shape (24×15 units, 16×10 cells), one fill that the view replaces. A test checks the SVG against the credited path. The mascot is as wide as the glyph box (16px, 14px small) and centred, so a cell is 1px in rows and the edges are sharp. Working: a hop of 1.5px up and back in .8s, at 30 frames a second, by whole device pixels; Reduce Motion, working slow, waiting, idle and needs you: still. Colors and idle opacity .45 do not change (D191). `assets/icons/claude.svg` is removed. | The owner asked for the Claude Code mascot and pointed to this icon. The cropped viewBox puts the cells on whole pixels. The hop only moves the mask, so a frame costs less than the turn, which rasterized the mark again at each angle. | -| D219 | 2026-11-09 | Stop, Close and Hide from the tab and the project row (#556; owner). `Stop` in the app keeps the ended terminal, and an ended orchestrator stays in the sidebar (D112), so the owner had no way to remove it. `Close` stops an agent when it is live and then removes its terminal (`terminal.rm`), so the row and the tab go; it also removes an agent that already ended. The context menu of an agent row is `Interrupt`, `Stop`, `Close`, `Open PR`. The context menu of an agent tab has `Stop` first, and its `Close` is on and closes the agent; a plain tab has no `Stop`. No item asks for a second click. `⌘W`, the close button and middle-click stay as in D85: they never stop an agent. The context menu of each project row has `Hide project`: it hides the project with the `Undo` toast of D207 and stops nothing. A project with agents also has `Close all agents`: it closes each agent row of the project (orchestrators and workers, not plain terminals). The menu key on a project row opens the same menu. `Open in Finder` and `Remove` wait. | +| D219 | 2026-11-09 | Partly replaced by D291 (the menu items and the name "Hide project"). Stop, Close and Hide from the tab and the project row (#556; owner). `Stop` in the app keeps the ended terminal, and an ended orchestrator stays in the sidebar (D112), so the owner had no way to remove it. `Close` stops an agent when it is live and then removes its terminal (`terminal.rm`), so the row and the tab go; it also removes an agent that already ended. The context menu of an agent row is `Interrupt`, `Stop`, `Close`, `Open PR`. The context menu of an agent tab has `Stop` first, and its `Close` is on and closes the agent; a plain tab has no `Stop`. No item asks for a second click. `⌘W`, the close button and middle-click stay as in D85: they never stop an agent. The context menu of each project row has `Hide project`: it hides the project with the `Undo` toast of D207 and stops nothing. A project with agents also has `Close all agents`: it closes each agent row of the project (orchestrators and workers, not plain terminals). The menu key on a project row opens the same menu. `Open in Finder` and `Remove` wait. | | D220 | 2026-11-09 | Session file updates are locked (#558). `session.Update` holds an exclusive `flock` on `.lock` over the read, the change and the write, as `owner.Store` does. A bad file gives an empty session, and the write replaces it. `contr hook post-tool` uses it for each tool it records (TodoWrite, TaskCreate, TaskUpdate, the artifacts tools) and skips other tools before it takes the lock. The app's `session.Read` takes no lock: the write is still a temp file and a rename (D195). | Two post-tool hooks for one terminal at the same time read the same old file, and the last write won: 8 parallel TaskCreate hooks kept 2 to 6 todos. | | D221 | 2026-11-09 | One git runner with a context (#562). `internal/git` has the runner: `ExecRunner` with `Run` and `RunInput` (a standard input), and `*git.Error` (args, exit error, stderr). `review.Repo`, `agents.runGit` and the diff stats use it; `clone.go` streams its output and keeps its own command. The methods of `review.Repo` take a context. `Review` and `FileTree` end the read in flight when a newer read starts and when the view ends (the window closes), so a git that hangs does not keep its goroutine. Each read of the two views has a time limit of 30 s (`gitReadTimeout`): one read runs many git commands, so it is longer than D52's 5 s for one diff. A hide of the Review view does not end its read; the end of the window does. A failed `check-ignore` that wrote paths still gives them. | | D222 | 2026-11-09 | The agent list connects to hc again after the connection ends (#559). `agents.Redial` holds the newest connection: it dials on first use and again after the connection ended, and sends a call once more only on `hc.ErrNotSent` (D87). The window's `Manager` uses it, so actions work after a core restart. `agentstate.Model` watches in a loop: after a failure it shows the problem line (D113), waits 500 ms doubled up to 5 s, reset by a good list (as D79), and subscribes again. No retry timer while the window is hidden (D9); showing the window or a click on the offline This Mac row tries at once. `contr mcp` keeps `managers` for now. | The sidebar stayed offline until contr started again, and actions returned `hc.ErrNotSent`. | @@ -263,3 +263,4 @@ replaces it. | D286 | 2026-11-11 | PR and issue chips show GitHub's Octicons (#714, slice B; docs/spec/ChatView.md, Chips and Blocks). A PR shows the pull request icon of its state: open, draft, merged or closed; an issue the issue icon, open or closed. The icons are in `assets/icons/github` (Octicons, MIT, in NOTICE) and `icons.PRIcon`, `icons.IssueIcon` map gh's states to them. They take an ink token (`ink-48`), never GitHub's colors (D119, D261). The dot of the checks (running, passed, failed) stays after the icon, so the chip still shows CI (replaced by D288); the open and closed dots go, as the icon says it. The read of a number (`gh pr view`, D262) adds `isDraft`; no other gh read is added. The board's PR cell shows the same icon. The work rows of the board panel (hidden, D147) are not changed. | The owner asked for GitHub's icons (#714). | | D287 | 2026-11-11 | A fenced `contr-seq` block in Claude's answer draws as a sequence diagram (#696, PR 2; ChatView.md, Diagrams), in package `internal/app/diagram` like the flow (D284): actors as columns with a dashed lifeline, one line per message (`A -> B: text`, `A --> B: text` for a dashed reply, `A -> A: text` to itself), tokens only and the chat text size. A block that does not parse or is too large (more than 6 actors or 16 messages, an actor over 28 or a message over 40 characters, wider than the column) shows as its code block. The `chat-writing` skill tells Claude when and how to write a `contr-flow` and a `contr-seq` block, and that other diagrams stay as text. | Issue #696, step 2. | | D288 | 2026-11-11 | PR and issue chips have no checks dot; the Octicon's color shows the state (#741; docs/spec/ChatView.md, Chips and Blocks). It replaces the ink-48 icon and the dot of D286. While a PR is open its checks win: failed `content-failed`, running `content-running`, passed `content-done`; then open `content-done`, draft `ink-48`. Merged `content-link` and closed `ink-48`, so a closed PR does not read as failed. An issue: open `content-done`, closed `content-link`. The palette has no purple, so merged and a closed issue take `content-link`, the nearest hue; no token is added. The number keeps `content-link`. The tooltip and the label still name the state. The board's PR cell does the same. | The owner asked to remove the dots and show the state by color (#741). | +| D291 | 2026-11-11 | An empty project can start something and can be closed (#746; owner; docs/spec/ProjectRow.md). The menu of a project row has "New orchestrator" while the project has no orchestrator, "New terminal", a separator, "Close all agents" only while the project has agents, and "Close project". The two starts run in that project's folder (the main checkout, else the first worktree, else the stored folder), through the paths of the Projects `+` menu (`NewOrchestrator.StartIn`) and of New tab (`NewTerminal.Start`). "Hide project" is now "Close project": the behavior does not change (the project leaves the sidebar, a toast " closed" with Undo, nothing stops, Open projects brings it back). The projects modal says "Close" and "⌘⌫ close project" for the same action. A click on an empty project selects it; the empty terminal then shows "New orchestrator" and "New terminal ⌘T" for that project, and ⌘T opens there too. Before, "New orchestrator in " showed only for a stored project with no terminal at all; a project whose terminals held no agent got only a disabled "Close all agents". It replaces those parts of D219, D207, D171 and D174. | diff --git a/docs/spec/ProjectRow.md b/docs/spec/ProjectRow.md index 546868ca..5eed6257 100644 --- a/docs/spec/ProjectRow.md +++ b/docs/spec/ProjectRow.md @@ -35,15 +35,22 @@ Design system page: component ProjectRow. Shared rules: [README.md](README.md). icon set has no chevron yet, so the row shows no fold mark; the state is in the screen-reader label (expanded). A folded project hides its agent rows, which sit under it in the same list (#344). -- An empty project has no `+` on its row (D171). Its context menu has "New - orchestrator in ": it starts an orchestrator in the project folder - (D116). With the list focused and the key cursor on an empty project, the - menu key opens that menu, and Enter starts the orchestrator (D174). -- Each project row has a context menu (D219). A project with agents has - "Close all agents": it stops each agent of the project and removes its - terminal, also an ended one, with no second click. Each project has "Hide project": it hides the project and shows a - toast with Undo (D207); it stops nothing. The menu key opens the menu of - the project under the key cursor. +- An empty project has no `+` on its row (D171). With the list focused and + the key cursor on an empty project, the menu key opens its menu; Enter + starts nothing (D174). +- Each project row has a context menu (D219, D291): "New orchestrator" while + the project has no orchestrator, "New terminal", a separator, "Close all + agents" while it has agents, and "Close project". The two starts run in the + project's folder (main checkout, else its first worktree, else the stored + folder), as the `+` menu and New tab do. "Close all agents" stops each + agent of the project and removes its terminal, also an ended one, with no + second click. "Close project" takes the project out of the sidebar and + shows the toast " closed" with Undo (D207); it stops nothing, and + Open projects brings it back. The menu key opens the menu of the project + under the key cursor. +- A click on an empty project selects it. The empty terminal then shows + "New orchestrator" and "New terminal ⌘T" for that project, and ⌘T opens + the terminal there (D291). - Empty projects sit after the projects with agents; the others keep the dragged order (D174). - The Projects head has one `+` button. Its menu has "New orchestrator" and @@ -85,7 +92,7 @@ Design system page: component ProjectRow. Shared rules: [README.md](README.md). - One `ui.List` holds the projects and their agents; each row is a `kit.Row`. Do not use `ui.Sidebar`: it shows the selection in the accent color. - Open and close a project group with `CollapsibleBase`, or keep a collapsed set in the list state. - Reorder projects by drag with `ListState.Reorder`; store the order with `internal/projects`. -- `.ContextMenu(...)`: Close all agents, Hide project (D219). Open in Finder and Remove wait. +- `.ContextMenu(...)`: New orchestrator, New terminal, Close all agents, Close project (D219, D291). Open in Finder and Remove wait. ## Do and do not diff --git a/docs/spec/ProjectsModal.md b/docs/spec/ProjectsModal.md index 5522b792..614e55b8 100644 --- a/docs/spec/ProjectsModal.md +++ b/docs/spec/ProjectsModal.md @@ -11,14 +11,14 @@ Opens from the "Open projects" item of the Projects head `+` menu (D171). The ow │ ⌕ Search projects, or paste a GitHub repo or a path esc │ search ├─────────────────────────────────────────────────────────────────────┤ │ Open · 2 drag to reorder the sidebar │ section + hint -│ ⋮⋮ [C ] contr ~/code/contr · main · orchestrator idle Hide ⏎ switch │ +│ ⋮⋮ [C ] contr ~/code/contr · main · orchestrator idle Close ⏎ switch │ │ ⋮⋮ [CR] contr-rune ~/code/contr-rune · main · 1 needs you │ │ Recent │ │ [S ] site ~/code/site · main 10m ago │ ├─────────────────────────────────────────────────────────────────────┤ -│ ↑↓ move ⏎ switch ⌘⌫ hide [Open folder… ⌘O] │ footer +│ ↑↓ move ⏎ switch ⌘⌫ close project [Open folder… ⌘O] │ footer └─────────────────────────────────────────────────────────────────────┘ - toast: " hidden from the sidebar" Undo + toast: " closed" Undo ``` | Part | Value | @@ -33,13 +33,13 @@ Opens from the "Open projects" item of the Projects head `+` menu (D171). The ow | Name | Weight 600 `ink-100`; a recent name is weight 500 `ink-88`. One line, ellipsis. The part that matches the field is underlined in `ink-100`. | | Meta | Mono 11.5 px `ink-48`, one line, ellipsis. | | Need you | The `N need you` part of the meta is `blocked` text. This is the one exception to the `blocked` rule (Q1). | -| Row actions | `Hide`: 12 px `ink-48`, radius 6, hover fill `hover`; shows on hover or selection. The hint is mono 11 px `ink-48`, only on the selected row. A recent row shows its age (mono 11 px `ink-48`) instead; the age hides on hover or selection. | +| Row actions | `Close` (D291): 12 px `ink-48`, radius 6, hover fill `hover`; shows on hover or selection. The hint is mono 11 px `ink-48`, only on the selected row. A recent row shows its age (mono 11 px `ink-48`) instead; the age hides on hover or selection. | | Progress | 2 px line, radius 2, track `hairline`, bar `ink-64`, indeterminate (Q3). | | Error line | Mono 11.5 px `blocked`, wraps, under the meta. | | Empty | Text `ink-48`, centred, padding 22×12. | | Footer | Padding 10×16, `hairline` above, text 11.5 px `ink-48`, gap 14. `kbd`: mono 10.5 px weight 500 `ink-48`, border `input-line`, radius 5, padding 3×5. | | Ghost button | `Open folder… ⌘O`: border `input-line`, radius 7, padding 5×10, 12 px `ink-64`; hover fill `hover` and `ink-100`. Hidden when the app has no folder picker. | -| Toast | Shown in the frame's toast viewport, so it outlives the dialog. Text ` hidden from the sidebar`; action `Undo`: weight 600 `ink-100`, no fill. | +| Toast | Shown in the frame's toast viewport, so it outlives the dialog. Text ` closed`; action `Undo`: weight 600 `ink-100`, no fill. | ## States @@ -65,9 +65,9 @@ Exact strings: - Sections: `Add`, `Open · N`, `Recent`. - Heading hint (not while filtering): `drag to reorder the sidebar`. - Add rows: `Clone owner/name`, meta `into /`, `Change folder`, `Cancel`, `Try again`, `Open ` (the path is the meta). -- Hide: `Hide`; toast ` hidden from the sidebar` with `Undo`. +- Close project: `Close`; toast ` closed` with `Undo`. - Empty: `No project matches "". Paste a GitHub repo or a path to add one.` -- Footer: `↑↓ move`, `⏎ `, `⌘⌫ hide`, ghost `Open folder… ⌘O`. +- Footer: `↑↓ move`, `⏎ `, `⌘⌫ close project`, ghost `Open folder… ⌘O`. - A zero count never shows: no `0 worktrees`, no `0 need you`. The count reads `1 needs you` or `N need you` (N > 1). ## Behavior @@ -92,7 +92,7 @@ Exact strings: |---|---| | `↑` `↓` | Move the selection; stop at the ends. | | `⏎` | Run the selected row (see the Enter label). | -| `⌘⌫` | Hide the selected open row, only while the field is empty. With text in the field it deletes text, as in any field. The footer shows `⌘⌫ hide` only then. | +| `⌘⌫` | Hide the selected open row, only while the field is empty. With text in the field it deletes text, as in any field. The footer shows `⌘⌫ close project` only then. | | `⌘O` | Open the folder dialog (same as the ghost button). | | Esc | With text in the field: clear it. With an empty field: close the dialog (Q2). | @@ -113,7 +113,7 @@ Exact strings: ## Accessibility - The dialog label is `projects modal`; the field label is `Search projects, or paste a GitHub repo or a path`; the list label is `projects list`. -- A row label is `project `; the `Hide` label is `Hide `. The add rows are `clone row` and `path row`. +- A row label is `project `; the `Close` label is `Close `. The add rows are `clone row` and `path row`. - The selected row reports selected. The progress line says `Clone progress`. ## Tokens diff --git a/internal/app/attach.go b/internal/app/attach.go index 88b8541d..4f28b3a2 100644 --- a/internal/app/attach.go +++ b/internal/app/attach.go @@ -180,6 +180,7 @@ func (f *Frame) attachSidebar(w Window, env view.Env, agentSnap func() agentstat if f.deps.OpenTerminal != nil { f.NewTerm = &NewTerminal{Open: f.deps.OpenTerminal, Env: env, Select: func(addr string) { f.Selection.Select(state.Selected{Kind: state.KindTerminal, ID: addr}) }} + l.OnNewTerminalIn = f.NewTerm.Start } return func() { f.SpacesList.Close() diff --git a/internal/app/frame.go b/internal/app/frame.go index 29719852..d18d41ab 100644 --- a/internal/app/frame.go +++ b/internal/app/frame.go @@ -754,6 +754,13 @@ func (f *Frame) newTerminal() { if f.NewTerm == nil { return } + if f.SpacesList != nil { + // A selected project row (an empty project, D291) is where the terminal opens. + if name, dir, ok := f.SpacesList.SelectedProject(); ok { + f.NewTerm.Start(name, dir) + return + } + } project := f.openProject f.NewTerm.Start(project, f.terminalDir(project)) } diff --git a/internal/app/projectsview/projectsmodal.go b/internal/app/projectsview/projectsmodal.go index c282f9ed..cbe74ae4 100644 --- a/internal/app/projectsview/projectsmodal.go +++ b/internal/app/projectsview/projectsmodal.go @@ -33,7 +33,7 @@ const ( LabelProjectsFooter = "projects footer" LabelProjectsDot = "orchestrator dot" LabelProjectsSearch = "Search projects, or paste a GitHub repo or a path" - ProjectsHide = "Hide" + ProjectsHide = "Close" ProjectsOpenFolder = "Open folder…" LabelCloneRow = "clone row" LabelPathRow = "path row" diff --git a/internal/app/projectsview/projectsmodal_hide.go b/internal/app/projectsview/projectsmodal_hide.go index f3aecd59..4ee3b7c9 100644 --- a/internal/app/projectsview/projectsmodal_hide.go +++ b/internal/app/projectsview/projectsmodal_hide.go @@ -14,8 +14,8 @@ import ( // ProjectsUndo is the label of the Undo button of the hide toast. const ProjectsUndo = "Undo" -// hiddenToast is the text of the toast after Hide. -func hiddenToast(name string) string { return name + " hidden from the sidebar" } +// hiddenToast is the text of the toast after Close project (D291). +func hiddenToast(name string) string { return name + " closed" } // Hide hides the project and offers Undo in a toast. func (m *ProjectsModal) Hide(dir string) { diff --git a/internal/app/projectsview/projectsmodal_hide_test.go b/internal/app/projectsview/projectsmodal_hide_test.go index ccfa8579..0e4ee5f1 100644 --- a/internal/app/projectsview/projectsmodal_hide_test.go +++ b/internal/app/projectsview/projectsmodal_hide_test.go @@ -27,7 +27,7 @@ func TestHideShowsToastWithUndo(t *testing.T) { if len(*calls) != 1 { t.Fatalf("toasts: %d", len(*calls)) } - if c := (*calls)[0]; c.msg != "b hidden from the sidebar" || c.action != "Undo" || c.undo == nil { + if c := (*calls)[0]; c.msg != "b closed" || c.action != "Undo" || c.undo == nil { t.Errorf("toast = %q %q", c.msg, c.action) } } diff --git a/internal/app/projectsview/projectsmodal_test.go b/internal/app/projectsview/projectsmodal_test.go index d8527a20..87974549 100644 --- a/internal/app/projectsview/projectsmodal_test.go +++ b/internal/app/projectsview/projectsmodal_test.go @@ -259,7 +259,7 @@ func TestProjectsModalCmdBackspaceWithTextDoesNotHide(t *testing.T) { r.open() r.tt.Type("a") r.tt.Frame() - if slices.Contains(r.tt.Texts(), "hide") { + if slices.Contains(r.tt.Texts(), "close project") { t.Error("the footer offers hide while the field has text") } r.tt.Key(ui.Cmd, ui.KeyBackspace) // deletes the text @@ -273,14 +273,14 @@ func TestProjectsModalFooterOffersHideOnOpenRowOnly(t *testing.T) { r := newModalRig(t) r.store.Touch("/w/z/.git", "/w/z", time.Unix(1, 0)) r.open() - if !slices.Contains(r.tt.Texts(), "hide") { + if !slices.Contains(r.tt.Texts(), "close project") { t.Error("no hide hint on an open row") } for range 3 { r.tt.Key(0, ui.KeyDown) } r.tt.Frame() - if slices.Contains(r.tt.Texts(), "hide") { + if slices.Contains(r.tt.Texts(), "close project") { t.Error("hide hint on a recent row") } } diff --git a/internal/app/projectsview/projectsmodal_view.go b/internal/app/projectsview/projectsmodal_view.go index 283b7ae2..b1f09848 100644 --- a/internal/app/projectsview/projectsmodal_view.go +++ b/internal/app/projectsview/projectsmodal_view.go @@ -354,7 +354,7 @@ func (m *ProjectsModal) footer(c *ui.Context, rows []paletteRow) { hint("⏎", lb) } if m.sel < len(rows) && rows[m.sel].kind == rowOpen && m.text() == "" { - hint("⌘⌫", "hide") + hint("⌘⌫", "close project") } ui.Spacer(c) if m.PickFolder == nil { diff --git a/internal/app/projectsview_frame_test.go b/internal/app/projectsview_frame_test.go index e9534aff..99223ea6 100644 --- a/internal/app/projectsview_frame_test.go +++ b/internal/app/projectsview_frame_test.go @@ -13,10 +13,10 @@ import ( func TestHideToastOfTheFrameRunsUndoOnClick(t *testing.T) { f, tt := toastFrame() ran := false - f.Toasts.ShowAction("b hidden from the sidebar", projectsview.ProjectsUndo, func() { ran = true }) + f.Toasts.ShowAction("b closed", projectsview.ProjectsUndo, func() { ran = true }) tt.Frame() tt.Frame() - if !slices.Contains(tt.Texts(), "b hidden from the sidebar") { + if !slices.Contains(tt.Texts(), "b closed") { t.Fatalf("no toast in %q", tt.Texts()) } if err := tt.Click(projectsview.ProjectsUndo); err != nil { @@ -29,8 +29,8 @@ func TestHideToastOfTheFrameRunsUndoOnClick(t *testing.T) { func TestHideToastsGetUniqueIDs(t *testing.T) { f, tt := toastFrame() - f.Toasts.ShowAction("b hidden from the sidebar", projectsview.ProjectsUndo, func() {}) - f.Toasts.ShowAction("b hidden from the sidebar", projectsview.ProjectsUndo, func() {}) + f.Toasts.ShowAction("b closed", projectsview.ProjectsUndo, func() {}) + f.Toasts.ShowAction("b closed", projectsview.ProjectsUndo, func() {}) a := f.Toasts.TakeActions() if len(a) != 2 || a[0].ID == a[1].ID { t.Errorf("ids = %v", a) diff --git a/internal/app/sidebar/sidebar.go b/internal/app/sidebar/sidebar.go index 68c3f1b9..1330b187 100644 --- a/internal/app/sidebar/sidebar.go +++ b/internal/app/sidebar/sidebar.go @@ -8,6 +8,8 @@ package sidebar // ui.List of kit.Row, then the Servers section with the This Mac row (D154). The model is in spaces.go. import ( + "slices" + "github.com/egoist/mygo/ui" "github.com/contursofran/contr/internal/app/icons" @@ -26,9 +28,12 @@ const ( LabelProjectsNew = "New orchestrator" // LabelProjectsAdd names the `+` button of the Projects head; its menu has the two above. LabelProjectsAdd = "Add" - // MenuCloseAll and MenuHideProject are the items of a project row's menu (D219). - MenuCloseAll = "Close all agents" - MenuHideProject = "Hide project" + // MenuNewOrchestrator, MenuNewTerminal, MenuCloseAll and MenuHideProject are the items + // of a project row's menu (D219, D291). + MenuNewOrchestrator = "New orchestrator" + MenuNewTerminal = "New terminal" + MenuCloseAll = "Close all agents" + MenuHideProject = "Close project" // LabelNoProjects heads the empty project list (D255). LabelNoProjects = "No projects yet" // LabelOpenProject is the first-run button that opens the projects modal, as ⌘O does @@ -83,9 +88,6 @@ func projectMeta(g spaces.RepoGroup) string { return g.Branch } -// LabelNewIn is the menu item of an empty project that starts an orchestrator there. -func LabelNewIn(g spaces.RepoGroup) string { return "New orchestrator in " + g.Name } - // View builds the sidebar head part. func (l *SpacesList) View(c *ui.Context) { ui.Column(c).FillWidth().FillHeight().Padding(theme.Space10, 0, 0, 0).Children(func() { @@ -332,26 +334,30 @@ func (l *SpacesList) noteNewRows() { } } -// projectMenu is the menu of a project row (D219). An empty project (D116) has -// a new orchestrator in its folder; a project with agents has Close all agents: -// it stops each agent and removes its terminal, also one that already ended. -// Both have Hide project. Close all agents asks for no second click, as Stop -// of an agent row. +// projectMenu is the menu of a project row (D219, D291): New orchestrator while the +// project has none, New terminal, then Close all agents while it has agents, and Close +// project. The two starts run in the project's folder. Close all agents stops each agent +// and removes its terminal, also one that already ended, with no second click, as Stop of +// an agent row. Close project takes the project out of the sidebar and stops nothing. func (l *SpacesList) projectMenu(m *ui.Menu, g spaces.RepoGroup) { - if g.Path != "" { - if m.Item(LabelNewIn(g)).Chosen() && l.OnNewIn != nil { - l.OnNewIn(g.Path) + lines := agentLines(g, l.Snapshot()) + dir := projectRoot(g) + if !slices.ContainsFunc(lines, func(a agentLine) bool { return a.orchestrator }) { + if m.Item(MenuNewOrchestrator).Disabled(l.OnNewIn == nil).Chosen() && l.OnNewIn != nil { + l.OnNewIn(dir) } - } else { - lines := agentLines(g, l.Snapshot()) - off := l.Source == nil || len(lines) == 0 - if m.Item(MenuCloseAll).Disabled(off).Chosen() && !off { + } + if m.Item(MenuNewTerminal).Disabled(l.OnNewTerminalIn == nil).Chosen() && l.OnNewTerminalIn != nil { + l.OnNewTerminalIn(g.Name, dir) + } + m.Separator() + if len(lines) > 0 && l.Source != nil { + if m.Item(MenuCloseAll).Chosen() { for _, a := range lines { l.act(a.w, "closed", l.Source.Close) } } } - m.Separator() if m.Item(MenuHideProject).Disabled(l.OnHide == nil).Chosen() && l.OnHide != nil { l.OnHide(g.CommonDir) } @@ -381,7 +387,7 @@ func (l *SpacesList) projectRow(c *ui.Context, g spaces.RepoGroup, index int) { } if g.Path != "" { // An empty project is dim: name ink-48 and icon ink-30, no `+` (#436). The - // start of an orchestrator in its folder (D116) is in the row's menu. + // starts in its folder (D116, D291) are in the row's menu. o.NameInk, iconInk = p.Ink48, projectIconInk(p, false, true) } o.Menu = func(m *ui.Menu) { l.projectMenu(m, g) } diff --git a/internal/app/sidebar/sidebar_keys_test.go b/internal/app/sidebar/sidebar_keys_test.go index 40154459..56e349b6 100644 --- a/internal/app/sidebar/sidebar_keys_test.go +++ b/internal/app/sidebar/sidebar_keys_test.go @@ -9,7 +9,6 @@ import ( "github.com/contursofran/contr/internal/app/apptest" "github.com/contursofran/contr/internal/app/ghsource" "github.com/contursofran/contr/internal/app/ghsource/ghsourcetest" - "github.com/contursofran/contr/internal/app/spaces" "github.com/contursofran/contr/internal/projects" ) @@ -162,7 +161,7 @@ func TestTheMenuKeyOnAnEmptyProjectOpensItsMenu(t *testing.T) { h, started := emptyProjectRig(t) moveToEmpty(t, h) h.tt.Key(0, ui.KeyContextMenu) - if err := h.tt.ChooseMenuItem(LabelNewIn(spaces.RepoGroup{Name: "gamma"})); err != nil { + if err := h.tt.ChooseMenuItem(MenuNewOrchestrator); err != nil { t.Fatalf("no menu on the menu key: %v", err) } if !slices.Equal(*started, []string{"/proj/gamma"}) { @@ -177,11 +176,11 @@ func TestTheMenuKeyOnAProjectWithAgentsOpensItsMenu(t *testing.T) { } h.tt.Key(0, ui.KeyDown) // the first row: a project with agents h.tt.Key(0, ui.KeyContextMenu) - if err := h.tt.ChooseMenuItem(LabelNewIn(spaces.RepoGroup{Name: "gamma"})); err == nil { - t.Error("the menu of a row with agents has a new orchestrator") + if slices.Contains(h.tt.Menu(), MenuNewOrchestrator) { + t.Error("the menu of a row with an orchestrator has a new orchestrator") } if !slices.Contains(h.tt.Menu(), MenuHideProject) { - t.Errorf("menu %q, want Hide project (D219)", h.tt.Menu()) + t.Errorf("menu %q, want Close project (D219, D291)", h.tt.Menu()) } } diff --git a/internal/app/sidebar/sidebar_test.go b/internal/app/sidebar/sidebar_test.go index cea1c99e..3a1b1680 100644 --- a/internal/app/sidebar/sidebar_test.go +++ b/internal/app/sidebar/sidebar_test.go @@ -320,13 +320,13 @@ func TestEmptyProjectMenuStartsAnOrchestratorThere(t *testing.T) { var started []string h.list.OnNewIn = func(dir string) { started = append(started, dir) } h.tt.Frame() - if _, ok := h.tt.Find(LabelNewIn(spaces.RepoGroup{Name: "gamma"})); ok { + if _, ok := h.tt.Find(MenuNewOrchestrator); ok { t.Fatal("an empty project row shows a + button") } if err := h.tt.RightClick("gamma, nothing open"); err != nil { t.Fatal(err) } - if err := h.tt.ChooseMenuItem(LabelNewIn(spaces.RepoGroup{Name: "gamma"})); err != nil { + if err := h.tt.ChooseMenuItem(MenuNewOrchestrator); err != nil { t.Fatal(err) } if !slices.Equal(started, []string{"/proj/gamma"}) { diff --git a/internal/app/sidebar/spaces.go b/internal/app/sidebar/spaces.go index 2143eded..2995a2a4 100644 --- a/internal/app/sidebar/spaces.go +++ b/internal/app/sidebar/spaces.go @@ -60,10 +60,13 @@ type SpacesList struct { // Plans gives the plan meters of the sidebar foot, left of the Settings // button (D196). Nil: no meters. Plans func() []kit.Meter - // OnNewIn runs when `+ new` of an empty project is clicked, with the - // project's folder (D116). Nil: the button does nothing. + // OnNewIn runs when "New orchestrator" of a project row's menu is chosen, + // with the project's folder (D116, D291). Nil: the item is off. OnNewIn func(dir string) - // OnHide runs when "Hide project" of a project row's menu is chosen, with + // OnNewTerminalIn runs when "New terminal" of a project row's menu is + // chosen, with the project's name and folder (D291). Nil: the item is off. + OnNewTerminalIn func(project, dir string) + // OnHide runs when "Close project" of a project row's menu is chosen, with // the project key (D219). Nil: the item is off. OnHide func(dir string) // OnReordered runs after a drag changed the order in the store, so the @@ -753,3 +756,22 @@ func (l *SpacesList) holds(g spaces.RepoGroup, sel state.Selected) bool { } return l.holdsAddress(g, sel.ID) } + +// SelectedProject is the project of a selected project row, as a click on an empty project +// selects it (D291): its name and the selected folder. ok is false while an agent or a +// terminal is selected, or the row is gone. +func (l *SpacesList) SelectedProject() (name, dir string, ok bool) { + if l.selection == nil { + return "", "", false + } + sel, has := l.selection.Current() + if !has || sel.Kind != state.KindWorktree { + return "", "", false + } + for _, g := range l.Groups() { + if l.holds(g, sel) { + return g.Name, sel.ID, true + } + } + return "", "", false +} diff --git a/internal/app/sidebar/spaces_projects_test.go b/internal/app/sidebar/spaces_projects_test.go index d83202bd..ee5ea618 100644 --- a/internal/app/sidebar/spaces_projects_test.go +++ b/internal/app/sidebar/spaces_projects_test.go @@ -98,15 +98,10 @@ func TestSidebarShowsStoredProjectWithoutTerminalAsEmptyGroup(t *testing.T) { h.list.OnNewIn = func(dir string) { started = append(started, dir) } h.tt.Frame() h.tt.RightClick(ProjectLabel(g)) - h.tt.ChooseMenuItem(LabelNewIn(g)) + h.tt.ChooseMenuItem(MenuNewOrchestrator) if len(started) != 1 || started[0] != "/proj/gamma" { t.Errorf("started %v", started) } - for _, name := range []string{"alpha", "beta"} { - if _, ok := h.tt.Find(LabelNewIn(spaces.RepoGroup{Name: name})); ok { - t.Errorf("group %s with terminals has an empty-group button", name) - } - } } func TestSidebarStoredProjectHiddenOrWithTerminalIsNotEmptyGroup(t *testing.T) { diff --git a/internal/app/sidebar/stopmenus_test.go b/internal/app/sidebar/stopmenus_test.go index 10e5eb21..4c8c81df 100644 --- a/internal/app/sidebar/stopmenus_test.go +++ b/internal/app/sidebar/stopmenus_test.go @@ -3,8 +3,6 @@ package sidebar import ( "slices" "testing" - - "github.com/contursofran/contr/internal/app/spaces" ) // ---- Stop and Hide in the project and tab menus (D219) ---- @@ -14,7 +12,7 @@ func TestProjectMenuClosesAllItsAgents(t *testing.T) { if err := h.tt.RightClick("alpha, main"); err != nil { t.Fatal(err) } - if got, want := h.tt.Menu(), []string{MenuCloseAll, "-", MenuHideProject}; !slices.Equal(got, want) { + if got, want := h.tt.Menu(), []string{MenuNewTerminal, "-", MenuCloseAll, MenuHideProject}; !slices.Equal(got, want) { t.Fatalf("menu %q, want %q", got, want) } if err := h.tt.ChooseMenuItem(MenuCloseAll); err != nil { @@ -31,7 +29,7 @@ func TestProjectMenuHidesTheProject(t *testing.T) { h := newRows(t) h.tt.RightClick("alpha, main") if err := h.tt.ChooseMenuItem(MenuHideProject); err == nil { - t.Error("Hide project could be chosen with no handler") + t.Error("Close project could be chosen with no handler") } var hidden []string h.list.OnHide = func(dir string) { hidden = append(hidden, dir) } @@ -51,11 +49,10 @@ func TestProjectMenuHidesTheProject(t *testing.T) { func TestEmptyProjectMenuHasHideAndNoCloseAll(t *testing.T) { h, _ := emptyProjectRig(t) moveToEmpty(t, h) - g := spaces.RepoGroup{Name: "gamma"} if err := h.tt.RightClick(ProjectLabel(h.list.groups[len(h.list.groups)-1])); err != nil { t.Fatal(err) } - if got, want := h.tt.Menu(), []string{LabelNewIn(g), "-", MenuHideProject}; !slices.Equal(got, want) { + if got, want := h.tt.Menu(), []string{MenuNewOrchestrator, MenuNewTerminal, "-", MenuHideProject}; !slices.Equal(got, want) { t.Errorf("menu %q, want %q", got, want) } } @@ -84,3 +81,79 @@ func TestAgentRowMenuClosesTheAgent(t *testing.T) { t.Errorf("status %q", told) } } + +// ---- The starts of a project row's menu (D291) ---- + +func TestProjectWithTerminalsButNoAgentsHasStartsAndNoCloseAll(t *testing.T) { + h, _ := twoRepos(t) + h.list.OnNewIn = func(string) {} + h.list.OnNewTerminalIn = func(string, string) {} + h.tt.Frame() + if err := h.tt.RightClick(ProjectLabel(h.list.groups[0])); err != nil { + t.Fatal(err) + } + if got, want := h.tt.Menu(), []string{MenuNewOrchestrator, MenuNewTerminal, "-", MenuHideProject}; !slices.Equal(got, want) { + t.Errorf("menu %q, want %q", got, want) + } +} + +func TestProjectWithWorkersButNoOrchestratorStartsOneInItsFolder(t *testing.T) { + h := newRows(t) + var started []string + h.list.OnNewIn = func(dir string) { started = append(started, dir) } + h.tt.Frame() + g := h.list.groups[1] + if err := h.tt.RightClick(ProjectLabel(g)); err != nil { + t.Fatal(err) + } + if got, want := h.tt.Menu(), []string{MenuNewOrchestrator, MenuNewTerminal, "-", MenuCloseAll, MenuHideProject}; !slices.Equal(got, want) { + t.Fatalf("menu %q, want %q", got, want) + } + if err := h.tt.ChooseMenuItem(MenuNewOrchestrator); err != nil { + t.Fatal(err) + } + if want := projectRoot(g); !slices.Equal(started, []string{want}) || want == "" { + t.Errorf("started %v, want [%s]", started, want) + } +} + +func TestProjectMenuNewTerminalOpensInThatProject(t *testing.T) { + h, _ := emptyProjectRig(t) + var got []string + h.list.OnNewTerminalIn = func(project, dir string) { got = append(got, project, dir) } + h.tt.Frame() + if err := h.tt.RightClick(ProjectLabel(h.list.groups[len(h.list.groups)-1])); err != nil { + t.Fatal(err) + } + if err := h.tt.ChooseMenuItem(MenuNewTerminal); err != nil { + t.Fatal(err) + } + if want := []string{"gamma", "/proj/gamma"}; !slices.Equal(got, want) { + t.Errorf("opened %v, want %v", got, want) + } +} + +func TestProjectMenuStartsAreOffWithNoHandler(t *testing.T) { + h, _ := emptyProjectRig(t) + h.list.OnNewIn, h.list.OnNewTerminalIn = nil, nil + h.tt.Frame() + h.tt.RightClick(ProjectLabel(h.list.groups[len(h.list.groups)-1])) + for _, item := range []string{MenuNewOrchestrator, MenuNewTerminal} { + if err := h.tt.ChooseMenuItem(item); err == nil { + t.Errorf("%s could be chosen with no handler", item) + } + } +} + +func TestAClickOnAnEmptyProjectSelectsIt(t *testing.T) { + h, _ := emptyProjectRig(t) + if _, _, ok := h.list.SelectedProject(); ok { + t.Fatal("a project row is selected before the click") + } + if err := h.tt.Click(ProjectLabel(h.list.groups[len(h.list.groups)-1])); err != nil { + t.Fatal(err) + } + if name, dir, ok := h.list.SelectedProject(); !ok || name != "gamma" || dir != "/proj/gamma" { + t.Errorf("selected %q %q %v, want gamma /proj/gamma", name, dir, ok) + } +} diff --git a/internal/app/terminalempty.go b/internal/app/terminalempty.go index a4136a78..46a1d040 100644 --- a/internal/app/terminalempty.go +++ b/internal/app/terminalempty.go @@ -89,12 +89,13 @@ func (f *Frame) emptyTerminal(c *ui.Context) { }) }) first := f.noProjectOpen() + starts := !first && f.projectStarts() gap := float32(theme.Space6) - if first { + if first || starts { gap = theme.Space10 } // Only the buttons take input: the texts of D247 let clicks through. - ui.Column(c).IgnoreInput(!first).Absolute().Left(0).Right(0).Bottom(0). + ui.Column(c).IgnoreInput(!first && !starts).Absolute().Left(0).Right(0).Bottom(0). HeightPercent(handsFade).AlignItems(ui.Center).Justify(ui.Center).Gap(gap). Padding(0, theme.Space20). LinearGradient(ui.LinearGradient{Angle: 180, Stops: []ui.GradientStop{{Color: p.Stage.Alpha(0), At: 0}, {Color: p.Stage, At: 0.65}}}). @@ -104,6 +105,10 @@ func (f *Frame) emptyTerminal(c *ui.Context) { f.firstRun(c) return } + if starts { + f.startsIn(c) + return + } kit.Styled(ui.Text(c, TerminalOffLine), theme.StyleLabel).SingleLine().MinWidth(0).TextColor(p.Ink64) ui.Row(c).AlignItems(ui.Center).Gap(theme.Space4).MinWidth(0).Children(func() { kit.Styled(ui.Text(c, TerminalNewLine), theme.StyleSmall).SingleLine().MinWidth(0).TextColor(p.Ink48) @@ -130,6 +135,36 @@ func (f *Frame) firstRun(c *ui.Context) { }) } +// projectStarts tells whether the empty terminal offers the starts of a selected project row +// (D291): a project row is selected, as a click on an empty project does. +func (f *Frame) projectStarts() bool { + if f.SpacesList == nil { + return false + } + _, _, ok := f.SpacesList.SelectedProject() + return ok +} + +// startsIn is the text and the two buttons of the empty terminal while a project row is +// selected (D291): "New orchestrator" and "New terminal ⌘T" start in that project's folder, +// as the items of its row menu do. A button whose action is not there does nothing. +func (f *Frame) startsIn(c *ui.Context) { + p := theme.Current() + name, dir, _ := f.SpacesList.SelectedProject() + kit.Styled(ui.Text(c, TerminalOffLine), theme.StyleLabel).SingleLine().MinWidth(0).TextColor(p.Ink64) + ui.Row(c).AlignItems(ui.Center).Gap(theme.Space8).MaxWidthPercent(100).MinWidth(0).Children(func() { + var orch, term kit.ClickFn + if n := f.NewOrch; n != nil { + orch = func() { n.StartIn(dir) } + } + if t := f.NewTerm; t != nil { + term = func() { t.Start(name, dir) } + } + kit.KeyButton(c, sidebar.MenuNewOrchestrator, "", kit.KeyMain, orch).Shrink(1) + kit.KeyButton(c, TerminalNewLine, shortcut.Of(shortcut.NewTab).Glyphs(), kit.KeyPlain, term).Shrink(1) + }) +} + // noProjectOpen tells whether the empty terminal shows the first run (D255): the sidebar // lists no project, or no project is open (the title bar names none and no row is selected). // It is the one place that says it. diff --git a/internal/app/terminalempty_test.go b/internal/app/terminalempty_test.go index c2cb37c3..9edf6f78 100644 --- a/internal/app/terminalempty_test.go +++ b/internal/app/terminalempty_test.go @@ -58,18 +58,26 @@ func TestEmptyTerminalWithNoProjectShowsTheFirstRun(t *testing.T) { } } -// With a project open and no terminal the texts of D247 stay, with no buttons (D255). -func TestEmptyTerminalWithAProjectOpenKeepsItsTexts(t *testing.T) { +// With a project row selected (a click on an empty project) and no terminal, the empty +// terminal offers the starts of that project: New orchestrator and New terminal ⌘T (D291). +func TestEmptyTerminalWithAProjectRowSelectedStartsInIt(t *testing.T) { f, _, tt, _ := terminalFrame(t, nil) dir := filepath.Join(t.TempDir(), "p") store := &projects.Store{} store.AddFolder(filepath.Join(dir, ".git"), dir) f.SpacesList = sidebar.NewSpacesList(nil, f.Selection, view.Env{}) f.SpacesList.SetProjects(store) - f.Selection.Select(state.Selected{Kind: state.KindWorktree, ID: dir}) + f.Selection.Select(state.Selected{Kind: state.KindWorktree, ID: dir}) // as a click on the row does + var spawned, opened []string + f.NewOrch = &NewOrchestrator{IsRepo: func(string) bool { return true }, + Spawn: func(d string) (string, error) { spawned = append(spawned, d); return "o1", nil }} + f.NewTerm = &NewTerminal{Open: func(project, d string) (string, error) { + opened = append(opened, project, d) + return "tNew", nil + }} tt.Frame() got := strings.Join(tt.Texts(), "\n") - for _, want := range []string{TerminalOffLine, TerminalNewLine, "⌘T"} { + for _, want := range []string{TerminalOffLine, sidebar.MenuNewOrchestrator, TerminalNewLine, "⌘T"} { if !strings.Contains(got, want) { t.Errorf("texts %q, want %q", got, want) } @@ -77,6 +85,38 @@ func TestEmptyTerminalWithAProjectOpenKeepsItsTexts(t *testing.T) { if strings.Contains(got, TerminalNoProjectLine) || strings.Contains(got, sidebar.LabelOpenProject) { t.Errorf("texts %q, want no first run while a project is open", got) } + if err := tt.Click(sidebar.MenuNewOrchestrator); err != nil { + t.Fatal(err) + } + if len(spawned) != 1 || spawned[0] != dir { + t.Errorf("spawned %v, want [%s]", spawned, dir) + } + if err := tt.Click(TerminalNewLine); err != nil { + t.Fatal(err) + } + f.openProject = "other" + f.newTerminal() // ⌘T opens in the selected project too, not in the title bar's + if want := []string{"p", dir, "p", dir}; strings.Join(opened, ",") != strings.Join(want, ",") { + t.Errorf("opened %v, want %v", opened, want) + } +} + +// With a project open but no project row selected, the empty terminal keeps the texts of D247, with no buttons. +func TestEmptyTerminalWithNoProjectRowSelectedKeepsItsTexts(t *testing.T) { + f, _, tt, _ := terminalFrame(t, nil) + dir := filepath.Join(t.TempDir(), "p") + store := &projects.Store{} + store.AddFolder(filepath.Join(dir, ".git"), dir) + f.SpacesList = sidebar.NewSpacesList(nil, f.Selection, view.Env{}) + f.SpacesList.SetProjects(store) + f.openProject = "p" // a project open in the title bar, no project row selected + tt.Frame() + if _, ok := tt.Find(sidebar.MenuNewOrchestrator); ok { + t.Error("no project row selected, yet it shows the starts of a project row") + } + if !strings.Contains(strings.Join(tt.Texts(), "\n"), TerminalOffLine) { + t.Errorf("texts %q, want %q", tt.Texts(), TerminalOffLine) + } } func TestNoProjectOpenNeedsAListedAndOpenProject(t *testing.T) {