diff --git a/internal/egg/server.go b/internal/egg/server.go index 99e282f6..131969a8 100644 --- a/internal/egg/server.go +++ b/internal/egg/server.go @@ -1022,6 +1022,13 @@ func (s *Server) RunSession(ctx context.Context, rc RunConfig) (runErr error) { log.Printf("egg: session %s kind=%s agent=%s command=%q pid=%d network=%s fs=%d", sessionID, rc.Kind, rc.Agent, rc.Command, cmd.Process.Pid, networkSummary, len(rc.FS)) + // The agent's own terminal title labels the session in browser lists. + sess.vterm.OnTitle(func(title string) { + if err := WriteSessionTitle(s.dir, title); err != nil { + log.Printf("egg: save session title: %v", err) + } + }) + // VTerm async processing goroutine — must start before readPTY go runVTermLoop(sess.vterm, sess.vtermCh, sess.done) diff --git a/internal/egg/session_title.go b/internal/egg/session_title.go new file mode 100644 index 00000000..9c01c93d --- /dev/null +++ b/internal/egg/session_title.go @@ -0,0 +1,88 @@ +package egg + +import ( + "errors" + "os" + "path/filepath" + "strings" + "unicode" +) + +// SessionTitleFile holds the latest title the agent set for its terminal. +const SessionTitleFile = "session.title" + +const maxSessionTitleRunes = 60 + +// Program names agents set before they have a topic say nothing about the session. +var genericSessionTitles = map[string]bool{"claude code": true, "claude": true, "codex": true} + +// CleanSessionTitle turns an OSC terminal title into a display label: it drops +// the leading spinner or status glyph (Claude uses ✳, ◐ and ◑), keeps letters, +// digits and plain punctuation only, and returns "" for generic program names. +func CleanSessionTitle(raw string) string { + var b strings.Builder + started, space := false, false + for _, r := range raw { + keep := unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsMark(r) || strings.ContainsRune(".,:;-_/#()+?!@%=", r) + if !started && !unicode.IsLetter(r) && !unicode.IsDigit(r) { + continue + } + started = true + if !keep { + space = space || unicode.IsSpace(r) + continue + } + if space && b.Len() > 0 { + b.WriteByte(' ') + } + space = false + b.WriteRune(r) + } + title := []rune(b.String()) + if len(title) > maxSessionTitleRunes { + title = title[:maxSessionTitleRunes] + } + cleaned := strings.TrimSpace(string(title)) + if genericSessionTitles[strings.ToLower(cleaned)] { + return "" + } + return cleaned +} + +// WriteSessionTitle replaces the egg's title file atomically; "" removes it. +func WriteSessionTitle(dir, title string) error { + path := filepath.Join(dir, SessionTitleFile) + if title == "" { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil + } + tmp, err := os.CreateTemp(dir, ".session-title-*") + if err != nil { + return err + } + if _, err := tmp.WriteString(title + "\n"); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmp.Name()) + return err + } + if err := os.Rename(tmp.Name(), path); err != nil { + os.Remove(tmp.Name()) + return err + } + return nil +} + +// ReadSessionTitle returns the saved title, cleaned again on the way out. +func ReadSessionTitle(dir string) string { + data, err := os.ReadFile(filepath.Join(dir, SessionTitleFile)) + if err != nil { + return "" + } + return CleanSessionTitle(string(data)) +} diff --git a/internal/egg/session_title_test.go b/internal/egg/session_title_test.go new file mode 100644 index 00000000..6178636d --- /dev/null +++ b/internal/egg/session_title_test.go @@ -0,0 +1,87 @@ +package egg + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestCleanSessionTitle(t *testing.T) { + cases := map[string]string{ + "◐ Math question": "Math question", + "✳ Arithmetic calculation": "Arithmetic calculation", + "✳ Claude Code": "", + "codex": "", + " SLIDE-1234 refund check": "SLIDE-1234 refund check", + "": "img src=x onerror=alert(1)", + "case \"42\" & 'quotes'": "case 42 quotes", + "\x1b[31mred\x07": "31mred", + "": "", + } + for raw, want := range cases { + if got := CleanSessionTitle(raw); got != want { + t.Errorf("CleanSessionTitle(%q) = %q, want %q", raw, got, want) + } + } + long := CleanSessionTitle("Investigate a very long support case title that keeps going well past the sidebar width") + if n := len([]rune(long)); n > maxSessionTitleRunes { + t.Errorf("title has %d runes, want at most %d", n, maxSessionTitleRunes) + } +} + +func TestVTermReportsTitleChangesOnce(t *testing.T) { + h := newAsyncVTermHarness(80, 24, 256) + defer h.close() + titles := make(chan string, 8) + h.vterm.OnTitle(func(title string) { titles <- title }) + + // A title split across PTY reads, the next spinner frame, then Claude's idle + // frame, whose ✳ the parser truncates at its 0x9C byte, then a new topic. + h.writeAndEnqueue([]byte("\x1b]0;◐ Math qu")) + h.writeAndEnqueue([]byte("estion\x07")) + h.writeAndEnqueue([]byte("\x1b]0;◑ Math question\x07")) + h.writeAndEnqueue([]byte("\x1b]0;✳ Math question\x07")) + h.writeAndEnqueue([]byte("\x1b]2;◐ Refund lookup\x07")) + h.fence() + + var got []string + for { + select { + case title := <-titles: + got = append(got, title) + continue + case <-time.After(50 * time.Millisecond): + } + break + } + if len(got) != 2 || got[0] != "Math question" || got[1] != "Refund lookup" { + t.Fatalf("title changes = %q, want [Math question Refund lookup]", got) + } +} + +func TestSessionTitleFileRoundTrip(t *testing.T) { + dir := t.TempDir() + if got := ReadSessionTitle(dir); got != "" { + t.Fatalf("missing title = %q, want empty", got) + } + if err := WriteSessionTitle(dir, "Math question"); err != nil { + t.Fatal(err) + } + if got := ReadSessionTitle(dir); got != "Math question" { + t.Fatalf("title = %q, want Math question", got) + } + // A tampered file is cleaned on read like a live title. + if err := os.WriteFile(filepath.Join(dir, SessionTitleFile), []byte("✳ bold\n"), 0o600); err != nil { + t.Fatal(err) + } + if got := ReadSessionTitle(dir); got != "bbold/b" { + t.Fatalf("tampered title = %q, want bbold/b", got) + } + if err := WriteSessionTitle(dir, ""); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, SessionTitleFile)); !os.IsNotExist(err) { + t.Fatalf("empty title left the file behind: %v", err) + } +} diff --git a/internal/egg/vterm.go b/internal/egg/vterm.go index aa2f2e2d..562197b5 100644 --- a/internal/egg/vterm.go +++ b/internal/egg/vterm.go @@ -2,9 +2,11 @@ package egg import ( "fmt" + "io" "log" "strings" "sync" + "unicode/utf8" uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/vt" @@ -24,6 +26,12 @@ type VTerm struct { altScreen bool cursorHidden bool cols, rows int + + title string + titleChanged bool + onTitle func(string) + + drained chan struct{} // closed when the reply drain exits } // NewVTerm creates a VTerm with the given dimensions. @@ -69,7 +77,26 @@ func NewVTerm(cols, rows int) *VTerm { // mu already held by caller (Write) v.cursorHidden = !visible }, + Title: func(raw string) { + // mu already held by caller (Write) + // The parser ends an OSC at byte 0x9C (C1 ST), which cuts Claude's idle + // glyph ✳ (e2 9c b3) to one byte; keep the last whole title instead. + if !utf8.ValidString(raw) { + return + } + if title := CleanSessionTitle(raw); title != v.title { + v.title, v.titleChanged = title, true + } + }, }) + // The emulator answers terminal queries (DA, XTVERSION, mode reports) into + // a pipe; unread, the first query blocks Write forever. The browser's real + // terminal already answers them, so the shadow copy's replies are dropped. + v.drained = make(chan struct{}) + go func() { + defer close(v.drained) + _, _ = io.Copy(io.Discard, v.emu) + }() return v } @@ -80,6 +107,22 @@ func (v *VTerm) Write(p []byte) (int, error) { return v.emu.Write(p) } +// OnTitle registers fn to receive each change of the title the agent sets +// for its terminal. fn runs on the VTerm goroutine, outside the lock. +func (v *VTerm) OnTitle(fn func(string)) { + v.mu.Lock() + defer v.mu.Unlock() + v.onTitle = fn +} + +func (v *VTerm) takeTitleChange() (string, func(string), bool) { + v.mu.Lock() + defer v.mu.Unlock() + changed := v.titleChanged + v.titleChanged = false + return v.title, v.onTitle, changed +} + // Resize changes the terminal dimensions. func (v *VTerm) Resize(cols, rows int) { v.mu.Lock() @@ -139,6 +182,12 @@ func (v *VTerm) ScrollbackLen() int { // Close releases the emulator resources. func (v *VTerm) Close() error { + // End the reply drain before the emulator marks itself closed; its Read + // checks that flag without a lock. + if pw, ok := v.emu.InputPipe().(*io.PipeWriter); ok { + _ = pw.Close() + } + <-v.drained v.mu.Lock() defer v.mu.Unlock() return v.emu.Close() @@ -194,6 +243,9 @@ func runVTermLoop(vt *VTerm, ch <-chan vtermMsg, done <-chan struct{}) { return } lastOffset = msg.offset + if title, fn, changed := vt.takeTitleChange(); changed && fn != nil { + fn(title) + } } if msg.resize != nil { vt.Resize(msg.resize.cols, msg.resize.rows) diff --git a/internal/egg/vterm_test.go b/internal/egg/vterm_test.go index cb6fb7fb..0ad8aa6a 100644 --- a/internal/egg/vterm_test.go +++ b/internal/egg/vterm_test.go @@ -849,3 +849,23 @@ func TestAsyncVTermConcurrentFenceAndWrites(t *testing.T) { } } } + +func TestVTermSurvivesTerminalQueries(t *testing.T) { + v := NewVTerm(80, 24) + defer v.Close() + done := make(chan struct{}) + go func() { + // Claude Code 2.1.292's startup queries: XTVERSION, kitty keyboard, DA1. + _, _ = v.Write([]byte("\x1b[>0q\x1b[?u\x1b[c\x1b[6n")) + _, _ = v.Write([]byte("after queries")) + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("VTerm.Write blocked on a terminal query reply") + } + if !strings.Contains(string(v.Snapshot()), "after queries") { + t.Fatal("output after the queries is missing from the snapshot") + } +} diff --git a/internal/wing/wing.go b/internal/wing/wing.go index f9ed1444..bbfca473 100644 --- a/internal/wing/wing.go +++ b/internal/wing/wing.go @@ -18,6 +18,7 @@ import ( "os/exec" "path/filepath" "runtime" + "sort" "strconv" "strings" "sync" @@ -1406,6 +1407,7 @@ func ListAliveEggSessions(cfg *config.Config) []ws.SessionInfo { } var out []ws.SessionInfo + started := make(map[string]int64) for _, e := range entries { if !e.IsDir() { continue @@ -1456,6 +1458,7 @@ func ListAliveEggSessions(cfg *config.Config) []ws.SessionInfo { info := ws.SessionInfo{ SessionID: sessionID, Name: eggclient.ReadSessionName(dir), + Title: egg.ReadSessionTitle(dir), Agent: agent, CWD: sessionCWD, EggConfig: renderedConfig, @@ -1475,11 +1478,25 @@ func ListAliveEggSessions(cfg *config.Config) []ws.SessionInfo { if _, err := os.Stat(filepath.Join(dir, "chat.jsonl.gz")); err == nil { info.Chat = true } + started[sessionID], _ = strconv.ParseInt(eggclient.ReadEggMetaValues(dir)["started_at"], 10, 64) out = append(out, info) } + sortSessionsByStart(out, started) return out } +// sortSessionsByStart lists sessions oldest first, so a browser reload keeps a +// new session where it appeared instead of sorting it in by its random ID. +func sortSessionsByStart(sessions []ws.SessionInfo, started map[string]int64) { + sort.SliceStable(sessions, func(i, j int) bool { + a, b := started[sessions[i].SessionID], started[sessions[j].SessionID] + if a != b { + return a < b + } + return sessions[i].SessionID < sessions[j].SessionID + }) +} + type pendingReattachAuth struct { attach ws.PTYAttach challenge []byte diff --git a/internal/wing/wing_test.go b/internal/wing/wing_test.go index c1fced05..a24edf13 100644 --- a/internal/wing/wing_test.go +++ b/internal/wing/wing_test.go @@ -512,3 +512,16 @@ func TestForgetAttentionStateRemovesAllSessionEntries(t *testing.T) { t.Fatal("attention nonce was retained") } } + +func TestSortSessionsByStartKeepsLaunchOrder(t *testing.T) { + sessions := []ws.SessionInfo{{SessionID: "11bb"}, {SessionID: "8e2e"}, {SessionID: "21da"}, {SessionID: "aa00"}} + started := map[string]int64{"11bb": 300, "8e2e": 200, "21da": 100, "aa00": 200} + sortSessionsByStart(sessions, started) + var got []string + for _, s := range sessions { + got = append(got, s.SessionID) + } + if strings.Join(got, ",") != "21da,8e2e,aa00,11bb" { + t.Fatalf("order = %v, want oldest first with ID tie-break", got) + } +} diff --git a/internal/ws/protocol.go b/internal/ws/protocol.go index 00f10c3f..d144ce2a 100644 --- a/internal/ws/protocol.go +++ b/internal/ws/protocol.go @@ -367,6 +367,7 @@ type SessionInfo struct { Lifecycle map[string]any `json:"lifecycle,omitempty"` SessionID string `json:"session_id"` Name string `json:"name,omitempty"` + Title string `json:"title,omitempty"` // agent-set terminal title, display only Agent string `json:"agent"` CWD string `json:"cwd,omitempty"` EggConfig string `json:"-"` // server-only effective YAML config snapshot diff --git a/test/web/orgmode.mjs b/test/web/orgmode.mjs index 2953da0b..843b6bd6 100644 --- a/test/web/orgmode.mjs +++ b/test/web/orgmode.mjs @@ -568,22 +568,15 @@ try { } try { - // Rename lives in the open session's details, reached from its title. - record('carol: sidebar rows carry no controls', - await p.locator('#session-tabs .session-tab button').count() === 0); - await p.focus('#header-title'); - await p.keyboard.press('Enter'); - await p.locator('#detail-egg-rename').waitFor({ state: 'visible', timeout: 5000 }); - record('carol: the keyboard opens session details from the title and lands inside them', - await p.evaluate(() => document.getElementById('detail-dialog').contains(document.activeElement))); - await p.click('#detail-egg-rename'); - await p.locator('#detail-dialog .session-name-input').fill('support-night-review'); - await p.locator('#detail-dialog .session-name-input').press('Enter'); + const tab = p.locator(`#session-tabs .session-tab[data-sid="${carolSessionID}"]`); + await tab.hover(); + await tab.locator('.session-rename-btn').click(); + await tab.locator('.session-name-input').fill('support-night-review'); + await tab.locator('.session-name-input').press('Enter'); await p.waitForFunction((sessionID) => { const candidate = document.querySelector(`#session-tabs .session-tab[data-sid="${sessionID}"] .tab-label`); return candidate && candidate.textContent === 'support-night-review'; }, carolSessionID, { timeout: 10000 }); - await p.click('#detail-backdrop', { position: { x: 8, y: 8 } }); await p.reload({ waitUntil: 'domcontentloaded' }); await p.waitForSelector(`#session-tabs .session-tab[data-sid="${carolSessionID}"] .tab-label`, { timeout: 20000 }); const persisted = await p.locator(`#session-tabs .session-tab[data-sid="${carolSessionID}"] .tab-label`).textContent(); @@ -649,13 +642,11 @@ try { const firstBox = await rows.nth(firstIndex).boundingBox(); const secondBox = await rows.nth(secondIndex).boundingBox(); if (!firstBox || !secondBox) throw new Error('copy fixture rows have no layout box'); - // Copy is not a resting control: it appears only for a selection. - if (await p.locator('#terminal-copy-btn').isVisible()) throw new Error('copy was visible before any selection'); await p.mouse.move(firstBox.x + 2, firstBox.y + firstBox.height / 2); await p.mouse.down(); await p.mouse.move(secondBox.x + 112, secondBox.y + secondBox.height / 2, { steps: 8 }); await p.mouse.up(); - await p.waitForSelector('#terminal-copy-btn', { state: 'visible', timeout: 5000 }); + await p.waitForFunction(() => !document.getElementById('terminal-copy-btn').disabled, null, { timeout: 5000 }); await p.click('#terminal-copy-btn'); const copied = await p.evaluate(() => window.__wingthingCopiedText); record('carol: clean copy preserves indentation and removes terminal line padding', @@ -708,19 +699,15 @@ try { try { await alice.page.goto(BASE + '/app/', { waitUntil: 'domcontentloaded' }); await alice.page.waitForSelector(`#session-tabs .session-tab[data-sid="${carolSessionID}"]`, { timeout: 20000 }); - const tabButtons = await alice.page.locator('#session-tabs .session-tab button').count(); + const adminRename = await alice.page.locator(`#session-tabs .session-tab[data-sid="${carolSessionID}"] .session-rename-btn`).count(); + record('admin: another user session does not expose rename', adminRename === 0, `rename buttons=${adminRename}`); const foreignCard = alice.page.locator(`#sessions-list .egg-box[data-sid="${carolSessionID}"]`); await foreignCard.waitFor({ state: 'visible', timeout: 20000 }); - const cardButtons = await foreignCard.locator('button').count(); - await foreignCard.locator('.inventory-details').click(); + await foreignCard.locator('.box-menu-btn').click(); await alice.page.waitForSelector('#detail-egg-delete', { state: 'visible', timeout: 10000 }); - const adminRename = await alice.page.locator('#detail-egg-rename, #detail-dialog .session-fork-btn').count(); - record('admin: another user session does not expose rename', adminRename === 0 && tabButtons === 0, - `rename/fork actions=${adminRename} sidebar buttons=${tabButtons}`); - record('admin: details preserve allowed inspection and stop while rename remains owner-only', - cardButtons === 1 && await foreignCard.locator('.inventory-details').count() === 1 && - await alice.page.locator('#detail-egg-delete').count() === 1 && - (await alice.page.locator('#detail-dialog').textContent()).includes('/opt/wingthing/support')); + record('admin: inventory preserves allowed inspection and stop while rename and fork remain owner-only', + await foreignCard.locator('.box-menu-btn, .egg-delete').count() === 2 && + await alice.page.locator('#detail-dialog .session-fork-btn').count() === 0); await alice.page.click('#detail-backdrop', { position: { x: 8, y: 8 } }); } catch (e) { record('admin: another user session does not expose rename', false, String(e).slice(0, 200)); @@ -741,25 +728,19 @@ try { await p.click('#home-btn'); const card = p.locator(`#sessions-list .egg-box[data-sid="${carolSessionID}"]`); await card.waitFor({ state: 'visible', timeout: 20000 }); - const cards = await p.locator('#sessions-list .egg-box').count(); - // A short inventory shows no search, and a single project needs no header. - const searchHidden = cards >= 8 || !(await p.locator('#session-inventory-search').isVisible()); - const headers = await p.locator('#sessions-list .inventory-group-header').count(); - record('carol: a named owned session stays inspectable under its project in a calm inventory', - searchHidden && headers === 0 && - await card.locator('button').count() === 1 && await card.locator('.inventory-details').count() === 1 && - (await card.getAttribute('aria-label')).includes('/opt/wingthing/support') && - (await card.locator('.egg-label').textContent()) === 'support-night-review', - `cards=${cards} search_hidden=${searchHidden} headers=${headers}`); - await p.locator('#sessions-list .egg-box').first().focus(); - for (let i = 0; i < cards && !(await p.evaluate((id) => document.activeElement?.dataset.sid === id, carolSessionID)); i++) { - await p.keyboard.press('ArrowDown'); - } - record('carol: keyboard inventory navigation focuses the exact matching session', - await p.evaluate((id) => document.activeElement?.dataset.sid === id, carolSessionID)); + // Home is the v0.147.0 inventory: a terminal thumbnail, the name and agent, + // details and stop, in the saved order, with no search or filter controls. + record('carol: Home shows the named session with its terminal thumbnail', + (await card.locator('.egg-label').textContent()).startsWith('support-night-review') && + await card.locator('.egg-preview img').count() === 1 && + await card.locator('.box-menu-btn, .egg-delete').count() === 2 && + await p.locator('#session-inventory-search, #session-inventory-agent').count() === 0); + const dots = await p.locator('#session-tabs .session-tab .tab-dot').evaluateAll((els) => els.map((el) => el.className)); + record('carol: sidebar dots show connection, not agent activity', + dots.length > 0 && dots.every((name) => /dot-(detached|live|offline|attention)/.test(name) && !/agent-status/.test(name)), JSON.stringify(dots)); await shot(p, 'carol-inventory'); } catch (e) { - record('carol: calm inventory and keyboard navigation', false, String(e).slice(0, 200)); + record('carol: Home inventory', false, String(e).slice(0, 200)); } // ---------- Carol (support member), mobile ---------- @@ -769,12 +750,6 @@ try { try { await waitWing(p); record('carol-mobile: dashboard renders with shared wing', true); - await p.waitForSelector('#sessions-list .egg-box', { state: 'visible', timeout: 20000 }); - const boxes = await p.locator('#sessions-list .egg-box').evaluateAll((rows) => rows.map((row) => row.getBoundingClientRect().toJSON())); - const previews = await p.locator('#sessions-list .egg-preview').count(); - record('carol-mobile: session cards are single full-width lines above the fold', - previews === 0 && boxes.every((box) => box.height >= 44 && box.height <= 50) && boxes[0].bottom <= 844, - JSON.stringify(boxes.map((box) => [Math.round(box.top), Math.round(box.height)]))); } catch (e) { record('carol-mobile: dashboard renders with shared wing', false, String(e).slice(0, 200)); } diff --git a/web/fixtures/session-inventory.js b/web/fixtures/session-inventory.js index ba8b2951..e368601f 100644 --- a/web/fixtures/session-inventory.js +++ b/web/fixtures/session-inventory.js @@ -11,12 +11,12 @@ S.wingsData = [ { wing_id: 'fixture-offline', wing_label: 'Travel laptop', hostname: 'travel-laptop', online: false, platform: 'darwin', agents: ['claude'], projects: [], user_id: 'fixture-owner' } ]; S.sessionsData = [ - { id: 'fixture-parent', wing_id: 'fixture-mac', name: 'weekend-coordinator', agent: 'claude', cwd: '/Users/example/Projects/wingthing', user_id: 'fixture-owner', swept: true, status: 'detached', conversation_id: 'fixture-logical-parent', conversation_role: 'parent', lifecycle: { state: 'idle', status: 'idle', state_source: 'claude_hook', process_alive: true }, lifecycle_seen_at: Date.now() }, - { id: 'fixture-child', wing_id: 'fixture-wsl', name: 'remote-reconnect-review', agent: 'claude', cwd: '/home/example/Projects/wingthing', user_id: 'fixture-owner', swept: true, status: 'detached', conversation_role: 'child', lifecycle: { state: 'working', status: 'working', state_source: 'claude_hook' } }, + { id: 'fixture-parent', wing_id: 'fixture-mac', name: 'weekend-coordinator', agent: 'claude', cwd: '/Users/example/Projects/wingthing', user_id: 'fixture-owner', swept: true, status: 'detached', conversation_id: 'fixture-logical-parent', conversation_role: 'parent', lifecycle: { state: 'idle', state_source: 'claude_hook', process_alive: true }, lifecycle_seen_at: Date.now() }, + { id: 'fixture-child', wing_id: 'fixture-wsl', name: 'remote-reconnect-review', agent: 'claude', cwd: '/home/example/Projects/wingthing', user_id: 'fixture-owner', swept: true, status: 'detached', conversation_role: 'child', lifecycle: { state: 'working', state_source: 'claude_hook' } }, { id: 'fixture-child', wing_id: 'fixture-mac', name: 'local-review-same-provider-id', agent: 'claude', cwd: '/Users/example/Projects/wingthing', user_id: 'fixture-owner', swept: true, status: 'detached', conversation_role: 'child', needs_attention: true }, - { id: 'fixture-input', wing_id: 'fixture-mac', name: 'ui-accessibility-check', agent: 'claude', cwd: '/Users/example/Projects/wingthing/web', user_id: 'fixture-owner', swept: true, status: 'detached', conversation_role: 'child', lifecycle: { state: 'needs_input', status: 'blocked', state_source: 'claude_hook' } }, + { id: 'fixture-input', wing_id: 'fixture-mac', name: 'ui-accessibility-check', agent: 'claude', cwd: '/Users/example/Projects/wingthing/web', user_id: 'fixture-owner', swept: true, status: 'detached', conversation_role: 'child', lifecycle: { state: 'needs_input', state_source: 'claude_hook' } }, { id: 'fixture-unknown', wing_id: 'fixture-wsl', name: 'provider-compatibility', agent: 'codex', cwd: '/home/example/Projects/provider-fixtures', user_id: 'fixture-owner', swept: true, status: 'detached' }, - { id: 'fixture-disconnected', wing_id: 'fixture-offline', name: 'docs-review', agent: 'claude', cwd: '/Users/example/Projects/wingthing/docs', user_id: 'fixture-owner', swept: false, status: 'detached', lifecycle: { state: 'working', status: 'working', state_source: 'claude_hook' } } + { id: 'fixture-disconnected', wing_id: 'fixture-offline', name: 'docs-review', agent: 'claude', cwd: '/Users/example/Projects/wingthing/docs', user_id: 'fixture-owner', swept: false, status: 'detached', lifecycle: { state: 'working', state_source: 'claude_hook' } } ]; function render() { renderSidebar(); renderDashboard(); } initParentDot(); @@ -25,8 +25,6 @@ render(); document.getElementById('fixture-parent-attention').addEventListener('click', function() { var parent = S.sessionsData.find(function(session) { return session.conversation_id === 'fixture-logical-parent'; }); parent.lifecycle.state = parent.lifecycle.state === 'needs_input' ? 'idle' : 'needs_input'; - // The egg maps state to status; the inventory reads status. - parent.lifecycle.status = parent.lifecycle.state === 'needs_input' ? 'blocked' : 'idle'; parent.lifecycle_seen_at = Date.now(); this.textContent = parent.lifecycle.state === 'needs_input' ? 'simulate parent ready' : 'simulate parent needs input'; render(); @@ -38,10 +36,10 @@ document.getElementById('fixture-disconnect').addEventListener('click', function render(); }); // Block every runtime action in this fixture, including keyboard attachment. -// Search, focus navigation, detail inspection and disconnect simulation work. +// Filters, focus navigation, detail inspection and disconnect simulation work. document.addEventListener('click', function(event) { var rowAction = event.target.closest('.egg-box, .session-tab') && !event.target.closest('.inventory-details'); - if (rowAction || event.target.closest('#detail-egg-connect, #detail-egg-rename, #detail-egg-delete, .session-fork-btn, .wing-box, #parent-dot')) { + if (rowAction || event.target.closest('.inventory-attach, .inventory-rename, .inventory-stop, .egg-delete, .session-rename-btn, #detail-egg-connect, #detail-egg-delete, .wing-box, #parent-dot')) { event.preventDefault(); event.stopImmediatePropagation(); document.getElementById('fixture-notice').textContent = 'Synthetic UI fixture: runtime actions are disabled.'; } diff --git a/web/index.html b/web/index.html index 0ac7d7d5..d8164cbf 100644 --- a/web/index.html +++ b/web/index.html @@ -29,7 +29,7 @@
- + @@ -96,13 +96,6 @@

connect a parent agent to every wing

-