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 @@
-
-
sessions
-
- Search sessions by name, agent, wing or folder
- clear
-
-
~
@@ -200,7 +193,7 @@
connect a parent agent to every wing
diff --git a/web/src/data.js b/web/src/data.js
index a81af6bd..b7565385 100644
--- a/web/src/data.js
+++ b/web/src/data.js
@@ -79,7 +79,7 @@ export function setCachedWingSessions(wingId, sessions) {
export function saveSessionCache() {
setCachedSessions(S.sessionsData.map(function(s) {
- return { id: s.id, name: s.name, wing_id: s.wing_id, agent: s.agent, cwd: s.cwd, audit: s.audit, user_id: s.user_id, email: s.email, lifecycle: s.lifecycle, conversation_id: s.conversation_id, root_conversation_id: s.root_conversation_id, parent_conversation_id: s.parent_conversation_id, conversation_role: s.conversation_role };
+ return { id: s.id, name: s.name, title: s.title, wing_id: s.wing_id, agent: s.agent, cwd: s.cwd, audit: s.audit, user_id: s.user_id, email: s.email, lifecycle: s.lifecycle, conversation_id: s.conversation_id, root_conversation_id: s.root_conversation_id, parent_conversation_id: s.parent_conversation_id, conversation_role: s.conversation_role };
}));
}
@@ -200,7 +200,7 @@ export async function fetchWingSessions(wingId) {
try {
var result = await sendTunnelRequest(wingId, { type: 'sessions.list' }, { skipPasskey: true });
return (result.sessions || []).map(function(s) {
- return { id: s.session_id, name: s.name, wing_id: (S.wingsData.find(function(w) { return w.wing_id === wingId; }) || {}).wing_id || '', agent: s.agent, cwd: s.cwd, status: 'detached', needs_attention: s.needs_attention, audit: s.audit, user_id: s.user_id, email: s.email, lifecycle: s.lifecycle, lifecycle_seen_at: Date.now(), conversation_id: s.conversation_id, root_conversation_id: s.root_conversation_id, parent_conversation_id: s.parent_conversation_id, conversation_role: s.conversation_role, forkable: s.forkable, fork_unavailable_reason: s.fork_unavailable_reason };
+ return { id: s.session_id, name: s.name, title: s.title, wing_id: (S.wingsData.find(function(w) { return w.wing_id === wingId; }) || {}).wing_id || '', agent: s.agent, cwd: s.cwd, status: 'detached', needs_attention: s.needs_attention, audit: s.audit, user_id: s.user_id, email: s.email, lifecycle: s.lifecycle, lifecycle_seen_at: Date.now(), conversation_id: s.conversation_id, root_conversation_id: s.root_conversation_id, parent_conversation_id: s.parent_conversation_id, conversation_role: s.conversation_role, forkable: s.forkable, fork_unavailable_reason: s.fork_unavailable_reason };
});
} catch (e) { return null; }
}
diff --git a/web/src/helpers.js b/web/src/helpers.js
index 8c4f4a86..ed54de7d 100644
--- a/web/src/helpers.js
+++ b/web/src/helpers.js
@@ -44,17 +44,23 @@ export function setupCopyable(container) {
});
}
+// A name the user chose wins, then the title the agent gave its terminal,
+// then the folder.
export function sessionDisplayName(session) {
if (session && typeof session.name === 'string' && session.name.trim()) {
return session.name.trim();
}
+ if (session && typeof session.title === 'string' && session.title.trim()) {
+ return session.title.trim();
+ }
return projectName(session && session.cwd);
}
-// The header names the session; agent and wing are in its details.
export function formatSessionTitle(session, agent, wingName) {
- if (session) return sessionDisplayName(session);
- return wingName || agent || '';
+ var resolvedAgent = agent || (session && session.agent) || '';
+ var label = session && session.name ? sessionDisplayName(session) : (wingName || '');
+ if (label && resolvedAgent) return label + ' \u00b7 ' + resolvedAgent;
+ return label || resolvedAgent;
}
export function validSessionName(name) {
diff --git a/web/src/main.js b/web/src/main.js
index 4d4bd8a1..70110d1e 100644
--- a/web/src/main.js
+++ b/web/src/main.js
@@ -9,7 +9,7 @@ import { initChatView } from './chat-view.js';
import { detachPTY, disconnectPTY, retryReconnect, attachPTY, handlePTYPasskey } from './pty.js';
import { showPalette, hidePalette, cyclePaletteAgent, cyclePaletteWing, navigatePalette, tabCompletePalette, launchFromPalette, debouncedDirList, isDirListPending } from './palette.js';
import { connectAppWS } from './dashboard.js';
-import { loadHome } from './data.js';
+import { loadHome, fetchWingSessions, mergeWingSessions } from './data.js';
import { scopeBrowserStateToUser } from './storage-scope.js';
import { closeAuditOverlay } from './audit.js';
import { hideDetailModal, showSessionInfo, renderSidebar, renderDashboard } from './render.js';
@@ -369,6 +369,17 @@ async function init() {
}
initNotifyListeners();
initParentDot();
+ // The open session's agent took a new title; show it without waiting for the poll.
+ window.addEventListener('wt-terminal-title', function() {
+ var wingId = S.ptyWingId;
+ if (!wingId) return;
+ fetchWingSessions(wingId).then(function(sessions) {
+ if (!sessions) return;
+ mergeWingSessions(wingId, sessions);
+ renderSidebar();
+ if (S.activeView === 'home') renderDashboard();
+ }).catch(function() {});
+ });
await loadHome();
setInterval(loadHome, 30000);
connectAppWS();
diff --git a/web/src/pty.js b/web/src/pty.js
index 9d911b5b..cb028be2 100644
--- a/web/src/pty.js
+++ b/web/src/pty.js
@@ -51,9 +51,8 @@ function handleTerminalControlError(ws, sessionId, wingId, message) {
}
function setSessionActions(active) {
- // Copy appears only once there is a selection (see terminal.js).
- DOM.terminalCopyBtn.style.display = 'none';
- DOM.terminalCopyBtn.disabled = true;
+ DOM.terminalCopyBtn.style.display = active ? '' : 'none';
+ if (!active) DOM.terminalCopyBtn.disabled = true;
// Leaving a terminal must not leave its end-session control in the header.
if (!active) {
DOM.sessionCloseBtn.style.display = 'none';
diff --git a/web/src/render.js b/web/src/render.js
index 40e36aa9..c496e3da 100644
--- a/web/src/render.js
+++ b/web/src/render.js
@@ -1,5 +1,5 @@
-import { S, DOM } from './state.js';
-import { escapeHtml, wingDisplayName, shortenPath, projectName, sessionDisplayName, validSessionName, formatRelativeTime, semverCompare, nestedRepoCount, agentWithIcon, dirParent, setupCopyable, formatSessionTitle } from './helpers.js';
+import { S, DOM, TERM_THUMB_PREFIX } from './state.js';
+import { escapeHtml, wingDisplayName, shortenPath, projectName, sessionDisplayName, validSessionName, formatRelativeTime, semverCompare, nestedRepoCount, agentIcon, agentWithIcon, dirParent, setupCopyable } from './helpers.js';
import { identityPubKey } from './crypto.js';
import { sendTunnelRequest, tunnelCloseWing } from './tunnel.js';
import { switchToSession, deleteSession } from './nav.js';
@@ -9,18 +9,18 @@ import { setLastTermAgent, getLastTermAgent, setWingOrder, setEggOrder, getCache
import { rebuildAgentLists } from './dashboard.js';
import { openAuditReplay, openAuditKeylog, downloadChatHistory } from './audit.js';
import { showTerminal } from './nav.js';
+import { safeTerminalThumbnail } from './security.js';
import { shouldFetchWingSessions } from './session-merge.js';
import { updateCanvasSessionName } from './canvas.js';
import { historyResumeState } from './session-resume.js';
import { sessionForkAvailable, sessionForkControl } from './session-fork.js';
-import { notificationForSession } from './session-reference.js';
-import { sessionInventoryState, sessionStatusDot, sessionInventoryActions, filterSessionInventory, captureSessionFocus, restoreSessionFocus, navigateSessionRows, findSessionResource, sessionIsSelected, sessionResourceKey, groupSessionInventory, groupSessionsByWing, sessionGroupHeader, sessionTabMarkup, sessionCardMarkup, inventorySearchVisible, inventoryCountLabel } from './session-inventory.js';
+import { readSessionContent, notificationForSession } from './session-reference.js';
+import { sessionInventoryState, sessionStatusDot, sessionInventoryActions, filterSessionInventory, captureSessionFocus, restoreSessionFocus, navigateSessionRows, findSessionResource, sessionIsSelected, sessionResourceKey, groupSessionInventory, sessionGroupHeader, unseenCompletionBadge, sessionProjectRoot } from './session-inventory.js';
import { refreshConversationInventory } from './conversation-view.js';
import { refreshParentDot } from './parent-dot.js';
-import { unseenSessionCompletions } from './session-completion.js';
+import { unseenSessionCompletions, acknowledgeSessionCompletions } from './session-completion.js';
import { browserLocalStorage } from './storage-scope.js';
-var inventoryFilters = { query: '' };
var sessionStopPending = new Set();
var sessionStopConfirm = new Map();
var sessionActionErrors = new Map();
@@ -35,7 +35,7 @@ function renderChannelBanner() {
var preview = S.currentUser && S.currentUser.release_channel === 'preview';
banner.style.display = preview ? '' : 'none';
if (preview) banner.textContent = (S.currentUser.channel_label || 'Wingthing Preview') +
- (S.currentUser.version ? ' · ' + S.currentUser.version : '');
+ ' · personal preview' + (S.currentUser.version ? ' · ' + S.currentUser.version : '');
}
function wingNameById(wingId) {
@@ -60,26 +60,44 @@ function wingHasCapability(wingId, capability) {
return !!wing && Array.isArray(wing.capabilities) && wing.capabilities.indexOf(capability) !== -1;
}
+// The dot shows connection as it always has: running on its wing (detached),
+// not yet confirmed or offline, or asking for attention.
+function sessionConnectionDot(s) {
+ if (notificationForSession(S.sessionNotifications, s)) return 'attention';
+ return s.status === 'active' ? 'live' : s.swept ? 'detached' : 'offline';
+}
+
export function renderSidebar() {
renderChannelBanner();
refreshParentDot();
+ // Live inventory refreshes must not discard an unfinished rename.
+ if (DOM.sessionTabs.querySelector('.renaming, .forking')) return;
var focus = captureSessionFocus(DOM.sessionTabs, document.activeElement);
- var unseen = unseenSessionCompletions(browserLocalStorage(), S.currentUser && S.currentUser.id);
var sessions = S.sessionsData.filter(function(s) {
if ((s.kind || 'terminal') === 'chat') return false;
if (sessionIsSelected(s, S.ptySessionId, S.ptyWingId)) return true;
return isWingVisible(s.wing_id);
});
function renderTab(s) {
- return sessionTabMarkup(s, sessionInventoryState(s, sessionWing(s), notificationForSession(S.sessionNotifications, s)), {
- active: S.activeView === 'terminal' && sessionIsSelected(s, S.ptySessionId, S.ptyWingId),
- unseen: unseen.has(sessionResourceKey(s)),
- wingName: wingNameById(s.wing_id)
- });
+ var name = sessionDisplayName(s);
+ var isActive = S.activeView === 'terminal' && sessionIsSelected(s, S.ptySessionId, S.ptyWingId);
+ var ownsSession = !!S.currentUser && !!s.user_id && s.user_id === S.currentUser.id;
+ var canRename = sessionInventoryActions(s, sessionWing(s), S.currentUser).rename;
+ var title = name + ' \u00b7 ' + (s.agent || '?');
+ if (!ownsSession) title += ' \u00b7 only the session owner can rename';
+ else if (!canRename) title += ' \u00b7 update this wing to rename';
+ return '
' +
+ ' ' +
+ '' + escapeHtml(name.charAt(0).toUpperCase()) + ' ' +
+ '' + escapeHtml(name) + ' ' +
+ (canRename ? 'rename ' : '') +
+ '
';
}
- // Keep the grouped order; the group context lives in each row's tooltip.
- var groups = groupSessionInventory(sessions, S.wingsData, S.sessionNotifications, unseen);
- DOM.sessionTabs.innerHTML = groups.map(function(group) { return group.sessions.map(renderTab).join(''); }).join('');
+ // Sessions keep their saved order; state changes never move a row.
+ DOM.sessionTabs.innerHTML = sessions.map(renderTab).join('');
DOM.sessionTabs.querySelectorAll('.session-tab').forEach(function(tab) {
function openSession() {
@@ -89,7 +107,10 @@ export function renderSidebar() {
if (s && !s.swept) return;
switchToSession(sid, undefined, tab.dataset.wingId);
}
- tab.addEventListener('click', openSession);
+ tab.addEventListener('click', function(e) {
+ if (e.target.closest('button, input, .forking')) return;
+ openSession();
+ });
tab.addEventListener('keydown', function(e) {
if (e.target === tab && navigateSessionRows(e, Array.from(DOM.sessionTabs.querySelectorAll('.session-tab')), tab)) return;
if ((e.key === 'Enter' || e.key === ' ') && e.target === tab) {
@@ -97,6 +118,14 @@ export function renderSidebar() {
openSession();
}
});
+ var rename = tab.querySelector('.session-rename-btn');
+ if (rename) {
+ rename.addEventListener('click', function(e) {
+ e.stopPropagation();
+ var session = findSessionResource(S.sessionsData, tab.dataset.sid, tab.dataset.wingId);
+ if (session) beginSessionRename(tab, session);
+ });
+ }
});
restoreSessionFocus(DOM.sessionTabs, focus);
}
@@ -169,7 +198,7 @@ function beginSessionFork(container, session, wingId, button) {
input.select();
}
-function beginSessionRename(tab, session, onSettled) {
+function beginSessionRename(tab, session) {
if (tab.classList.contains('renaming')) return;
tab.classList.add('renaming');
var label = tab.querySelector('.tab-label');
@@ -195,7 +224,6 @@ function beginSessionRename(tab, session, onSettled) {
tab.classList.remove('renaming');
renderSidebar();
if (S.activeView === 'home') renderSessionInventory();
- if (onSettled) onSettled();
}
function save() {
if (settled) return;
@@ -216,12 +244,11 @@ function beginSessionRename(tab, session, onSettled) {
saveSessionCache();
updateCanvasSessionName(session.id, session.name, session.wing_id);
if (sessionIsSelected(session, S.ptySessionId, S.ptyWingId) && S.activeView === 'terminal') {
- DOM.headerTitle.textContent = formatSessionTitle(session);
+ DOM.headerTitle.textContent = session.name + ' \u00b7 ' + (session.agent || '?');
}
tab.classList.remove('renaming');
renderSidebar();
if (S.activeView === 'home') renderDashboard();
- if (onSettled) onSettled();
})
.catch(function(err) {
settled = false;
@@ -1331,24 +1358,10 @@ function loadOrgMembers(org, containerId) {
});
}
-var detailReturnFocus = null;
-
-// Move focus into the dialog so keyboard users reach rename and fork, and
-// remember where it came from. A re-render of an open dialog keeps the origin.
-function openDetailModal() {
- if (!DOM.detailOverlay.classList.contains('open')) detailReturnFocus = document.activeElement;
- DOM.detailOverlay.classList.add('open');
- var first = DOM.detailDialog.querySelector('.detail-actions button:not(:disabled):not([hidden])');
- (first || DOM.detailDialog).focus({ preventScroll: true });
-}
-
export function hideDetailModal() {
DOM.detailOverlay.classList.remove('open');
DOM.detailDialog.classList.remove('forking');
DOM.detailDialog.innerHTML = '';
- var origin = detailReturnFocus;
- detailReturnFocus = null;
- if (origin && origin.isConnected && typeof origin.focus === 'function') origin.focus({ preventScroll: true });
}
export function renderWingDetailPage(wingId) {
@@ -1624,9 +1637,8 @@ function renderActiveSessionRows(sessions) {
'
keylog '
: '';
return '
' +
- sessionStatusDot(presentation.status) +
+ ' ' +
'' + escapeHtml(sName) + ' \u00b7 ' + agentWithIcon(s.agent || '?') + ' ' +
- '' + escapeHtml(presentation.agentLabel) + ' ' +
auditBadge +
auditBtns +
(actions.stop ? 'stop ' : '') +
@@ -2289,61 +2301,47 @@ export function showEggDetail(sessionId, wingId) {
escapeHtml(parts.join(' | ')) + '
';
}
- var viewing = S.activeView === 'terminal' && sessionIsSelected(s, S.ptySessionId, S.ptyWingId);
- var owner = s.user_id && !(S.currentUser && s.user_id === S.currentUser.id) ? s.email || 'unknown owner' : '';
DOM.detailDialog.innerHTML =
- '
' + escapeHtml(name) + ' ' +
+ '
' + escapeHtml(name) + ' · ' + escapeHtml(s.agent || '?') + ' ' +
'
session ' + escapeHtml(s.id) + '
' +
'
wing ' + escapeHtml(wingName || 'unknown') + '
' +
'
type ' + escapeHtml(kind) + '
' +
'
agent ' + escapeHtml(s.agent || '?') + '
' +
'
cwd ' + escapeHtml(cwdDisplay) + '
' +
- (owner ? '
owner ' + escapeHtml(owner) + '
' : '') +
- (s.conversation_role ? '
role ' + escapeHtml(s.conversation_role) + '
' : '') +
'
agent state ' + escapeHtml(state.agentLabel) + '
' +
'
connection ' + escapeHtml(state.connectionLabel + ' · ' + state.attachment) + '
' +
- (viewing ? '
e2e ' + (S.e2eKey ? 'active' : 'none') + '
' : '') +
configSummary +
'
' +
- (viewing ? '' : 'attach ') +
- (actions.rename ? 'rename ' : '') +
+ 'attach ' +
sessionForkControl(s, sessionWing(s), S.currentUser) +
(actions.stop ? 'stop session ' : '') +
'
';
setupCopyable(DOM.detailDialog);
- openDetailModal();
+ DOM.detailOverlay.classList.add('open');
- var connectBtn = document.getElementById('detail-egg-connect');
- if (connectBtn) connectBtn.addEventListener('click', function() {
+ document.getElementById('detail-egg-connect').addEventListener('click', function() {
hideDetailModal();
switchToSession(sessionId, undefined, s.wing_id);
});
- var renameBtn = document.getElementById('detail-egg-rename');
- if (renameBtn) renameBtn.addEventListener('click', function() {
- renameBtn.hidden = true;
- beginSessionRename(DOM.detailDialog.querySelector('h3'), s, function() {
- // The dialog may have been dismissed while the rename was saving.
- if (DOM.detailOverlay.classList.contains('open')) showEggDetail(s.id, s.wing_id);
- });
- });
-
- var forkBtn = DOM.detailDialog.querySelector('.session-fork-btn');
- if (forkBtn) forkBtn.addEventListener('click', function() { beginSessionFork(DOM.detailDialog, s, s.wing_id, forkBtn); });
-
var delBtn = document.getElementById('detail-egg-delete');
if (delBtn) delBtn.addEventListener('click', function() {
stopInventorySession(s, delBtn, hideDetailModal);
});
+ bindDetailFork(s);
+}
+
+// Fork starts from a session's details, never from a list row.
+function bindDetailFork(s) {
+ var fork = DOM.detailDialog.querySelector('.session-fork-btn');
+ if (fork) fork.addEventListener('click', function() { beginSessionFork(DOM.detailDialog, s, s.wing_id, fork); });
}
export function showSessionInfo() {
var s = findSessionResource(S.sessionsData, S.ptySessionId, S.ptyWingId);
- // A known session gets the same details and actions as its Home card.
- if (s) { showEggDetail(s.id, s.wing_id); return; }
var w = S.ptyWingId ? S.wingsData.find(function(w) { return w.wing_id === S.ptyWingId; }) : null;
- if (!w) return;
+ if (!s && !w) return;
var wingName = w ? wingDisplayName(w) : 'unknown';
var agent = s ? (s.agent || '?') : '?';
@@ -2376,10 +2374,12 @@ export function showSessionInfo() {
'
wing ' + escapeHtml(wingName) + '
' +
'
version ' + escapeHtml(wingVersion) + '
' +
'
platform ' + escapeHtml(wingPlatform) + '
' +
- '
agents ' + escapeHtml(wingAgents) + '
';
+ '
agents ' + escapeHtml(wingAgents) + '
' +
+ (s && sessionForkAvailable(s, w, S.currentUser) ? '
' + sessionForkControl(s, w, S.currentUser) + '
' : '');
setupCopyable(DOM.detailDialog);
- openDetailModal();
+ DOM.detailOverlay.classList.add('open');
+ if (s) bindDetailFork(s);
}
export function renderDashboard() {
@@ -2409,16 +2409,21 @@ export function renderDashboard() {
(w.passkey_enrolled && !w.locked ? '
🔑 ' : ''));
var draggable = ('ontouchstart' in window || navigator.maxTouchPoints > 0) ? '' : ' draggable="true"';
var isMine = S.currentUser && w.user_id === S.currentUser.id;
- // One line: the dot carries normal state; only a reason the wing
- // cannot be used is spelled out. The rest is on the wing page.
- var exception = isUnreachable ? '
unreachable ' :
- (needsPasskeySetup || needsAuth || isCardPasskey) ? lockedBadge :
- w.online === false ? '
offline ' : '';
- var summary = [name, plat, (w.agents || []).join(', '), projectCount ? projectCount + ' proj' : '', w.owner && !isMine ? w.owner : ''].filter(Boolean).join(' · ');
- return '
' +
- '
' +
- '
' + escapeHtml(name) + lockIcon + ' ' +
- exception +
+ var ownerTag = (w.owner && !isMine) ? '
' + escapeHtml(w.owner) + ' ' : '';
+ return '
' +
+ '
' +
+ ' ' +
+ '' + escapeHtml(name) + lockIcon + ' ' +
+ ownerTag +
+ '
' +
+ '
' + ((needsPasskeySetup || needsAuth || isCardPasskey) ? '' : ((w.agents || []).length === 0 ? 'no agents ' : (w.agents || []).map(function(a) {
+ return agentIcon(a) || escapeHtml(a);
+ }).join(' '))) + ' ' +
+ '
' +
+ '' + escapeHtml(plat) + ' ' + (w.online === true ? 'online' : w.online === false ? 'offline' : 'checking') + ' ' +
+ (isUnreachable ? 'unreachable ' :
+ ((needsPasskeySetup || needsAuth || isCardPasskey) ? lockedBadge : (projectCount ? '' + projectCount + ' proj ' : ' '))) +
+ '
' +
'
';
}).join('');
wingHtml += '
';
@@ -2486,7 +2491,6 @@ export function renderDashboard() {
}
renderChannelBanner();
- initSessionInventoryControls();
renderSessionInventory();
}
@@ -2496,124 +2500,113 @@ function visibleInventorySessions() {
});
}
-function initSessionInventoryControls() {
- var controls = document.getElementById('session-inventory-controls');
- if (!controls || controls.dataset.bound) return;
- controls.dataset.bound = '1';
- var search = document.getElementById('session-inventory-search');
- search.addEventListener('input', function() {
- inventoryFilters.query = search.value;
- renderSessionInventory();
- });
- document.getElementById('session-inventory-clear').addEventListener('click', function() {
- inventoryFilters = { query: '' };
- search.value = '';
- renderSessionInventory();
- search.focus();
- });
- search.addEventListener('keydown', function(event) {
- if (event.key === 'ArrowDown') {
- var first = DOM.sessionsList.querySelector('.egg-box');
- if (first) { event.preventDefault(); first.focus(); }
- } else if (event.key === 'Escape') {
- event.preventDefault();
- this.value = '';
- inventoryFilters.query = '';
- renderSessionInventory();
- }
- });
-}
-
export function renderSessionInventory() {
- var focus = captureSessionFocus(DOM.sessionsList, document.activeElement);
- var allSessions = visibleInventorySessions();
+ // Preserve the active editor while background status probes finish.
+ if (DOM.sessionsList.querySelector('.renaming, .forking')) return;
+ var visibleSessions = visibleInventorySessions();
var hasWings = S.wingsData.some(function(wing) { return wingDisplayName(wing); });
- DOM.emptyState.style.display = allSessions.length ? 'none' : '';
+ DOM.emptyState.style.display = visibleSessions.length ? 'none' : '';
var noWings = document.getElementById('empty-no-wings');
var noSessions = document.getElementById('empty-no-sessions');
- if (noWings) noWings.style.display = !allSessions.length && !hasWings ? '' : 'none';
- if (noSessions) noSessions.style.display = !allSessions.length && hasWings ? '' : 'none';
+ if (noWings) noWings.style.display = !visibleSessions.length && !hasWings ? '' : 'none';
+ if (noSessions) noSessions.style.display = !visibleSessions.length && hasWings ? '' : 'none';
var signedIn = document.getElementById('empty-signed-in');
if (signedIn && S.currentUser) {
var account = S.currentUser.email || S.currentUser.display_name || '';
signedIn.textContent = account ? 'signed in as ' + account : '';
signedIn.style.display = account ? '' : 'none';
}
- var controls = document.getElementById('session-inventory-controls');
- if (controls) controls.style.display = allSessions.length ? '' : 'none';
- if (!allSessions.length) { DOM.sessionsList.innerHTML = ''; return; }
-
- var sessions = filterSessionInventory(allSessions, S.wingsData, S.sessionNotifications, inventoryFilters);
- var unseen = unseenSessionCompletions(browserLocalStorage(), S.currentUser && S.currentUser.id);
- var filtered = !!inventoryFilters.query;
- // Search appears only once the list is long enough to need it.
- var search = document.querySelector('#session-inventory-controls .inventory-search');
- if (search) search.hidden = !inventorySearchVisible(allSessions.length, inventoryFilters.query);
- var count = document.getElementById('session-inventory-count');
- if (count) count.textContent = inventoryCountLabel(allSessions.length, sessions.length, filtered);
- var clear = document.getElementById('session-inventory-clear');
- if (clear) { clear.disabled = !filtered; clear.hidden = !filtered; }
- if (!sessions.length) {
- DOM.sessionsList.innerHTML = '
No sessions match this search. Clear it to see every session.
';
- return;
+ if (!visibleSessions.length) { DOM.sessionsList.innerHTML = ''; return; }
+
+ function renderEggCard(s) {
+ var name = sessionDisplayName(s);
+ var wing = sessionWing(s);
+ var dotClass = sessionConnectionDot(s);
+ var thumbUrl = '';
+ try { thumbUrl = readSessionContent(localStorage, TERM_THUMB_PREFIX, s.wing_id, s.id) || ''; } catch (e) {}
+ thumbUrl = safeTerminalThumbnail(thumbUrl);
+ var wingName = wing ? wingDisplayName(wing) : '';
+ var error = sessionActionErrors.get(sessionResourceKey(s));
+ return '
' +
+ '
' + (thumbUrl ? '
' : '') + '
' +
+ '' +
+ (wingName ? '
' + escapeHtml(wingName) + '
' : '') +
+ (error ? '
' + escapeHtml(error) + '
' : '') +
+ '
';
}
- function renderEggCard(session) {
- var wing = sessionWing(session);
- var resourceKey = sessionResourceKey(session);
- return sessionCardMarkup(session, sessionInventoryState(session, wing, notificationForSession(S.sessionNotifications, session)), {
- wingName: wing && wingDisplayName(wing),
- selected: S.activeView === 'terminal' && sessionIsSelected(session, S.ptySessionId, S.ptyWingId),
- unseen: unseen.has(resourceKey),
- owner: S.currentUser && session.user_id && session.user_id !== S.currentUser.id ? session.email || 'unknown owner' : '',
- error: sessionActionErrors.get(resourceKey)
+ var eggHtml;
+ if (S.currentUser && S.currentUser.roost_mode) {
+ var groups = {};
+ var groupOrder = [];
+ visibleSessions.forEach(function(s) {
+ var key = s.user_id || '_unknown';
+ if (!groups[key]) {
+ groups[key] = { email: s.email || 'unknown', sessions: [] };
+ groupOrder.push(key);
+ }
+ groups[key].sessions.push(s);
+ });
+ groupOrder.sort(function(a, b) {
+ if (S.currentUser.id && a === S.currentUser.id) return -1;
+ if (S.currentUser.id && b === S.currentUser.id) return 1;
+ return (groups[a].email).localeCompare(groups[b].email);
+ });
+ eggHtml = '
eggs ';
+ groupOrder.forEach(function(uid) {
+ var g = groups[uid];
+ var label = (S.currentUser.id && uid === S.currentUser.id) ? 'my eggs' : g.email;
+ eggHtml += '
' +
+ '
' + escapeHtml(label) + ' (' + g.sessions.length + ') ' +
+ '
' + g.sessions.map(renderEggCard).join('') + '
' +
+ '
';
});
+ } else {
+ eggHtml = '
eggs ' + visibleSessions.map(renderEggCard).join('') + '
';
}
+ DOM.sessionsList.innerHTML = eggHtml;
- // A wing name heads its cards only when more than one wing has sessions;
- // the project stays in the card's details. Count wings over the whole
- // inventory so a search cannot make headers flicker.
- var wings = new Set(allSessions.map(function(session) { return session.wing_id || ''; }));
- var groups = groupSessionsByWing(groupSessionInventory(sessions, S.wingsData, S.sessionNotifications, unseen));
- DOM.sessionsList.innerHTML = groups.map(function(group) {
- return '
' + (wings.size > 1 ? sessionGroupHeader(group, { compact: true }) : '') +
- '' + group.sessions.map(renderEggCard).join('') + '
';
- }).join('');
- var cards = Array.from(DOM.sessionsList.querySelectorAll('.egg-box'));
- cards.forEach(function(card) {
+ DOM.sessionsList.querySelectorAll('.egg-box').forEach(function(card) {
var session = findSessionResource(S.sessionsData, card.dataset.sid, card.dataset.wingId);
- // An unavailable session opens its details rather than doing nothing.
- function open() {
- if (sessionInventoryActions(session, sessionWing(session), S.currentUser).attach) switchToSession(session.id, undefined, session.wing_id);
- else showEggDetail(session.id, session.wing_id);
- }
- card.addEventListener('click', function(event) { if (!event.target.closest('button, input')) open(); });
- card.addEventListener('keydown', function(event) {
- if (event.target !== card) return;
- if (navigateSessionRows(event, cards, card)) return;
- if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); open(); }
+ card.addEventListener('click', function(e) {
+ if (e.target.closest('.box-menu-btn, .egg-delete')) return;
+ if (!session || !sessionInventoryActions(session, sessionWing(session), S.currentUser).attach) {
+ var label = card.querySelector('.egg-label');
+ if (label) { var orig = label.innerHTML; label.textContent = 'wing offline'; setTimeout(function() { label.innerHTML = orig; }, 1500); }
+ return;
+ }
+ switchToSession(session.id, undefined, session.wing_id);
+ });
+ card.querySelector('.box-menu-btn').addEventListener('click', function(e) {
+ e.stopPropagation();
+ showEggDetail(card.dataset.sid, card.dataset.wingId);
+ });
+ var stop = card.querySelector('.egg-delete');
+ if (stop && session) stop.addEventListener('click', function(e) {
+ e.stopPropagation();
+ stopInventorySession(session, stop);
});
- card.querySelector('.inventory-details').addEventListener('click', function() { showEggDetail(session.id, session.wing_id); });
});
setupEggDrag();
- if (focus && !restoreSessionFocus(DOM.sessionsList, focus)) {
- // The search box is hidden on short lists; never focus a hidden field.
- var fallback = inventorySearchVisible(allSessions.length, inventoryFilters.query) ? document.getElementById('session-inventory-search') : DOM.sessionsList.querySelector('.egg-box');
- if (fallback) fallback.focus({ preventScroll: true });
- }
}
function stopInventorySession(session, button, onStopped) {
var key = sessionResourceKey(session);
if (!sessionInventoryActions(session, sessionWing(session), S.currentUser).stop || sessionStopPending.has(key)) return;
if ((sessionStopConfirm.get(key) || 0) <= Date.now()) {
- var label = button.textContent;
sessionStopConfirm.set(key, Date.now() + 4000);
- button.textContent = 'stop now?';
+ var restLabel = button.textContent;
+ button.textContent = button.classList.contains('egg-delete') ? 'sure?' : 'stop now?';
button.classList.add('btn-armed');
setTimeout(function() {
if ((sessionStopConfirm.get(key) || 0) > Date.now() || sessionStopPending.has(key)) return;
- button.textContent = label;
+ button.textContent = restLabel;
button.classList.remove('btn-armed');
}, 4100);
return;
diff --git a/web/src/session-inventory.js b/web/src/session-inventory.js
index 73e4b009..9f14b080 100644
--- a/web/src/session-inventory.js
+++ b/web/src/session-inventory.js
@@ -117,7 +117,7 @@ export function groupSessionInventory(sessions, wings, notifications = {}, unsee
var project = sessionProjectRoot(session, wing);
var key = JSON.stringify([session.wing_id || '', project]);
if (!groups.has(key)) groups.set(key, { key: key, wingId: session.wing_id || '', wing: wing, project: project,
- sessions: [], rollup: { blocked: 0, working: 0, idle: 0, unseen: 0 }, priority: 3, priorities: priorities });
+ sessions: [], rollup: { blocked: 0, working: 0, idle: 0, unseen: 0 }, priority: 3 });
var group = groups.get(key);
var status = statusForSession ? statusForSession(session) : sessionInventoryState(session, wing, notificationForSession(notifications, session)).status;
var unread = unseen.has(sessionResourceKey(session));
@@ -134,87 +134,14 @@ export function groupSessionInventory(sessions, wings, notifications = {}, unsee
});
}
-// Home labels wings, not projects: project groups of one session each would
-// put a header over every card. Keeps the attention order across projects.
-export function groupSessionsByWing(groups) {
- var wings = new Map();
- groups.forEach(function(group) {
- if (!wings.has(group.wingId)) wings.set(group.wingId, { key: JSON.stringify([group.wingId]), wingId: group.wingId, wing: group.wing, sessions: [] });
- var wing = wings.get(group.wingId);
- wing.sessions = wing.sessions.concat(group.sessions);
- wing.priorities = group.priorities;
- });
- return Array.from(wings.values()).map(function(wing) {
- wing.sessions.sort(function(a, b) { return wing.priorities.get(a) - wing.priorities.get(b); });
- return wing;
- });
-}
-
export function unseenCompletionBadge(unseen) {
return unseen ? '
' + (typeof unseen === 'number' ? unseen + ' ' : '') + 'unseen completion' + (unseen > 1 ? 's' : '') + ' ' : '';
}
-// Only exceptions earn a visible word; the dot already carries normal states.
-// A wing still being checked is transient, so it prints nothing either.
-export function sessionExceptionLabel(state) {
- if (state.connection !== 'available' && state.connection !== 'checking') return state.connectionLabel;
- if (state.status === 'blocked') return 'needs input';
- if (state.status === 'exited') return 'exited';
- return state.attention ? 'attention' : '';
-}
-
-export var INVENTORY_SEARCH_MIN = 8;
-
-export function inventorySearchVisible(total, query) {
- return !!query || total >= INVENTORY_SEARCH_MIN;
-}
-
-export function inventoryCountLabel(total, shown, filtered) {
- return filtered ? shown + ' of ' + total : '';
-}
-
-function resourceAttributes(session) {
- return 'data-sid="' + escapeHtml(session.id) + '" data-wing-id="' + escapeHtml(session.wing_id || '') + '"';
-}
-
-// One line per session: dot and name. Every other fact stays in the tooltip
-// and accessible label; actions live in the details dialog.
-export function sessionTabMarkup(session, state, opts) {
- opts = opts || {};
- var name = sessionDisplayName(session);
- var title = [name, session.agent || '?', opts.wingName || '', session.cwd || '~', state.connectionLabel, state.agentLabel].join(' · ') +
- (opts.unseen ? ' · unseen completion' : '');
- // Attention short of blocked marks the dot, never the words "needs input".
- return '
' +
- sessionStatusDot(state.status, true) +
- '' + escapeHtml(name.charAt(0).toUpperCase()) + ' ' +
- '' + escapeHtml(name) + '
';
-}
-
-export function sessionCardMarkup(session, state, opts) {
- opts = opts || {};
- var name = sessionDisplayName(session);
- var exception = sessionExceptionLabel(state);
- var label = [name, session.agent || 'unknown agent', opts.wingName || 'unknown wing', session.cwd || '~'].concat(opts.owner ? [opts.owner] : [])
- .concat([state.connectionLabel, state.agentLabel]).join(' · ') + (opts.unseen ? ' · unseen completion' : '');
- return '
' +
- '' +
- (opts.error ? '' + escapeHtml(opts.error) + '
' : '') + ' ';
-}
-
-export function sessionGroupHeader(group, opts) {
+export function sessionGroupHeader(group) {
var counts = group.rollup;
// The wing ID stays in the tooltip; the visible label is the wing's name.
var wing = wingDisplayName(group.wing) || group.wingId || 'unknown wing';
- if (opts && opts.compact) {
- return '';
- }
var path = group.project ? shortenPath(group.project) : 'no project';
var rollup = ['blocked', 'working', 'idle'].filter(function(status) { return counts[status] > 0; }).map(function(status) {
return '
' + counts[status] + ' ' + status + ' ';
diff --git a/web/src/session-merge.js b/web/src/session-merge.js
index 082fe800..d2dad915 100644
--- a/web/src/session-merge.js
+++ b/web/src/session-merge.js
@@ -18,6 +18,7 @@ export function reconcileWingSessions(existingSessions, wingId, remoteSessions)
session.agent = remote.agent;
session.name = remote.name;
+ session.title = remote.title;
session.cwd = remote.cwd;
session.needs_attention = remote.needs_attention;
session.audit = remote.audit;
diff --git a/web/src/terminal.js b/web/src/terminal.js
index a51bac99..a0ccfcbc 100644
--- a/web/src/terminal.js
+++ b/web/src/terminal.js
@@ -123,11 +123,24 @@ export function initTerminal() {
if (S.ptySessionId) setNotification(S.ptySessionId, S.ptyWingId);
});
+ // Agents retitle their terminal as they work (Claude: "◐ Math question");
+ // ask for a list refresh once per new topic so the sidebar label follows.
+ var titleKey = '';
+ var titleTimer = null;
+ S.term.onTitleChange(function(raw) {
+ var topic = String(raw || '').replace(/^[^\p{L}\p{N}]+/u, '').trim();
+ var key = S.ptyWingId + '\n' + S.ptySessionId + '\n' + topic;
+ if (!topic || key === titleKey) return;
+ titleKey = key;
+ clearTimeout(titleTimer);
+ titleTimer = setTimeout(function() { window.dispatchEvent(new CustomEvent('wt-terminal-title')); }, 1500);
+ });
+
S.term.onSelectionChange(function() {
- var selected = S.term.hasSelection();
- DOM.terminalCopyBtn.disabled = !selected;
- DOM.terminalCopyBtn.style.display = selected && S.ptySessionId ? '' : 'none';
- DOM.terminalCopyBtn.title = 'Copy selection without terminal line padding';
+ DOM.terminalCopyBtn.disabled = !S.term.hasSelection();
+ DOM.terminalCopyBtn.title = S.term.hasSelection()
+ ? 'Copy selection without terminal line padding'
+ : 'Select terminal text to copy';
});
DOM.terminalCopyBtn.addEventListener('click', function() { copyTerminalSelection(); });
diff --git a/web/style.css b/web/style.css
index 45cbc790..1c3cf13e 100644
--- a/web/style.css
+++ b/web/style.css
@@ -68,7 +68,7 @@ body {
display: flex;
flex-direction: column;
align-items: stretch;
- padding: 8px 6px;
+ padding: 8px;
flex-shrink: 0;
gap: 2px;
overflow-y: auto;
@@ -79,12 +79,12 @@ body {
#home-btn {
width: 100%;
- height: 32px;
+ height: 36px;
border-radius: 8px;
background: transparent;
border: none;
color: var(--text-muted);
- font-size: 14px;
+ font-size: 18px;
font-weight: 600;
cursor: pointer;
display: flex;
@@ -104,13 +104,13 @@ body {
#new-session-btn {
width: 100%;
- height: 32px;
+ height: 36px;
border-radius: 8px;
background: transparent;
border: 1px dashed var(--text-muted);
color: var(--text-dim);
- font-size: 14px;
- font-weight: 400;
+ font-size: 18px;
+ font-weight: 300;
cursor: pointer;
display: flex;
align-items: center;
@@ -145,20 +145,20 @@ body {
.session-tab {
width: 100%;
- min-height: 36px;
+ min-height: 44px;
border-radius: 6px;
background: transparent;
border: none;
color: var(--text-dim);
font-size: 14px;
- font-weight: 500;
+ font-weight: 600;
cursor: pointer;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
- gap: 6px;
- padding: 0 6px;
+ gap: 8px;
+ padding: 6px 8px;
position: relative;
transition: all 0.15s;
}
@@ -172,6 +172,35 @@ body {
text-align: left;
}
+.session-rename-btn {
+ position: absolute;
+ top: 50%;
+ right: 8px;
+ transform: translateY(-50%);
+ border: 0;
+ background: var(--bg-card);
+ color: var(--text-muted);
+ font: inherit;
+ font-size: 10px;
+ cursor: pointer;
+ padding: 3px 0 3px 8px;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.session-tab:hover .session-rename-btn,
+.session-tab:focus-within .session-rename-btn {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.session-tab:hover .tab-label,
+.session-tab:focus-within .tab-label { padding-right: 42px; }
+
+.session-tab.renaming .session-rename-btn { display: none; }
+
+.session-rename-btn:hover { color: var(--accent); }
+
.session-fork-form {
display: flex;
flex-wrap: wrap;
@@ -220,10 +249,6 @@ body {
color: var(--text);
}
-/* An unseen completion is a bold name, not a badge. */
-.session-tab[data-unseen="true"] .tab-label,
-.egg-box[data-unseen="true"] .egg-label { color: var(--text); font-weight: 600; }
-
.session-tab.active {
background: var(--bg-card);
color: var(--text);
@@ -245,8 +270,6 @@ body {
.tab-dot.dot-live { background: var(--green); }
.tab-dot.dot-detached { background: var(--yellow); }
-/* Attention rings the status dot; the fill still reports the agent state. */
-.session-tab[data-attention="true"] .tab-dot { box-shadow: 0 0 0 2px #12122a, 0 0 0 3.5px var(--yellow); }
.tab-dot.dot-attention {
background: var(--orange);
animation: pulse 1s ease-in-out infinite;
@@ -325,24 +348,18 @@ body {
flex: 1;
display: flex;
align-items: center;
- gap: 14px;
+ gap: 8px;
min-width: 0;
}
#header-title {
- background: none;
- border: 0;
- padding: 0;
- font: inherit;
font-size: 13px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
- min-width: 0;
cursor: pointer;
}
-#header-title:empty { display: none; }
#header-title:hover { color: var(--text); }
#pty-status {
@@ -369,25 +386,6 @@ body {
background: rgba(233, 69, 96, 0.1);
}
-/* Header session controls are plain words; only x keeps an outline. */
-#main-header .session-action,
-#main-header #canvas-toggle-btn {
- background: none;
- border: 0;
- color: var(--text-dim);
- font-family: var(--font);
- font-size: 12px;
- font-weight: 400;
- padding: 2px 0;
- border-radius: 0;
-}
-#main-header .session-action:hover:not(:disabled),
-#main-header #canvas-toggle-btn:hover,
-#main-header .session-action[aria-pressed="true"] {
- color: var(--accent);
- background: none;
-}
-
.session-action[aria-pressed="true"] {
color: var(--accent);
border-color: var(--accent);
@@ -491,25 +489,52 @@ body {
/* === Wing boxes === */
-.wing-grid { display: flex; flex-wrap: wrap; gap: 8px; }
+.wing-grid { display: flex; flex-wrap: wrap; gap: 10px; }
.wing-box {
width: 280px;
- height: 44px;
display: flex;
- align-items: center;
+ flex-direction: column;
gap: 8px;
background: var(--bg-card);
- border-radius: 6px;
- padding: 0 12px;
+ border-radius: 8px;
+ padding: 14px 16px;
cursor: grab;
user-select: none;
transition: opacity 0.15s;
position: relative;
}
-.wing-name { font-weight: 500; font-size: 13px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.wing-exception, .wing-pinned-badge { margin-left: auto; flex-shrink: 0; font-size: 12px; color: var(--text-dim); }
+.wing-box-top {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.wing-name { font-weight: 600; font-size: 15px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.wing-owner { font-size: 12px; color: var(--text-dim); margin-left: auto; flex-shrink: 0; max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.wing-agents {
+ font-size: 14px;
+ color: var(--text-dim);
+}
+
+.wing-agents .agent-icon {
+ width: 18px;
+ height: 18px;
+ vertical-align: -3px;
+ margin-right: 4px;
+}
+
+.wing-statusbar {
+ display: flex;
+ justify-content: space-between;
+ font-size: 13px;
+ color: var(--text-muted);
+ border-top: 1px solid var(--border);
+ padding-top: 6px;
+ margin-top: auto;
+}
.wing-dot {
width: 8px;
@@ -557,7 +582,6 @@ body {
}
.wing-box:hover .box-menu-btn,
.egg-box:hover .box-menu-btn,
-.egg-box:focus-within .box-menu-btn,
.box-menu-btn.active { opacity: 1; }
.box-menu-btn:hover { color: var(--accent); }
@@ -640,23 +664,13 @@ body {
.box-detail-dialog .detail-actions {
display: flex;
- flex-direction: row;
- flex-wrap: wrap;
- align-items: center;
- gap: 8px;
+ flex-direction: column;
+ gap: 6px;
margin-top: 12px;
border-top: 1px solid var(--border);
padding-top: 12px;
}
-.box-detail-dialog .detail-actions .btn-sm { width: auto; margin-top: 0; }
-.box-detail-dialog .detail-actions .btn-danger { margin-left: auto; }
-
-/* Rename feedback flows under the input; the row-anchored popover would
- land at the dialog's bottom edge. */
-.box-detail-dialog h3.renaming .session-name-input { display: block; width: 100%; font-size: 14px; padding: 6px 8px; }
-.box-detail-dialog .session-rename-status:not(:empty) { position: static; display: block; margin-top: 6px; font-size: 12px; font-weight: 400; }
-
.btn-accent {
background: var(--accent);
color: #fff;
@@ -2384,11 +2398,9 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
@media (max-width: 600px) {
:root { --sidebar-w: 52px; }
- #sidebar { padding: 8px 4px; }
- #home-btn, #new-session-btn { justify-content: center; font-size: 18px; height: 44px; }
- .session-tab { min-height: 44px; }
- #main-header .session-action { min-height: 44px; padding: 0 6px; }
- .nav-label, #session-tabs .tab-label { display: none; }
+ #sidebar { padding: 8px; }
+ #home-btn, #new-session-btn { justify-content: center; }
+ .nav-label, .tab-label, .session-rename-btn, .session-tab-actions, #session-tabs .session-fork-form { display: none; }
.nav-symbol, .tab-letter { display: inline; }
.session-tab { justify-content: center; padding: 0; }
#session-files-panel { right: 8px; width: calc(100vw - 16px); }
@@ -2401,11 +2413,10 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.parent-dot-name { display: none; }
#modifier-keys { display: flex; flex-wrap: wrap; }
.mod-key { flex: 1; min-width: 40px; }
- .wing-box { width: 100%; height: 48px; }
- .wing-name { font-size: 14px; }
- .egg-box { width: 100%; }
+ .wing-box { width: 100%; padding: 10px; }
+ .egg-box { width: 220px; }
.egg-preview { height: 120px; }
- .inventory-session .box-menu-btn { opacity: 1; }
+ .egg-delete { opacity: 1; }
#palette-dialog { margin: 0 12px; }
}
@@ -2554,10 +2565,9 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
padding: 8px;
}
-#main-header #canvas-toggle-btn.active {
- color: var(--accent);
- border-color: var(--accent);
- background: rgba(233, 69, 96, 0.1);
+#canvas-toggle-btn.active {
+ background: var(--accent);
+ color: #fff;
}
.canvas-drag-ghost {
@@ -2680,7 +2690,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
/* Session inventory: visible controls and distinct provider/connection state. */
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
:focus-visible { outline: 2px solid var(--accent-hover); outline-offset: 3px; }
-#channel-banner { margin: 0 0 6px; padding: 4px 0; color: var(--yellow); font-size: 12px; opacity: 0.8; }
+#channel-banner { margin: 4px 0 10px; padding: 9px 12px; border: 1px solid var(--yellow); border-radius: 6px; color: var(--yellow); font-size: 12px; }
.inventory-project-group { min-width: 0; margin-bottom: 18px; }
.inventory-project-group:last-child { margin-bottom: 0; }
.inventory-group-header { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin-bottom: 8px; padding: 4px 0 4px 10px; border-left: 2px solid var(--border); }
@@ -2693,53 +2703,77 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.inventory-project-group[data-blocked="true"] .rollup-blocked { color: var(--yellow); font-weight: 700; }
.unseen-completion { display: inline-block; align-self: flex-start; color: var(--green); border: 1px solid currentColor; border-radius: 4px; padding: 2px 5px; font-size: 11px; }
.inventory-project-group[data-blocked="true"] .unseen-completion { color: #aeb8cb; }
-#sessions-list .inventory-session[data-blocked="true"] { box-shadow: inset 2px 0 0 var(--yellow); }
+#sessions-list .inventory-session[data-blocked="true"] { border-color: var(--yellow); box-shadow: inset 3px 0 0 var(--yellow); }
.session-tab[data-blocked="true"] { box-shadow: inset 2px 0 0 var(--yellow); }
-#session-inventory-controls { margin: 12px 0 8px; }
-.inventory-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 6px; }
-.inventory-heading .section-label { margin: 0; }
+.inventory-session-owner { color: #aeb8cb; font-size: 11px; margin-top: 5px; overflow-wrap: anywhere; }
+#session-tabs .inventory-group-header { padding: 2px 0 2px 8px; gap: 4px; margin: 4px 0 6px; }
+#session-tabs .inventory-group-header h4 { flex-basis: 100%; font-size: 11px; }
+#session-tabs .inventory-group-wing, #session-tabs .inventory-rollup { font-size: 10px; }
+#session-tabs .inventory-acknowledge { font-size: 10px; }
+#session-tabs .inventory-project-group { margin-bottom: 8px; }
+#session-inventory-controls { margin: 18px 0 14px; }
+.inventory-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 10px; }
+.inventory-heading .section-label { margin: 0; color: var(--text); }
#session-inventory-count { color: #aeb8cb; font-size: 12px; }
.inventory-filters { display: flex; flex-wrap: wrap; gap: 8px; }
.inventory-filters label { display: flex; min-width: 0; }
.inventory-filters label[hidden], #session-inventory-clear[hidden] { display: none; }
-.inventory-search { flex: 0 1 320px; }
-.inventory-filters input { background: var(--bg-card); color: var(--text); border: 1px solid var(--border); border-radius: 4px; min-height: 30px; padding: 5px 8px; font: inherit; font-size: 12px; min-width: 0; }
+.inventory-search { flex: 1 1 260px; }
+.inventory-filters input, .inventory-filters select { background: var(--bg-card); color: var(--text); border: 1px solid var(--border); border-radius: 6px; min-height: 40px; padding: 8px 10px; font: inherit; font-size: 12px; min-width: 0; }
.inventory-search input { width: 100%; }
.inventory-filters input::placeholder { color: #aeb8cb; }
+.inventory-filters select { max-width: 200px; }
+.inventory-filters .btn-sm { min-height: 40px; }
.inventory-no-results { padding: 28px 16px; border: 1px dashed var(--border); border-radius: 8px; color: #aeb8cb; font-size: 13px; }
-#sessions-list .egg-grid { gap: 8px; }
-#sessions-list .inventory-project-group { margin-bottom: 14px; }
-#sessions-list .inventory-group-header { display: block; margin: 0 0 6px; padding: 0; border: 0; }
-#sessions-list .inventory-group-header h4 { display: block; margin: 0; font-size: 12px; font-weight: 400; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-#sessions-list .inventory-group-project, #sessions-list .inventory-group-wing { color: inherit; font-size: inherit; }
-#sessions-list .inventory-session { width: 280px; border-radius: 6px; }
-.inventory-session.selected { box-shadow: inset 2px 0 0 var(--accent); }
-.inventory-session .egg-footer { min-height: 44px; padding: 0 6px 0 12px; font-size: 13px; }
-.inventory-session .box-menu-btn { margin-left: 0; width: 28px; flex-shrink: 0; }
-.inventory-exception { flex-shrink: 0; font-size: 12px; color: var(--text-dim); }
-.inventory-exception.status-attention { color: var(--yellow); }
-.inventory-exception.status-failed { color: #ff8b84; }
+#sessions-list .egg-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 285px), 1fr)); align-items: start; gap: 12px; }
+#sessions-list .inventory-session { width: auto; min-width: 0; border: 1px solid var(--border); padding: 14px; }
+.inventory-session.selected { border-color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); }
+.inventory-session .egg-footer { padding: 0; gap: 8px; }
+.inventory-session .egg-label { display: block; font-size: 15px; font-weight: 700; min-width: 0; flex: 1; }
+.inventory-session .egg-preview { height: 110px; margin: -14px -14px 12px; border-radius: 7px 7px 0 0; }
+.inventory-session-meta { display: flex; align-items: center; gap: 7px; margin-top: 8px; color: #bdc7db; font-size: 12px; min-width: 0; }
+.inventory-session-meta > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.inventory-session-meta .agent-icon { width: 14px; height: 14px; vertical-align: -2px; }
+.inventory-session-path { color: #aeb8cb; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 5px; }
+.inventory-session-state { display: flex; flex-direction: column; gap: 3px; margin-top: 8px; font-size: 11px; color: #aeb8cb; }
+.inventory-status { color: #bdc7db; }
+.inventory-status.status-attention { color: var(--yellow); }
+.inventory-status.status-failed { color: #ff8b84; }
.session-dot.failed { background: var(--red); }
-.inventory-action-status { font-size: 11px; color: #ff8b84; margin: 0 12px 8px; overflow-wrap: anywhere; }
+.session-role { font-size: 10px; color: var(--blue); border: 1px solid var(--border); border-radius: 4px; padding: 2px 5px; }
+.inventory-session-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
+.inventory-session-actions .btn-sm { min-height: 34px; padding: 5px 8px; font-size: 11px; }
+.inventory-session-actions .btn-sm:not(.btn-accent):not(.btn-danger) { background: none; border: 1px solid var(--border); color: var(--text-dim); font-weight: 400; }
+.inventory-session-actions .btn-sm:not(.btn-accent):not(.btn-danger):hover:not(:disabled) { background: rgba(233, 69, 96, 0.1); border-color: var(--accent); color: var(--accent); }
+.inventory-session .session-fork-form { margin-top: 8px; }
+.inventory-session .egg-footer .inventory-stop { min-height: 26px; padding: 3px 8px; flex-shrink: 0; }
+.inventory-action-status:not(:empty) { font-size: 11px; color: #ff8b84; margin-top: 10px; overflow-wrap: anywhere; }
+.inventory-session.renaming .inventory-rename { display: none; }
+.inventory-session .session-rename-status:not(:empty) { position: static; margin-top: 8px; }
+.tab-copy { display: flex; flex-direction: column; flex: 1; min-width: 0; text-align: left; }
+.tab-copy .tab-label { display: block; width: 100%; }
+.tab-meta { color: #aeb8cb; font-size: 10px; font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.session-tab.renaming .tab-meta { display: none; }
.wd-session-row:focus-within .wd-kill-btn, .wd-session-row:focus .wd-kill-btn { opacity: 1; }
.wd-session-state { font-size: 10px; color: #aeb8cb; }
.wd-kill-btn:disabled { opacity: 0.5; cursor: not-allowed; }
button:disabled { cursor: not-allowed; }
.box-detail-dialog { max-width: min(460px, calc(100vw - 28px)); min-width: min(360px, calc(100vw - 28px)); max-height: calc(100dvh - 40px); overflow-y: auto; }
@media (max-width: 600px) {
+ .tab-copy { display: none; }
+ #session-tabs .inventory-group-header { display: none; }
.inventory-group-header h4 { flex-basis: 100%; }
.inventory-group-header .inventory-acknowledge { min-height: 44px; }
+ .inventory-filters label:not(.inventory-search) { flex: 1 1 calc(50% - 8px); }
+ .inventory-filters select { width: 100%; max-width: none; }
.inventory-filters .inventory-search { flex-basis: 100%; }
- .inventory-filters input { min-height: 44px; font-size: 16px; }
- #sessions-list .inventory-session { width: 100%; }
- .inventory-session .egg-footer { min-height: 48px; font-size: 14px; }
- .inventory-session .box-menu-btn { width: 44px; min-height: 44px; }
+ .inventory-session-actions .btn-sm { min-height: 44px; padding: 8px 10px; }
.wd-session-row { flex-wrap: wrap; }
.wd-kill-btn { opacity: 1; min-height: 40px; }
.wd-past-row { flex-wrap: wrap; }
.wd-past-row .btn-sm { min-height: 40px; }
}
-@media (hover: none) and (pointer: coarse) { .wd-kill-btn, .box-menu-btn { opacity: 1; pointer-events: auto; } }
+@media (hover: none) and (pointer: coarse) { .wd-kill-btn, .session-rename-btn { opacity: 1; pointer-events: auto; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
/* Persistent conversations share the same personal inventory surface. */
diff --git a/web/test/session-inventory.test.js b/web/test/session-inventory.test.js
index e5909574..c88d4b31 100644
--- a/web/test/session-inventory.test.js
+++ b/web/test/session-inventory.test.js
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { sessionInventoryState, sessionStatusDot, sessionInventoryActions, filterSessionInventory, captureSessionFocus, restoreSessionFocus, navigateSessionRows, findSessionResource, sessionIsSelected, sessionIsViewed, sessionResourceKey, sessionProjectRoot, groupSessionInventory, groupSessionsByWing, sessionGroupHeader, sessionExceptionLabel, sessionTabMarkup, sessionCardMarkup, inventorySearchVisible, inventoryCountLabel } from '../src/session-inventory.js';
+import { sessionInventoryState, sessionStatusDot, sessionInventoryActions, filterSessionInventory, captureSessionFocus, restoreSessionFocus, navigateSessionRows, findSessionResource, sessionIsSelected, sessionIsViewed, sessionResourceKey, sessionProjectRoot, groupSessionInventory, sessionGroupHeader } from '../src/session-inventory.js';
import { sessionRoute, parseSessionRoute } from '../src/session-route.js';
const wing = { wing_id: 'mac', wing_label: 'Personal Mac', hostname: 'mac-mini', online: true, capabilities: ['session.rename.v1'] };
@@ -28,20 +28,6 @@ test('groups use exact wing IDs and cwd roots, not names or basename collisions'
assert.equal(groups[3].project, '');
});
-test('Home groups by wing, keeping attention order across that wing\'s projects', () => {
- const wings = [wing, { ...wing, wing_id: 'linux' }];
- const row = (id, status, wingId, cwd) => ({ ...session, id, wing_id: wingId, cwd, lifecycle: { status, state_source: 'claude_hook' } });
- // Project /a holds the most urgent session, so its idle session would
- // otherwise sit ahead of /b's working one.
- const sessions = [row('a-idle', 'idle', 'mac', '/a'), row('a-blocked', 'blocked', 'mac', '/a'), row('b-working', 'working', 'mac', '/b'),
- row('linux-working', 'working', 'linux', '/c'), row('c-unknown', 'unknown', 'mac', '/c')];
- const groups = groupSessionsByWing(groupSessionInventory(sessions, wings));
- assert.deepEqual(groups.map(group => group.wingId), ['mac', 'linux']);
- assert.deepEqual(groups[0].sessions.map(item => item.id), ['a-blocked', 'b-working', 'a-idle', 'c-unknown']);
- assert.deepEqual(groups[1].sessions.map(item => item.id), ['linux-working']);
- assert.equal(new Set(groups.map(group => group.key)).size, 2);
-});
-
test('project grouping uses the nearest known root and respects path boundaries', () => {
const known = { ...wing, projects: [{ path: '/repos/app/' }, { path: '/repos/app/nested' }] };
assert.equal(sessionProjectRoot({ cwd: '/repos/app/src' }, known), '/repos/app');
@@ -86,75 +72,6 @@ test('blocked keeps the attention tone offline and group headers escape remote p
assert.match(markup, /title="<img> · mac<svg>"/, 'the wing ID stays in the tooltip');
});
-test('the compact Home group header names only the wing, escaped, with no rollup or action', () => {
- const hostile = { ...session, wing_id: 'mac
', cwd: '/repo/