Skip to content

Refine hook UX, terminal behavior, and commit graph badges#18

Merged
liam-russell merged 17 commits intomainfrom
Hooks
May 8, 2026
Merged

Refine hook UX, terminal behavior, and commit graph badges#18
liam-russell merged 17 commits intomainfrom
Hooks

Conversation

@liam-russell
Copy link
Copy Markdown
Contributor

@liam-russell liam-russell commented May 6, 2026

  • Hook editor defaults to after_worktree_create trigger with trigger_worktree as the intelligently-selected execution target
  • Form reorganised into logical sections: Identity → When/Where → Behavior → Dependencies → Script
  • Empty-hooks state restyled with dashed border and centred layout
  • keep_open_on_completion flag wired through to terminal session autoCloseOnExit: terminal tabs spawned by hooks auto-close when the process exits unless the hook has keep-open enabled
  • Blank terminal auto-spawn suppressed when a hook launch request is already pending for the same container
  • Context menu icons unified to lucide components (Repeat, FolderOpen, Copy, Play, Pencil, ShieldAlert)
  • Commit graph worktree indicator consolidated onto the branch ref badge with GitBranch + TreePine icons side-by-side; tag refs use Tag icon instead of 'tag:' text prefix
  • E2E panel-count assertion updated to >= 2 to reflect intentional suppression of the blank auto-spawn session when hook terminals are launched

Release Notes

This release polishes the hook authoring experience and wires up terminal lifecycle behavior so hook-launched terminals clean up after themselves automatically.

  • Hook editor now defaults to the most common configuration: after_worktree_create trigger targeting the new worktree, so new hooks require fewer form interactions to get started
  • The empty hooks list now shows a styled placeholder with a dashed border instead of a plain text message
  • Terminal tabs opened by hooks with Keep open after completion disabled now auto-close when the script finishes — the tab disappears from the session bar automatically on all platforms, including Windows
  • A blank default terminal tab is no longer created when SproutGit is about to launch a hook terminal in the same container, preventing an extra empty session appearing alongside hook sessions
  • Commit graph ref badges now show a branch icon for all branches, a tree icon alongside for worktree branches, and a tag icon for tags — removing the old "WT" badge and the tag: text prefix
  • Context menu items across the app now use consistent Lucide icon components

- Hook editor defaults to after_worktree_create trigger with trigger_worktree
  as the intelligently-selected execution target
- Form reorganised into logical sections: Identity → When/Where → Behavior →
  Dependencies → Script
- Empty-hooks state restyled with dashed border and centred layout
- keep_open_on_completion flag wired through to terminal session autoCloseOnExit:
  terminal tabs spawned by hooks auto-close when the process exits unless the
  hook has keep-open enabled
- Blank terminal auto-spawn suppressed when a hook launch request is already
  pending for the same container
- Context menu icons unified to lucide components (Repeat, FolderOpen, Copy,
  Play, Pencil, ShieldAlert)
- Commit graph worktree indicator consolidated onto the branch ref badge with
  GitBranch + TreePine icons side-by-side; tag refs use Tag icon instead of
  'tag:' text prefix
- E2E panel-count assertion updated to >= 2 to reflect intentional suppression
  of the blank auto-spawn session when hook terminals are launched
@liam-russell liam-russell self-assigned this May 6, 2026
Copilot AI review requested due to automatic review settings May 6, 2026 11:51
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refines the workspace hook and terminal UX by improving hook editor defaults/layout, unifying context-menu icon rendering, and adjusting terminal session lifecycle so hook-launched terminals can auto-close on exit unless configured to remain open. It also updates commit graph ref badges to use consistent iconography and updates an E2E assertion to match the new terminal auto-spawn behavior.

