Skip to content

Triage the project rail: priority ranking, waiting-on-you colors, and notifications that repeat until you answer - #14778

Open
samithaj wants to merge 53 commits into
warpdotdev:masterfrom
samithaj:samithaj/rail-triage
Open

Triage the project rail: priority ranking, waiting-on-you colors, and notifications that repeat until you answer#14778
samithaj wants to merge 53 commits into
warpdotdev:masterfrom
samithaj:samithaj/rail-triage

Conversation

@samithaj

@samithaj samithaj commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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_status checks is_long_running() before consulting the agent's own status — but a claude sitting on a permission prompt is a long-running command, so Blocked was shadowed by InProgress and 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.

Blocked carried 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

  • Priority ranking — an ordered project list from the row's context menu, persisted on a canonical ProjectKey string 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.
  • Colour, not reordering — rows tint while an agent waits on you, deepen past five minutes, and go green for a finished result nobody has looked at ("seen" = the pane was focused while Success). Projects never move in response to agent events; spatial memory is what makes a sidebar fast. Precedence is a derived Ord over an urgency enum, so adding a state means placing it in a list, not editing a comparison chain.
  • Nag until unblocked — ranked projects announce immediately and repeat every 3 minutes; unranked ones debounce 60s (most prompts are answered in seconds and should never make a sound) then repeat every 15. Looking at the pane silences the cycle; looking away while still blocked re-arms after a grace period. Only the agent leaving Blocked stops 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_title returned "" as the final fallback).

Linked Issue

#14730

  • Where appropriate, screenshots or a short video of the implementation are included below

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 warnings clean. Rebased onto current master.

  • I have manually tested my changes locally with ./script/run

Agent Mode

  • Warp Agent Mode - This PR was created via Warp's AI Agent 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.

samithaj added 30 commits July 24, 2026 20:58
…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.
samithaj added 23 commits August 4, 2026 15:11
…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.
@cla-bot cla-bot Bot added the cla-signed label Aug 6, 2026
@oz-for-oss

oz-for-oss Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@samithaj

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

@github-actions github-actions Bot added the external-contributor Indicates that a PR has been opened by someone outside the Warp team. label Aug 6, 2026

@oz-for-oss oz-for-oss Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@samithaj

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed external-contributor Indicates that a PR has been opened by someone outside the Warp team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Triage the project rail: priority ranking, waiting-on-you colors, and notifications that repeat until you answer

1 participant