OpenCode Integration commited on
Commit
ceeb8d2
·
1 Parent(s): b1069c6

revert: remove custom frontend SPA, restore opencode's native web UI

Browse files
DELIVERABLES.md DELETED
@@ -1,41 +0,0 @@
1
- # OpenCode-Serve — Final Working State
2
-
3
- ## Architecture
4
- ```
5
- HF Load Balancer (HTTP/2)
6
- ↓
7
- uvicorn gateway :7860 (HF-exposed)
8
- ├─ /terminal/* → ttyd :7681 (embedded bash)
9
- ├─ / → redirect HTML (auto-load latest session)
10
- ├─ /server/<base> (bare) → redirect HTML
11
- └─ /* → opencode serve :4096 (chat API + SPA)
12
- ```
13
-
14
- ## Key Files
15
- - `Dockerfile` — debian:bookworm-slim + python3 + opencode + ttyd + uvicorn/httpx
16
- - `entrypoint.sh` — starts ttyd, opencode serve, uvicorn gateway
17
- - `backend/app/main.py` — FastAPI gateway with proxy + redirect logic
18
- - `cleaner.py` — SQLite self-healing daemon (from opencode-cli)
19
-
20
- ## Routes Verified
21
- | Route | Behavior | Status |
22
- |-------|----------|--------|
23
- | `GET /` | Redirect HTML → auto-load latest session | ✅ |
24
- | `GET /server/<base>` | Redirect HTML → auto-load latest session | ✅ |
25
- | `GET /server/<base>/session/<id>` | Chat UI (opencode SPA) | ✅ |
26
- | `GET /api/session` | Proxy → opencode (50 sessions) | ✅ |
27
- | `GET /global/health` | Proxy → opencode | ✅ |
28
- | `GET /terminal/` | Proxy → ttyd (embedded bash) | ✅ |
29
- | `GET /_healthz` | Gateway health check | ✅ |
30
-
31
- ## URLs
32
- - **Space**: https://jishnupg-opencode-serve.hf.space
33
- - **Chat**: `https://jishnupg-opencode-serve.hf.space/server/<base64(SPACE_URL)>/session/<session_id>`
34
- - **Terminal**: https://jishnupg-opencode-serve.hf.space/terminal/
35
- - **API**: https://jishnupg-opencode-serve.hf.space/api/session
36
-
37
- ## Key Learnings
38
- 1. **Previous proxy failures (HTTP/2 INTERNAL_ERROR)**: Caused by using `StreamingResponse` for ALL responses. Fixed by buffering normal responses and only streaming SSE/chunked.
39
- 2. **ttyd `--base-path`**: Doesn't work as expected. Fixed by stripping `/terminal` prefix in proxy.
40
- 3. **`PYTHONPATH=/app`**: Required because entrypoint `cd`s to `/projects/default`.
41
- 4. **HF only exposes one port (7860)**: Must proxy everything through a single gateway.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile CHANGED
@@ -33,7 +33,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
33
  RUN mkdir -p /projects/default
34
 
35
  COPY backend/ /app/backend/
36
- COPY frontend/ /app/frontend/
37
  COPY cleaner.py /cleaner.py
38
  COPY entrypoint.sh /entrypoint.sh
39
  RUN chmod +x /entrypoint.sh
 
33
  RUN mkdir -p /projects/default
34
 
35
  COPY backend/ /app/backend/
 
36
  COPY cleaner.py /cleaner.py
37
  COPY entrypoint.sh /entrypoint.sh
38
  RUN chmod +x /entrypoint.sh
backend/app/main.py CHANGED
@@ -1,59 +1,80 @@
1
- """OpenCode-Serve gateway.
2
 
3
  Routes:
4
- /assets/* -> frontend static files
5
- /terminal/* -> ttyd on :7681 (internal)
6
- / -> frontend SPA index.html
7
- /favicon-*.png, /apple-touch-*.png, /site.webmanifest -> frontend assets
8
- /* -> opencode serve on :4096 (passthrough)
9
  """
10
  from __future__ import annotations
11
 
12
  import asyncio
13
- import mimetypes
14
  import os
15
- from pathlib import Path
16
  from urllib.parse import urlencode
17
 
18
  import httpx
19
  from fastapi import FastAPI, Request, WebSocket
20
- from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
21
 
22
- OC_HOST = "127.0.0.1"
23
- OC_PORT = int(os.environ.get("OPENCODE_PORT", "4096"))
24
  TTYD_PORT = int(os.environ.get("TTYD_PORT", "7681"))
25
 
26
- FRONTEND_DIR = Path("/app/frontend")
27
-
28
  app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
29
 
30
 
31
- def _read_frontend(name: str) -> bytes | None:
32
- p = FRONTEND_DIR / name
33
- if p.exists() and p.is_file():
34
- return p.read_bytes()
35
- return None
36
-
37
-
38
- _INDEX_HTML = _read_frontend("index.html") or b"<h1>frontend not found</h1>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
 
41
  def _is_terminal(path: str) -> bool:
42
  return path == "/terminal" or path.startswith("/terminal/")
43
 
44
 
45
- def _is_frontend_asset(path: str) -> bool:
46
- if path.startswith("assets/"):
47
- return True
48
- if path in ("favicon-96x96-v3.png", "favicon-v3.svg", "favicon-v3.ico",
49
- "apple-touch-icon-v3.png", "site.webmanifest", "social-share.png"):
50
- return True
51
- return False
52
-
53
-
54
  def _needs_redirect(path: str) -> bool:
55
  if not path or path in ("/", ""):
56
  return True
 
 
 
 
 
57
  return False
58
 
59
 
@@ -70,42 +91,43 @@ async def _forward(request: Request, port: int, path: str):
70
  headers[k] = v
71
 
72
  body = await request.body()
 
73
 
74
- client = httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=10.0), follow_redirects=False)
75
- try:
76
- up = await client.send(
77
- client.build_request(method=request.method, url=url, headers=headers, content=body if body else None),
78
- stream=True,
79
- )
80
- except (httpx.ConnectError, httpx.ConnectTimeout, httpx.HTTPError) as exc:
81
- await client.aclose()
82
- return Response(status_code=502, content=f"upstream {port} unreachable: {exc}".encode())
83
-
84
- resp_headers = {}
85
- for k, v in up.headers.items():
86
- kl = k.lower()
87
- if kl in ("content-length", "content-encoding", "transfer-encoding", "connection"):
88
- continue
89
- resp_headers[k] = v
90
-
91
- ct = up.headers.get("content-type", "application/octet-stream")
92
-
93
- async def relay():
94
  try:
95
- async for chunk in up.aiter_bytes():
96
- if chunk:
97
- yield chunk
98
- finally:
99
- try:
100
- await up.aclose()
101
- except Exception:
102
- pass
103
- try:
104
- await client.aclose()
105
- except Exception:
106
- pass
107
-
108
- return StreamingResponse(relay(), status_code=up.status_code, headers=resp_headers, media_type=ct)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
 
111
  @app.get("/_healthz", include_in_schema=False)
@@ -115,34 +137,19 @@ async def healthz():
115
 
116
  @app.api_route(
117
  "/{path:path}",
118
- methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
119
  include_in_schema=False,
120
  )
121
  async def main_route(request: Request, path: str):
122
  full = "/" + path
123
-
124
- # Terminal proxy
125
  if _is_terminal(full):
126
- fwd_path = path[len("terminal"):]
 
127
  if not fwd_path or fwd_path[0] != "/":
128
  fwd_path = "/" + fwd_path
129
  return await _forward(request, TTYD_PORT, fwd_path)
130
-
131
- # Frontend assets
132
- if _is_frontend_asset(path):
133
- name = path.split("/", 1)[-1] if "/" in path else path
134
- data = _read_frontend(name)
135
- if data is not None:
136
- ct, _ = mimetypes.guess_type(name)
137
- return Response(content=data, media_type=ct or "application/octet-stream")
138
-
139
- # Frontend SPA: serve index.html for / or /server/ paths
140
- if _needs_redirect(full) or (path.startswith("server") and not any(
141
- seg in path.split("/") for seg in ("session", "message", "event", "prompt")
142
- )):
143
- return Response(content=_INDEX_HTML, media_type="text/html")
144
-
145
- # Everything else -> opencode upstream
146
  return await _forward(request, OC_PORT, path)
147
 
148
 
@@ -150,16 +157,13 @@ async def main_route(request: Request, path: str):
150
  async def ws_route(websocket: WebSocket, path: str):
151
  full = "/" + path
152
  target_port = TTYD_PORT if _is_terminal(full) else OC_PORT
153
- target_url = f"ws://{OC_HOST}:{target_port}/{path}"
154
  if websocket.query_params:
155
  target_url += "?" + urlencode(websocket.query_params)
156
-
157
  import websockets as ws_lib
158
-
159
  await websocket.accept()
160
  try:
161
- async with ws_lib.connect(target_url, max_size=32 * 1024 * 1024) as upstream:
162
-
163
  async def c2s():
164
  try:
165
  while True:
@@ -172,7 +176,6 @@ async def ws_route(websocket: WebSocket, path: str):
172
  await upstream.send(msg["bytes"])
173
  except Exception:
174
  return
175
-
176
  async def s2c():
177
  try:
178
  async for raw in upstream:
@@ -182,7 +185,6 @@ async def ws_route(websocket: WebSocket, path: str):
182
  await websocket.send_bytes(raw)
183
  except Exception:
184
  return
185
-
186
  await asyncio.gather(c2s(), s2c())
187
  except Exception:
188
  try:
 
1
+ """Terminal-on-top-of-opencode gateway.
2
 
3
  Routes:
4
+ /terminal/* -> ttyd on :7681 (internal, terminal)
5
+ / -> redirect HTML (auto-load latest session)
6
+ /server/<base> (bare, no session id) -> redirect HTML
7
+ /* -> opencode serve on :4096 (passthrough)
 
8
  """
9
  from __future__ import annotations
10
 
11
  import asyncio
 
12
  import os
 
13
  from urllib.parse import urlencode
14
 
15
  import httpx
16
  from fastapi import FastAPI, Request, WebSocket
17
+ from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Response
18
 
19
+ OC_HOST = "127.0.0.1"
20
+ OC_PORT = int(os.environ.get("OPENCODE_PORT", "4096"))
21
  TTYD_PORT = int(os.environ.get("TTYD_PORT", "7681"))
22
 
 
 
23
  app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
24
 
25
 
26
+ _REDIRECT_HTML = r"""<!DOCTYPE html>
27
+ <html lang="en">
28
+ <head>
29
+ <meta charset="UTF-8">
30
+ <title>OpenCode</title>
31
+ <style>
32
+ *{margin:0;padding:0;box-sizing:border-box}
33
+ body{background:#0d1117;color:#c9d1d9;font-family:'JetBrains Mono',Menlo,monospace;
34
+ display:flex;align-items:center;justify-content:center;height:100vh}
35
+ .box{background:#161b22;padding:28px 36px;border:1px solid #30363d;border-radius:8px;
36
+ max-width:560px;text-align:center}
37
+ h2{color:#58a6ff;font-size:13px;letter-spacing:.06em;text-transform:uppercase;margin:0 0 14px}
38
+ pre{color:#c9d1d9;font-size:12px;line-height:1.6;text-align:left;
39
+ white-space:pre-wrap;word-break:break-all;background:transparent;margin:0}
40
+ a{color:#22c55e;text-decoration:none}a:hover{text-decoration:underline}
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <div class="box">
45
+ <h2>OpenCode</h2>
46
+ <pre id="s">loading&hellip;</pre>
47
+ </div>
48
+ <script>
49
+ (async function(){
50
+ var s=document.getElementById("s"),h=location.protocol+"//"+location.host;
51
+ try{
52
+ var r=await fetch("/api/session");
53
+ if(!r.ok)throw new Error(r.status+" "+r.statusText);
54
+ var d=await r.json(),arr=(d&&d.data)||[];
55
+ if(!arr.length){s.innerHTML='no sessions yet &mdash; <a href="/server/">open dashboard</a>';return}
56
+ var sid=arr[0].id,dest="/server/"+btoa(h)+"/session/"+encodeURIComponent(sid);
57
+ s.innerHTML='<a href="'+dest+'">opening session&hellip;</a>';
58
+ location.replace(dest);
59
+ }catch(e){s.innerHTML="error: "+e.message+"<br>try <a href='/server/'>/server/</a>"}
60
+ })();
61
+ </script>
62
+ </body>
63
+ </html>"""
64
 
65
 
66
  def _is_terminal(path: str) -> bool:
67
  return path == "/terminal" or path.startswith("/terminal/")
68
 
69
 
 
 
 
 
 
 
 
 
 
70
  def _needs_redirect(path: str) -> bool:
71
  if not path or path in ("/", ""):
72
  return True
73
+ if path.startswith("/server"):
74
+ parts = [p for p in path.split("/") if p]
75
+ # /server, /server/, /server/<base> -- no session id present
76
+ if len(parts) < 4:
77
+ return True
78
  return False
79
 
80
 
 
91
  headers[k] = v
92
 
93
  body = await request.body()
94
+ timeout = httpx.Timeout(600.0, connect=10.0)
95
 
96
+ async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  try:
98
+ up = await client.request(
99
+ method=request.method,
100
+ url=url,
101
+ headers=headers,
102
+ content=body if body else None,
103
+ )
104
+ except (httpx.ConnectError, httpx.ConnectTimeout, httpx.HTTPError) as exc:
105
+ return Response(status_code=502, content=f"upstream {port} unreachable: {exc}".encode())
106
+
107
+ # Build response headers
108
+ resp_headers = {}
109
+ for k, v in up.headers.items():
110
+ kl = k.lower()
111
+ if kl in ("content-length", "content-encoding", "transfer-encoding", "connection"):
112
+ continue
113
+ resp_headers[k] = v
114
+
115
+ ct = up.headers.get("content-type", "application/octet-stream")
116
+
117
+ # For SSE / streaming: relay raw bytes
118
+ if "text/event-stream" in ct or "chunked" in up.headers.get("transfer-encoding", ""):
119
+ async def relay():
120
+ try:
121
+ async for chunk in up.aiter_bytes():
122
+ yield chunk
123
+ finally:
124
+ await up.aclose()
125
+ return StreamingResponse(relay(), status_code=up.status_code, headers=resp_headers, media_type=ct)
126
+
127
+ # Buffer entire response (most responses are small JSON or HTML)
128
+ content = up.content
129
+ await up.aclose()
130
+ return Response(status_code=up.status_code, content=content, headers=resp_headers, media_type=ct)
131
 
132
 
133
  @app.get("/_healthz", include_in_schema=False)
 
137
 
138
  @app.api_route(
139
  "/{path:path}",
140
+ methods=["GET","POST","PUT","DELETE","PATCH","HEAD","OPTIONS"],
141
  include_in_schema=False,
142
  )
143
  async def main_route(request: Request, path: str):
144
  full = "/" + path
 
 
145
  if _is_terminal(full):
146
+ # Strip /terminal prefix when forwarding to ttyd
147
+ fwd_path = path[len("terminal"):] # "terminal/foo" -> "/foo"
148
  if not fwd_path or fwd_path[0] != "/":
149
  fwd_path = "/" + fwd_path
150
  return await _forward(request, TTYD_PORT, fwd_path)
151
+ if _needs_redirect(full):
152
+ return HTMLResponse(content=_REDIRECT_HTML, status_code=200)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  return await _forward(request, OC_PORT, path)
154
 
155
 
 
157
  async def ws_route(websocket: WebSocket, path: str):
158
  full = "/" + path
159
  target_port = TTYD_PORT if _is_terminal(full) else OC_PORT
160
+ target_url = f"ws://{OC_HOST}:{target_port}/{path}"
161
  if websocket.query_params:
162
  target_url += "?" + urlencode(websocket.query_params)
 
163
  import websockets as ws_lib
 
164
  await websocket.accept()
165
  try:
166
+ async with ws_lib.connect(target_url, max_size=32*1024*1024) as upstream:
 
167
  async def c2s():
168
  try:
169
  while True:
 
176
  await upstream.send(msg["bytes"])
177
  except Exception:
178
  return
 
179
  async def s2c():
180
  try:
181
  async for raw in upstream:
 
185
  await websocket.send_bytes(raw)
186
  except Exception:
187
  return
 
188
  await asyncio.gather(c2s(), s2c())
189
  except Exception:
190
  try:
frontend/chat.js DELETED
@@ -1,20 +0,0 @@
1
- // OpenCode-Serve SPA
2
- // Provides a ChatGPT-style sidebar with full session management backed by
3
- // opencode's existing /api/session and /api/session/{id}/message APIs.
4
- //
5
- // Architecture:
6
- // - Vanilla ES modules, no build step
7
- // - Single-page app that consumes opencode's HTTP API verbatim
8
- // - localStorage holds UI state only: pinnedIds, archivedIds, drawerCollapsed,
9
- // lastSessionId, draftMessage. Authoritative session data always comes from
10
- // /api/session so that nothing is duplicated.
11
- // - SSE on /api/event streams real-time updates for sessions and messages.
12
- // - Title auto-generation is sent through opencode's existing session APIs.
13
- //
14
- // All UI state is intentionally local; opencode remains the source of truth
15
- // for session lifecycle.
16
-
17
- import { mount } from "./components.js";
18
- import { openOverlay } from "./ui.js";
19
-
20
- mount();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/components.js DELETED
@@ -1,587 +0,0 @@
1
- /* components.js — sidebar + chat shell. Everything lives here. */
2
-
3
- import {
4
- listSessions, getSession, createSession, deleteSessions, renameSession,
5
- getMessages, sendMessage, sessionStatus, abortSession,
6
- listProviders, listAgents, openEventStream, partText,
7
- } from "./http.js";
8
- import {
9
- h, clear, debounce, formatTimeAgo, groupByDate, setOverlay, openOverlay,
10
- promptInput, confirmAction, snackbar,
11
- } from "./ui.js";
12
- import {
13
- getPinnedIds, getArchivedIds, getLastSessionId, setLastSessionId,
14
- getDrawerCollapsed, setDrawerCollapsed,
15
- getSelectedModel, setSelectedModel, getSelectedAgent, setSelectedAgent,
16
- getDraft, setDraft, togglePin, toggleArchive,
17
- refreshSessions, getFilteredSessions, subscribe,
18
- upsertSessionFromEvent, updateSessionFromEvent,
19
- } from "./store.js";
20
-
21
- // ─── state ───
22
- let _currentSessionId = null;
23
- let _abortCtrl = null;
24
- let _sidebarOpen = window.innerWidth > 860;
25
- let _search = "";
26
- let _showArchived = false;
27
- let _sending = false;
28
- let _agents = [];
29
- let _providers = [];
30
- let _currentAgent = null;
31
- let _currentModel = null;
32
- let _sse = null;
33
-
34
- // ─── root DOM references (created in mount) ───
35
- let _root;
36
- let _sidebar;
37
- let _chat;
38
- let _msgContainer;
39
- let _inputBox;
40
- let _titleEl;
41
-
42
- // ─── sidebar ───
43
- function renderSidebar() {
44
- clear(_sidebar);
45
- const collapsed = getDrawerCollapsed();
46
- _sidebar.style.width = collapsed ? "0px" : "";
47
- _sidebar.style.minWidth = collapsed ? "0px" : "";
48
- _sidebar.style.overflow = collapsed ? "hidden" : "";
49
- _sidebar.style.padding = collapsed ? "0" : "";
50
-
51
- if (collapsed) {
52
- const btn = h("button", { title: "Open sidebar", style: { padding: "10px", fontSize: "18px", color: "var(--fg-2)" }, text: "\u2630" });
53
- btn.addEventListener("click", () => { setDrawerCollapsed(false); renderSidebar(); });
54
- _sidebar.appendChild(btn);
55
- return;
56
- }
57
-
58
- // Header
59
- const header = h("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "8px 12px", borderBottom: "1px solid var(--border)" } });
60
- const collapseBtn = h("button", { title: "Collapse sidebar", style: { padding: "6px 10px", color: "var(--fg-3)", fontSize: "14px" }, text: "\u25c0" });
61
- collapseBtn.addEventListener("click", () => { setDrawerCollapsed(true); renderSidebar(); });
62
- const newBtn = h("button", { style: { padding: "6px 14px", background: "var(--accent)", color: "#0a0a0a", borderRadius: "6px", fontWeight: "600", fontSize: "13px" }, text: "New Chat" });
63
- newBtn.addEventListener("click", newChat);
64
- header.append(collapseBtn, h("div", { style: { flex: "1" } }), newBtn);
65
- _sidebar.appendChild(header);
66
-
67
- // Search
68
- const searchWrap = h("div", { style: { padding: "10px 12px 6px" } });
69
- const searchInput = h("input", {
70
- placeholder: "Search chats...",
71
- value: _search,
72
- style: { width: "100%", padding: "8px 10px", background: "var(--bg-2)", border: "1px solid var(--border)", borderRadius: "6px", fontSize: "13px" },
73
- });
74
- searchInput.addEventListener("input", (e) => { _search = e.target.value; renderSessionList(); });
75
- searchWrap.appendChild(searchInput);
76
- _sidebar.appendChild(searchWrap);
77
-
78
- // Toggle archived
79
- const toggleRow = h("div", { style: { padding: "4px 12px", display: "flex", gap: "8px" } });
80
- const archiveBtn = h("button", {
81
- style: { padding: "4px 10px", fontSize: "12px", borderRadius: "4px", color: _showArchived ? "var(--accent)" : "var(--fg-3)", border: _showArchived ? "1px solid var(--accent)" : "1px solid var(--border)" },
82
- text: "Archived",
83
- });
84
- archiveBtn.addEventListener("click", () => { _showArchived = !_showArchived; renderSidebar(); });
85
- toggleRow.appendChild(archiveBtn);
86
- _sidebar.appendChild(toggleRow);
87
-
88
- // Session list
89
- const listWrap = h("div", { style: { flex: "1", overflow: "auto", padding: "0 6px" } });
90
- _sidebar.appendChild(listWrap);
91
-
92
- // Re-render just the list part
93
- _renderSessionListInto(listWrap);
94
- }
95
-
96
- function renderSessionList() {
97
- // Find the list container and re-render
98
- if (!_sidebar) return;
99
- const listWrap = _sidebar.querySelector("div:last-child");
100
- if (!listWrap) return;
101
- _renderSessionListInto(listWrap);
102
- }
103
-
104
- function _renderSessionListInto(container) {
105
- clear(container);
106
- const sessions = getFilteredSessions({ search: _search, showArchived: _showArchived });
107
- if (!sessions.length) {
108
- container.appendChild(h("div", { style: { padding: "24px 12px", textAlign: "center", color: "var(--fg-3)", fontSize: "13px" }, text: _search ? "No matches" : "No chats yet" }));
109
- return;
110
- }
111
-
112
- const groups = groupByDate(sessions);
113
- for (const [label, items] of Object.entries(groups)) {
114
- if (!items.length) continue;
115
- const section = h("div", { style: { marginBottom: "8px" } });
116
- const heading = h("div", { style: { padding: "8px 12px 4px", fontSize: "11px", fontWeight: "600", color: "var(--fg-3)", textTransform: "uppercase", letterSpacing: "0.05em" }, text: label });
117
- section.appendChild(heading);
118
- for (const s of items) {
119
- section.appendChild(renderSessionItem(s));
120
- }
121
- container.appendChild(section);
122
- }
123
- }
124
-
125
- function renderSessionItem(s) {
126
- const pinned = getPinnedIds().has(s.id);
127
- const active = s.id === _currentSessionId;
128
- const archived = getArchivedIds().has(s.id);
129
- const preview = s.__preview || s.title || "New chat";
130
- const modelLabel = (s.model && s.model.id) ? s.model.id.split("/").pop() : "";
131
-
132
- const row = h("div", {
133
- style: {
134
- padding: "10px 12px",
135
- borderRadius: "6px",
136
- cursor: "pointer",
137
- background: active ? "var(--bg-3)" : "transparent",
138
- marginBottom: "2px",
139
- position: "relative",
140
- },
141
- "data-sid": s.id,
142
- });
143
- row.addEventListener("click", () => openSession(s.id));
144
-
145
- // Hover effects
146
- row.addEventListener("mouseenter", () => { if (!active) row.style.background = "var(--bg-2)"; });
147
- row.addEventListener("mouseleave", () => { if (!active) row.style.background = "transparent"; });
148
-
149
- const titleRow = h("div", { style: { display: "flex", alignItems: "center", gap: "6px" } });
150
- if (pinned) titleRow.appendChild(h("span", { style: { fontSize: "11px", color: "var(--accent)" }, text: "\u{1F4CC}" }));
151
- titleRow.appendChild(h("span", {
152
- style: { flex: "1", fontSize: "13px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontWeight: active ? "600" : "400" },
153
- text: preview,
154
- }));
155
-
156
- // Actions menu button (on hover)
157
- const actionsWrap = h("div", { style: { display: "flex", gap: "2px", opacity: "0", transition: "opacity .12s" } });
158
- row.addEventListener("mouseenter", () => { actionsWrap.style.opacity = "1"; });
159
- row.addEventListener("mouseleave", () => { actionsWrap.style.opacity = "0"; });
160
-
161
- const pinBtn = h("button", { title: pinned ? "Unpin" : "Pin", style: { padding: "2px 5px", fontSize: "12px", color: "var(--fg-3)" }, text: pinned ? "\u{1F4CC}" : "\u{1F4CE}" });
162
- pinBtn.addEventListener("click", (e) => { e.stopPropagation(); togglePin(s.id); renderSessionList(); });
163
- const renameBtn = h("button", { title: "Rename", style: { padding: "2px 5px", fontSize: "12px", color: "var(--fg-3)" }, text: "\u270F\uFE0F" });
164
- renameBtn.addEventListener("click", (e) => { e.stopPropagation(); renameSessionAction(s); });
165
- const archiveBtn2 = h("button", { title: archived ? "Restore" : "Archive", style: { padding: "2px 5px", fontSize: "12px", color: "var(--fg-3)" }, text: archived ? "\u21A9\uFE0F" : "\u{1F4E6}" });
166
- archiveBtn2.addEventListener("click", (e) => { e.stopPropagation(); toggleArchive(s.id); renderSessionList(); });
167
- const deleteBtn = h("button", { title: "Delete", style: { padding: "2px 5px", fontSize: "12px", color: "var(--danger)" }, text: "\u{1F5D1}\uFE0F" });
168
- deleteBtn.addEventListener("click", (e) => { e.stopPropagation(); deleteSessionAction(s); });
169
- actionsWrap.append(pinBtn, renameBtn, archiveBtn2, deleteBtn);
170
-
171
- titleRow.appendChild(actionsWrap);
172
- row.appendChild(titleRow);
173
-
174
- // Meta line
175
- const meta = h("div", { style: { display: "flex", alignItems: "center", gap: "6px", marginTop: "3px", fontSize: "11px", color: "var(--fg-3)" } });
176
- meta.appendChild(h("span", { text: formatTimeAgo(s.time && s.time.updated) }));
177
- if (modelLabel) meta.appendChild(h("span", { text: modelLabel, style: { padding: "1px 5px", background: "var(--bg-3)", borderRadius: "3px" } }));
178
- row.appendChild(meta);
179
-
180
- return row;
181
- }
182
-
183
- // ─── session actions ───
184
- async function newChat() {
185
- try {
186
- const s = await createSession();
187
- const id = s && s.id;
188
- if (id) {
189
- await openSession(id);
190
- renderSidebar();
191
- }
192
- } catch (e) {
193
- snackbar("Failed to create chat", "error");
194
- }
195
- }
196
-
197
- async function openSession(id) {
198
- if (_abortCtrl) { _abortCtrl.abort(); _abortCtrl = null; }
199
- _sending = false;
200
- _currentSessionId = id;
201
- setLastSessionId(id);
202
- renderSidebar();
203
- renderChat();
204
- // Load messages
205
- await loadMessages(id);
206
- }
207
-
208
- async function loadMessages(id) {
209
- if (!_msgContainer) return;
210
- clear(_msgContainer);
211
- _msgContainer.appendChild(h("div", { class: "skeleton", style: { height: "60px", marginBottom: "8px" } }));
212
- _msgContainer.appendChild(h("div", { class: "skeleton", style: { height: "40px", width: "70%", marginBottom: "8px" } }));
213
-
214
- try {
215
- const { messages } = await getMessages(id);
216
- clear(_msgContainer);
217
- if (!messages.length) {
218
- _msgContainer.appendChild(h("div", {
219
- style: { padding: "40px 20px", textAlign: "center", color: "var(--fg-3)" },
220
- html: '<div style="font-size:36px;margin-bottom:12px">&#x1F4AC;</div><div style="font-size:14px">Send a message to start the conversation</div>',
221
- }));
222
- return;
223
- }
224
- for (const m of messages) {
225
- renderMessage(m);
226
- }
227
- _msgContainer.scrollTop = _msgContainer.scrollHeight;
228
- } catch (e) {
229
- clear(_msgContainer);
230
- _msgContainer.appendChild(h("div", { style: { padding: "40px", textAlign: "center", color: "var(--danger)" }, text: "Failed to load messages" }));
231
- }
232
- }
233
-
234
- function renderMessage(m) {
235
- const role = (m.role || "user").toLowerCase();
236
- const isUser = role === "user";
237
- const wrap = h("div", {
238
- style: {
239
- padding: "16px 20px",
240
- borderBottom: "1px solid var(--border)",
241
- background: isUser ? "transparent" : "var(--bg-1)",
242
- maxWidth: "100%",
243
- },
244
- });
245
- const label = h("div", {
246
- style: { display: "flex", alignItems: "center", gap: "8px", marginBottom: "8px", fontSize: "12px", fontWeight: "600", color: isUser ? "var(--fg-2)" : "var(--accent)" },
247
- text: isUser ? "You" : "Assistant",
248
- });
249
- wrap.appendChild(label);
250
-
251
- const content = h("div", {
252
- style: { fontSize: "14px", lineHeight: "1.7", whiteSpace: "pre-wrap", wordBreak: "break-word" },
253
- });
254
-
255
- // Render parts
256
- if (m.parts && m.parts.length) {
257
- for (const p of m.parts) {
258
- if (p.type === "text") {
259
- content.appendChild(h("div", { text: p.text || "" }));
260
- } else if (p.type === "reasoning") {
261
- // Collapsible reasoning
262
- const details = h("details", { style: { marginBottom: "8px", color: "var(--fg-3)", fontSize: "12px" } });
263
- details.appendChild(h("summary", { text: "Reasoning", style: { cursor: "pointer", marginBottom: "4px" } }));
264
- details.appendChild(h("div", { text: p.text || "", style: { whiteSpace: "pre-wrap", padding: "8px", background: "var(--bg-2)", borderRadius: "4px" } }));
265
- content.appendChild(details);
266
- } else if (p.type === "tool-invocation") {
267
- const toolWrap = h("div", { style: { margin: "8px 0", padding: "8px 12px", background: "var(--bg-2)", borderRadius: "6px", border: "1px solid var(--border)", fontSize: "12px" } });
268
- toolWrap.appendChild(h("div", { style: { fontWeight: "600", marginBottom: "4px", color: "var(--fg-2)" }, text: p.toolInvocation && p.toolInvocation.toolName ? p.toolInvocation.toolName : "Tool" }));
269
- if (p.toolInvocation && p.toolInvocation.state === "result") {
270
- toolWrap.appendChild(h("div", { text: String((p.toolInvocation.result || "").slice(0, 500)), style: { color: "var(--fg-3)" } }));
271
- }
272
- content.appendChild(toolWrap);
273
- }
274
- }
275
- } else if (m.content) {
276
- // Fallback: plain content string
277
- if (typeof m.content === "string") {
278
- content.appendChild(h("div", { text: m.content }));
279
- } else if (Array.isArray(m.content)) {
280
- for (const part of m.content) {
281
- if (typeof part === "string") content.appendChild(h("div", { text: part }));
282
- else if (part && part.text) content.appendChild(h("div", { text: part.text }));
283
- }
284
- }
285
- }
286
- wrap.appendChild(content);
287
- _msgContainer.appendChild(wrap);
288
- }
289
-
290
- // ─── chat view ───
291
- function renderChat() {
292
- clear(_chat);
293
- if (!_currentSessionId) {
294
- _chat.appendChild(h("div", {
295
- style: { display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--fg-3)" },
296
- html: '<div style="text-align:center"><div style="font-size:48px;margin-bottom:16px">&#x1F44B;</div><div style="font-size:16px;margin-bottom:8px">Welcome to OpenCode</div><div style="font-size:13px">Select a chat or start a new one</div></div>',
297
- }));
298
- return;
299
- }
300
-
301
- // Header
302
- const hdr = h("div", { style: { display: "flex", alignItems: "center", gap: "10px", padding: "10px 16px", borderBottom: "1px solid var(--border)", background: "var(--bg-1)" } });
303
- const menuBtn = h("button", { title: "Toggle sidebar", style: { padding: "6px 10px", color: "var(--fg-2)", fontSize: "16px" }, text: "\u2630" });
304
- menuBtn.addEventListener("click", () => { _sidebarOpen = !_sidebarOpen; _sidebar.style.display = _sidebarOpen ? "" : "none"; });
305
- _titleEl = h("span", { style: { flex: "1", fontSize: "14px", fontWeight: "600", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, text: "Loading..." });
306
- const statusEl = h("span", { style: { fontSize: "11px", color: "var(--fg-3)" } });
307
- const abortBtn = h("button", { title: "Stop generating", style: { padding: "4px 10px", borderRadius: "4px", background: "var(--danger)", color: "#fff", fontSize: "12px", display: "none" }, text: "Stop" });
308
- abortBtn.addEventListener("click", () => { if (_abortCtrl) _abortCtrl.abort(); });
309
- hdr.append(menuBtn, _titleEl, statusEl, abortBtn);
310
- _chat.appendChild(hdr);
311
-
312
- // Messages container
313
- _msgContainer = h("div", { style: { flex: "1", overflow: "auto" } });
314
- _chat.appendChild(_msgContainer);
315
-
316
- // Input area
317
- const inputArea = h("div", { style: { padding: "12px 16px", borderTop: "1px solid var(--border)", background: "var(--bg-1)" } });
318
-
319
- // Model/Agent selector row
320
- const selectorRow = h("div", { style: { display: "flex", gap: "8px", marginBottom: "8px", alignItems: "center" } });
321
- const agentSelect = h("select", { style: { padding: "4px 8px", background: "var(--bg-2)", border: "1px solid var(--border)", borderRadius: "4px", fontSize: "12px", color: "var(--fg-2)" } });
322
- agentSelect.appendChild(h("option", { value: "", text: "Agent: build" }));
323
- for (const a of _agents) {
324
- const opt = h("option", { value: a.id || a, text: a.id || a });
325
- if ((getSelectedAgent() || "build") === (a.id || a)) opt.selected = true;
326
- agentSelect.appendChild(opt);
327
- }
328
- agentSelect.addEventListener("change", () => setSelectedAgent(agentSelect.value));
329
- selectorRow.appendChild(agentSelect);
330
- inputArea.appendChild(selectorRow);
331
-
332
- // Text input
333
- const inputWrap = h("div", { style: { display: "flex", gap: "8px", alignItems: "flex-end" } });
334
- _inputBox = h("textarea", {
335
- placeholder: "Message...",
336
- rows: "1",
337
- style: { flex: "1", padding: "10px 14px", background: "var(--bg-2)", border: "1px solid var(--border)", borderRadius: "8px", fontSize: "14px", resize: "none", maxHeight: "180px", lineHeight: "1.5", minHeight: "42px" },
338
- });
339
-
340
- // Auto-resize textarea
341
- _inputBox.addEventListener("input", () => {
342
- _inputBox.style.height = "auto";
343
- _inputBox.style.height = Math.min(_inputBox.scrollHeight, 180) + "px";
344
- });
345
-
346
- // Ctrl+Enter to send
347
- _inputBox.addEventListener("keydown", (e) => {
348
- if (e.key === "Enter" && !e.shiftKey) {
349
- e.preventDefault();
350
- submitMessage();
351
- }
352
- });
353
-
354
- // Restore draft
355
- if (_currentSessionId) {
356
- const draft = getDraft(_currentSessionId);
357
- if (draft) _inputBox.value = draft;
358
- }
359
-
360
- // Save draft on input
361
- _inputBox.addEventListener("input", () => {
362
- if (_currentSessionId) setDraft(_currentSessionId, _inputBox.value);
363
- });
364
-
365
- const sendBtn = h("button", { title: "Send (Enter)", style: { padding: "10px 16px", background: "var(--accent)", color: "#0a0a0a", borderRadius: "8px", fontWeight: "600", fontSize: "14px", height: "42px" }, text: "Send" });
366
- sendBtn.addEventListener("click", submitMessage);
367
-
368
- inputWrap.append(_inputBox, sendBtn);
369
- inputArea.appendChild(inputWrap);
370
- _chat.appendChild(inputArea);
371
-
372
- // Load title from session data
373
- loadSessionTitle();
374
- }
375
-
376
- async function loadSessionTitle() {
377
- if (!_currentSessionId || !_titleEl) return;
378
- try {
379
- const s = await getSession(_currentSessionId);
380
- if (s) {
381
- _titleEl.textContent = s.title || "Untitled";
382
- // Update sidebar to reflect new title
383
- renderSessionList();
384
- }
385
- } catch {
386
- _titleEl.textContent = "Chat";
387
- }
388
- }
389
-
390
- async function submitMessage() {
391
- if (!_inputBox || !_currentSessionId || _sending) return;
392
- const text = _inputBox.value.trim();
393
- if (!text) return;
394
-
395
- _sending = true;
396
- const agent = getSelectedAgent() || "build";
397
- _inputBox.value = "";
398
- setDraft(_currentSessionId, "");
399
-
400
- // Append user message optimistically
401
- renderMessage({ role: "user", parts: [{ type: "text", text }], content: text });
402
- _msgContainer.scrollTop = _msgContainer.scrollHeight;
403
-
404
- // Create assistant placeholder
405
- const assistantEl = h("div", {
406
- style: { padding: "16px 20px", borderBottom: "1px solid var(--border)", background: "var(--bg-1)" },
407
- });
408
- assistantEl.appendChild(h("div", { style: { display: "flex", alignItems: "center", gap: "8px", marginBottom: "8px", fontSize: "12px", fontWeight: "600", color: "var(--accent)" }, text: "Assistant" }));
409
- const contentEl = h("div", { style: { fontSize: "14px", lineHeight: "1.7", whiteSpace: "pre-wrap", wordBreak: "break-word", color: "var(--fg-3)" }, text: "Thinking..." });
410
- assistantEl.appendChild(contentEl);
411
- _msgContainer.appendChild(assistantEl);
412
- _msgContainer.scrollTop = _msgContainer.scrollHeight;
413
-
414
- // Find abort button and show it
415
- const abortBtn = _chat.querySelector("button[title='Stop generating']");
416
- if (abortBtn) abortBtn.style.display = "";
417
-
418
- _abortCtrl = new AbortController();
419
-
420
- try {
421
- const parts = [{ type: "text", text }];
422
- const resp = await sendMessage(_currentSessionId, parts, { agent, signal: _abortCtrl.signal });
423
- if (resp && resp.ok && resp.data) {
424
- // Render full response from resp.data
425
- clear(contentEl);
426
- const data = resp.data;
427
- if (data.parts && data.parts.length) {
428
- for (const p of data.parts) {
429
- if (p.type === "text") contentEl.appendChild(h("div", { text: p.text || "" }));
430
- else if (p.type === "reasoning") {
431
- const d = h("details", { style: { marginBottom: "8px", color: "var(--fg-3)", fontSize: "12px" } });
432
- d.appendChild(h("summary", { text: "Reasoning", style: { cursor: "pointer", marginBottom: "4px" } }));
433
- d.appendChild(h("div", { text: p.text || "", style: { whiteSpace: "pre-wrap", padding: "8px", background: "var(--bg-2)", borderRadius: "4px" } }));
434
- contentEl.appendChild(d);
435
- }
436
- }
437
- } else if (data.content) {
438
- if (typeof data.content === "string") contentEl.textContent = data.content;
439
- else contentEl.textContent = JSON.stringify(data.content);
440
- }
441
- contentEl.style.color = "";
442
- } else if (resp && resp.data) {
443
- // Error or unexpected format
444
- clear(contentEl);
445
- contentEl.textContent = typeof resp.data === "string" ? resp.data : JSON.stringify(resp.data);
446
- contentEl.style.color = "var(--danger)";
447
- }
448
- } catch (e) {
449
- if (e.name === "AbortError") {
450
- clear(contentEl);
451
- contentEl.textContent = "(stopped)";
452
- contentEl.style.color = "var(--fg-3)";
453
- } else {
454
- clear(contentEl);
455
- contentEl.textContent = "Error: " + (e.message || "Unknown error");
456
- contentEl.style.color = "var(--danger)";
457
- }
458
- } finally {
459
- _sending = false;
460
- _abortCtrl = null;
461
- if (abortBtn) abortBtn.style.display = "none";
462
- _msgContainer.scrollTop = _msgContainer.scrollHeight;
463
- // Refresh title in sidebar
464
- renderSessionList();
465
- }
466
- }
467
-
468
- // ─── session actions ───
469
- async function renameSessionAction(s) {
470
- const title = await promptInput("Rename chat", "Enter new title", s.title || "");
471
- if (title && title !== s.title) {
472
- try {
473
- await renameSession(s.id, title);
474
- renderSessionList();
475
- if (s.id === _currentSessionId && _titleEl) _titleEl.textContent = title;
476
- snackbar("Renamed");
477
- } catch (e) {
478
- snackbar("Rename failed", "error");
479
- }
480
- }
481
- }
482
-
483
- async function deleteSessionAction(s) {
484
- const ok = await confirmAction("Delete chat?", `Delete "${s.title || "Untitled"}"? This cannot be undone.`, { okText: "Delete", danger: true });
485
- if (!ok) return;
486
- try {
487
- await deleteSessions([s.id]);
488
- snackbar("Deleted");
489
- if (_currentSessionId === s.id) {
490
- _currentSessionId = null;
491
- setLastSessionId(null);
492
- renderChat();
493
- }
494
- renderSessionList();
495
- } catch (e) {
496
- snackbar("Delete failed", "error");
497
- }
498
- }
499
-
500
- // ─── SSE ───
501
- function startSSE() {
502
- if (_sse) _sse.close();
503
- _sse = openEventStream((evt) => {
504
- const t = evt && evt.type;
505
- if (t === "session.updated" || t === "session.created") {
506
- updateSessionFromEvent(evt);
507
- // Reload title if it's current session
508
- if (_currentSessionId && evt.data && ((evt.data.id || evt.aggregateID) === _currentSessionId)) {
509
- loadSessionTitle();
510
- }
511
- } else if (t === "session.deleted") {
512
- const id = evt.data && evt.data.id;
513
- if (id) {
514
- const sessions = getFilteredSessions();
515
- // Just refresh
516
- refreshSessions().then(() => renderSessionList());
517
- }
518
- } else if (t === "message.updated" || t === "message.part.updated") {
519
- // Could stream partial messages, but for now we do full response
520
- }
521
- }, (err) => {
522
- // SSE error — will auto-retry inside openEventStream
523
- });
524
- }
525
-
526
- // ─── boot ───
527
- export async function mount() {
528
- _root = document.getElementById("root");
529
- clear(_root);
530
-
531
- // Add overlay and snackbar containers
532
- const overlayHost = h("div", { id: "overlay", style: { display: "none" } });
533
- const snackHost = h("div", { id: "snackbars", style: { position: "fixed", bottom: "16px", right: "16px", zIndex: "9999", display: "flex", flexDirection: "column", gap: "8px" } });
534
-
535
- // Layout
536
- const layout = h("div", { style: { display: "flex", height: "100vh", width: "100vw" } });
537
-
538
- // Sidebar
539
- _sidebar = h("div", {
540
- id: "sidebar",
541
- style: {
542
- width: "280px",
543
- minWidth: "280px",
544
- background: "var(--bg)",
545
- borderRight: "1px solid var(--border)",
546
- display: "flex",
547
- flexDirection: "column",
548
- overflow: "hidden",
549
- transition: "width .15s",
550
- },
551
- });
552
-
553
- // Main chat
554
- _chat = h("div", { id: "chat", style: { flex: "1", display: "flex", flexDirection: "column", minWidth: "0" } });
555
-
556
- layout.append(_sidebar, _chat);
557
- _root.append(layout, overlayHost, snackHost);
558
-
559
- // Load initial data
560
- await refreshSessions();
561
- renderSidebar();
562
- renderChat();
563
-
564
- // Open last session or show welcome
565
- const lastId = getLastSessionId();
566
- const sessions = getFilteredSessions();
567
- if (lastId && sessions.find(s => s.id === lastId)) {
568
- await openSession(lastId);
569
- } else if (sessions.length) {
570
- // Auto-open the most recent
571
- await openSession(sessions[0].id);
572
- }
573
-
574
- // Start SSE
575
- startSSE();
576
-
577
- // Periodic refresh (every 30s) to catch any missed updates
578
- setInterval(async () => {
579
- await refreshSessions();
580
- renderSessionList();
581
- }, 30000);
582
-
583
- // Subscribe to store changes
584
- subscribe(() => {
585
- renderSessionList();
586
- });
587
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/http.js DELETED
@@ -1,185 +0,0 @@
1
- /* http.js — thin fetch helpers around opencode's API */
2
-
3
- const H = {
4
- json: { "content-type": "application/json", accept: "application/json" },
5
- };
6
-
7
- export async function api(path, opts = {}) {
8
- const r = await fetch(path, {
9
- method: opts.method || "GET",
10
- headers: { ...H.json, ...(opts.headers || {}) },
11
- body: opts.body ? JSON.stringify(opts.body) : undefined,
12
- signal: opts.signal,
13
- });
14
- const text = await r.text();
15
- let data = null;
16
- if (text) {
17
- try {
18
- data = JSON.parse(text);
19
- } catch {
20
- data = text;
21
- }
22
- }
23
- return { ok: r.ok, status: r.status, data, headers: r.headers };
24
- }
25
-
26
- export async function listSessions(workspace) {
27
- const q = workspace ? `?directory=${encodeURIComponent(workspace)}` : "";
28
- const r = await api(`/api/session${q}`);
29
- return r.ok ? (r.data && r.data.data) || [] : [];
30
- }
31
-
32
- export async function getSession(id) {
33
- const r = await api(`/api/session/${encodeURIComponent(id)}`);
34
- return r.ok ? r.data : null;
35
- }
36
-
37
- export async function createSession(body = {}) {
38
- const r = await api("/api/session", { method: "POST", body });
39
- return r.ok ? r.data : null;
40
- }
41
-
42
- export async function deleteSessions(ids) {
43
- if (!ids || !ids.length) return { ok: true };
44
- const r = await api("/api/session/delete", { method: "POST", body: { ids } });
45
- return r;
46
- }
47
-
48
- export async function renameSession(id, title) {
49
- try {
50
- const r = await api(`/api/session/${id}`, {
51
- method: "PATCH",
52
- body: { title },
53
- });
54
- if (r.ok) return r.data;
55
- } catch {}
56
- // Fallback for older opencode builds that use body title
57
- const r = await api(`/api/session/${id}`, {
58
- method: "PATCH",
59
- body: { title },
60
- });
61
- return r.ok ? r.data : null;
62
- }
63
-
64
- export async function shareSession(id) {
65
- const r = await api(`/api/session/${id}/share`, { method: "POST" });
66
- return r.ok ? r.data : null;
67
- }
68
-
69
- export async function initSession(id) {
70
- const r = await api(`/api/session/${id}/init`, {
71
- method: "POST",
72
- body: { messageID: "" },
73
- });
74
- return r.ok ? r.data : null;
75
- }
76
-
77
- export async function getMessages(id, cursor, signal) {
78
- const q = new URLSearchParams();
79
- if (cursor) q.set("cursor", cursor);
80
- const qstr = q.toString();
81
- const r = await fetch(`/api/session/${encodeURIComponent(id)}/message${qstr ? "?" + qstr : ""}`, {
82
- headers: { accept: "application/json" },
83
- signal,
84
- });
85
- if (!r.ok) return { messages: [], hasMore: false };
86
- const json = await r.json();
87
- return {
88
- messages: (json && json.data) || [],
89
- cursor: json && json.cursor,
90
- };
91
- }
92
-
93
- export async function sendMessage(id, parts, opts = {}) {
94
- return api(`/api/session/${encodeURIComponent(id)}/message`, {
95
- method: "POST",
96
- body: { parts, agent: opts.agent || "build" },
97
- signal: opts.signal,
98
- });
99
- }
100
-
101
- export async function sessionStatus(id) {
102
- const r = await api(`/api/session/${encodeURIComponent(id)}/status`);
103
- return r.ok ? r.data : null;
104
- }
105
-
106
- export async function abortSession(id) {
107
- return api(`/api/session/${encodeURIComponent(id)}/abort`, {
108
- method: "POST",
109
- });
110
- }
111
-
112
- export async function abortSessionStatus(id) {
113
- return api(`/api/session/${encodeURIComponent(id)}/abort`, {
114
- method: "POST",
115
- body: {},
116
- });
117
- }
118
-
119
- export async function listProviders() {
120
- const r = await api("/api/provider");
121
- return r.ok ? (r.data && r.data.data) || [] : [];
122
- }
123
-
124
- export async function listAgents() {
125
- const r = await api("/api/agent");
126
- return r.ok ? (r.data && r.data.data) || [] : [];
127
- }
128
-
129
- export function openEventStream(onEvent, onError, signal) {
130
- // opencode's SSE endpoint is /api/event; it streams session lifecycle and
131
- // message progress events. We keep the connection alive and rebind handlers
132
- // on disconnect so transient network blips do not break the UI.
133
- let es = null;
134
- let retry = 0;
135
- let stopped = false;
136
-
137
- async function connect() {
138
- if (stopped) return;
139
- try {
140
- es = new EventSource("/api/event");
141
- es.onmessage = (e) => {
142
- retry = 0;
143
- try {
144
- const data = JSON.parse(e.data);
145
- onEvent && onEvent(data);
146
- } catch (err) {
147
- onError && onError(err);
148
- }
149
- };
150
- es.onerror = () => {
151
- if (es) {
152
- es.close();
153
- es = null;
154
- }
155
- if (stopped) return;
156
- retry = Math.min(retry + 1, 6);
157
- const delay = 500 * Math.pow(2, retry);
158
- setTimeout(connect, delay);
159
- onError && onError(new Error("sse disconnected"));
160
- };
161
- } catch (err) {
162
- onError && onError(err);
163
- setTimeout(connect, 1000);
164
- }
165
- }
166
-
167
- connect();
168
-
169
- return {
170
- close() {
171
- stopped = true;
172
- if (es) {
173
- es.close();
174
- es = null;
175
- }
176
- },
177
- };
178
- }
179
-
180
- export function partText(p) {
181
- if (!p) return "";
182
- if (p.type === "text") return p.text || "";
183
- if (p.type === "reasoning") return p.text || "";
184
- return "";
185
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/index.html DELETED
@@ -1,30 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>OpenCode</title>
7
- <link rel="icon" href="/favicon-96x96-v3.png" sizes="96x96">
8
- <style>
9
- *,*::before,*::after{box-sizing:border-box}
10
- html,body,#root{height:100%;margin:0;padding:0}
11
- body{font-family:-apple-system,BlinkMacSystemFont,"Inter","Segoe UI",sans-serif;background:#0a0a0a;color:#ececec;overflow:hidden;-webkit-font-smoothing:antialiased}
12
- a{color:inherit;text-decoration:none}
13
- button{font:inherit;color:inherit;cursor:pointer;background:none;border:none;padding:0}
14
- input,textarea{font:inherit;color:inherit;background:none;border:none;outline:none}
15
- ::-webkit-scrollbar{width:8px;height:8px}
16
- ::-webkit-scrollbar-thumb{background:#2a2a2a;border-radius:8px}
17
- ::-webkit-scrollbar-thumb:hover{background:#3a3a3a}
18
- ::-webkit-scrollbar-track{background:transparent}
19
- :root{--bg:#0a0a0a;--bg-1:#171717;--bg-2:#1f1f1f;--bg-3:#2a2a2a;--fg:#ececec;--fg-2:#a0a0a0;--fg-3:#6e6e6e;--accent:#10a37f;--danger:#ef4146;--warn:#f0b232;--border:#2a2a2a;--border-hi:#3a3a3a;--radius:8px;--shadow:0 4px 12px rgba(0,0,0,0.4)}
20
- .skeleton{background:linear-gradient(90deg,#1f1f1f 0%,#2a2a2a 50%,#1f1f1f 100%);background-size:200% 100%;animation:s 1.4s infinite;border-radius:6px}
21
- @keyframes s{0%{background-position:200% 0}100%{background-position:-200% 0}}
22
- .fade-in{animation:fade .18s ease-out}
23
- @keyframes fade{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:translateY(0)}}
24
- </style>
25
- </head>
26
- <body>
27
- <div id="root"><div style="display:flex;align-items:center;justify-content:center;height:100%;color:#6e6e6e">loading\u2026</div></div>
28
- <script type="module" src="/assets/chat.js"></script>
29
- </body>
30
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/store.js DELETED
@@ -1,158 +0,0 @@
1
- /* store.js — session state + localStorage UI metadata */
2
-
3
- import { listSessions, renameSession as apiRename, deleteSessions as apiDelete } from "./http.js";
4
-
5
- const LS_KEY = "oc_ui_meta";
6
- const LS_LAST = "oc_last_session";
7
-
8
- // UI-only metadata that lives in localStorage
9
- let _meta = {};
10
- try {
11
- _meta = JSON.parse(localStorage.getItem(LS_KEY) || "{}");
12
- } catch {
13
- _meta = {};
14
- }
15
-
16
- function saveMeta() {
17
- localStorage.setItem(LS_KEY, JSON.stringify(_meta));
18
- }
19
-
20
- export function getPinnedIds() { return new Set(_meta.pinned || []); }
21
- export function getArchivedIds() { return new Set(_meta.archived || []); }
22
- export function getFavorites() { return new Set(_meta.favorites || []); }
23
- export function getLastSessionId() { return _meta.lastSessionId || localStorage.getItem(LS_LAST) || null; }
24
- export function setLastSessionId(id) { _meta.lastSessionId = id; localStorage.setItem(LS_LAST, id || ""); saveMeta(); }
25
- export function getDrawerCollapsed() { return !!_meta.drawerCollapsed; }
26
- export function setDrawerCollapsed(v) { _meta.drawerCollapsed = v; saveMeta(); }
27
- export function getSelectedModel() { return _meta.selectedModel || null; }
28
- export function setSelectedModel(v) { _meta.selectedModel = v; saveMeta(); }
29
- export function getSelectedAgent() { return _meta.selectedAgent || null; }
30
- export function setSelectedAgent(v) { _meta.selectedAgent = v; saveMeta(); }
31
- export function getDraft(sessionId) { return (_meta.drafts || {})[sessionId] || ""; }
32
- export function setDraft(sessionId, text) {
33
- if (!_meta.drafts) _meta.drafts = {};
34
- if (text) _meta.drafts[sessionId] = text;
35
- else delete _meta.drafts[sessionId];
36
- saveMeta();
37
- }
38
-
39
- export function togglePin(id) {
40
- const s = getPinnedIds();
41
- if (s.has(id)) s.delete(id); else s.add(id);
42
- _meta.pinned = [...s];
43
- saveMeta();
44
- return s.has(id);
45
- }
46
-
47
- export function toggleArchive(id) {
48
- const s = getArchivedIds();
49
- if (s.has(id)) s.delete(id); else s.add(id);
50
- _meta.archived = [...s];
51
- saveMeta();
52
- return s.has(id);
53
- }
54
-
55
- export function toggleFavorite(id) {
56
- const s = getFavorites();
57
- if (s.has(id)) s.delete(id); else s.add(id);
58
- _meta.favorites = [...s];
59
- saveMeta();
60
- return s.has(id);
61
- }
62
-
63
- // Live session list
64
- let _sessions = [];
65
- let _listeners = new Set();
66
- let _loading = false;
67
- let _error = null;
68
-
69
- export function getSessions() { return _sessions; }
70
- export function subscribe(fn) { _listeners.add(fn); return () => _listeners.delete(fn); }
71
- function emit() { for (const fn of _listeners) fn(_sessions); }
72
-
73
- export function getFilteredSessions({ search = "", showArchived = false } = {}) {
74
- const pinned = getPinnedIds();
75
- const archived = getArchivedIds();
76
- let list = _sessions;
77
-
78
- if (!showArchived) {
79
- list = list.filter(s => !archived.has(s.id));
80
- }
81
-
82
- if (search) {
83
- const q = search.toLowerCase();
84
- list = list.filter(s => (s.title || "").toLowerCase().includes(q));
85
- }
86
-
87
- // Sort: pinned first, then by updated time
88
- list = [...list].sort((a, b) => {
89
- const pa = pinned.has(a.id) ? 1 : 0;
90
- const pb = pinned.has(b.id) ? 1 : 0;
91
- if (pa !== pb) return pb - pa;
92
- const ua = (a.time && a.time.updated) || 0;
93
- const ub = (b.time && b.time.updated) || 0;
94
- return ub - ua;
95
- });
96
-
97
- return list;
98
- }
99
-
100
- export async function refreshSessions() {
101
- _loading = true;
102
- _error = null;
103
- try {
104
- _sessions = await listSessions();
105
- } catch (e) {
106
- _error = e;
107
- }
108
- _loading = false;
109
- emit();
110
- return _sessions;
111
- }
112
-
113
- export async function renameSession(id, title) {
114
- await apiRename(id, title);
115
- const s = _sessions.find(x => x.id === id);
116
- if (s) s.title = title;
117
- emit();
118
- }
119
-
120
- export async function deleteSession(id) {
121
- await apiDelete([id]);
122
- _sessions = _sessions.filter(s => s.id !== id);
123
- emit();
124
- }
125
-
126
- export function upsertSessionFromEvent(evt) {
127
- if (!evt || !evt.data) return;
128
- const d = evt.data;
129
- const existing = _sessions.find(s => s.id === d.id);
130
- if (existing) {
131
- Object.assign(existing, d);
132
- } else {
133
- _sessions.unshift(d);
134
- }
135
- emit();
136
- }
137
-
138
- export function updateSessionFromEvent(evt) {
139
- if (!evt || !evt.data) return;
140
- const id = (evt.data && evt.data.id) || (evt.aggregateID);
141
- if (!id) return;
142
- const s = _sessions.find(x => x.id === id);
143
- if (s) {
144
- // Merge data from event (session.updated events carry full info)
145
- const info = evt.data.info || evt.data;
146
- if (info) Object.assign(s, info);
147
- }
148
- emit();
149
- }
150
-
151
- // Title auto-generation
152
- let _titleTimers = {};
153
- export function scheduleTitleGeneration(sessionId, userMessage) {
154
- if (_titleTimers[sessionId]) return;
155
- // Fire-and-forget: send a small message to trigger title, then check back
156
- // Actually we just let opencode's own title system handle this.
157
- // If the title is still "New session", we can let the user rename.
158
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/ui.js DELETED
@@ -1,285 +0,0 @@
1
- /* ui.js — lightweight DOM helpers. No external runtime. */
2
-
3
- export function h(tag, props = {}, children = []) {
4
- const el = document.createElement(tag);
5
- for (const k in props) {
6
- const v = props[k];
7
- if (k === "style" && typeof v === "object") Object.assign(el.style, v);
8
- else if (k === "class") el.className = v;
9
- else if (k === "html") el.innerHTML = v;
10
- else if (k === "text") el.textContent = v;
11
- else if (k.startsWith("on") && typeof v === "function")
12
- el.addEventListener(k.slice(2).toLowerCase(), v);
13
- else if (v != null) el.setAttribute(k, v);
14
- }
15
- for (const c of [].concat(children)) {
16
- if (c == null) continue;
17
- el.append(c.nodeType ? c : document.createTextNode(String(c)));
18
- }
19
- return el;
20
- }
21
-
22
- export function clear(el) {
23
- while (el.firstChild) el.removeChild(el.firstChild);
24
- }
25
-
26
- export function el(id) {
27
- return document.getElementById(id);
28
- }
29
-
30
- let _uid = 0;
31
- export function uid(prefix = "id") {
32
- _uid += 1;
33
- return `${prefix}-${_uid}`;
34
- }
35
-
36
- export function debounce(fn, ms = 200) {
37
- let t;
38
- return (...args) => {
39
- clearTimeout(t);
40
- t = setTimeout(() => fn(...args), ms);
41
- };
42
- }
43
-
44
- export function formatTimeAgo(ts) {
45
- if (!ts) return "";
46
- const d = new Date(ts);
47
- const now = Date.now();
48
- const diff = now - ts;
49
- const s = Math.floor(diff / 1000);
50
- if (s < 60) return "just now";
51
- const m = Math.floor(s / 60);
52
- if (m < 60) return `${m}m`;
53
- const h = Math.floor(m / 60);
54
- if (h < 24) return `${h}h`;
55
- const day = Math.floor(h / 24);
56
- if (day === 0) return "today";
57
- if (day === 1) return "yesterday";
58
- if (day < 7) return `${day}d`;
59
- if (day < 30) return `${Math.floor(day / 7)}w`;
60
- return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
61
- }
62
-
63
- export function groupByDate(sessions) {
64
- const now = Date.now();
65
- const today = new Date();
66
- today.setHours(0, 0, 0, 0);
67
- const yesterday = today.getTime() - 86400000;
68
- const week = today.getTime() - 7 * 86400000;
69
- const month = today.getTime() - 30 * 86400000;
70
-
71
- const groups = { Today: [], Yesterday: [], "Previous 7 Days": [], "Previous 30 Days": [], Older: [] };
72
- for (const s of sessions) {
73
- const updated = s.time && s.time.updated ? s.time.updated : 0;
74
- if (updated >= today.getTime()) groups.Today.push(s);
75
- else if (updated >= yesterday) groups.Yesterday.push(s);
76
- else if (updated >= week) groups["Previous 7 Days"].push(s);
77
- else if (updated >= month) groups["Previous 30 Days"].push(s);
78
- else groups.Older.push(s);
79
- }
80
- for (const k in groups) {
81
- groups[k].sort((a, b) => {
82
- const ua = a.time && a.time.updated ? a.time.updated : 0;
83
- const ub = b.time && b.time.updated ? b.time.updated : 0;
84
- return ub - ua;
85
- });
86
- }
87
- return groups;
88
- }
89
-
90
- export function pickPreview(s) {
91
- // The session list itself rarely carries last-message content; we use title
92
- // as a fallback preview. When caller already has a preview, prefer that.
93
- return s.__preview || s.title || "New chat";
94
- }
95
-
96
- export function setOverlay(node) {
97
- const host = document.getElementById("overlay");
98
- if (!host) return;
99
- while (host.firstChild) host.removeChild(host.firstChild);
100
- host.appendChild(node || document.createTextNode(""));
101
- host.style.display = node ? "flex" : "none";
102
- }
103
-
104
- export function openOverlay(build, onClose) {
105
- const host = document.createElement("div");
106
- host.className = "modal-backdrop fade-in";
107
- Object.assign(host.style, {
108
- position: "fixed",
109
- inset: "0",
110
- background: "rgba(0,0,0,0.55)",
111
- display: "flex",
112
- alignItems: "center",
113
- justifyContent: "center",
114
- zIndex: "9999",
115
- });
116
- const close = () => {
117
- document.body.removeChild(host);
118
- document.removeEventListener("keydown", esc);
119
- onClose && onClose();
120
- };
121
- const esc = (e) => {
122
- if (e.key === "Escape") close();
123
- };
124
- host.addEventListener("click", (e) => {
125
- if (e.target === host) close();
126
- });
127
- document.addEventListener("keydown", esc);
128
- document.body.appendChild(host);
129
- build(host, close);
130
- return close;
131
- }
132
-
133
- export function snackbar(text, kind = "info") {
134
- const host = document.getElementById("snackbars") || (() => {
135
- const n = document.createElement("div");
136
- n.id = "snackbars";
137
- Object.assign(n.style, {
138
- position: "fixed",
139
- bottom: "16px",
140
- right: "16px",
141
- zIndex: "9999",
142
- display: "flex",
143
- flexDirection: "column",
144
- gap: "8px",
145
- });
146
- document.body.appendChild(n);
147
- return n;
148
- })();
149
- const node = document.createElement("div");
150
- node.textContent = text;
151
- Object.assign(node.style, {
152
- padding: "8px 14px",
153
- background: kind === "error" ? "#3a1a1a" : kind === "warn" ? "#3a2e1a" : "#1a2a23",
154
- border: `1px solid ${kind === "error" ? "#5a2a2a" : kind === "warn" ? "#5a4a1a" : "#1f3a2c"}`,
155
- borderRadius: "6px",
156
- color: "#ececec",
157
- fontSize: "13px",
158
- boxShadow: "var(--shadow)",
159
- animation: "fade .2s ease-out",
160
- });
161
- host.appendChild(node);
162
- setTimeout(() => {
163
- node.style.opacity = "0";
164
- setTimeout(() => node.remove(), 300);
165
- }, 2600);
166
- }
167
-
168
- export function promptInput(title, placeholder = "", initial = "") {
169
- return new Promise((resolve) => {
170
- openOverlay((host, close) => {
171
- const wrap = document.createElement("div");
172
- Object.assign(wrap.style, {
173
- background: "#171717",
174
- border: "1px solid var(--border-hi)",
175
- borderRadius: "10px",
176
- padding: "20px",
177
- width: "420px",
178
- maxWidth: "90vw",
179
- display: "flex",
180
- flexDirection: "column",
181
- gap: "12px",
182
- boxShadow: "var(--shadow)",
183
- });
184
- const t = document.createElement("div");
185
- t.textContent = title;
186
- t.style.fontSize = "15px";
187
- t.style.fontWeight = "600";
188
- wrap.appendChild(t);
189
- const input = document.createElement("input");
190
- input.value = initial || "";
191
- input.placeholder = placeholder;
192
- Object.assign(input.style, {
193
- padding: "10px 12px",
194
- background: "#0a0a0a",
195
- border: "1px solid var(--border)",
196
- borderRadius: "6px",
197
- fontSize: "14px",
198
- width: "100%",
199
- });
200
- input.addEventListener("keydown", (e) => {
201
- if (e.key === "Enter") {
202
- close();
203
- resolve(input.value.trim() || null);
204
- }
205
- if (e.key === "Escape") {
206
- close();
207
- resolve(null);
208
- }
209
- });
210
- wrap.appendChild(input);
211
- const row = document.createElement("div");
212
- Object.assign(row.style, { display: "flex", justifyContent: "flex-end", gap: "8px" });
213
- const cancel = document.createElement("button");
214
- cancel.textContent = "Cancel";
215
- Object.assign(cancel.style, {
216
- padding: "8px 14px",
217
- borderRadius: "6px",
218
- color: "var(--fg-2)",
219
- });
220
- cancel.addEventListener("click", () => { close(); resolve(null); });
221
- const ok = document.createElement("button");
222
- ok.textContent = "OK";
223
- ok.className = "btn-primary";
224
- Object.assign(ok.style, {
225
- padding: "8px 14px",
226
- borderRadius: "6px",
227
- background: "var(--accent)",
228
- color: "#0a0a0a",
229
- fontWeight: "600",
230
- });
231
- ok.addEventListener("click", () => { close(); resolve(input.value.trim() || null); });
232
- row.append(cancel, ok);
233
- wrap.appendChild(row);
234
- host.appendChild(wrap);
235
- setTimeout(() => input.focus(), 50);
236
- });
237
- });
238
- }
239
-
240
- export function confirmAction(title, body, opts = {}) {
241
- return new Promise((resolve) => {
242
- openOverlay((host, close) => {
243
- const wrap = document.createElement("div");
244
- Object.assign(wrap.style, {
245
- background: "#171717",
246
- border: "1px solid var(--border-hi)",
247
- borderRadius: "10px",
248
- padding: "20px",
249
- width: "420px",
250
- maxWidth: "90vw",
251
- display: "flex",
252
- flexDirection: "column",
253
- gap: "12px",
254
- });
255
- const t = document.createElement("div");
256
- t.textContent = title;
257
- t.style.fontSize = "15px";
258
- t.style.fontWeight = "600";
259
- wrap.appendChild(t);
260
- const p = document.createElement("div");
261
- p.textContent = body;
262
- Object.assign(p.style, { color: "var(--fg-2)", fontSize: "13px", lineHeight: "1.5" });
263
- wrap.appendChild(p);
264
- const row = document.createElement("div");
265
- Object.assign(row.style, { display: "flex", justifyContent: "flex-end", gap: "8px" });
266
- const cancel = document.createElement("button");
267
- cancel.textContent = opts.cancelText || "Cancel";
268
- Object.assign(cancel.style, { padding: "8px 14px", color: "var(--fg-2)" });
269
- cancel.addEventListener("click", () => { close(); resolve(false); });
270
- const ok = document.createElement("button");
271
- ok.textContent = opts.okText || "Confirm";
272
- Object.assign(ok.style, {
273
- padding: "8px 14px",
274
- borderRadius: "6px",
275
- background: opts.danger ? "var(--danger)" : "var(--accent)",
276
- color: "#0a0a0a",
277
- fontWeight: "600",
278
- });
279
- ok.addEventListener("click", () => { close(); resolve(true); });
280
- row.append(cancel, ok);
281
- wrap.appendChild(row);
282
- host.appendChild(wrap);
283
- });
284
- });
285
- }