Changes:

  • Reworked hook editor defaults (trigger/execution target) and reorganized hook modal UI/empty state.
  • Added autoCloseOnExit plumbing through terminal components and suppressed blank terminal auto-spawn when a hook launch is pending.
  • Unified context menu icons (lucide components) and refined commit graph ref/worktree/tag badges (GitBranch/TreePine/Tag).

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/routes/workspace/+page.svelte Wires hook keep-open intent into terminal launch requests; replaces string menu icons with lucide components.
src/lib/components/WorkspaceHooksModal.svelte Updates defaults and restructures hook editor sections + improved empty state.
src/lib/components/TerminalPanel.svelte Adds autoCloseOnExit + callback hook for closing tabs after process exit.
src/lib/components/TerminalContainer.svelte Suppresses blank auto-spawn when hook launch pending; stores per-session auto-close behavior and closes sessions automatically on exit.
src/lib/components/ContextMenu.svelte Allows menu icons to be either strings or Svelte components and renders them consistently.
src/lib/components/CommitGraph.svelte Consolidates worktree indicator into ref badge; uses Tag icon and strips tag: prefix for display.
e2e/specs/daily-workflow.spec.ts Updates terminal panel-count assertion to reflect suppressed blank auto-spawn when hook terminals launch.

Comment thread src/routes/workspace/+page.svelte Outdated
Comment thread e2e/specs/daily-workflow.spec.ts
@liam-russell
Copy link
Copy Markdown
Contributor Author

@copilot apply changes based on the comments in this thread

…d auto-close E2E test

- Add keep_open_on_completion field to HookTerminalLaunchEvent Rust struct
- Include hook.keep_open_on_completion when emitting hook-terminal-launch event
- Add keepOpenOnCompletion: boolean to TypeScript HookTerminalLaunchEvent type
- Update frontend handler to use event.keepOpenOnCompletion directly instead
  of looking up the value from operationHooks (which may be stale/empty)
- Add E2E test asserting keepOpenOnCompletion:0 tabs auto-close and
  keepOpenOnCompletion:1 tabs remain visible after process exit

Agent-Logs-Url: https://github.com/InterestingSoftware/SproutGit/sessions/7c4c4258-03ec-40aa-bce3-20dcdfe93741

Co-authored-by: liam-russell <17897133+liam-russell@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI commented May 6, 2026

@copilot apply changes based on the comments in this thread

Done in 370c67e. Both review comments are addressed:

  1. keepOpenOnCompletion in backend payload: Added keep_open_on_completion: bool to the Rust HookTerminalLaunchEvent struct and included hook.keep_open_on_completion in the emit. Updated the TypeScript HookTerminalLaunchEvent type with keepOpenOnCompletion: boolean. The frontend handler now reads event.keepOpenOnCompletion directly — no more lookup against operationHooks, so the value is always authoritative regardless of whether hook metadata is preloaded.

  2. E2E auto-close assertion: Added a new test 'auto-closes terminal session when keepOpenOnCompletion is false' that seeds one hook with keepOpenOnCompletion: 0 and one with keepOpenOnCompletion: 1, triggers both via worktree create, and asserts the first session tab disappears after exit while the second remains visible.

@liam-russell
Copy link
Copy Markdown
Contributor Author

@copilot tests are failing:

11 e2e/specs/daily-workflow.spec.ts:744:3 › Daily developer workflow › auto-closes terminal session when keepOpenOnCompletion is false

  1. e2e/specs/daily-workflow.spec.ts:744:3 › Daily developer workflow › auto-closes terminal session when keepOpenOnCompletion is false
Error: expected element not to be visible

  814 |       await new Promise(resolve => setTimeout(resolve, 150));
  815 |     }
> 816 |     await expect(autoCloseSessionTab).not.toBeVisible();
      |                                           ^
  817 |
  818 |     // The keep-open session should still be visible.
  819 |     await expect(keepOpenSessionTab).toBeVisible();
    at /Users/runner/work/SproutGit/SproutGit/e2e/specs/daily-workflow.spec.ts:816:43

Error Context: test-results/playwright-output/daily-workflow-Daily-devel-1691f-epOpenOnCompletion-is-false/error-context.md

1 failed
e2e/specs/daily-workflow.spec.ts:744:3 › Daily developer workflow › auto-closes terminal session when keepOpenOnCompletion is false

Fix it please

…; fix E2E test

- compose_terminal_command now takes exit_after: bool; when true, appends
  `exit` (POSIX) or `\rexit` (PowerShell) after the hook script so the
  interactive shell exits upon completion, which triggers onTerminalClosed
  and the onAutoClosed → closeSession chain that removes the tab
- Call site passes !hook.keep_open_on_completion as exit_after
- E2E test: add insertHookDependency so keepOpen runs after autoClose,
  preventing the hookTerminalLaunchRequest reactive-update race
- E2E test: drop the simultaneous-visibility assertion; wait for autoClose
  tab to appear then disappear, then wait for keepOpen tab to appear and
  verify it remains visible

Agent-Logs-Url: https://github.com/InterestingSoftware/SproutGit/sessions/cc8b6b08-9fba-4a2b-9a71-fc5c36179d87

Co-authored-by: liam-russell <17897133+liam-russell@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI commented May 6, 2026

@copilot tests are failing:

11 e2e/specs/daily-workflow.spec.ts:744:3 › Daily developer workflow › auto-closes terminal session when keepOpenOnCompletion is false
...

Fixed in f7330df.

Root cause: compose_terminal_command sends the hook script as keystrokes to an interactive shell. After the script runs, the shell stays alive waiting for input — the PTY process never exits, so onTerminalClosed never fires and the tab never auto-closes.

Fix:

  • compose_terminal_command now takes an exit_after: bool parameter. When true (i.e., keep_open_on_completion is false), it appends exit (POSIX) or \rexit (PowerShell) to the composed command, causing the interactive shell to exit after the script completes. This fires onTerminalClosedonAutoClosedcloseSession, removing the tab.
  • E2E test: added insertHookDependency so keepOpen runs after autoClose (matching the multi-hooks test pattern, avoids the hookTerminalLaunchRequest reactive-update race). Removed the simultaneous-visibility assertion — instead: wait for autoClose tab → wait for it to disappear → wait for keepOpen tab → verify it remains.

@liam-russell
Copy link
Copy Markdown
Contributor Author

@copilot windows still fails:

10 e2e\specs\daily-workflow.spec.ts:660:3 › Daily developer workflow › runs multiple terminal hooks after create and opens named terminal sessions (1.5m)
x 11 e2e\specs\daily-workflow.spec.ts:744:3 › Daily developer workflow › auto-closes terminal session when keepOpenOnCompletion is false (23.7s)

  • 12 e2e\specs\hero-screenshots.spec.ts:70:3 › Hero screenshots @Screenshots › captures canonical UI screenshots from the pinned hero repo
    ok 13 e2e\specs\import-workflow.spec.ts:13:3 › Import workflow › imports a local repo and records it in recent projects (2.7s)
    ok 14 e2e\specs\worktree-workflow.spec.ts:24:3 › Worktree workflow › creates, switches, and deletes managed worktrees (4.2s)
  1. e2e\specs\daily-workflow.spec.ts:660:3 › Daily developer workflow › runs multiple terminal hooks after create and opens named terminal sessions
