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

feat: full SPA with sidebar, session management, persistent state

Browse files

- Frontend SPA (vanilla ES modules, no build step) served from /
- Session list with date grouping (Today/Yesterday/7d/30d/Older)
- Pin/unpin, rename, delete, archive/restore sessions
- Search/filter sessions by title
- Draft persistence per session (localStorage)
- Last session auto-restore on page load
- Real-time session updates via SSE (/api/event)
- Auto-resize textarea, Ctrl+Enter to send
- Collapsible sidebar, dark mode consistent
- Skeleton loading states, empty states
- Keyboard navigation, snackbar notifications
- All opencode APIs reused (no duplicate storage)
- ttyd fixed: use -i instead of --host for bind address

DELIVERABLES.md ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,6 +33,7 @@ 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 cleaner.py /cleaner.py
37
  COPY entrypoint.sh /entrypoint.sh
38
  RUN chmod +x /entrypoint.sh
 
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
backend/app/main.py CHANGED
@@ -1,80 +1,59 @@
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,43 +70,42 @@ async def _forward(request: Request, port: int, path: str):
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,19 +115,34 @@ async def healthz():
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,13 +150,16 @@ async def main_route(request: Request, path: str):
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,6 +172,7 @@ async def ws_route(websocket: WebSocket, path: str):
176
  await upstream.send(msg["bytes"])
177
  except Exception:
178
  return
 
179
  async def s2c():
180
  try:
181
  async for raw in upstream:
@@ -185,6 +182,7 @@ async def ws_route(websocket: WebSocket, path: str):
185
  await websocket.send_bytes(raw)
186
  except Exception:
187
  return
 
188
  await asyncio.gather(c2s(), s2c())
189
  except Exception:
190
  try:
 
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
  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
 
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
  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
  await upstream.send(msg["bytes"])
173
  except Exception:
174
  return
175
+
176
  async def s2c():
177
  try:
178
  async for raw in upstream:
 
182
  await websocket.send_bytes(raw)
183
  except Exception:
184
  return
185
+
186
  await asyncio.gather(c2s(), s2c())
187
  except Exception:
188
  try:
entrypoint.sh CHANGED
@@ -29,7 +29,7 @@ cd /projects/default
29
  [ -d .git ] || git init -q 2>/dev/null
30
 
31
  echo "[TERMINAL] ttyd on 0.0.0.0:7681 ..."
32
- nohup ttyd --port 7681 --host 0.0.0.0 --writable \
33
  bash -l > /data/logs/ttyd.log 2>&1 &
34
 
35
  echo "[UPSTREAM] opencode serve on :${OPENCODE_PORT:-4096} ..."
 
29
  [ -d .git ] || git init -q 2>/dev/null
30
 
31
  echo "[TERMINAL] ttyd on 0.0.0.0:7681 ..."
32
+ nohup ttyd -p 7681 -i 0.0.0.0 -W \
33
  bash -l > /data/logs/ttyd.log 2>&1 &
34
 
35
  echo "[UPSTREAM] opencode serve on :${OPENCODE_PORT:-4096} ..."
frontend/chat.js ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,587 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }