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/LICENSE +21 -0
- package/README.md +185 -2
- package/dist/gui-experimental.cjs +21 -0
- package/dist/island/index.html +24 -0
- package/package.json +68 -4
- package/src/env.d.ts +1 -0
- package/src/gui.tsx +81 -0
- package/src/index.ts +11 -0
- package/src/island/app/components/island.tsx +487 -0
- package/src/island/app/components/ui/button.tsx +63 -0
- package/src/island/app/components/ui/toggle-group.tsx +83 -0
- package/src/island/app/components/ui/toggle.tsx +44 -0
- package/src/island/app/global.d.ts +22 -0
- package/src/island/app/index.html +15 -0
- package/src/island/app/island.css +101 -0
- package/src/island/app/lib/utils.ts +6 -0
- package/src/island/app/main.tsx +10 -0
- package/src/island/app/tsconfig.json +20 -0
- package/src/island/preload.cjs +20 -0
- package/src/model.ts +416 -0
- package/src/state.ts +59 -0
- package/src/window.ts +262 -0
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-island",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|