Spaces:
Paused
Paused
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 +41 -0
- Dockerfile +1 -0
- backend/app/main.py +91 -93
- entrypoint.sh +1 -1
- frontend/chat.js +20 -0
- frontend/components.js +587 -0
- frontend/http.js +185 -0
- frontend/index.html +30 -0
- frontend/store.js +158 -0
- frontend/ui.js +285 -0
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 |
-
"""
|
| 2 |
|
| 3 |
Routes:
|
| 4 |
-
/
|
| 5 |
-
/
|
| 6 |
-
/
|
| 7 |
-
/*
|
|
|
|
| 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,
|
| 18 |
|
| 19 |
-
OC_HOST
|
| 20 |
-
OC_PORT
|
| 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 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 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…</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 — <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…</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 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 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 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 -
|
| 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">💬</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">👋</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 |
+
}
|