Triage the project rail: priority ranking, waiting-on-you colors, and notifications that repeat until you answer - #14778
Triage the project rail: priority ranking, waiting-on-you colors, and notifications that repeat until you answer#14778samithaj wants to merge 53 commits into
Conversation
…undation) ProjectKey is the stable project identity for the upcoming Projects × Tasks sidebar. Local git repos key on their shared common `.git` directory (Repository::common_git_dir), so all worktrees of a repo collapse into one project; non-git dirs key on the canonical path; remotes on the remote id. Derivation reuses repo_metadata's DetectedRepositories — no new git plumbing. Foundation only (not yet consumed); unit-tested for worktree unification and folder-name labels.
ProjectLayout derives the distinct projects from the open tabs (keyed by ProjectKey), maps each tab to its project (Other for repo-less tabs), and exposes visible_tab_indices(project) — the single source the rail, top bar, and every navigation path will consult instead of filtering the raw tabs vector. Pure projection; unit-tested for selection, first-seen ordering, and the Other bucket. Not yet wired into Workspace.
…t (Phase 1) Adds Workspace.selected_project (derived, not persisted) and enforces the Projects × Tasks invariant in set_active_tab_index: activating any tab re-points the rail selection at that tab's project. Adds select_project() (activates a project's MRU-first visible tab) and project_layout()/ selected_project() accessors. All gated behind FeatureFlag::Projects, so the default (flag off) is unchanged.
…on (Phase 1) In project mode (FeatureFlag::Projects), the top tab bar is built from the selected project's visible tab indices (manual groups suppressed, real indices preserved), and drag insertion geometry uses the same filter so on-screen and hit-test agree. next/prev cycle within the project (cycle_next/cycle_prev, unit-tested), Cmd-N counts within the project, and close-others/left/right are scoped so they can never close another project's tabs. All via one shared project_visible_indices() helper. Deferred refinements (non-data-loss): MRU Ctrl-Tab palette, pinned region, multi-select range, remove-tab reactivation.
WorkspaceAction::SelectProject(ProjectId) dispatches to Workspace::select_project, activating the chosen project's MRU-first visible tab. Backbone for the rail.
Adds render_project_rail — a clickable row per open project (caret marks the selected one) that dispatches WorkspaceAction::SelectProject. Wired as an outer sibling at the far left of render_panels, shown only when FeatureFlag::Projects is enabled and >1 project is open. Per-project MouseStateHandles are stored on Workspace (created once, persisted) per the mouse-state ownership rule.
…tical tabs The Projects × Tasks layout is projects-on-the-left-rail + tasks-on-the-top-bar, but with use_vertical_tabs on, tasks kept rendering in the vertical sidebar (and the top bar renders no tab list in that mode), so project filtering was invisible. Centralizes the vertical-vs-horizontal decision in one helper, tab_settings::vertical_tabs_layout_active, which returns false when FeatureFlag::Projects is enabled, and routes all 27 previously-duplicated call sites through it so no render site can disagree about the layout.
…ppy/format pass In project mode, a new tab now inherits the selected project's working directory (taken from that project's most-recently-used tab), so "+" creates a task under the project you're looking at instead of landing in another project or "Other". The project key is the repo's shared git dir — for a linked worktree that points at the main checkout — so the MRU tab's cwd is used to stay in the worktree the user was actually in. A restored conversation's own directory still takes precedence, and remote/unavailable dirs fall back to the default logic. Factors the MRU lookup into Workspace::mru_tab_index (shared with select_project). Clears clippy -D warnings: drops the unused selected_project() getter and two clones of the Copy FamilyId. ./script/format clean.
…rail Nav: the Ctrl-Tab / most-recently-used palette now lists only the selected project's tasks (tab_navigation_data filtered through the projection), and shift-click range selection is clamped to the project's visible tabs so it can never sweep in tabs from another project that aren't on screen. Rail: replaces the caret marker with real selected/hover row backgrounds (theme overlay fills, rounded rows with side margins), a fixed 168px width, a muted section header, and a pointing-hand cursor. format + clippy (-D warnings) + tests all clean.
Adds appearance.project_layout.enabled (gated by FeatureFlag::Projects, GUI surface, cloud-synced) so the layout can be turned on without a rebuild, and introduces tab_settings::project_layout_active as the single predicate gating the rail, the tab projection, and the vertical-tabs suppression. Toggling it live seeds the rail selection from the active tab (so the active-in-selected invariant holds immediately) and closes the vertical panel; turning it off restores the previous layout. Per AGENTS.md, the setting also gets Command Palette enable/disable entries via AppearancePageAction::ToggleProjectLayout and PROJECT_LAYOUT_CONTEXT_FLAG.
With many projects open, the question the rail should answer at a glance is "which project is waiting on me?" — not just "what is open". Each project row now shows an aggregate agent status, reusing the shared render_status_element so the icons and colors match every other status surface. A task that needs the user (blocked on an approval, or waiting for input) wins over one that is merely working; projects with no agent activity show nothing. Long-running shell commands read as in-progress, matching the tab's own indicator. Project labels ellipsize so the status stays visible.
The rail was hidden until a second project was open, so it appeared and disappeared as repos were opened and closed and gave no indication the layout was active with one project. Shows it whenever the layout is enabled, making it a stable part of the window. Drops ProjectLayout::has_multiple_projects and the Workspace::project_layout wrapper, which existed only for that condition.
… in them Renaming an agent session (e.g. Claude Code's /rename) updated the vertical tab but not the horizontal one: vertical tabs resolve a title through the agent chain, while the horizontal bar only ever read PaneGroup::display_title — the terminal/shell title — so the same tab had two different names depending on the surface. Adds workspace::tab_title as the single answer to "what is the agent in this tab called": an explicit rename wins, then the agent session name, then the terminal title. The agent name prefers a plugin-backed CLI agent's own title (non-plugin-backed agents never report updates, so their context would pin a stale name) and falls back to the Oz conversation title, honouring the existing use_latest_user_prompt_as_conversation_title_in_tab_names preference rather than inventing a second rule. Adds appearance.tabs.use_agent_session_name (default on) to opt back out to the shell title / last command.
A tab could only show one thing, so naming it after the agent session hid what that session was actually doing. Mirrors the vertical tabs' own model — view mode × primary info × subtitle — for the horizontal bar: appearance.tabs.line_count single_line (default) | two_line appearance.tabs.primary_info agent_session (default) | command | working_directory | branch appearance.tabs.secondary_info command (default) | working_directory | branch | agent_session Both lines resolve through one tab_info_text() in workspace::tab_title, and a secondary choice that collides with the primary falls back, so a tab never shows the same information twice. Replaces use_agent_session_name_in_tab_titles, which was a two-value subset of primary_info (unreleased, no migration). Tab height is content-driven, so the pill grows on its own; the bar is what had to follow. PANEL_HEADER_HEIGHT is no longer an alias of TAB_BAR_HEIGHT — the tab bar grows in two-line mode and the resource center / theme chooser headers must not grow with it. The macOS titlebar is now sized from total_tab_bar_height(), which is what positions the traffic lights, and the drop-insertion indicator scales with the row. Single-line remains the default and renders exactly as before.
Both were reachable only from the Command Palette or by hand-editing settings.toml, which is how they ended up being explained wrongly and set in the wrong channel's file. Adds switches under Appearance > Tabs: Group sessions by project -> appearance.tabs (project layout on/off) Show two lines on tabs -> appearance.tabs.line_count line_count is rendered as a switch rather than a dropdown because it is binary; primary_info and secondary_info are 4-way and still need real dropdowns, so they remain settings.toml-only for now. Also unit-tests the secondary-info conflict avoidance exhaustively over every primary/secondary pairing, plus the non-conflicting and default cases.
agent_session_title re-implemented the plugin-backed check, the latest-prompt preference and the CLI-vs-conversation fallback that terminal_agent_text and preferred_agent_tab_titles already did, so the two surfaces had two copies of the same rule and were free to drift. Moves those helpers out of the 7k-line vertical_tabs view module into workspace::tab_title — the module whose stated purpose is being the single place both surfaces agree — and reduces agent_session_title to calling them. vertical_tabs imports them back, so its call sites and its existing preferred_agent_tab_titles tests are unchanged; those tests now cover the shared code.
…ruction
Showing the agent's name on one line and the instruction that produced it on
the other was not expressible: "latest user prompt" was a single preference
that flipped *both* lines together, so choosing it for the subtitle also
renamed the title. Adds UserInstruction as a line option on both settings,
resolved directly from the latest prompt and independent of that preference,
and makes it the fallback subtitle under an AgentSession title.
Two readability fixes the first build made obvious:
* Titles were clipped at the start, which suits a path ("…/dev/mifos/app")
but hides the identifying words of prose — an instruction rendered as
"users and store passwords in env". Only a working-directory line clips at
the start now; names and instructions ellipsize at the end.
* Two-line tabs cap at 320px rather than 200px, since two lines of prose need
materially more room than a shell title.
…the rail The rail showed one aggregated status per project, which is actively misleading once a project has several tasks: "needs you" beat "working", so a project with one blocked and one finished task rendered as simply blocked, with no way to see which task was which or how many there were. The tasks themselves were only visible on the horizontal bar, and only for the selected project — so the question the rail exists to answer, "which task, in which project, is waiting on me?", required clicking through every project. Each project now lists its tasks inline, each with its own status icon (the rich agent_icon path, not the aggregate). Rows are always expanded — ProjectLayout is a projection recomputed each render with nowhere to hang a collapsed flag, so avoiding collapse avoids new Workspace state. Labels use Text::new, which soft-wraps, so a long session name or instruction spills to another line instead of being cut off. Clicking a row activates that task by pane-group id (immune to tabs closing between paint and click); the rail selection follows via the existing active-in-selected invariant, so no second action is needed. Listing every task makes overflow the norm, so the rail now scrolls with a fixed header, its scroll offset held on Workspace rather than rebuilt each render. Two settings, both in Settings > Appearance: rail_show_tasks restores the compact aggregate-only rail, and rail_task_info chooses what a row shows (agent session / latest instruction / command / working directory / branch), resolved through the same tab_info_text the tab lines use rather than a fourth rule. rail_task_info is this codebase's first Dropdown for these settings, so the outstanding primary_info/secondary_info dropdowns are now mechanical.
The rail was pinned at 168px, which is too narrow for deep project names and too wide when the terminal needs the room. Wrap it in `Resizable` with a right-hand drag bar, exactly as the vertical tabs panel does. The width is session-lived, held in a `ResizableStateHandle` on `Workspace` rather than registered with `ResizableData` — the same choice the vertical tabs panel makes, and it keeps the rail out of session restoration. `RAIL_MIN_WIDTH` is deliberately 120, well under the 168 default: `ResizableState::clamp_size` applies the bounds on the first layout pass, so a minimum above the default would silently widen the rail on startup instead of leaving it where it starts.
…(resume-project-task Phase A+B)
The rail's task rows lose both their conversation name and their resume
handle the moment an agent exits: CLIAgentSessionsModel is in-memory only
and remove_session deletes the entry. This lands the durable layer that
fixes both at once, behind FeatureFlag::ResumeProjectTasks (dogfood).
Phase A — durable handles:
- agent_session_handles table (rebuildable index; transcript store stays
the source of truth). Task identity is (agent, session_id); pane_uuid is
provenance plus the in-flight slot key, enforced by two partial unique
indexes.
- Write path from plugin session events: insert in-flight on session_start,
identify/merge when the id arrives (Codex/Cursor reveal ids late), Touch
on turn boundaries only. remove_session stamps last_seen_at and must NOT
delete the handle — the handle is the future dormant row.
- GC: 20 handles per cwd, 30-day age-out, only ever driven by successful
writes. Remote sessions are never persisted.
Phase B — naming + resume commands:
- cli_agent_resume: exact resume/continue verbs per CLIAgent (exhaustive
16-variant match), gated on session-id validation ([A-Za-z0-9_-]{1,64});
injection suite covers shell metacharacters and newlines. Never emits the
headless driver's --dangerously-skip-permissions.
- transcript_naming: bounded 256KB head read of the Claude transcript;
last ai-title record wins, first user prompt is the floor; junk names
(<dir>-<2hex>, bare dirname) rejected. Resolved off-thread on Stop and
cached onto the handle (SetTitle).
- Startup read surface: handle dump in the log so the write path is
verifiable before any UI reads the table.
Specs, research trail, and the interactive rail mock live in
specs/samithaj/resume-project-task/. Phase C (rail UI) and Phase D
(Agent Mode project filter) are not part of this change.
31 new unit tests; cargo clippy -D warnings clean on warp, persistence,
warp_features.
…ect-task Phase C)
Phase A+B made agent sessions durable but nothing read the table. This
lands the UI: a project's past agent tasks appear as rows in the rail and
resume in place, behind FeatureFlag::ResumeProjectTasks.
- AgentSessionHandlesModel: in-memory mirror of the identified handles,
hydrated from PersistedData at startup and kept in sync by applying the
same ops enqueued for the sqlite writer. UI reads never touch the DB.
- ProjectLayout::compute_with_handles buckets handles into projects by cwd
(memoised per pass, never persisted) and contributes PROJECTS as well as
rows, so the rail is populated after a restart when the tabs-only
projection is empty. compute() stays tabs-only, so dormant tasks can
never reach the tab bar or cycle-next/prev. A handle whose session is
live is suppressed — the live row wins.
- Rail renders dormant rows after live ones in the same row shape ("live"
is a property of a row, not a section): muted label, status-less agent
icon, never selected-bg, capped at 5 so past tasks cannot bury live ones.
- ResumeDormantAgentTask opens a NEW tab at the handle's stored cwd with
the resume command prefilled, never executed. Always a new tab: a pane's
project derives from its cwd, so reusing another project's pane would
silently re-bucket it. A missing cwd (deleted worktree) toasts instead of
resuming somewhere wrong.
10 new tests (41 total across the feature); clippy -D warnings clean.
…ed tasks Two gaps found by running the dev app: 1. Nothing was ever written to agent_session_handles. ResumeProjectTasks lives in DOGFOOD_FLAGS, but OSS builds deliberately do not apply them (see the existing Projects opt-in right above). Add it to the same local opt-in list so the feature is reachable at all. 2. Rail rows for a tab whose agent has already exited fell back to the truncated cwd — the original "six rows all reading ..repos/poa-agent" bug. agent_session_title() goes None the moment CLIAgentSessionsModel drops the live session, but the durable handle outlives it, so rail_task_label now consults the handle store's cached title before falling back. Matched by directory, since the session id is precisely what disappears when the agent exits.
Validating the restart path in a real build exposed two defects in the
directory-based match added with the rail's dormant rows:
- Two sequential agent sessions in the same repo borrowed each other's
names, since cwd alone cannot tell them apart.
- After a restart, a restored tab AND its dormant handle both rendered —
the same task twice — because nothing linked the tab back to the handle.
Both now match on the handle's pane_uuid via the existing public
PaneGroup::find_terminal_pane_by_session_uuid. That is exact rather than
fuzzy, and restore-stable: terminal_panes.uuid is written back on save,
so a restored tab is still recognised as the pane that ran the session.
ProjectLayout uses the same match to suppress the duplicate dormant row.
Verified end to end in a dev build: an agent session's conversation name
("Summarize AGENTS.md file content") survives the agent exiting and a full
app quit/relaunch, and appears exactly once.
The pane-uuid dedup correctly stopped a restored tab and its stored handle rendering as two rows — but it left the surviving row with no way to resume: clicking it merely focused a dead shell. While that tab was open, the session was unresumable from the rail entirely. A tab whose agent has exited now carries its handle's identity: - stored_handle_for_tab returns (agent, session_id, title) for a pane with a stored handle and NO running agent, so a live session is never offered as resumable. - Clicking such a row dispatches ResumeDormantAgentTask instead of a plain activate. - resume_dormant_agent_task prefers the pane that already owns the session: it is sitting in the right directory, so the resume command is prefilled there rather than opening a second tab for the same work. Only when no such pane is open does it fall back to a new tab. This is the "resume then and there" behaviour the rail was designed for.
…sion Resuming from the rail failed with "No conversation found with session ID": the pane that ran a session was still matched by uuid alone, so a shell that had `cd`-ed away — or a restored pane reopened at its own startup directory — still claimed it. The resume command was then prefilled in the wrong directory, and an agent's resume lookup is scoped to the working directory. The same mismatch mis-filed the task: the row appeared under whichever project the drifted pane now sat in, not the project the session ran in. A tab now hosts a stored session only when BOTH its persistent pane uuid and its current directory match the handle. Applied in all three places that ask the question, so they cannot disagree: - find_by_pane_and_cwd (naming and the resumable-row check) - ProjectLayout dedup (a drifted pane no longer absorbs the dormant row) - resume_dormant_agent_task (falls back to a new tab at the stored cwd)
Phase C is done and confirmed in a real build: a session's conversation name survives the agent exiting and a full app relaunch, the task is filed under the project it actually ran in, and clicking the row resumes it. Also records the three defects only a real build surfaced — the flag never being applied in OSS builds, directory-based tab matching, and pane-uuid matching without a directory check — so the next reader sees why the identity rule ended up as "pane uuid AND cwd".
…l starts Restoring a window full of tabs made the project rail churn: every tab appeared under "Other" and then visibly re-sorted into its real project one by one, over several seconds. With ~50 tabs it reads as the rail grouping things late, and the startup feels laggy. The cause is that a pane's project comes from active_session_path_if_local, which reads the working directory out of active_block_metadata — and that only exists once the shell has emitted its first block. Until then the pane has no directory and falls into the "Other" bucket. The directory is already known at restore time: TerminalPaneSnapshot.cwd is persisted and handed to create_session as the shell's startup directory. It was simply consumed by the spawn and dropped. TerminalPane now retains it, and PaneGroup::session_path falls back to it when the live answer is not available yet, so a pane is attributed correctly on the first frame. Panes with no local shell of their own (cloud mode, shared-session and conversation viewers, ambient/child agents) pass None and are unaffected. This is also a prerequisite for deferring shell startup until a tab is opened: without it, a tab whose shell never starts would have no directory at all and would sit in "Other" permanently.
Restoring ~50 tabs spawns ~50 shells at once, which is most of the startup lag and is wasted for tabs the user never opens. The spec deliberately stops short of a design: the gate is a survey of every caller that reaches a TerminalManager through a pane, because "no shell yet" is not currently representable (TerminalPane holds it non-optionally, with a drop-order constraint) and that survey decides whether this is a week or a month of work. Records that the rail-churn half is already fixed, and that the same fix is a prerequisite here: without a persisted startup directory, a tab whose shell never starts would have no directory and would sit in "Other" forever.
Regression from 7371bc3: a running CLI agent's rail row went back to showing the truncated cwd, so a Claude Code /rename never appeared. That commit merged "what is this tab called" and "can this tab be resumed" into one helper and guarded it with "no live agent". The guard is right for resumability — a running session must never be offered as resumable — but wrong for naming: Warp has no live channel carrying a CLI agent's conversation name (cli_agent_title resolves to session_context.summary, a permission blurb), so a running session has no name of its own either. The guard therefore removed the only name such a row had. Split the two: stored_handle_lookup does the pane-uuid + cwd match with no guard and backs naming, while stored_handle_for_tab keeps the guard and backs resumability. Verified that /rename does reach the resolver: for a session renamed to "ship-matcher-fix-harden", the last ai-title record in its transcript is exactly that string, so the value was always available — it was only being suppressed.
v1 asserted that TerminalPane owns a non-optional terminal manager and proposed hanging a SessionState enum off it. It owns no manager: its fields are model_event_sender, uuid, startup_directory, pane_configuration and view. The manager is PaneStack associated data for the backing pane view. A doc comment describing that ownership was misread as a field declaration, so the whole design targeted the wrong object. Three further corrections: - create_session hands blocks and restoration state straight into LocalTtyTerminalManager::create_model, which returns surface and manager together. There is no "surface without PTY" seam, so the lifecycle split is the core of the work, not mechanical follow-on. - TerminalPane::snapshot derives cwd, read-only, shell launch data, input config, profile and conversation state from the LIVE view. A deferred pane has nothing to ask, so saving one degrades the snapshot and a restore/quit/restore cycle loses data permanently. Promoted from "open question" to blocker with a required lossless contract. - "Activation as the only trigger" is not a safe intermediate phase: restoration activates the saved tab through the workspace path, and broadcast/synchronized input addresses background panes by design. v2 records the contracts that must be answered in writing before any code, the deterministic acceptance tests, and deliberately stops short of proposing a state machine until the ownership survey is done.
…l startup Step 1 of the lazy-shell plan, and the gate on scoping it. Result: the surface is far smaller than feared. The manager is reached through exactly one seam — TerminalPane:: terminal_manager, the app's only child_data reach — and 7 call sites, none of them outside pane_group/. PaneGroup::terminal_manager already returns Option, because a pane may not be a terminal pane, so callers already handle absence; that is the shape deferral needs. Classified: 2 must-start (focus, active-session model), 3 fine-without (settings broadcast, transcript viewer, close-confirmation), 1 requires-live that already logs and bails, 1 boundary. Two contracts fall out directly. Closing an untouched pane cannot start it: the only close-path reach asks whether a long-running command is active, which is trivially false, and block cleanup goes through delete_blocks and the pane uuid. The settings broadcast must skip deferred panes rather than start them, or one setting change starts every tab. Synchronized typed input is a separate path that bypasses the accessor entirely (send_sync_event_to_session -> receive_sync_input_event -> model), and gets the opposite answer: it should start a deferred pane, because the user opted those panes in and expects the keystrokes to land. Verdict: the audit is not the hard part. The remaining work is the create_session lifecycle split and the lossless re-snapshot contract.
TerminalPane::snapshot reads cwd and shell_launch_data from the live TerminalView, and those only become answerable once the shell has started and reported in. With many tabs that window is seconds long, so quitting during it saved None for both — and None is not "unknown" here, it is destructive: cwd and shell_launch_data are what the NEXT restore uses to place the pane, so it silently reopened in the default directory with the default shell, then saved that as the new truth on the following quit. TerminalPane now retains the snapshot it was restored from, and snapshot() falls back per field through preserved_on_save: the live answer always wins when there is one, otherwise re-persist what we were restored with. Panes that were never restored (new tabs, cloud/viewer/ambient panes) pass None and are unaffected. Found while specifying lazy shell startup, where a deferred pane would have no live view to ask at all. Fixed on its own because the loss already happens today, and it is the machinery that work needs.
…pened
Restoring a window with ~50 tabs spawned ~50 shells at once. Startup was
visibly laggy, panes showed "Seems like your shell is taking a while to
start...", and nearly all of that work was wasted: most restored tabs are
never touched, yet each paid a full shell startup with the user's zsh/
starship/pyenv profile.
The seam already existed. create_model_with_manager builds everything
synchronously -- channels, the TerminalModel with its restored blocks, the
view surface, the manager -- and only *schedules* the shell, last, via
ctx.spawn. So a surface without a PTY is a state that already occurs on
every launch; it just lasts milliseconds. Deferral moves the inputs of that
spawn into DeferredShellStart and runs it later, leaving the manager
non-optional and every existing caller untouched.
ensure_shell_started is idempotent by construction: it Option::take()s the
payload, so whichever trigger fires first wins and the rest no-op. That
matters because the triggers are independent -- focusing the pane and
synchronized input can each be the first.
Triggers, per the reach-through survey:
start TerminalPane::focus -- the restore path activates the saved tab,
which focuses it, so the front tab starts exactly one shell
start send_sync_event_to_session -- synchronized input is opt-in user
input; dropping keystrokes silently would be wrong
skip send_prompt_change_bindkey_to_all_sessions -- a settings push, not
input. Starting 50 shells to deliver a bindkey defeats the feature
skip close_pane_with_confirmation -- a pane with no shell trivially has
no long-running command to confirm
active_session_terminal_model needs no trigger: every caller reaches it
through active_tab_pane_group, which is the focused tab. The one exception,
active_session_ps1_grid_info, walks all tabs as a read-only find_map
fallback and correctly skips a pane that has no prompt grid yet.
Tab and rail labels needed one fix. display_working_directory reads a prompt
chip or pwd(), both of which require a live shell, so a deferred pane fell
all the way through to the shell name -- ~50 tabs reading "zsh" instead of
their folders. It now falls back to the restored directory, mirroring what
PaneGroup::session_path already does for project attribution.
Behind FeatureFlag::LazyShellStartup, and only the restore path defers; new
tabs, splits, and panes with no local shell are unchanged.
v2 was discovery. This is v3: the implemented design, the two prerequisite fixes it depended on, the trigger table as built, and the two places the plan was wrong -- active_session_terminal_model needed no trigger after all, and FIRST_FRAME_DRAWN does not exist in this tree. Also records why three of the four planned unit tests were dropped rather than faked: TerminalSurface's only implementors are the two real front-end views, and new_for_test bypasses create_model, so the deferral branch is not reachable without spawning a real PTY. Those assertions moved to the e2e run, with the baseline measured (49 shells) rather than estimated.
active_session_terminal_model turned out not to need a trigger -- every caller reaches it through the focused tab -- so listing it here as one of the racing callers was wrong. Focus and synchronized input are the two.
Two macOS security prompts block startup before session restoration is ever reached, so the shell-count table is specified but not yet observed. Both trace to the same cause: this machine has no code-signing identity, so install-warposs-dev signs ad-hoc, and an ad-hoc signature is content-derived -- every reinstall is a new identity, so neither the TCC grant nor the Keychain ACL survives. Worth writing down because the symptom reads exactly like a deadlock in app code -- no window, 0% CPU, log stopping mid-startup -- and AGENTS.md warns about precisely that failure mode for terminal-model locking. sample(1) is what tells them apart: these leaves are kernel syscalls under Security and open(), not a FairMutex. Also records the discriminator that stopped me misreading shells=0 as a broken active tab: 'Creating terminal model' logs before the deferral branch, so zero of those lines means restoration never ran at all.
Walks the process tree down from one bundle's terminal-server, so it counts that build's shells rather than the machine's. Global pgrep is no use: macOS pgrep has no -c, ~110 zsh processes here belong to other apps, and stable and dev Warp are usually both running -- comparing them is the entire point. Documents how to read the result, including the case that misled me: 0 means either a broken active tab or restoration never running, and the two are told apart by whether 'Creating terminal model' appears in the log, since that logs before the deferral branch. Measured baseline on stable (eager): 49 shells for 49 restored tabs.
…struction
Measured: the feature did not work. 46 of 48 restored tabs still spawned a
shell, against a baseline of 50 -- essentially no change.
The trigger was in the wrong place. TerminalPane::focus looked like "the user
opened this tab", but PaneGroup::new_internal focuses every pane group as it
constructs it, behind DragTabsToWindows -- a RELEASE_FLAGS flag, so on
everywhere. Restoring 49 tabs therefore constructed 49 pane groups, each of
which focused itself, each of which started its shell. Deferral was working
exactly as designed and then immediately undone.
Moved the start to the two paths that only run when a user actually opens
something:
Workspace::focus_active_tab a tab became the active tab. This is also the
restore path's single activation, so the front
tab still starts exactly one shell.
PaneGroup::focus_pane focus moved to a pane by id -- clicking a
split, or any of the id-based focus helpers.
Neither is reachable from new_internal, which calls PaneGroup::focus directly.
Split panes the user has not touched now stay deferred until clicked, which is
the intended behaviour rather than a side effect.
Both go through PaneGroup::ensure_session_started, which also replaces the
hand-rolled manager lookup in send_sync_event_to_session.
Worth recording: this is precisely the failure the shell count was chosen to
catch, and it is invisible to every static check -- it compiled, passed
clippy, and read correctly. Only the measurement found it.
TerminalPane::focus reads like 'the user opened this tab' and is reached on tab activation -- the call graph in the survey was right. What was wrong was assuming it is reached ONLY then: pane-group construction focuses each group too, so restoring 49 tabs undid deferral 49 times. Recorded because nothing static could have caught it, which is the whole argument for measuring a feature whose purpose is a runtime resource count.
restored_working_directory went through active_session_path, which resolves via the pane group's focused-pane id. Probing showed this actually works for restored tabs (new_internal seeds a fallback active session), so this is robustness rather than the fix for the zsh-labelled rail rows: it removes a dependency on focus state and lets a sibling pane in a split supply the directory when the focused pane has none. The zsh rows have a different cause entirely: the rail's default label source is AgentSession, whose chain ends at the terminal title -- "zsh" until the shell's OSC title reports a cwd -- and never consults the WorkingDirectory branch this helper feeds. The genuine gap is that the handle store only knows sessions Warp itself spawned, so imported tabs have no names at all. That is addressed separately. Adds a mod_tests case covering a never-focused restored tab.
Second attempt at placing the lazy-shell trigger, this time from a traced root cause rather than a plausible reading. Measured failure: 46 of 48 restored tabs still spawned shells. The chain is add_tab_with_pane_layout calling activate_tab_internal unconditionally after inserting each tab (view.rs:13015 -> 5422 -> focus_active_tab), so during window restore every tab is genuinely, committedly activated -- active_tab_index, MRU front-insert, window title and all -- one after another. The trigger moved in bce054e from one per-tab path onto a different per-tab path. TabActivationSource { User, Restore } threads activation provenance through new _from variants; the existing activate_tab_internal / focus_active_tab names keep User semantics so the ~40 existing call sites are untouched. Only the ensure_focused_session_started call is gated -- every other activation side effect still runs for Restore, because restore depends on them (writing tab colors and MRU order against active_tab_index). The one activation that means "this is what the user will see" -- the end of the Restored arm activating the saved active tab -- explicitly passes User, so the front tab still starts exactly one shell. Known accepted quirk: the GetStarted onboarding tab reached from the Restored arm now classifies as Restore. Behavior-neutral -- GetStartedPane owns no shell -- but recorded here in case it ever grows one.
…cripts The durable handle store only knows sessions Warp itself spawned, so any session Warp never witnessed -- an imported profile, a session run in another terminal -- had no name and often no row at all. The mechanism here is ported from Orbit, which reads Claude Code's own state directly: a transcript's existence IS the session, no witnessing required. Discovery: ~/.claude/projects/<mangled-cwd>/<uuid>.jsonl, where the mangle is "every non-alphanumeric char becomes '-'" applied to the canonicalized path. Filenames are filtered by an anchored UUID regex because the project dir also holds bare <uuid>/ subdirectories (session-memory, subagents) that must not become rows. Directory names are never reverse-mapped to paths -- the encoding is lossy -- so the scan set is the tabs' and handles' own cwds. Naming walks the transcript tail first (64 KiB, backwards), because /rename appends a fresh ai-title record at the end: measured on this machine, a head-only read misses 14 of 158 titled transcripts and every rename. Then the head (ai-title, first real user prompt with wrapper/Caveat rejection and leading-summary skip, de-kebabed slug). All reads are seek-bounded; a transcript is never read whole. Authority split, enforced by lookup order in resume_dormant_agent_task: the handle store owns pane binding -- a scanned session can never fabricate a tab association and always resumes into a fresh tab -- while the scan owns names and the existence of unwitnessed sessions. Witnessed sessions also pick up post-spawn renames for free via the shared tail read in refresh_cached_title. Riding along, deliberately: encode_cwd in the agent_sdk harness replaced only '/' and '.', but Claude replaces every non-alphanumeric. Measured against 47 populated project dirs, the old rule disagreed with Claude on 18 -- meaning envelopes and session-index entries were written to directories Claude never reads, silently breaking claude --resume for any project path containing underscores or spaces. Fixing the shared encoder repairs the write path too. Deferred with reasons in-code: sibling-dir scan for sessions that cd away (1 of 47 dirs; failure mode is a missing row, never a wrong one), the live sessions/<pid>.json name tier (scanned rows are non-live by construction), and fs-watchers (the rail's existing recompute cadence is the refresh). Gated behind FeatureFlag::ResumeProjectTasks.
48 models restored, 1 shell spawned, stable across two readings 30s apart. Eager baseline 49-50; both mis-placed-trigger attempts measured 46.
…nblocked Decided design: ordered project rank in its own panel (ProjectKey-keyed so worktrees share a rank, readable later by token budgets), rail sorted by rank with color doing the signaling in place -- orange blocked, red after 5 min, green done-unseen -- plus header jump-chips, and a nag engine that repeats 3/15 min until the agent actually leaves Blocked. Grounded in an infra survey: Blocked and NeedsAttention already exist; the two real gaps are that the project-header aggregate cannot see a plain CLI pane's Blocked, and Blocked carries no timestamp. Those are Phase 0.
…age Phase 0) Three status fixes the triage design sits on, specs/samithaj/rail-triage §3: 1. tab_conversation_status checked is_long_running() first -- but a claude pane blocked on a permission prompt IS a long-running command, so every plain CLI pane's Blocked was buried under InProgress and the project header could never show it. The agent's own protocol status now comes first, with the same rich-status gating as the per-row badge: a session that cannot really know it is blocked must not claim to be. With no conversation to consult, the agent's non-blocked status is aggregated too, which is what lets a finished agent read as done. 2. Blocked gains a wall-clock: blocked_since on the session (not a payload on the status enum -- it derives PartialEq, and a timestamp in the variant would make identical blocked states unequal). Blocked -> Blocked keeps the original stamp: repeated prompts are the same unanswered wait. The orange->red escalation and the nag engine both read this. 3. Command-detected sessions (no plugin listener) rendered no badge at all -- indistinguishable from a plain shell. They now get the neutral running badge, and only that: without rich status the one thing such a session knows is that an agent process is running. 294 tests green, clippy -D warnings clean.
An ordered priority list over projects, managed from a right-click menu on the project row and mirrored in the command palette. The rail renders a ranked band on top in rank order, a hairline divider, then the unranked band in first-seen order. Ordering depends only on the list -- agent events never move a project, color will do that signaling (Phase 2). Storage is a settings Vec of variant-tagged ProjectKey encodings (git:<common .git dir> / dir:<path> / remote:<host>\x1f<path>), rank = index so collisions are unrepresentable and reordering is a splice. The git tag carries the shared common-.git dir, so every worktree of a repo shares one rank -- deliberately, since per-project token budgets will read rank_of and budgets want per-repo, not per-worktree. Malformed hand-edited entries are skipped, not fatal. ProjectId::Other has no stable identity and stays unrankable. Banding is a pure function (rail_project_rows) so the ordering rules are unit-tested without a renderer. A dedicated drag-reorder panel was investigated and skipped: the only DnD component (ChipConfigurator) is a closed two-variant toolbar bank whose readback drops foreign items; not worth bending three call sites for. The context menu is the interaction. 59 tests, clippy -D warnings clean.
…ge Phase 2)
Color now does the signaling the spec forbade reordering from doing. Task
rows tint by the agent's own status: yellow while it waits on the user, red
once the wait passes five minutes, green for a finished run whose result has
not been looked at, neutral otherwise -- with the wait's age alongside. The
project header inherits its most urgent child, and the "Projects" title row
gains two chips ("2 waiting" / "1 done") counting across every project,
ranked or not; clicking one cycles through the matching tasks, ranked band
first. Chips are words rather than the mock's emoji: the rail font renders
those glyphs inconsistently and a tofu box is worse than a word.
The rulebook lives in rail_triage.rs as pure functions over a derived-Ord
urgency (Running < Unseen < Waiting < Overdue), so precedence is structural
and a new state is added by placing it in the list, not by editing a
comparison chain. One triage pass feeds row tints, header badges and chip
counts alike, so they cannot disagree.
"Seen" for a green row means the pane was focused while the status was
Success -- focusing a still-working agent must not pre-acknowledge a result
it has not produced. The hook sits in TerminalPane::focus, the one funnel
every real focus change passes through; construction-time focus no-ops there
because a pane being built has no session yet.
Wait ages refresh on a self-rescheduling 30s timer armed only while
something is blocked and the rail is active, torn down the moment nothing
is.
330 targeted tests green, clippy -D warnings clean.
…ortcuts The rail could create a task inside the selected project (the tab bar's "+" is project-aware) but had no way to start a project at all. The Projects header now carries a "+" that dispatches the existing OpenRepository action -- folder picker, project-model upsert, and a tab opened in the chosen directory, which is what actually makes a rail row exist -- rather than a new action that would duplicate all three. Both behaviors are now named, editable keybindings, listed in Settings > Keyboard shortcuts and the command palette: workspace:new_project cmd-ctrl-n (mac) / ctrl-alt-n workspace:new_task_in_project no default chord The second deliberately ships unbound: cmd-t already has the identical effect (AddDefaultTab resolves the selected project's directory), so the binding exists to make that discoverable and rebindable under its own name, not to spend a second chord on the same keystroke. cmd-shift-N was not available -- project_buttons:create_new_project holds it. 53 targeted tests green, clippy -D warnings clean.
A blocked agent now keeps asking for the user until the user actually answers it. Ranked projects announce immediately and repeat every 3 minutes; unranked ones get a 60-second debounce -- most prompts are answered in seconds, and those should never make a sound -- then repeat every 15. Looking at the blocked pane silences its cycle; looking away while it is still blocked re-arms after a 2-minute grace. Nothing but the agent leaving Blocked stops the nag for good. The rules are a pure, clock-injected state machine (nag_engine.rs): the caller hands poll() the complete set of currently-blocked tasks and a now, and gets back what to announce. Absence IS cleanup -- unblocked, killed, pane closed and window gone all collapse to "not observed", so there is no cancellation call site anyone can forget (spec section 8: a dead session must never nag forever). Deadlines are absolute, so the engine never subtracts two instants. Announcements coalesce: one banner counting agents and naming distinct projects, and a re-notify speaks for every armed waiter and restarts all their cadences, so banners never stack. When Warp is frontmost the banner is suppressed but the bell still rings (AudibleBell::ring -- the one sound-without-banner path that exists) for waiters in tabs the user is not looking at; an announced task is never one that is in view, so a bell always means "something you cannot see wants you". The engine takes over the first announcement from the one-shot NeedsAttention path -- the debounce means the first banner deliberately does not coincide with the status change -- with the notifications discovery banner preserved for users who have not enabled the feature. Spec's urgent-vs-soft two-sound table is not achievable: there is no custom-sound API, only the play_sound bool and the system bell. Reported, not faked. Engine lives on Workspace: delivery is window-scoped (send_desktop_ notification exists only on ViewContext), "in view" means this window's active tab, and a window's banner names that window's waiters. 18 clock-injected state-machine tests; 325 targeted tests green; clippy -D warnings clean.
…be blank Two problems from one screenshot. The blank rows were three real bugs, each a path where an empty or whitespace title reached the renderer unfiltered: the AgentSession branch of tab_info_text returned before the emptiness guard (and AgentSession is the default rail label source); stored handle titles were persisted verbatim and fed both live and dormant rows; and PaneGroup::display_title is unwrap_or_default over panes several of which are constructed with an empty title -- and that empty string was rail_task_label's FINAL fallback. The fix is structural rather than per-site: every label now passes a non_blank gate through one pure resolver that bottoms out at the shell name or "Shell", so no combination of inputs renders nothing. Dormant rows floor at "Agent · <uuid[..8]>". The zsh noise gets a persisted toggle, "Hide shells without agents", on the rail header and in Settings > Appearance, with the palette enable/disable pair per house rule (ToggleSettingActionPair -- this is a bool setting, not a chord-worthy command). Hidden means: no live CLI agent, no non-passive Agent Mode conversation, no stored handle. The active tab is always kept -- hiding the row of the tab you are looking at is disorienting -- and the selected project keeps its MRU row rather than presenting a bare header. Collapsed projects show an inert dim "N shells" summary. A row eligible for a waiting/done tint checks the same sources the triage pass reads, so a row that could be nagging you can never be the one that is hidden. Also: install-warposs-local gains --no-quit, swapping the bundle under the running app (the process survives on the deleted inode; the build applies at the next natural relaunch) instead of prompting it to quit. 104 targeted tests green plus the 406-test settings suite; clippy -D warnings clean.
Promoting is the common priority edit -- something just became urgent -- and stepping a project up one rank at a time is the part of ranking that gets tedious first. The entry reuses AddProjectToPriorities, whose with_added_to_top already promotes an existing entry rather than duplicating it, so this is a label that tells the truth about an action that was already there rather than new behaviour. Shown only when the project is ranked and not already first, alongside Move up / Move down.
# Conflicts: # app/src/terminal/local_tty/terminal_manager.rs # app/src/workspace/util.rs # app/src/workspace/view.rs
install-warposs-local builds and installs a personal side-by-side WarpOss into /Applications for dogfooding. It is a local workflow tool, not part of Warp's distribution pipeline, which is script/bundle.
|
Every PR must be linked to a same-repo issue before Oz can review it. This PR is linked to #14730, but no linked issue is marked See the contribution guidelines for the full readiness model. Powered by Oz |
There was a problem hiding this comment.
Every PR must be linked to a same-repo issue before Oz can review it.
This PR is linked to #14730, but no linked issue is marked ready-to-implement yet. Only repository maintainers apply that label, so please wait for a maintainer to mark the issue. Once it is marked, push a new commit or comment /oz-review to re-trigger review.
See the contribution guidelines for the full readiness model.
Powered by Oz
Description
Closes #14730. Stacks on #14735 (and therefore #14734) — those commits are included here, so merge them first.
With several coding agents running, the rail treats them all alike: one blocked on a permission prompt in a critical project looks exactly like a scratch project idling for a week.
Two bugs first, both fixable on their own merits
The project header could never say "waiting on you" for a plain CLI-agent pane.
tab_conversation_statuschecksis_long_running()before consulting the agent's own status — but aclaudesitting on a permission prompt is a long-running command, soBlockedwas shadowed byInProgressand only orchestrated child conversations ever reached the header. The function's own doc comment promises the opposite ("a task that needs the user … wins over merely working"), which the ordering silently defeated.Blockedcarried no timestamp, so nothing could distinguish "just asked" from "waiting twenty minutes". And command-detected sessions (no plugin listener) rendered no badge at all, visually identical to a plain shell.Then the feature
ProjectKeystring so every worktree of a repo shares one rank.rank_of()is deliberately a readable attribute rather than just a sort key: per-project agent token budgets can read the same number.Success). Projects never move in response to agent events; spatial memory is what makes a sidebar fast. Precedence is a derivedOrdover an urgency enum, so adding a state means placing it in a list, not editing a comparison chain.Blockedstops it for good. Announcements coalesce into one banner; when Warp is frontmost the banner is suppressed but the bell still rings for waiters in other tabs.Cleanup is structural rather than a call site anyone can forget:
poll()receives the complete set of currently-blocked tasks and drops anything absent, so unblocked, killed, pane-closed and window-gone all collapse to "not observed" — a dead session cannot nag forever.Also included: a "+" in the rail header for starting a project, a "hide shells without agents" toggle, and a fix making a rail row label structurally unable to render empty (three separate paths could produce a blank row, including one where
display_titlereturned""as the final fallback).Linked Issue
#14730
Testing
The rules live in pure modules —
rail_triage.rs,rail_shells.rs,project_priorities.rs,nag_engine.rs— so behaviour is testable without a renderer or a clock. The nag engine's tests are(events, now) → what fires, covering debounce, both cadences, acknowledge, grace re-arm, coalescing and vanished sessions.423 tests green across the rail/triage/priority/nag suites;
cargo clippy -p warp --all-targets -- -D warningsclean. Rebased onto current master../script/runAgent Mode
CHANGELOG-NEW-FEATURE: Project rail triage — rank your projects, see at a glance which agents are waiting on you, and keep getting notified until you answer them.