Test timeout of 90000ms exceeded while running "beforeEach" hook.

  211 |
  212 | test.describe('Daily developer workflow', () => {
> 213 |   test.beforeEach(async ({ tauriPage }) => {
      |        ^
  214 |     // Navigate the app to the home screen BEFORE deleting test directories.
  215 |     // On Windows, the Tauri file watcher holds open handles on the worktrees
  216 |     // directory from the previous test. Navigating away first causes the
    at D:\a\SproutGit\SproutGit\e2e\specs\daily-workflow.spec.ts:213:8

Error Context: test-results\playwright-output\daily-workflow-Daily-devel-0cb28-ens-named-terminal-sessions\error-context.md
  1. e2e\specs\daily-workflow.spec.ts:744:3 › Daily developer workflow › auto-closes terminal session when keepOpenOnCompletion is false
Error: expected element not to be visible

  816 |       await new Promise(resolve => setTimeout(resolve, 150));
  817 |     }
> 818 |     await expect(autoCloseSessionTab).not.toBeVisible();
      |                                           ^
  819 |
  820 |     // keepOpen hook runs after autoClose completes. Wait for its tab to appear
  821 |     // and verify it remains visible (shell stays alive — no exit appended).
    at D:\a\SproutGit\SproutGit\e2e\specs\daily-workflow.spec.ts:818:43

Error Context: test-results\playwright-output\daily-workflow-Daily-devel-1691f-epOpenOnCompletion-is-false\error-context.md

2 failed
e2e\specs\daily-workflow.spec.ts:660:3 › Daily developer workflow › runs multiple terminal hooks after create and opens named terminal sessions
e2e\specs\daily-workflow.spec.ts:744:3 › Daily developer workflow › auto-closes terminal session when keepOpenOnCompletion is false

Replaces the PTY-input `exit` approach with `-NonInteractive -Command`
(PowerShell) / `-c` (POSIX) spawning when keep_open_on_completion=false.

On Windows ConPTY the interactive shell keeps its output-buffer open after
`exit` is received, so the background reader thread may not detect EOF for
tens of seconds — far beyond the 20-second E2E poll window. Spawning the
shell non-interactively lets the OS signal EOF immediately when the script
process terminates, giving a reliable `terminal-closed` event.

Changes:
- compose_terminal_command: for exit_after=true, produce semicolon-joined
  format (PowerShell) / newline format (POSIX) suitable for -Command/-c;
  remove the appended `exit` command
- spawn_terminal: add optional `command: Option<String>` parameter; when
  set, adds -NonInteractive -Command <cmd> or -c <cmd> to CommandBuilder
- spawnTerminal (TypeScript): expose optional `command` parameter
- TerminalPanel: when autoCloseOnExit=true and initialCommand is set, pass
  it to spawnTerminal as the non-interactive command; set sentInitialCommand
  to prevent the PTY-input $effect from double-submitting

Agent-Logs-Url: https://github.com/InterestingSoftware/SproutGit/sessions/887bbe73-825c-49af-8214-6a2ebe8990e8
Copilot stopped work on behalf of liam-russell due to an error May 7, 2026 01:29
… process exit on Windows

On Windows ConPTY, an interactive bash/PowerShell session does not exit
after its hook script finishes. This caused the auto-close terminal E2E
test to time out at the not.toBeVisible() assertion.

Fix: when keep_open_on_completion=false, spawn the PTY in non-interactive
mode via bash -c / powershell -NonInteractive -Command so the shell exits
naturally when the script completes, reliably firing terminal-closed.

- terminal.rs: add optional command param; use -c/-NonInteractive -Command
- hooks.rs: add exit_after flag to compose_terminal_command; emit keep_open_on_completion in hook-terminal-launch event
- sproutgit.ts: thread command param through spawnTerminal; add keepOpenOnCompletion to HookTerminalLaunchEvent type
- TerminalPanel.svelte: non-interactive spawn when autoCloseOnExit=true
…rminals

When two hooks with a dependency relationship both use terminal_tab execution
mode, the Rust hook runner considers the first hook complete as soon as it
emits hook-terminal-launch, then immediately starts the second hook. Both
events arrive at the frontend in rapid succession, and Svelte 5 batches
reactive updates — so a single-value hookTerminalLaunchRequest state was
overwritten before the TerminalContainer effect could process it, causing
the first session to be silently dropped.

Fix: replace hookTerminalLaunchRequest (single value) with
hookTerminalLaunchRequests (array queue). The TerminalContainer now accepts
a launchRequests array prop and tracks processed IDs in a plain Set so every
entry is handled exactly once, even when multiple requests arrive in the same
reactive batch.
On Windows, the ConPTY master output pipe does not signal EOF when the child
process exits — it blocks indefinitely until the pseudo-console handle is
explicitly closed. This meant terminal-closed was never emitted for
auto-close hook sessions, leaving the terminal tab stuck open forever.

Fix: when spawn_terminal receives a command argument (non-interactive mode),
skip the PTY entirely and use std::process::Command with piped stdout/stderr.
A dedicated thread waits for child.wait(), then emits terminal-closed-{id}.
This is reliably cross-platform: Linux/macOS/Windows all signal process exit
correctly through a plain OS pipe.

The PTY path (command == None) is unchanged and still handles interactive
terminal sessions.
performVerifiedReload was using a hardcoded 15s budget for the
waitForMainWindow call after window.location.reload(). On Windows CI
the Tauri webview re-initialises from scratch after a reload and can
take 20-40 s to reach document.readyState === 'complete'. This was
marginal on slower runners and caused spurious beforeEach timeouts
(90s test timeout exceeded) after c3453d1 increased the binary size.

Use STARTUP_UI_TIMEOUT (45s) for the post-reload wait, consistent with
every other window-ready call in reloadToHome.

Before: 15_000 ms hardcoded
After: STARTUP_UI_TIMEOUT (45_000 ms)
…e CWD handle

On Windows, dropping the ConPTY master sends CTRL_CLOSE_EVENT to the
attached shell process (typically PowerShell). PowerShell installs a
custom console control handler and can take several seconds to run its
exit cleanup before finally terminating. During that window the process
holds an open handle to its current working directory (the worktree
path), causing rmSync to fail with EBUSY when the next E2E test's
beforeEach runs resetTestDirs.

Fix: store the child process handle in PtySession and call child.kill()
at the top of close_terminal, before dropping the master PTY. On Windows
this calls TerminateProcess which is unconditional and immediate, ensuring
the CWD handle is released before the caller returns.
reloadToHome had four sequential waits each with a STARTUP_UI_TIMEOUT
(45 s) budget:

  A: waitForMainWindow  (before ensureHome)
  D: waitForMainWindow  (before reload, inside performVerifiedReload)
  F: waitForMainWindow  (after reload,  inside performVerifiedReload)
  G: waitForHomeReady   (after reload)

Calls D and F were redundant:
- D is duplicated work: ensureHome just verified the home screen is
  visible, meaning the main window is responsive.
- F is subsumed by G: waitForHomeReady polls evaluate() in a loop with
  the same isMissingMainWindowError catch, so it already waits for
  document.readyState === 'complete' before checking for the import
  button.

On a slow Windows CI runner, 4 × ~23 s = 92 s exceeded the 90 s
per-test timeout, causing spurious beforeEach failures on the
auto-close terminal test (and potentially other tests near the end of
the suite where cumulative startup variance accumulates).

Fix: inline the reload into reloadToHome and delete performVerifiedReload.
The new chain is: waitForMainWindow + ensureHome + reload + waitForHomeReady
= 3 waits maximum, giving comfortable headroom on slow runners.

Before: 4 × STARTUP_UI_TIMEOUT (45 s) = 180 s worst case
After:  3 × STARTUP_UI_TIMEOUT (45 s) = 135 s worst case
        (in practice 1 s + 5 s + 20 s = ~26 s on a typical run)
On Windows, the keep-open hook terminal left a live PowerShell process
with CWD set to the feature worktree directory. This held a directory
handle that caused EBUSY in the next test's resetTestDirs() even after
the 20-retry × 250ms budget.

Two fixes:

1. Add close_all_terminals Tauri command that kills every PTY session
   in the TerminalManager at once. More reliable than per-panel
   close_terminal because it catches sessions whose ptyId the frontend
   may not have observed (spawnTerminal IPC race with page unmount).

2. Workspace onDestroy now calls closeAllTerminals() before
   stopWatchingWorktrees(). By the time waitForHomeReady returns and
   resetTestDirs() runs, all PTY children are dead and directory
   handles are released.

Also fix beforeEach reset order in all 6 specs:
  resetConfigDb() → reloadToHome() → resetTestDirs()

resetConfigDb() must run first because the home page fires
listRecentWorkspaces() on mount. If the config DB is deleted after
reloadToHome() triggers a webview reload, the IPC call races against
run_config_migrations closing its rusqlite handle and connect_sqlite
opening a fresh empty file — producing 'no such table: recent_workspaces'
on macOS CI. The config DB lives in a separate run-scoped directory with
nothing holding it open, so deleting it first is safe.
Two CI failures addressed:

Windows — beforeEach 90s timeout on 'deletes merged bugfix worktree'
  reloadToHome() contained a window.location.reload() that took 20–45 s
  on slow Windows CI runners (WebView cold-start cost), consistently
  pushing beforeEach past the 90 s per-test timeout.  The reload was not
  needed for isolation: SvelteKit route navigation already tears down and
  remounts all page components.  The Tauri Playwright adapter cheatsheet
  also recommends UI-driven navigation over hard reloads as the suite
  default.  Fix: move clearCachedWorkspaceHint() BEFORE ensureHome() so
  the home page onMount never sees the previous workspace hint, then drop
  the reload and waitForHomeReady entirely.  Worst-case beforeEach budget
  is now two sequential 45 s waits (window + navigation) instead of three.

Ubuntu — auto-close tab never appears (line 783, daily-workflow.spec.ts)
  The auto-close terminal tab exists for only ~300 ms (the shell runs
  echo + sleep 0.3 then exits, and keepOpenOnCompletion=false removes the
  tab on exit).  On fast Ubuntu CI the tab can be created and destroyed
  inside the time createWorktreeViaUi() spends polling for the success
  toast, so waitFor() on the auto-close tab times out.  Fix: wait for the
  keepOpen tab instead — its keepOpenOnCompletion=true hook depends on
  the auto-close hook, so seeing the keepOpen tab proves the auto-close
  hook ran and completed.  The auto-close tab is then asserted absent,
  which is the important invariant under test.
Previous fix incorrectly assumed that 'keepOpen tab visible' proves
'autoClose tab gone'.  It does not: in terminal_tab execution mode the
hook runner emits hook-terminal-launch and returns success IMMEDIATELY
without waiting for the spawned process to exit, so insertHookDependency
only sequences hook *dispatch order*, not *process completion*.

Real sequence:
  1. autoClose hook dispatches  -> launch event -> returns success
  2. autoClose process runs (echo + sleep 0.3 ~= 300 ms)
  3. keepOpen hook dispatches (dep satisfied by step 1) -> launch event
     -> keepOpen tab appears
  4. autoClose process exits -> terminal-closed event -> tab removed

Steps 2 and 3 run in parallel.  Both tabs coexist for the duration of
the autoClose process plus IPC delivery.  On macOS CI under load the
close pipeline (PTY exit -> wait-thread -> Tauri IPC -> frontend
listener -> reactive update -> DOM removal) can exceed expect()'s
implicit 5 s retry budget, causing the assertion to fail while the tab
is still visible.

Fix: wait for keepOpen tab to appear (both hooks dispatched), then poll
explicitly for the autoClose tab to disappear with DEFAULT_UI_TIMEOUT
(20 s).  Long comment added in the test body explaining the race so
this isn't 'fixed' incorrectly a third time.
…c E2E sync

When a terminal session is spawned with a hook_id, the non-interactive
PTY wait-thread now records the exit timestamp (epoch ms) into a new
TerminalManager.closed_hook_terminals map.  A new Tauri command,
is_hook_terminal_closed(hook_id), exposes this state.

This gives callers a deterministic, IPC-free synchronisation point for
'when did the hook process actually exit', upstream of the long chain
that drives terminal-tab disappearance:

    PTY child exit
      -> wait-thread
      -> Tauri event
      -> frontend listener
      -> Svelte reactive update
      -> DOM removal

The auto-close hook E2E test now polls is_hook_terminal_closed() rather
than the DOM, removing the multi-step async chain from its timeout
budget.  This is the architectural fix for the recurring Ubuntu-CI
flake on the auto-close terminal-tab assertion.

Plumbed through:
- src-tauri/src/terminal.rs:    new closed_hook_terminals map +
                                hook_id arg + is_hook_terminal_closed
- src-tauri/src/lib.rs:         register new command
- src/lib/sproutgit.ts:         spawnTerminal hookId + isHookTerminalClosed
- TerminalPanel.svelte:         hookId prop forwarded to spawnTerminal
- TerminalContainer.svelte:     hookId on Session + TerminalLaunchRequest
- workspace/+page.svelte:       set hookId on launch request
- daily-workflow.spec.ts:       sync via backend timestamp
@liam-russell liam-russell merged commit 07d741b into main May 8, 2026
28 of 29 checks passed
@liam-russell liam-russell deleted the Hooks branch May 8, 2026 00:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants