Skip to content

Spec Pending Start Rows

tavlean edited this page Jul 26, 2026 · 3 revisions

Spec: Pending start rows in the dashboard

Written 2026-07-25. Status: implemented and since extended; this page is a historical artifact, kept for the API findings and the decision trail. The shipped behavior has outgrown it in three ways: restarts get rows too, resolution is a shared predicate (resolvingServer) rather than cwd-presence, and pending state persists across the command unloading, all of which the "non-goals" below deferred. Spawn Flow is the authoritative description of what runs today.

Decided in conversation on 2026-07-25 after a Raycast-API feasibility review; the API findings below were verified against developers.raycast.com and @raycast/api@1.104.18 typedefs on that date.

Objective

When the dashboard spawns dev servers, each target immediately gets a synthetic "starting" row in the list: project name, a progress-ring icon counting toward the 15s watchdog, and a yellow Starting… tag. If the server binds its port, the synthetic row hands off to the real server row (selection follows, as it already does). If the watchdog fires, the row turns into a persistent red Failed row whose ActionPanel carries the remedies (View Startup Log, Open in Terminal, Copy Fix Command when a fix is known, Dismiss). The failure toast for starts goes away; a toast cannot be a workspace, its actions vanish with it, and it degrades to an actionless HUD when the Raycast window is closed. The success toast stays.

Non-goals

  • Restarts. restart() keeps its current toast behavior. Its state machine is local to one async function; lifting it into shared state is a later phase, after the row machinery exists.
  • Retry action on failed rows. Needs a re-entry path into the once-only spawn flow (spawnFlowFired ref). Open in Terminal covers the need manually. Design it later.
  • Persistence across dashboard close. Pending state is in-memory, same as the toast today.
  • Menu bar changes. Menu-bar starts already launch the dashboard with launchContext.spawn, so they get rows for free.

References

  • src/index.tsx — everything below lives here. Anchors as of 2026-07-25 (commit f46e67c); re-locate by symbol, not line number:
    • SpawnFailure, SPAWN_FAILURE, copyFixAction (~715-745): the diagnosis vocabulary and the copy-fix action. Reuse as-is.
    • diagnoseSpawnFailure (~750): log-offset diagnosis. Reuse as-is.
    • SpawnLogView (~301): already reusable, three call sites.
    • Spawn state machine: SpawnPhase type (~700), flow effect (~887-1024, guarded by spawnFlowFired), watch effect (~1030-1070), watchdog timeout effect (~1082-1140).
    • expecting: Map<cwd, { name, logStart }> built at ~1020: this is exactly the data a pending row needs.
    • Selection wiring: selectedItemId / onSelectionChange (~865, ~1381-1382) with the comment explaining why selection is two-way (pin once, then let the user own it).
    • EmptyView guard (~1405): currently keys on servers.length === 0 && !effectiveLoading.
    • Row rendering + List.Section grouping by projectKey (~1431-1459); rows set id={String(server.pid)}.
    • Row action shortcuts already bound (14 of them, ~340-615). Free chords for the failed-row panel: ⌘D, ⌘G, ⌘⇧L, ⌘⇧T.
  • Wiki: Spawn Flow (state machine + toast-copy table; update both when this ships), UI Conventions (toast copy budget), Portless (why the no-TTY sentence is matched exactly).
  • Raycast API findings (verified 2026-07-25): List.Item has no per-row isLoading and no animated icons; getProgressIcon(progress, color) from @raycast/utils is the progress affordance and is documented with a List.Item example. Accessory tag renders a colored pill: { tag: { value: "Starting…", color: Color.Yellow } }. List.Item ids need only be unique. selectedItemId behavior when pointing at a not-yet-rendered id is undocumented. Native search filtering may reorder sections unless filtering={{ keepSectionOrder: true }}.

Design (decisions are locked; implement as stated)

State shape

type PendingStart = {
  name: string;       // project display name, from the spawn target
  logStart: number;   // spawn-log byte offset, copied from the expecting map
  startedAt: number;  // Date.now() at spawn, drives the progress ring
  status: "starting" | "failed";
  reason: SpawnFailure | null; // set when failed and diagnosable, else null
};
// keyed by cwd
const [pendingStarts, setPendingStarts] = useState<Map<string, PendingStart>>(new Map());

Separate state slice. Do not inject synthetic entries into the useCachedPromise servers array via optimisticUpdate: every kill/restart handler runs optimisticUpdate filters over that array typed as DevServer[], and a fake entry would flow through all of them.

Row identity and placement

  • Row id and key: `starting:${cwd}`. Namespaced so it can never collide with pid ids (bare digit strings).
  • A dedicated List.Section title="Starting" rendered before the server sections, only when it has visible rows.
  • Add filtering={{ keepSectionOrder: true }} to the List so a search query cannot demote the section.

The handoff rule (the critical invariant)

Visible pending rows are derived, not stored: a pending entry renders only while its cwd is absent from servers. Compute in render:

const visiblePending = [...pendingStarts].filter(([cwd]) => !servers.some((s) => s.cwd === cwd));

This guarantees the synthetic row disappears in the same render frame that the real row (same servers array) appears, so there is no frame where both are missing and selection falls off. A separate cleanup effect deletes resolved entries from pendingStarts state after the fact; state cleanup timing is then cosmetic, not correctness-bearing. Selection onto the real row is already handled by the existing watch effect.

Row content

  • Starting: icon getProgressIcon(min(elapsed / 15000, 1)), title = project name, accessory { tag: { value: "Starting…", color: Color.Yellow } }. Elapsed needs a driver: run a 1s setInterval tick effect while any entry has status === "starting" (the servers poll does not re-render on a fixed cadence; sameServers returns the previous reference when nothing changed).
  • Failed: icon Icon.XMarkCircle tinted Color.Red, accessory { tag: { value: "Failed", color: Color.Red }, tooltip: reason ? SPAWN_FAILURE[reason].title : "Not detected after 15s" }. Subtitle: SPAWN_FAILURE[reason].title when diagnosed, else omit.
  • Failed row ActionPanel, in order: View Startup Log (push SpawnLogView, ⌘L to match server rows), Copy Fix Command (only when reason has a fix; reuse the command string from SPAWN_FAILURE), Open in Terminal (Action.Open with terminalApp, ⌘T to match server rows), Dismiss (⌘⇧D; deletes the entry from pendingStarts). Row-level shortcuts may mirror server-row chords because the two row types never need both meanings at once.

Wiring into the spawn machine

  • On transition to phase: "spawning" (where expecting is built), also populate pendingStarts from the same data, status: "starting".
  • Watchdog effect: for each still-missing cwd, set status: "failed" and reason: diagnoseSpawnFailure(cwd, logStart). Hide the toast entirely on failure (toast.hide()); the rows are the failure surface now. Move selection to the first failed row (starting:${cwd}). Delete the watchdog's toast-failure branch (title/message/actions); SPAWN_FAILURE and copyFixAction stay, now consumed by rows. The restart path's toast usage is untouched.
  • Success path (watch effect): unchanged, still flips the toast to Success. The cleanup effect removes resolved pending entries.
  • Starting a cwd that already has a failed pending row resets that entry to status: "starting" with fresh logStart/startedAt.

EmptyView guard

Widen to servers.length === 0 && visiblePending.length === 0 && !effectiveLoading, else a cold-start spawn renders the empty state and the starting row simultaneously.

Edge cases

Input Required behavior
Multi-target start, some bind, some fail Bound cwds hand off to real rows; watchdog marks only missing cwds failed, each with its own diagnosis. Toast hidden.
Same cwd started again while a failed row shows Entry resets to starting (fresh logStart, startedAt); no duplicate row.
Server binds after the watchdog marked it failed Handoff rule still applies: cwd appears in servers, so the failed row disappears and the real row shows. Cleanup effect removes the entry.
User dismisses a failed row Entry deleted; if the list is now empty, EmptyView shows.
User types in search while a start is pending Starting section keeps position (keepSectionOrder: true); synthetic rows are filterable by title like any row.
Spawn throws before detaching (no runnable script) Existing per-target failure toast path at spawn time is unchanged; no pending entry is created for targets that never entered expecting.
Dashboard closed mid-spawn and reopened Pending state is gone (non-goal); behavior identical to today.

Test plan

Automated: npx tsc --noEmit, npm run lint, npm run build must pass.

Manual, using disposable fixtures (recreate in ~/Downloads/devserver-tests/, never the Desktop; delete after):

  • 1-portless-down/package.json: dev script echo 'Proxy is not running and no TTY is available for sudo.' && exit 1
  • 2-portless-benign/package.json: dev script echo 'Proxy is not running.' && sleep 60
  • 3-port-conflict/package.json: dev script echo 'Error: listen EADDRINUSE: address already in use :::3000' && exit 1
  • Any real project for the happy path.

Run npm run dev, then start each from the dashboard.

Acceptance criteria

  1. npx tsc --noEmit, npm run lint, npm run build all exit 0.
  2. Starting any project renders a Starting section row with that project's name and a yellow Starting… tag within one render of confirming, while the animated toast still shows.
  3. The progress icon visibly advances over the 15s window (at least three distinct states).
  4. Happy path: when the server binds, the synthetic row is gone, the real row is present, and selection sits on the real row. At no point are both absent (no selection jump to an unrelated row).
  5. Fixture 1 after 15s: row shows red Failed tag, subtitle Portless proxy isn't running; ActionPanel contains View Startup Log, Copy Fix Command, Open in Terminal, Dismiss. Copy Fix Command puts exactly portless service install on the clipboard. No failure toast is visible.
  6. Fixture 2 after 15s: row fails with no portless subtitle (undiagnosed), tooltip Not detected after 15s; no Copy Fix Command action.
  7. Fixture 3 after 15s: subtitle Port already in use; no Copy Fix Command action.
  8. Multi-start of fixtures 1+3 plus a real project: real project binds and hands off; two failed rows remain, each with its own subtitle; selection lands on the first failed row.
  9. Dismiss removes the row; with nothing else running, EmptyView appears (not alongside any row).
  10. Starting fixture 1 again from its failed row's project resets the row to Starting… (no duplicate).
  11. Restart (⌘⇧R) on a healthy server behaves exactly as before this change, including its timeout toast.
  12. With a pending start visible, typing in the search bar does not move the Starting section below the server sections.

Verification

npx tsc --noEmit && npm run lint && npm run build

All three exit 0 = pass for criterion 1. Criteria 2-12 are the manual checklist above; report each as PASS/FAIL individually.

Guardrails

  • Work on the branch pending-start-rows, created off main (create it if absent). The toast release (commits through f46e67c) passed store review on 2026-07-25 and merges shortly; main must stay clean so /pull-contributions can reconcile the CHANGELOG there without conflict. Do not amend or revert published commits. Do not push. Checkpoint-commit on the branch after each working increment.
  • Do not touch the published entries in CHANGELOG.md. Add a new unreleased {PR_MERGE_DATE} entry for this feature.
  • Do not modify restart() beyond what already shipped, and do not add pending rows for restarts.
  • Do not inject synthetic entries into the useCachedPromise data or use mutate/optimisticUpdate for pending state.
  • Do not add a docs/ folder to the extension repo; docs go to DevServers.wiki (update Spawn Flow's toast table and state machine, and UI Conventions, in the same working session).
  • No em dashes in any written output (comments, changelog, docs).
  • If selectedItemId pinned to a just-inserted synthetic row does not take focus on a real device (undocumented API behavior), stop and report; do not build a workaround without discussion.
  • If the watch/watchdog effects' dependency arrays need restructuring beyond adding pending-row bookkeeping, stop and ask; they encode subtle races documented in their comments.

Assumptions to re-verify before starting

The executor has no access to the conversation that produced this spec. Verify each; if any fails, stop and reassess rather than adapt silently:

  1. src/index.tsx still contains SPAWN_FAILURE, copyFixAction, diagnoseSpawnFailure, SpawnLogView, and the three-effect spawn machine (spawnFlowFired ref, watch effect, watchdog effect). Locate by symbol; line anchors above are from commit f46e67c.
  2. Whether the store PR containing the toast rework has merged yet (check whether CHANGELOG.md's head entry still says {PR_MERGE_DATE}, or whether git log shows a Pull contributions merge after f46e67c). It passed review on 2026-07-25, so either state is expected. Not a blocker either way: work on the pending-start-rows branch and add this feature's changelog entry as a new unreleased section above the head entry.
  3. @raycast/utils exports getProgressIcon at the installed version.
  4. Server rows still use id={String(server.pid)} and sections are still grouped by projectKey in unsorted PID order.

Clone this wiki locally