diff --git a/src/server/desktop_gateway_methods.py b/src/server/desktop_gateway_methods.py index 2515d127..e45c2e0c 100644 --- a/src/server/desktop_gateway_methods.py +++ b/src/server/desktop_gateway_methods.py @@ -387,6 +387,8 @@ def __init__(self, websocket: WebSocket, state: DesktopServeState) -> None: "session.list": self.session_active_list, "session.interrupt": self.session_interrupt, "session.clear": self.session_clear, + "session.title": self.session_title, + "session.usage": self.session_usage, "prompt.submit": self.prompt_submit, "approval.respond": self.approval_respond, "permission.cycle": self.permission_cycle, @@ -519,6 +521,26 @@ async def session_clear(self, params: dict[str, Any]) -> dict[str, Any]: result = await self._session(params).control_query("clear", {}) return {"ok": (result or {}).get("ok", True) is not False} + async def session_title(self, params: dict[str, Any]) -> dict[str, Any]: + title = str(params.get("title") or params.get("name") or "").strip() + result = await self._session(params).control_query("rename", {"name": title}) + name = (result or {}).get("name") if isinstance(result, dict) else None + # Also stamp the saved-session file so the sidebar row updates without a + # live turn (rename control persists the runtime session; the sidebar + # reads the file). + try: + from src.server.desktop_sessions import update_session_meta + + update_session_meta(self.state.saved_sessions_dir(), + str(params.get("session_id") or ""), name=name or None) + except Exception: # noqa: BLE001 — best-effort file sync + pass + return {"title": name or title, "ok": True} + + async def session_usage(self, params: dict[str, Any]) -> dict[str, Any]: + result = await self._session(params).control_query("get_context_usage", {}) + return result if isinstance(result, dict) else {} + async def prompt_submit(self, params: dict[str, Any]) -> dict[str, Any]: session = self._session(params) text = str(params.get("text") or "") diff --git a/src/server/desktop_serve.py b/src/server/desktop_serve.py index c556e842..61482e66 100644 --- a/src/server/desktop_serve.py +++ b/src/server/desktop_serve.py @@ -210,9 +210,36 @@ async def elevenlabs_voices(request: Request) -> Response: async def session_detail(request: Request) -> Response: if not _token_ok(state, _rest_token(request)): return JSONResponse({"error": "unauthorized"}, status_code=401) + sid = request.path_params["session_id"] + + if request.method == "DELETE": + from src.server.desktop_sessions import delete_session + + ok = delete_session(state.saved_sessions_dir(), sid) + return JSONResponse({"ok": ok}) + + if request.method == "PATCH": + from src.server.desktop_sessions import update_session_meta + + try: + body = await request.json() + except Exception: # noqa: BLE001 + body = {} + fields: dict[str, Any] = {} + # The renderer sends whichever it's changing: name / archived / pinned. + if "name" in body: + fields["name"] = body["name"] + if "title" in body: + fields["name"] = body["title"] + if "archived" in body: + fields["archived"] = bool(body["archived"]) + if "pinned" in body: + fields["pinned"] = bool(body["pinned"]) + ok = update_session_meta(state.saved_sessions_dir(), sid, **fields) if fields else False + return JSONResponse({"ok": ok}) + from src.server.desktop_sessions import load_session_messages - sid = request.path_params["session_id"] found = load_session_messages(state.saved_sessions_dir(), sid) if found is None: return JSONResponse({"error": "session not found"}, status_code=404) @@ -220,6 +247,15 @@ async def session_detail(request: Request) -> Response: "messages": found["messages"], "message_count": found["message_count"]}) + async def sessions_search(request: Request) -> Response: + if not _token_ok(state, _rest_token(request)): + return JSONResponse({"error": "unauthorized"}, status_code=401) + from src.server.desktop_sessions import search_sessions + + return JSONResponse( + search_sessions(state.saved_sessions_dir(), request.query_params.get("q", "")) + ) + def _sessions_slice(request: Request, *, profile_tag: str = "default") -> dict[str, Any]: """One filtered slice of the saved-session list (shared by the profile-scoped route and the batched sidebar).""" @@ -393,7 +429,9 @@ async def not_found(request: Request) -> Response: Route("/api/config/schema", config_schema), Route("/api/cron/jobs", cron_jobs), Route("/api/audio/elevenlabs/voices", elevenlabs_voices), - Route("/api/sessions/{session_id}", session_detail), + Route("/api/sessions/search", sessions_search), + Route("/api/sessions/{session_id}", session_detail, + methods=["GET", "PATCH", "DELETE"]), Route("/api/audio/transcribe", audio_transcribe, methods=["POST"]), Route("/", index), WebSocketRoute("/api/ws", gateway_ws), diff --git a/src/server/desktop_sessions.py b/src/server/desktop_sessions.py index 265046d4..8a56e547 100644 --- a/src/server/desktop_sessions.py +++ b/src/server/desktop_sessions.py @@ -133,4 +133,72 @@ def load_session_messages(sessions_dir: Path, session_id: str) -> dict[str, Any] } -__all__ = ["list_session_rows", "load_session_messages"] +def _write_session_file(path: Path, data: dict[str, Any]) -> bool: + tmp = path.with_name(path.name + ".tmp") + try: + tmp.write_text(json.dumps(data), encoding="utf-8") + tmp.replace(path) + return True + except OSError: + logger.warning("desktop: could not write %s", path, exc_info=True) + return False + + +def update_session_meta(sessions_dir: Path, session_id: str, **fields: Any) -> bool: + """Patch metadata (name/archived/pinned) on a saved session file. + + Only the given keys are touched; ``name=None`` clears a custom title. + Unknown session id or unreadable file → False. + """ + safe = _safe_id(session_id) + if safe is None: + return False + path = sessions_dir / f"{safe}.json" + data = _read_session_file(path) + if data is None: + return False + for key, value in fields.items(): + if value is None: + data.pop(key, None) + else: + data[key] = value + return _write_session_file(path, data) + + +def delete_session(sessions_dir: Path, session_id: str) -> bool: + safe = _safe_id(session_id) + if safe is None: + return False + path = sessions_dir / f"{safe}.json" + try: + path.unlink() + return True + except FileNotFoundError: + return False + except OSError: + logger.warning("desktop: could not delete %s", path, exc_info=True) + return False + + +def search_sessions(sessions_dir: Path, query: str, *, limit: int = 40) -> dict[str, Any]: + """Substring search over saved sessions' title/preview, newest-first.""" + needle = (query or "").strip().lower() + listing = list_session_rows(sessions_dir, limit=1000) + if not needle: + return {"sessions": listing["sessions"][:limit], "query": query} + hits = [ + row for row in listing["sessions"] + if needle in str(row.get("title", "")).lower() + or needle in str(row.get("preview", "")).lower() + or needle in str(row.get("id", "")).lower() + ] + return {"sessions": hits[:limit], "query": query} + + +__all__ = [ + "delete_session", + "list_session_rows", + "load_session_messages", + "search_sessions", + "update_session_meta", +] diff --git a/tests/server/test_desktop_sessions.py b/tests/server/test_desktop_sessions.py index 7e82d8b1..4345df4b 100644 --- a/tests/server/test_desktop_sessions.py +++ b/tests/server/test_desktop_sessions.py @@ -180,6 +180,62 @@ def test_session_detail_route(rest: TestClient, tmp_path: Path) -> None: assert rest.get("/api/sessions/missing", headers=AUTH).status_code == 404 +def test_session_rename_archive_delete(rest: TestClient, tmp_path: Path) -> None: + d = tmp_path / "sessions" + _write_session(d, "chat-y", preview="original", count=2) + + # Rename via PATCH → the sidebar row's title updates. + assert rest.patch("/api/sessions/chat-y", headers=AUTH, + json={"name": "Renamed"}).json() == {"ok": True} + rows = rest.get("/api/sessions", headers=AUTH).json()["sessions"] + assert next(r for r in rows if r["id"] == "chat-y")["title"] == "Renamed" + + # Archive flag persists on the file. + assert rest.patch("/api/sessions/chat-y", headers=AUTH, + json={"archived": True}).json() == {"ok": True} + saved = json.loads((d / "chat-y.json").read_text()) + assert saved["archived"] is True + assert saved["name"] == "Renamed" + + # Delete removes the file. + assert rest.request("DELETE", "/api/sessions/chat-y", headers=AUTH).json() == {"ok": True} + assert not (d / "chat-y.json").exists() + assert rest.request("DELETE", "/api/sessions/chat-y", headers=AUTH).json() == {"ok": False} + + +def test_session_search_route(rest: TestClient, tmp_path: Path) -> None: + d = tmp_path / "sessions" + _write_session(d, "find-me", preview="a unique needle here", count=1) + _write_session(d, "other", preview="nothing matching", count=1) + + hits = rest.get("/api/sessions/search?q=unique+needle", headers=AUTH).json() + assert [s["id"] for s in hits["sessions"]] == ["find-me"] + assert hits["query"] == "unique needle" + # Empty query returns all, doesn't error. + assert len(rest.get("/api/sessions/search?q=", headers=AUTH).json()["sessions"]) == 2 + # Search route wins over the {id} route (literal precedence). + assert rest.get("/api/sessions/search").status_code == 401 + + +def test_session_mutations_refuse_traversal(rest: TestClient, tmp_path: Path) -> None: + # A traversal id is refused (either the router 404s the malformed path, or + # the handler's id-charset guard returns ok:false) — never a write. + sentinel = tmp_path / "etc" + for method in ("PATCH", "DELETE"): + res = rest.request(method, "/api/sessions/..%2Fetc", headers=AUTH, + json={"name": "x"}) + assert res.status_code == 404 or res.json() == {"ok": False} + assert not sentinel.exists() + + +def test_session_meta_helper_refuses_bad_id(tmp_path: Path) -> None: + from src.server.desktop_sessions import delete_session, update_session_meta + + assert update_session_meta(tmp_path, "../etc", name="x") is False + assert update_session_meta(tmp_path, "has/slash", name="x") is False + assert delete_session(tmp_path, "../etc") is False + + def test_profile_sessions_slice_filters_sources( rest: TestClient, tmp_path: Path ) -> None: