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