Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 112 additions & 8 deletions packaging/mac-client/Sources/Lisa/MenuBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,9 @@ final class MenuBarController: NSObject, NSPopoverDelegate {

private func refreshOnce() async {
// Two parallel requests — ping (mood + desire + idle message) and
// sessions (Claude Code active). Either failing means "offline".
// sessions (ALL observed agents). Either failing means "offline".
let pingURL = URL(string: "http://localhost:5757/api/island/ping")!
let sessURL = URL(string: "http://localhost:5757/api/claude/sessions")!
let sessURL = URL(string: "http://localhost:5757/api/agents/sessions")!
let cfg = URLRequest.CachePolicy.reloadIgnoringLocalCacheData

async let pingResult: PingDTO? = await Self.fetch(PingDTO.self, url: pingURL, cachePolicy: cfg)
Expand Down Expand Up @@ -379,26 +379,113 @@ final class MenuBarController: NSObject, NSPopoverDelegate {
v.alignment = .leading
v.spacing = 6
v.translatesAutoresizingMaskIntoConstraints = false
let t = NSTextField(labelWithString: "CLAUDE CODE · \(sessions.count) ACTIVE")
let t = NSTextField(labelWithString: "AGENTS · \(sessions.count) ACTIVE")
t.font = .systemFont(ofSize: 10, weight: .semibold)
t.textColor = .tertiaryLabelColor
v.addArrangedSubview(t)

let row = NSStackView()
row.orientation = .horizontal
row.spacing = 6
let orange = NSColor(srgbRed: 1.0, green: 0.55, blue: 0.26, alpha: 1)
let red = NSColor(srgbRed: 1.0, green: 0.33, blue: 0.47, alpha: 1)
if waiting > 0 { row.addArrangedSubview(chip("\(waiting) waiting", color: orange)) }
if working > 0 { row.addArrangedSubview(chip("\(working) working", color: orange.withAlphaComponent(0.72))) }
if errors > 0 { row.addArrangedSubview(chip("\(errors) errored", color: red)) }
if waiting > 0 { row.addArrangedSubview(chip("\(waiting) waiting", color: Self.stateOrange)) }
if working > 0 { row.addArrangedSubview(chip("\(working) working", color: Self.stateOrange.withAlphaComponent(0.72))) }
if errors > 0 { row.addArrangedSubview(chip("\(errors) errored", color: Self.stateRed)) }
if waiting == 0 && working == 0 && errors == 0 {
row.addArrangedSubview(chip("idle", color: .systemGray))
}
v.addArrangedSubview(row)

// Per-agent rows, attention-sorted (error > waiting > working), capped.
let rank: [String: Int] = ["error": 0, "waiting": 1, "working": 2]
let sorted = sessions.sorted { (rank[$0.state] ?? 9) < (rank[$1.state] ?? 9) }
let cap = 6
for s in sorted.prefix(cap) { v.addArrangedSubview(agentRow(s, inner: inner)) }
if sorted.count > cap {
let more = NSTextField(labelWithString: "+\(sorted.count - cap) more")
more.font = .systemFont(ofSize: 10)
more.textColor = .tertiaryLabelColor
v.addArrangedSubview(more)
}
return v
}

private static let stateOrange = NSColor(srgbRed: 1.0, green: 0.55, blue: 0.26, alpha: 1)
private static let stateRed = NSColor(srgbRed: 1.0, green: 0.33, blue: 0.47, alpha: 1)

/// One agent's row: ● state-dot · kind · branch/project label, with the
/// structural activity line beneath. Mirrors the GUI roster row.
private func agentRow(_ s: SessionDTO, inner: CGFloat) -> NSView {
let dotColor: NSColor = s.state == "error" ? Self.stateRed
: (s.state == "waiting" || s.state == "working") ? Self.stateOrange : .systemGray

let dot = NSView()
dot.wantsLayer = true
dot.layer?.backgroundColor = dotColor.cgColor
dot.layer?.cornerRadius = 3.5
dot.translatesAutoresizingMaskIntoConstraints = false
dot.widthAnchor.constraint(equalToConstant: 7).isActive = true
dot.heightAnchor.constraint(equalToConstant: 7).isActive = true

let kind = NSTextField(labelWithString: (s.agent ?? "agent"))
kind.font = .systemFont(ofSize: 9, weight: .semibold)
kind.textColor = .tertiaryLabelColor

let label = NSTextField(labelWithString: oneLine(agentLabel(s), 40))
label.font = .systemFont(ofSize: 12, weight: .medium)
label.textColor = .labelColor
label.lineBreakMode = .byTruncatingTail

let top = NSStackView(views: [dot, kind, label])
top.orientation = .horizontal
top.spacing = 6
top.alignment = .centerY

let col = NSStackView(views: [top])
col.orientation = .vertical
col.alignment = .leading
col.spacing = 1

let act = agentActivity(s)
if !act.isEmpty {
let actLabel = NSTextField(labelWithString: oneLine(act, 52))
actLabel.font = .systemFont(ofSize: 10)
actLabel.textColor = .secondaryLabelColor
actLabel.lineBreakMode = .byTruncatingTail
col.addArrangedSubview(actLabel)
}
return col
}

/// Branch label (strip "claude/"), else project. Mirrors rosterLabel.
private func agentLabel(_ s: SessionDTO) -> String {
if let b = s.activity?.gitBranch, !b.isEmpty {
return b.hasPrefix("claude/") ? String(b.dropFirst("claude/".count)) : b
}
return s.project ?? "agent"
}

/// One-line structural activity. Mirrors agent-roster.ts formatActivity.
private func agentActivity(_ s: SessionDTO) -> String {
guard let a = s.activity else { return "" }
if let p = a.pendingPermission, !p.isEmpty { return "⚠ wants to run " + p }
var bits: [String] = []
if let e = a.lastError, !e.isEmpty { bits.append("✗ " + e) }
var prog: [String] = []
if let n = a.turnCount, n > 0 { prog.append("turn \(n)") }
if let tk = a.tokens {
let tot = (tk.input ?? 0) + (tk.output ?? 0)
if tot > 0 { prog.append(tot >= 1000 ? "\(Int((Double(tot) / 1000).rounded()))k tok" : "\(tot) tok") }
}
if !prog.isEmpty { bits.append(prog.joined(separator: " ")) }
if let c = a.lastCommandName, !c.isEmpty { bits.append("$ " + c) }
let tool = a.lastTools?.last ?? ""
let file = (a.filesTouched?.last).map { String($0.split(separator: "/").last ?? "") } ?? ""
if !tool.isEmpty && !file.isEmpty { bits.append(tool + " " + file) }
else if !tool.isEmpty { bits.append(tool) }
else if !file.isEmpty { bits.append(file) }
return bits.joined(separator: " · ")
}

/// A small rounded status chip (tinted background + colored label).
private func chip(_ text: String, color: NSColor) -> NSView {
let label = NSTextField(labelWithString: text)
Expand Down Expand Up @@ -499,6 +586,23 @@ final class MenuBarController: NSObject, NSPopoverDelegate {
}
private struct SessionDTO: Decodable {
let state: String
var agent: String?
var project: String?
var activity: ActivityDTO?
}
private struct ActivityDTO: Decodable {
var gitBranch: String?
var turnCount: Int?
var tokens: TokensDTO?
var lastTools: [String]?
var filesTouched: [String]?
var lastCommandName: String?
var lastError: String?
var pendingPermission: String?
}
private struct TokensDTO: Decodable {
var input: Int?
var output: Int?
}
private struct PingDTO: Decodable {
let online: Bool?
Expand Down
34 changes: 32 additions & 2 deletions src/web/agent-roster.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { mergeAgentSession, aggregateAgentState, rosterLabel, type RosterSession } from "./agent-roster.js";
import { mergeAgentSession, aggregateAgentState, rosterLabel, formatActivity, type RosterSession } from "./agent-roster.js";

const NOW = 1_700_000_000_000;
const WINDOW = 30 * 60_000;
Expand Down Expand Up @@ -57,7 +57,7 @@ describe("source-injection safety (island injects these verbatim)", () => {
// island.ts embeds `${mergeAgentSession}` into the page; the browser eval's
// the function source. Assert each is self-contained: its source eval's to a
// working function with no external references.
for (const fn of [mergeAgentSession, aggregateAgentState, rosterLabel]) {
for (const fn of [mergeAgentSession, aggregateAgentState, rosterLabel, formatActivity]) {
test(`${fn.name} source eval's to a working function`, () => {
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const rebuilt = new Function(`return (${fn.toString()})`)() as (...a: unknown[]) => unknown;
Expand All @@ -66,13 +66,43 @@ describe("source-injection safety (island injects these verbatim)", () => {
assert.equal(rebuilt([s({ state: "error" })], NOW, WINDOW), "error");
} else if (fn === rosterLabel) {
assert.equal(rebuilt(s({ project: "p" })), "p");
} else if (fn === formatActivity) {
assert.equal(rebuilt(s({ activity: { pendingPermission: "bash" } })), "⚠ wants to run bash");
} else {
assert.equal((rebuilt([], s(), NOW, WINDOW) as RosterSession[]).length, 1);
}
});
}
});

describe("formatActivity", () => {
test("no activity → empty", () => {
assert.equal(formatActivity(s({ activity: undefined })), "");
});
test("pendingPermission wins over everything", () => {
assert.equal(
formatActivity(s({ activity: { pendingPermission: "Bash", lastError: "x", turnCount: 5 } })),
"⚠ wants to run Bash",
);
});
test("error · progress · cmd · tool file, in order", () => {
const out = formatActivity(s({ activity: {
lastError: "ENOENT",
turnCount: 12,
tokens: { input: 1200, output: 800 },
lastCommandName: "npm",
lastTools: ["Read", "Edit"],
filesTouched: ["/a/b/foo.ts"],
} }));
assert.equal(out, "✗ ENOENT · turn 12 2k tok · $ npm · Edit foo.ts");
});
test("tokens under 1000 shown raw; only-tools / only-files handled", () => {
assert.equal(formatActivity(s({ activity: { turnCount: 1, tokens: { input: 300, output: 100 } } })), "turn 1 400 tok");
assert.equal(formatActivity(s({ activity: { lastTools: ["Grep"] } })), "Grep");
assert.equal(formatActivity(s({ activity: { filesTouched: ["/x/y/bar.py"] } })), "bar.py");
});
});

describe("rosterLabel", () => {
test("prefers the git branch, stripping the claude/ prefix", () => {
assert.equal(rosterLabel(s({ activity: { gitBranch: "claude/fix-sentry-build-upload" } })), "fix-sentry-build-upload");
Expand Down
49 changes: 49 additions & 0 deletions src/web/agent-roster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,52 @@ export function rosterLabel(s: RosterSession): string {
}
return s.project;
}

/**
* One-line structural summary of a session's Tier-2 activity — what it's *doing*
* and how far along, never conversation content: a pending-permission warning,
* else "✗ error · turn N · Mk tok · $ cmd · Tool file". Returns "" when there's
* no activity. Pure + self-contained (island injects this source; the
* injection-safety test guards it — no imports, no named const-arrows).
*/
export function formatActivity(s: RosterSession): string {
const a = s.activity as
| {
pendingPermission?: unknown;
lastError?: unknown;
turnCount?: unknown;
tokens?: { input?: unknown; output?: unknown };
lastCommandName?: unknown;
lastTools?: unknown;
filesTouched?: unknown;
}
| undefined;
if (!a || typeof a !== "object") return "";
if (typeof a.pendingPermission === "string" && a.pendingPermission) {
return "⚠ wants to run " + a.pendingPermission;
}
const bits: string[] = [];
if (typeof a.lastError === "string" && a.lastError) bits.push("✗ " + a.lastError);

const prog: string[] = [];
if (typeof a.turnCount === "number" && a.turnCount > 0) prog.push("turn " + a.turnCount);
if (a.tokens && typeof a.tokens === "object") {
const tin = typeof a.tokens.input === "number" ? a.tokens.input : 0;
const tout = typeof a.tokens.output === "number" ? a.tokens.output : 0;
const tot = tin + tout;
if (tot > 0) prog.push(tot >= 1000 ? Math.round(tot / 1000) + "k tok" : tot + " tok");
}
if (prog.length) bits.push(prog.join(" "));

if (typeof a.lastCommandName === "string" && a.lastCommandName) bits.push("$ " + a.lastCommandName);

const tools = Array.isArray(a.lastTools) ? (a.lastTools as unknown[]) : [];
const tool = tools.length ? String(tools[tools.length - 1]) : "";
const files = Array.isArray(a.filesTouched) ? (a.filesTouched as unknown[]) : [];
const file = files.length ? String(files[files.length - 1]).split("/").pop() || "" : "";
if (tool && file) bits.push(tool + " " + file);
else if (tool) bits.push(tool);
else if (file) bits.push(file);

return bits.join(" · ");
}
32 changes: 7 additions & 25 deletions src/web/island.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* so the browser runs the exact unit-tested code instead of a drifting copy.
*/

import { mergeAgentSession, aggregateAgentState, rosterLabel } from "./agent-roster.js";
import { mergeAgentSession, aggregateAgentState, rosterLabel, formatActivity } from "./agent-roster.js";

export const ISLAND_HTML = `<!doctype html>
<html lang="en">
Expand Down Expand Up @@ -562,7 +562,7 @@ export const ISLAND_HTML = `<!doctype html>
<ul id="advisor-list"></ul>
</div>
<div id="claude-section">
<div class="section-label">claude code · <span id="claude-count">0</span> active</div>
<div class="section-label">agents · <span id="claude-count">0</span> active</div>
<ul id="claude-list"></ul>
<div id="notify-cta" role="button" tabindex="0">🔔 Notify me when Claude is waiting</div>
</div>
Expand All @@ -588,6 +588,7 @@ export const ISLAND_HTML = `<!doctype html>
${mergeAgentSession}
${aggregateAgentState}
${rosterLabel}
${formatActivity}
// Composite identity for a roster row: the same sessionId seen under two
// agents (e.g. a git + a shell observer) are DISTINCT rows. Mirrors the
// key mergeAgentSession uses; the UI keys per-row history, open-state, and
Expand Down Expand Up @@ -884,28 +885,9 @@ export const ISLAND_HTML = `<!doctype html>
return Math.round(ms / 3600_000) + 'h ago';
}

// O2 — compact one-line summary of a session's Tier-2 activity. Structural
// only (tool names, last command, basename of a touched file). Returns ''
// when there's no activity (e.g. visibility=metadata).
function basename(p) {
if (!p) return '';
const parts = String(p).split('/');
return parts[parts.length - 1] || p;
}
function formatActivity(s) {
const a = s.activity;
if (!a) return '';
if (a.pendingPermission) return '⚠ wants to run ' + a.pendingPermission;
const bits = [];
if (a.lastError) bits.push('✗ ' + a.lastError);
if (a.lastCommandName) bits.push('$ ' + a.lastCommandName);
const tool = a.lastTools && a.lastTools.length ? a.lastTools[a.lastTools.length - 1] : '';
const file = a.filesTouched && a.filesTouched.length ? basename(a.filesTouched[a.filesTouched.length - 1]) : '';
if (tool && file) bits.push(tool + ' ' + file);
else if (tool) bits.push(tool);
else if (file) bits.push(file);
return bits.join(' · ');
}
// O2 — compact one-line Tier-2 activity summary is the source-injected
// formatActivity (from agent-roster.ts, above) — structural only, now also
// surfacing turn count + tokens. Shared with the GUI sidebar.

function renderClaudeList() {
const recent = recentSessions();
Expand All @@ -925,7 +907,7 @@ export const ISLAND_HTML = `<!doctype html>
const rb = stateRank[b.state] ?? 9;
if (ra !== rb) return ra - rb;
return new Date(b.lastMtime).getTime() - new Date(a.lastMtime).getTime();
}).slice(0, 5);
}).slice(0, 8);
for (const s of rows) {
const li = document.createElement('li');
if (rowOpen.has(agentKey(s))) li.classList.add('row-open');
Expand Down
36 changes: 35 additions & 1 deletion src/web/lisa-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,31 @@ if ('serviceWorker' in navigator) {
sbReflectionBody.textContent = '"' + text.replace(/^["“”]+|["“”]+$/g, '').trim() + '"';
};

// Compact one-line activity summary — mirrors agent-roster.ts formatActivity
// (kept inline because this client script is a no-interpolation template
// literal; the island uses the source-injected shared version).
function sbActivity(s) {
const a = s.activity;
if (!a || typeof a !== 'object') return '';
if (a.pendingPermission) return '⚠ wants to run ' + a.pendingPermission;
const bits = [];
if (a.lastError) bits.push('✗ ' + a.lastError);
const prog = [];
if (typeof a.turnCount === 'number' && a.turnCount > 0) prog.push('turn ' + a.turnCount);
if (a.tokens && (a.tokens.input || a.tokens.output)) {
const tot = (a.tokens.input || 0) + (a.tokens.output || 0);
prog.push(tot >= 1000 ? Math.round(tot / 1000) + 'k tok' : tot + ' tok');
}
if (prog.length) bits.push(prog.join(' '));
if (a.lastCommandName) bits.push('$ ' + a.lastCommandName);
const tool = a.lastTools && a.lastTools.length ? a.lastTools[a.lastTools.length - 1] : '';
const file = a.filesTouched && a.filesTouched.length ? (String(a.filesTouched[a.filesTouched.length - 1]).split('/').pop() || '') : '';
if (tool && file) bits.push(tool + ' ' + file);
else if (tool) bits.push(tool);
else if (file) bits.push(file);
return bits.join(' · ');
}

function setClaudeSessions(sessions) {
const cutoff = Date.now() - ACTIVE_WINDOW_MS;
const recent = sessions.filter(s => new Date(s.lastMtime).getTime() >= cutoff);
Expand All @@ -1025,7 +1050,7 @@ if ('serviceWorker' in navigator) {
const rb = rank[b.state] ?? 9;
if (ra !== rb) return ra - rb;
return new Date(b.lastMtime).getTime() - new Date(a.lastMtime).getTime();
}).slice(0, 5);
}).slice(0, 8);
while (sbClaudeRows.firstChild) sbClaudeRows.removeChild(sbClaudeRows.firstChild);
if (rows.length === 0) {
const empty = document.createElement('div');
Expand Down Expand Up @@ -1066,6 +1091,15 @@ if ('serviceWorker' in navigator) {
row.appendChild(pip);
row.appendChild(name);
row.appendChild(when);
// Second line: structural activity (turns/tokens/tool·file, ⚠pending, ✗err).
const actText = sbActivity(s);
if (actText) {
const act = document.createElement('div');
act.className = 'session-act';
act.textContent = actText;
act.title = actText;
row.appendChild(act);
}
row.title = (s.stateReason ? s.state + ' · ' + s.stateReason : s.state) + ' · ' + s.project + ' · ' + s.sessionId;
sbClaudeRows.appendChild(row);
}
Expand Down
Loading