mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Follow-up to #285. Violet sits close to the terminal's own dim foreground, so the arcs lost contrast exactly where they cross text, which is most of their length. Blue reads at a glance on the dark skins and on the light ones. Colour still comes from a token every skin block already defines and tunes for its own background (--session-blue instead of --session-purple), so it stays one rule for all seven skins with no per-skin override, and the two blues are not even the same: --session-blue is per palette while the subagent rule hardcodes #3b82f6. Hue no longer separates this layer from the subagent lines, so the separation now rests entirely on shape (a lineage arc hangs under the strip and never reaches a window), weight and dash pattern. Noted in the rule. CSS only: no geometry, no markup, no settings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
247 lines
13 KiB
Markdown
247 lines
13 KiB
Markdown
# Session lineage lines (spawn lines between tabs)
|
|
|
|
**Goal:** when a session spawns another session (the `codeman` agent skill starting a
|
|
worker, or anything else that says who it is), draw the same kind of glowing connection
|
|
line the subagent windows already use, but **tab → tab**, so a glance at the strip shows
|
|
which tab spawned which.
|
|
|
|
Status: PLAN. Nothing implemented yet.
|
|
|
|
---
|
|
|
|
## 1. The blocking fact: no parent relationship exists today
|
|
|
|
There is no spawn-parent link between sessions anywhere in the codebase:
|
|
|
|
- `SessionState` (`src/types/session.ts:388`) has no `parentSessionId` / `spawnedBy` /
|
|
`createdBy`.
|
|
- `POST /api/quick-start` and `POST /api/sessions` record only `owner = ownerFor(req)`,
|
|
which is the multi-user **human**, not the calling session.
|
|
- The only parent links that do exist are `TeamConfig.leadSessionId` (agent teams) and
|
|
`subagent-parents.json` (a frontend **window-layout** store for subagent windows).
|
|
Neither says "session A spawned session B".
|
|
- Nothing in the HTTP request identifies the caller: an agent's spawn call is plain
|
|
`curl` from inside a tmux pane, so there is no socket-level identity to recover
|
|
(`SO_PEERCRED` needs a unix socket; the API is TCP).
|
|
|
|
So the caller has to **tell** us. It already knows its own id: every managed pane gets
|
|
`CODEMAN_SESSION_ID` exported by `session-cli-builder.ts` (and the skill's §0 preamble
|
|
already binds it to `$SELF`).
|
|
|
|
## 2. Wire format
|
|
|
|
Two ways in, because they serve different callers. Body wins when both are present.
|
|
|
|
| Where | Shape | Who uses it |
|
|
| --- | --- | --- |
|
|
| body field | `"parentSessionId": "<uuid>"` | anything hand-writing one create call |
|
|
| request header | `X-Codeman-Parent-Session: <uuid>` | the skill: added **once** to the `CURL` array in the §0 preamble, so every present and future create call carries it with no per-recipe edit |
|
|
|
|
Rules, all of them deliberate:
|
|
|
|
- **Advisory decoration only.** It never grants access, never scopes anything, never
|
|
affects lifecycle. A child is not killed when its parent dies; the line just stops
|
|
being drawn once the parent tab is gone.
|
|
- **Never fails a spawn.** An unknown / stale / foreign parent id is silently dropped
|
|
(field ends up `undefined`), not a `400`. A cosmetic field must not be able to break
|
|
worker creation.
|
|
- **Resolved, not trusted.** The id must match a live session the caller can already
|
|
see (`canAccessOwned`), and the resolved parent's `owner` must equal the new
|
|
session's `owner`. Otherwise a user could staple their session under another user's
|
|
tab in multi-user mode.
|
|
- Exact id match first; a `>= 8`-char **unique** prefix match as a fallback (ids appear
|
|
truncated in mux names and UI surfaces; ambiguous prefixes resolve to nothing).
|
|
|
|
## 3. Server changes
|
|
|
|
| File | Change |
|
|
| --- | --- |
|
|
| `src/types/session.ts` | `SessionState.parentSessionId?: string` with a doc comment saying it is UI decoration and never a permission signal |
|
|
| `src/session.ts` | constructor option `parentSessionId` → `_parentSessionId`, public getter, emitted from `toState()` (~line 1170) |
|
|
| `src/web/schemas.ts` | `parentSessionId: z.string().max(100).optional()` on `CreateSessionSchema` (272) and `QuickStartSchema` (680). Neither is `.strict()`, so this is additive |
|
|
| `src/web/route-helpers.ts` | new `resolveParentSessionId(ctx, req, bodyValue, owner)` implementing §2's rules; returns `string \| undefined`, never throws |
|
|
| `src/web/routes/session-routes.ts` | pass it into the three `new Session({...})` sites: `POST /api/sessions` (846), `POST /api/run` (2522), `POST /api/quick-start` (2896) |
|
|
| `src/web/server.ts` | recovery path (~2617): `parentSessionId: savedState?.parentSessionId` so the link survives a restart |
|
|
|
|
**No new SSE event.** `session_created` / `session_updated` broadcast
|
|
`getSessionStateWithRespawn(session)`, which is `toState()`-derived, so the field rides
|
|
along to the browser for free — and the frontend already does
|
|
`this.sessions.set(data.id, data)`, so `session.parentSessionId` is simply there.
|
|
|
|
Optional follow-up: surface it on `/api/sessions/unified` rows so the Session Manager
|
|
and the home rails can show "spawned by w3-claudeman".
|
|
|
|
## 4. Frontend rendering
|
|
|
|
### 4.1 Where the code goes
|
|
|
|
`_updateConnectionLinesImmediate()` (`subagent-windows.js:242`) is a strict
|
|
**batched read → batched write** pass, and it already has an extension point:
|
|
ultracode appends its own layer via `_appendUltracodeConnectionLines(svg, rects)` at
|
|
the end, sharing the `rects` cache so no layer forces a second reflow.
|
|
|
|
Lineage lines follow that exactly: a new module `src/web/public/session-lineage.js`
|
|
(load order 15.6, after `ultracode-windows.js`) exporting
|
|
`_appendLineageConnectionLines(svg, rects)` onto `CodemanApp.prototype`, called from the
|
|
same tail. **The core function keeps ownership of the read/write split**; the new layer
|
|
only reads through the shared `rects` map and only appends paths.
|
|
|
|
The path math itself lives in `constants.js` as a pure
|
|
`computeLineagePath(parentRect, childRect, stripRect, depth)` — same treatment as
|
|
`computeTabScrollLeft`, so the geometry is unit-testable without a browser.
|
|
|
|
### 4.2 Geometry
|
|
|
|
Both endpoints are tabs in one horizontal strip, so the subagent shape (tab-bottom →
|
|
window-top) does not apply. **One case**, a **U-bridge hanging below the strip** that
|
|
touches both tabs on their bottom edge:
|
|
|
|
```
|
|
y0 = max(parent.bottom, child.bottom)
|
|
d = clamp(14 + |x2 - x1| * 0.085, 22, 104) + depth * 8 + |child.bottom - parent.bottom|
|
|
path: M x1 parent.bottom C x1 y0+d, x2 y0+d, x2 child.bottom
|
|
```
|
|
|
|
`depth` is the child's index among its siblings, so several children of one parent
|
|
**nest** instead of overprinting.
|
|
|
|
> **Superseded (2026-08-14): the two shapes this section used to specify.** The dip was
|
|
> `clamp(14 + span * 0.06, 16, 44) + depth * 6`, and a wrapped strip
|
|
> (`tabs-two-rows` / `tabs-auto-wrap`) got its own parent-bottom → child-**top** bezier.
|
|
> Both were tuned against two tabs side by side and failed at the distances the feature
|
|
> is used at:
|
|
>
|
|
> - a skill worker is appended to the **end** of the strip, so the real span is
|
|
> 800-1500px, where a 44px cap is a 33px sag, i.e. a line that reads as straight and
|
|
> crosses the terminal instead of bracketing under the strip;
|
|
> - and when the strip wraps, parent-bottom (34) to child-top (48) leaves **14px** to
|
|
> bend in, so the arc was a flat line hidden in the row gap, with siblings drawn on
|
|
> top of each other. Reported as *"they connect already, but the lines are straight
|
|
> and not easy visible"*.
|
|
>
|
|
> Anchoring both ends at the tab bottoms and hanging the control points below the
|
|
> **lower** row gives the wrapped case the same bracket as the flat one, and removes the
|
|
> branch. Pinned by `test/session-lineage-lines.test.ts`.
|
|
|
|
A small `<circle r="3.5">` at the child end marks direction (it breathes to 4.5 while that worker is busy) (an SVG `marker` would need a
|
|
`<defs>` block and fights `stroke-dasharray`).
|
|
|
|
Each path gets `class="connection-line lineage-line"`, `data-parent-tab`,
|
|
`data-child-tab`, and `data-agent-id="lineage:<childId>"` — that last one is what makes
|
|
the existing entrance machinery (`markConnectionLineEntering` / `_applyLineEntrances`,
|
|
keyed on `data-agent-id`) work on these lines with **zero** new animation code,
|
|
including the negative-`animation-delay` resume across the `svg.innerHTML = ''` rebuild.
|
|
|
|
### 4.3 Clipping
|
|
|
|
`.session-tabs` is `overflow-x: auto`, so a tab scrolled out of the strip still has a
|
|
rect — one that lies outside the strip box and would draw an arc across the logo or the
|
|
header buttons. **Skip any edge whose parent or child center falls outside
|
|
`stripRect` (4px tolerance).** Skipping is honest; clamping would draw a line to a tab
|
|
that is not there.
|
|
|
|
### 4.4 Redraw triggers
|
|
|
|
`updateConnectionLines()` already coalesces through `scheduleBackground`, so extra
|
|
callers are cheap. Needed:
|
|
|
|
- `_fullRenderSessionTabs()` — already calls it (app.js:3912). Free.
|
|
- `_renderSessionTabsImmediate()` — does **not**. A badge appearing widens a tab and
|
|
moves every tab after it, which slides the arcs off their anchors. Add the call,
|
|
guarded on `this._lineageEdgeCount > 0` so nobody pays for it without the feature.
|
|
- **strip `scroll`** (passive listener on `#sessionTabs`) — the arcs must track the
|
|
scroller. This is new; no existing line layer needed it.
|
|
- window `resize` — piggyback the throttled handler in `terminal-ui.js:930`.
|
|
- `_onSessionCreated` — `markConnectionLineEntering('lineage:' + data.id)` so a new
|
|
child draws in **if** the user has a line-entrance theme on (all entrance styles are
|
|
`legacy`/off by default, so this is a no-op for an untouched install).
|
|
|
|
### 4.5 Styling
|
|
|
|
`.connection-line.lineage-line`: blue stroke from the per-skin `--session-blue` token
|
|
(violet until 2026-08-14, changed because it lost contrast against the terminal's own
|
|
dim foreground the moment the arc crossed text),
|
|
`stroke-width: 2.5`, `dasharray 5 5`, `opacity: .72` (`.95` while the child works),
|
|
softer than the subagent lines so the two layers still read as different things now that
|
|
hue no longer separates them (shape does most of that work: a lineage arc hangs under the
|
|
strip and never reaches a window), but the contrast against the terminal comes from a
|
|
**second, wider glow** rather than more weight, because the first
|
|
cut (2px / `4 4` / `.55` / one 5px glow) disappeared into terminal text on a real 1080p
|
|
desktop. `lineage-flow` marches by two dash cycles, so it moves with the dash array
|
|
(`5 5` → `-20`). Trap to respect: the skin block nests under
|
|
`html:not([data-skin="og"])`, so a bare `.lineage-line` rule inside it would outrank the
|
|
base rule at higher specificity. **Define the color as a token per skin, keep exactly
|
|
one `.lineage-line` rule.** Light skins get a darker stroke.
|
|
|
|
Optional signal worth having: `.lineage-line--working` (a slow `stroke-dashoffset`
|
|
march) only while the **child** session is working, wrapped in
|
|
`prefers-reduced-motion: no-preference`. Static otherwise — a permanently marching line
|
|
per tab pair is noise and battery.
|
|
|
|
### 4.6 Desktop only, and why
|
|
|
|
The SVG overlay is `z-index: 999`. On desktop the header is `z-index: 100`, so arcs
|
|
paint **over** the header and can touch tab bottoms. Under 1024px `mobile.css` makes the
|
|
header `position: fixed; z-index: 1200`, which would **bury** the arcs — and the phone
|
|
strip is a scroller where both endpoints are rarely on screen together anyway. So the
|
|
layer returns early unless `MobileDetection.getDeviceType() === 'desktop'`.
|
|
|
|
Raising the SVG to ~1250 (above the fixed header, below modals at 1300) is a possible
|
|
phase 2, but it needs a real check against the mobile overview and the drawer.
|
|
|
|
### 4.7 Setting
|
|
|
|
`sessionLineageLines`, **per-device** — so it goes in the `displayKeys` set in
|
|
`settings-ui.js` and must **not** be added to `SettingsUpdateSchema` (`.strict()`;
|
|
sending an undeclared key fails the whole PUT). Rendered as a switch in
|
|
App Settings → Appearance, beside the entrance-animation pickers.
|
|
|
|
**Default: ON for desktop** (phones never render it). This is the one deliberate
|
|
departure from the "new visual surfaces ship OFF" convention — the feature is the
|
|
request, and a user with 12 unrelated tabs has a one-click off switch. Flag for the
|
|
owner if the convention should win instead.
|
|
|
|
## 5. Optional extras (call them separately, none are required)
|
|
|
|
1. **Order children after their parent** in `sessionOrder` on create, so arcs stay short
|
|
and the strip reads as a tree. Real cost: it renumbers the Alt+N badges and moves
|
|
tabs under the user's cursor, so it should be its own toggle, default OFF.
|
|
2. **Lineage hover focus**: hovering a tab dims unrelated arcs and brightens its own
|
|
subtree.
|
|
3. **"Spawned by" in the Session Manager / home rails**, once `parentSessionId` is on
|
|
the unified rows.
|
|
4. **Inherited tab tint**: children pick up a faded version of the parent's tab color.
|
|
|
|
## 6. Tests
|
|
|
|
- `test/session-lineage.test.ts` (route-level, `app.inject`): round-trips through
|
|
`POST /api/sessions` + `POST /api/quick-start`, header path, body-wins-over-header,
|
|
unknown id dropped without failing the spawn, cross-owner parent dropped in
|
|
multi-user, field present in `GET /api/sessions` and persisted state.
|
|
- `test/session-lineage-lines.test.ts` (jsdom, pure): `computeLineagePath` — same-row U,
|
|
wrapped-row bezier, sibling nesting depth, off-strip skip, degenerate zero-width rects.
|
|
- Browser check (not in `test:ci`): spawn two workers with a parent, assert two
|
|
`path.lineage-line` elements anchored to the right tabs, then scroll the strip and
|
|
assert they moved.
|
|
- Existing guards that must stay green: `test/mobile-header-buttons-policy.test.ts`
|
|
(nothing new on phones), `test/app-settings-structure.test.ts` (the new switch pairs
|
|
with its rail section).
|
|
|
|
## 7. Skill side (owned by the release session, not this plan)
|
|
|
|
One line in the `codeman` skill's §0 preamble covers every spawn recipe:
|
|
|
|
```bash
|
|
CURL=(curl -sk "${AUTH[@]}" -H "X-Codeman-Parent-Session: $SELF")
|
|
```
|
|
|
|
plus a `CODEMAN_PREAMBLE` version bump so stale cached preambles fail loudly instead of
|
|
silently spawning unparented workers. Recipes that build a create payload by hand can
|
|
alternatively send `"parentSessionId":"'"$SELF"'"`.
|
|
|
|
## 8. Docs to update when it lands
|
|
|
|
`CLAUDE.md` (a Key Patterns bullet), `docs/architecture-invariants.md` (new anchor: the
|
|
resolve-don't-trust rule, the desktop-only z-index reason, the shared `rects` pass),
|
|
`docs/api-reference.md` (the new field + header on the create endpoints).
|