opencode-island 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,70 @@
1
1
  {
2
2
  "name": "opencode-island",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "A macOS Dynamic Island for OpenCode: live status of every session and agent on your machine, plus permission prompts you can answer without leaving your work.",
5
+ "keywords": [
6
+ "opencode",
7
+ "opencode-plugin",
8
+ "dynamic-island",
9
+ "macos",
10
+ "electron",
11
+ "monitoring",
12
+ "agents",
13
+ "status"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "Hamza",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/hamzasoussi53/opencode-island.git"
20
+ },
21
+ "homepage": "https://github.com/hamzasoussi53/opencode-island#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/hamzasoussi53/opencode-island/issues"
24
+ },
25
+ "type": "module",
26
+ "exports": {
27
+ ".": "./src/index.ts",
28
+ "./gui-experimental": "./dist/gui-experimental.cjs"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "dist",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "scripts": {
40
+ "build": "vite build --config vite.island.config.ts && vite build",
41
+ "dev": "vite build --config vite.island.config.ts && vite build --watch",
42
+ "prepare": "npm run build",
43
+ "typecheck": "tsc --noEmit && tsc -p src/island/app --noEmit",
44
+ "check": "node scripts/check.ts"
45
+ },
46
+ "dependencies": {
47
+ "@opencode/plugin": "^2.2.7"
48
+ },
49
+ "devDependencies": {
50
+ "@phosphor-icons/react": "^2.1.10",
51
+ "@tailwindcss/vite": "^4.3.5",
52
+ "@types/node": "^24.0.0",
53
+ "@types/react": "^19.3.0",
54
+ "@types/react-dom": "^19.3.0",
55
+ "@vitejs/plugin-react": "^5.2.0",
56
+ "class-variance-authority": "^0.7.1",
57
+ "clsx": "^2.1.1",
58
+ "electron": "*",
59
+ "radix-ui": "^1.8.1",
60
+ "react": "^19.3.1",
61
+ "react-dom": "^19.3.1",
62
+ "solid-js": "^1.9.0",
63
+ "tailwind-merge": "^3.7.0",
64
+ "tailwindcss": "^4.3.5",
65
+ "typescript": "^5.7.0",
66
+ "vite": "^7.0.0",
67
+ "vite-plugin-singlefile": "^2.3.4",
68
+ "vite-plugin-solid": "^2.11.0"
69
+ }
70
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
package/src/gui.tsx ADDED
@@ -0,0 +1,81 @@
1
+ import { OpenCode } from "@opencode/client"
2
+ import { Plugin } from "@opencode/plugin/gui"
3
+ import { createModel } from "./model"
4
+ import { createIslandWindow } from "./window"
5
+ import type { IslandMessage, IslandState } from "./state"
6
+
7
+ /**
8
+ * The island lives in the desktop app's main process, not in a window.
9
+ *
10
+ * A window setup would stop when the last app window closes, which is exactly
11
+ * when the island is most useful. Main also has `serverEndpoints`, so the
12
+ * island can read the app's own server directly and sees sessions from every
13
+ * project on the machine.
14
+ */
15
+ export default Plugin.define({
16
+ main(ctx) {
17
+ let state: IslandState = { sessions: [], working: 0, waiting: 0 }
18
+ let open = false
19
+ let lastWaiting = 0
20
+ let model: ReturnType<typeof createModel> | undefined
21
+ let controller: AbortController | undefined
22
+ let connected = ""
23
+
24
+ const message = (): IslandMessage => ({ ...state, open })
25
+
26
+ const island = createIslandWindow({
27
+ log: (level, text, data) => ctx.log.write(level, text, data),
28
+ onReady: () => island.send(message()),
29
+ onToggle: () => {
30
+ open = !open
31
+ island.send(message())
32
+ },
33
+ onBlur: () => {
34
+ if (!open) return
35
+ open = false
36
+ island.send(message())
37
+ },
38
+ onReply: (reply) => {
39
+ open = false
40
+ island.send(message())
41
+ void model?.reply(reply).catch((error: unknown) => {
42
+ ctx.log.write("warn", "island permission reply failed", { error: String(error) })
43
+ })
44
+ },
45
+ })
46
+
47
+ // The sidecar appears after startup and moves when it restarts, so the
48
+ // endpoint is followed rather than read once. A restart keeps the URL but
49
+ // rotates the password, so the headers are part of the identity.
50
+ const connect = () => {
51
+ const endpoint = ctx.serverEndpoints.get("sidecar")
52
+ if (!endpoint) return
53
+ const key = endpoint.url + JSON.stringify(endpoint.headers)
54
+ if (key === connected) return
55
+ connected = key
56
+ controller?.abort()
57
+ controller = new AbortController()
58
+ model = createModel(
59
+ OpenCode.make({ baseUrl: endpoint.url, headers: endpoint.headers }),
60
+ (next) => {
61
+ // A new approval opens the island; the user collapsing it stands.
62
+ if (next.waiting > lastWaiting) open = true
63
+ lastWaiting = next.waiting
64
+ state = next
65
+ island.send(message())
66
+ },
67
+ )
68
+ model.start(controller.signal)
69
+ ctx.log.write("info", "island connected", { url: endpoint.url })
70
+ }
71
+
72
+ connect()
73
+ const timer = setInterval(connect, 1000)
74
+
75
+ return () => {
76
+ clearInterval(timer)
77
+ controller?.abort()
78
+ island.dispose()
79
+ }
80
+ },
81
+ })
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+
3
+ /**
4
+ * The server part exists so the desktop app discovers the plugin and reads its
5
+ * `./gui-experimental` bundle. Everything the island needs it reads from the
6
+ * app's own server in the main process, so there is nothing to do here.
7
+ */
8
+ export default Plugin.define({
9
+ id: "opencode-island",
10
+ setup() {},
11
+ })
@@ -0,0 +1,487 @@
1
+ import {
2
+ useEffect,
3
+ useLayoutEffect,
4
+ useRef,
5
+ useState,
6
+ type MouseEvent as ReactMouseEvent,
7
+ } from "react"
8
+ import {
9
+ CursorClick,
10
+ DotsSixVertical,
11
+ ListDashes,
12
+ Monitor,
13
+ Moon,
14
+ Sun,
15
+ } from "@phosphor-icons/react"
16
+ import type { IslandMessage, PermissionDecision, SessionView } from "../../../state"
17
+ import { Button } from "@/components/ui/button"
18
+ import { Toggle } from "@/components/ui/toggle"
19
+ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
20
+ import { cn } from "@/lib/utils"
21
+
22
+ const EMPTY: IslandMessage = { sessions: [], working: 0, waiting: 0, open: false }
23
+
24
+ type Theme = "light" | "dark" | "system"
25
+
26
+ function elapsed(ms: number) {
27
+ if (!ms || ms < 0) return ""
28
+ const seconds = Math.floor(ms / 1000)
29
+ if (seconds < 60) return `${seconds}s`
30
+ const minutes = Math.floor(seconds / 60)
31
+ if (minutes < 60) return `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`
32
+ return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`
33
+ }
34
+
35
+ function count(n: number) {
36
+ if (!n) return "0"
37
+ if (n < 1000) return String(n)
38
+ if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}K`
39
+ return `${(n / 1_000_000).toFixed(2)}M`
40
+ }
41
+
42
+ function money(n: number) {
43
+ if (!n) return ""
44
+ return `$${n < 0.01 ? n.toFixed(4) : n.toFixed(2)}`
45
+ }
46
+
47
+ function label(session: SessionView) {
48
+ return session.permission
49
+ ? `${session.permission.action} needs approval`
50
+ : session.title || "Untitled session"
51
+ }
52
+
53
+ /** The closed tab is a spine: ten words is already a long one. */
54
+ function trim(text: string, max: number) {
55
+ const words = text.trim().split(/\s+/)
56
+ return words.length > max ? `${words.slice(0, max).join(" ")}…` : text
57
+ }
58
+
59
+ function latestOf(sessions: SessionView[]) {
60
+ let latest: SessionView | undefined
61
+ for (const session of sessions) {
62
+ if (session.status === "idle" && !session.permission) continue
63
+ if (!latest || session.updated > latest.updated) latest = session
64
+ }
65
+ return latest
66
+ }
67
+
68
+ /** Running sessions only, each followed by its subagents. */
69
+ function rowsOf(sessions: SessionView[]) {
70
+ const active = sessions.filter((session) => session.status !== "idle" || session.permission)
71
+ const known = new Set(active.map((session) => session.id))
72
+ const children = new Map<string, SessionView[]>()
73
+ const roots: SessionView[] = []
74
+
75
+ for (const session of active) {
76
+ if (session.parentID && known.has(session.parentID)) {
77
+ const list = children.get(session.parentID) ?? []
78
+ list.push(session)
79
+ children.set(session.parentID, list)
80
+ } else {
81
+ roots.push(session)
82
+ }
83
+ }
84
+
85
+ const rows: { session: SessionView; sub: boolean }[] = []
86
+ for (const root of roots) {
87
+ rows.push({ session: root, sub: false })
88
+ for (const child of children.get(root.id) ?? []) rows.push({ session: child, sub: true })
89
+ }
90
+ return rows
91
+ }
92
+
93
+ export function Island() {
94
+ const [state, setState] = useState<IslandMessage>(EMPTY)
95
+ const [theme, setTheme] = useState<Theme>("dark")
96
+ const [clickThrough, setClickThrough] = useState(false)
97
+ const [showLatest, setShowLatest] = useState(true)
98
+ const [systemDark, setSystemDark] = useState(true)
99
+ const [now, setNow] = useState(() => Date.now())
100
+ const [hoverHandle, setHoverHandle] = useState(false)
101
+ /** The height the user asked for, as a share of the screen. Null until set. */
102
+ const [share, setShare] = useState<number | null>(null)
103
+ const [screen, setScreen] = useState({ width: 1920, height: 1200 })
104
+
105
+ const shell = useRef<HTMLDivElement>(null)
106
+ const handle = useRef<HTMLDivElement>(null)
107
+ const pressY = useRef<number | null>(null)
108
+ const moved = useRef(false)
109
+ /** True while the bottom edge of the closed bar is being dragged. */
110
+ const resizing = useRef(false)
111
+ /** True just after a resize, so the release does not open the island. */
112
+ const resized = useRef(false)
113
+ const lastSize = useRef("")
114
+
115
+ const { sessions, working, waiting, open } = state
116
+
117
+ useEffect(() => {
118
+ window.island.onState(setState)
119
+ window.island.ready()
120
+ void window.island.screen().then(setScreen)
121
+ }, [])
122
+
123
+ useEffect(() => {
124
+ const query = window.matchMedia("(prefers-color-scheme: dark)")
125
+ const onChange = () => setSystemDark(query.matches)
126
+ onChange()
127
+ query.addEventListener("change", onChange)
128
+ return () => query.removeEventListener("change", onChange)
129
+ }, [])
130
+
131
+ // The tab has no clock, so only tick while the list is open.
132
+ useEffect(() => {
133
+ if (!open) return
134
+ const timer = window.setInterval(() => setNow(Date.now()), 1000)
135
+ return () => window.clearInterval(timer)
136
+ }, [open])
137
+
138
+ // The window is sized to the page; report every content change. A share of
139
+ // the screen wins over the content height, so the panel can be a set length.
140
+ useLayoutEffect(() => {
141
+ const element = shell.current
142
+ if (!element) return
143
+ const report = () => {
144
+ // While the bar is resized the drag owns the height.
145
+ if (resizing.current) return
146
+ const rect = element.getBoundingClientRect()
147
+ const width = Math.ceil(rect.width)
148
+ const height = Math.ceil(rect.height)
149
+ // The share is the length of the closed tab, so the panel stays as it is.
150
+ const wanted = share && !open ? Math.round((screen.height * share) / 100) : height
151
+ const next = `${width}x${wanted}`
152
+ if (next === lastSize.current) return
153
+ lastSize.current = next
154
+ window.island.size(width, wanted)
155
+ }
156
+ report()
157
+ const observer = new ResizeObserver(report)
158
+ observer.observe(element)
159
+ return () => observer.disconnect()
160
+ }, [share, open, screen])
161
+
162
+ // Press and move on the closed tab to slide it along the edge.
163
+ useEffect(() => {
164
+ const onMove = (event: MouseEvent) => {
165
+ if (pressY.current !== null && Math.abs(event.screenY - pressY.current) > 3) {
166
+ moved.current = true
167
+ }
168
+ }
169
+ const onUp = () => {
170
+ if (pressY.current === null) return
171
+ pressY.current = null
172
+ window.island.dragEnd()
173
+ }
174
+ document.addEventListener("mousemove", onMove)
175
+ document.addEventListener("mouseup", onUp)
176
+ return () => {
177
+ document.removeEventListener("mousemove", onMove)
178
+ document.removeEventListener("mouseup", onUp)
179
+ }
180
+ }, [])
181
+
182
+ // Dragging the bottom edge of the closed bar makes it longer or shorter.
183
+ // The window does the arithmetic, so the bottom edge follows the mouse.
184
+ useEffect(() => {
185
+ const onMove = (event: MouseEvent) => {
186
+ if (!resizing.current) return
187
+ window.island.resize(event.screenY)
188
+ }
189
+ const onUp = () => {
190
+ if (!resizing.current) return
191
+ resizing.current = false
192
+ resized.current = true
193
+ window.setTimeout(() => {
194
+ resized.current = false
195
+ }, 500)
196
+ // The window knows the length the drag asked for; keep it.
197
+ const next = Math.round((window.innerHeight / screen.height) * 100)
198
+ setShare(Math.min(90, Math.max(10, next)))
199
+ window.island.resizeEnd()
200
+ }
201
+ document.addEventListener("mousemove", onMove)
202
+ document.addEventListener("mouseup", onUp)
203
+ return () => {
204
+ document.removeEventListener("mousemove", onMove)
205
+ document.removeEventListener("mouseup", onUp)
206
+ }
207
+ }, [screen.height])
208
+
209
+ // While the bar is being resized the window tells us how long it is now.
210
+ useEffect(() => {
211
+ const sync = () => {
212
+ if (!resizing.current) return
213
+ const next = Math.round((window.innerHeight / screen.height) * 100)
214
+ setShare((current) => (current === next ? current : Math.min(90, Math.max(10, next))))
215
+ }
216
+ window.addEventListener("resize", sync)
217
+ return () => window.removeEventListener("resize", sync)
218
+ }, [screen.height])
219
+
220
+ const ignore = clickThrough && !open && !hoverHandle
221
+
222
+ useEffect(() => {
223
+ window.island.ignore(ignore)
224
+ }, [ignore])
225
+
226
+ // With clicks ignored the page still gets forwarded moves, so hovering the
227
+ // grip can take the window out of click-through for the click that opens it.
228
+ useEffect(() => {
229
+ if (!clickThrough || open) {
230
+ setHoverHandle(false)
231
+ return
232
+ }
233
+ const onMove = (event: MouseEvent) => {
234
+ const element = handle.current
235
+ if (!element) return
236
+ const rect = element.getBoundingClientRect()
237
+ setHoverHandle(
238
+ event.clientX >= rect.left &&
239
+ event.clientX <= rect.right &&
240
+ event.clientY >= rect.top &&
241
+ event.clientY <= rect.bottom,
242
+ )
243
+ }
244
+ document.addEventListener("mousemove", onMove)
245
+ return () => document.removeEventListener("mousemove", onMove)
246
+ }, [clickThrough, open])
247
+
248
+ const onMouseDown = (event: ReactMouseEvent) => {
249
+ if (event.button !== 0 || open) return
250
+ pressY.current = event.screenY
251
+ moved.current = false
252
+ window.island.dragStart()
253
+ }
254
+
255
+ const onClick = (event: ReactMouseEvent) => {
256
+ if (moved.current) {
257
+ moved.current = false
258
+ return
259
+ }
260
+ // A resize that ended away from the handle still lands as a click.
261
+ if (resized.current) {
262
+ resized.current = false
263
+ return
264
+ }
265
+ if ((event.target as HTMLElement).closest("button, [data-row], [data-resize]")) return
266
+ window.island.toggle()
267
+ }
268
+
269
+ // The bottom edge of the closed bar is its length handle.
270
+ const onResizeDown = (event: ReactMouseEvent) => {
271
+ if (event.button !== 0) return
272
+ event.stopPropagation()
273
+ resizing.current = true
274
+ window.island.resizeStart()
275
+ }
276
+
277
+ const reply = (decision: PermissionDecision) => {
278
+ const pending = sessions.find((session) => session.permission)?.permission
279
+ if (pending) window.island.reply({ sessionID: pending.sessionID, requestID: pending.id, decision })
280
+ }
281
+
282
+ const alert = waiting > 0
283
+ const muted = alert ? "text-warning-foreground/70" : "text-muted-foreground"
284
+ const resolved = theme === "system" ? (systemDark ? "dark" : "light") : theme
285
+
286
+ const what = alert
287
+ ? `${waiting} approval${waiting === 1 ? "" : "s"} needed`
288
+ : working > 0
289
+ ? `${working} agent${working === 1 ? "" : "s"} working`
290
+ : sessions.length
291
+ ? "Idle"
292
+ : "No sessions"
293
+
294
+ const latest = latestOf(sessions)
295
+ const work = showLatest && !alert ? latest : undefined
296
+ const rows = rowsOf(sessions)
297
+ const pending = sessions.find((session) => session.permission)?.permission
298
+ const totalTokens = sessions.reduce((sum, session) => sum + (session.tokens || 0), 0)
299
+ const totalCost = sessions.reduce((sum, session) => sum + (session.cost || 0), 0)
300
+
301
+ const dot = alert ? "bg-warning-foreground" : working > 0 ? "bg-success" : "bg-muted-foreground"
302
+
303
+ return (
304
+ <div
305
+ ref={shell}
306
+ data-theme={resolved}
307
+ data-waiting={alert}
308
+ onMouseDown={onMouseDown}
309
+ onClick={onClick}
310
+ className={cn(
311
+ "relative flex shrink-0 flex-col overflow-hidden rounded-l-lg rounded-r-none border transition-colors duration-300",
312
+ alert
313
+ ? "border-warning bg-warning text-warning-foreground"
314
+ : "border-border bg-background text-foreground",
315
+ open ? "w-[420px]" : share ? "h-full w-9" : "max-h-[520px] w-9",
316
+ )}
317
+ >
318
+ {open ? (
319
+ <div className="flex w-full flex-col">
320
+ <div className={cn("flex items-center gap-2 border-b px-4 py-2 text-xs", muted)}>
321
+ <span className={cn("font-semibold", alert ? "text-warning-foreground" : "text-foreground")}>
322
+ {what}
323
+ </span>
324
+ <span className="flex-1" />
325
+ {totalTokens ? <span className="tabular-nums">{count(totalTokens)} tokens</span> : null}
326
+ {totalCost ? <span className="tabular-nums">{money(totalCost)}</span> : null}
327
+ </div>
328
+
329
+ <div className="max-h-[280px] overflow-y-auto py-1">
330
+ {rows.length ? (
331
+ rows.map(({ session, sub }) => (
332
+ <Row key={session.id} session={session} sub={sub} now={now} alert={alert} />
333
+ ))
334
+ ) : (
335
+ <p className={cn("px-4 py-2.5 text-xs", muted)}>Nothing running</p>
336
+ )}
337
+ </div>
338
+
339
+ {pending ? (
340
+ <div className="border-t px-4 py-3">
341
+ <p className="mb-0.5 text-[12.5px]">{pending.action}</p>
342
+ <p className={cn("mb-2.5 truncate font-mono text-[11.5px]", muted)}>
343
+ {pending.resources.join(" ") || pending.message || ""}
344
+ </p>
345
+ <div className="flex gap-2">
346
+ <Button size="xs" onClick={() => reply("once")}>
347
+ Allow once
348
+ </Button>
349
+ <Button size="xs" variant="secondary" onClick={() => reply("always")}>
350
+ Always allow
351
+ </Button>
352
+ <Button size="xs" variant="ghost" onClick={() => reply("reject")}>
353
+ Deny
354
+ </Button>
355
+ </div>
356
+ </div>
357
+ ) : null}
358
+
359
+ <div className="flex items-center gap-1 border-t px-2 py-1.5">
360
+ <ToggleGroup
361
+ type="single"
362
+ size="sm"
363
+ variant="outline"
364
+ value={theme}
365
+ onValueChange={(value) => {
366
+ if (value) setTheme(value as Theme)
367
+ }}
368
+ aria-label="Theme"
369
+ >
370
+ <ToggleGroupItem value="light" aria-label="Light" title="Light">
371
+ <Sun weight="duotone" />
372
+ </ToggleGroupItem>
373
+ <ToggleGroupItem value="dark" aria-label="Dark" title="Dark">
374
+ <Moon weight="duotone" />
375
+ </ToggleGroupItem>
376
+ <ToggleGroupItem value="system" aria-label="System" title="System">
377
+ <Monitor weight="duotone" />
378
+ </ToggleGroupItem>
379
+ </ToggleGroup>
380
+
381
+ <span className="flex-1" />
382
+
383
+ <Toggle
384
+ size="sm"
385
+ variant="outline"
386
+ pressed={clickThrough}
387
+ onPressedChange={setClickThrough}
388
+ aria-label="Click through"
389
+ title="Click through"
390
+ >
391
+ <CursorClick weight="duotone" />
392
+ </Toggle>
393
+ <Toggle
394
+ size="sm"
395
+ variant="outline"
396
+ pressed={showLatest}
397
+ onPressedChange={setShowLatest}
398
+ aria-label="Show latest work"
399
+ title="Show latest work"
400
+ >
401
+ <ListDashes weight="duotone" />
402
+ </Toggle>
403
+ </div>
404
+ </div>
405
+ ) : (
406
+ <div className="flex w-full items-center gap-2 px-2 pb-5 pt-3 [writing-mode:vertical-rl]">
407
+ <div
408
+ ref={handle}
409
+ className={cn("flex size-5 shrink-0 items-center justify-center rounded-sm", muted)}
410
+ >
411
+ <DotsSixVertical size={14} weight="duotone" />
412
+ </div>
413
+ <span className={cn("size-[7px] shrink-0 rounded-full transition-colors duration-300", dot)} />
414
+ {work ? (
415
+ <>
416
+ <span className="text-[12.5px] font-medium">
417
+ {work.stream || trim(work.title || "Untitled session", 10)}
418
+ </span>
419
+ {work.tool ? <span className={cn("text-xs", muted)}>{work.tool}</span> : null}
420
+ </>
421
+ ) : (
422
+ <span className="text-[12.5px] font-medium">{what}</span>
423
+ )}
424
+ </div>
425
+ )}
426
+
427
+ {open ? null : (
428
+ <div
429
+ data-resize
430
+ onMouseDown={onResizeDown}
431
+ title="Drag to change the length"
432
+ className={cn(
433
+ "absolute inset-x-0 bottom-0 flex h-4 cursor-ns-resize items-center justify-center",
434
+ muted,
435
+ )}
436
+ >
437
+ <DotsSixVertical size={14} weight="duotone" className="rotate-90" />
438
+ </div>
439
+ )}
440
+ </div>
441
+ )
442
+ }
443
+
444
+ function Row({
445
+ session,
446
+ sub,
447
+ now,
448
+ alert,
449
+ }: {
450
+ session: SessionView
451
+ sub: boolean
452
+ now: number
453
+ alert: boolean
454
+ }) {
455
+ const waiting = Boolean(session.permission)
456
+ const muted = alert ? "text-warning-foreground/70" : "text-muted-foreground"
457
+
458
+ return (
459
+ <div
460
+ data-row
461
+ className={cn("grid grid-cols-[8px_1fr_auto] items-start gap-2.5 px-4 py-1.5", sub && "pl-9")}
462
+ >
463
+ <span
464
+ className={cn(
465
+ "mt-1 size-[7px] shrink-0 rounded-full",
466
+ waiting || session.status === "retry" ? "bg-warning" : "bg-success",
467
+ )}
468
+ />
469
+ <div className="min-w-0">
470
+ <div className={cn("truncate text-[12.5px]", sub && "text-xs", sub && muted)}>
471
+ {!sub && session.agent ? <span className={cn("mr-1.5", muted)}>{session.agent}</span> : null}
472
+ {!sub && session.phase && session.phase !== "idle" ? (
473
+ <span className={cn("mr-1.5 text-[11px] uppercase", muted)}>{session.phase}</span>
474
+ ) : null}
475
+ {label(session)}
476
+ {session.tool ? <span className={cn("ml-1.5", muted)}>{session.tool}</span> : null}
477
+ </div>
478
+ {session.stream ? (
479
+ <div className={cn("truncate text-[11.5px]", muted)}>{session.stream}</div>
480
+ ) : null}
481
+ </div>
482
+ <span className={cn("text-[11.5px] tabular-nums", muted)}>
483
+ {elapsed(now - (sub ? session.updated : session.created))}
484
+ </span>
485
+ </div>
486
+ )
487
+ }