diff --git a/README.md b/README.md index adb8a5b..151eeeb 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,8 @@ Other install methods: [pip install](#alternative-install-with-pip) | [uv instal ## πŸ”₯πŸ”₯πŸ”₯ News (Pacific Time) -- May 10, 2026 (latest): **Web Chat UI fixes β€” slash commands no longer reply twice; `--web --model X` actually applies the model.** Two related issues that surfaced when wiring a self-hosted vLLM endpoint into the Chat UI. (1) **Issue #111 β€” slash commands duplicated in Chat UI but not in terminal.** `web/api.py:handle_slash_sync` was both returning events inline in the HTTP response **and** broadcasting the same events to the WS subscribers of the same client; `chat.js` then iterated `data.events` AND fired `_handleEvent` from `ws.onmessage`, rendering every reply twice. Same bug in `handle_slash_stream` for SSE-streamed long commands (`/brainstorm`, `/worker`, `/agent`, `/plan`). Both helpers now deliver events through a single channel β€” HTTP/SSE only β€” so `_handleEvent` runs exactly once per event. Background-thread events (sentinel flows, agent runs) are unaffected: by the time the worker thread emits, `_broadcast` is already restored to the live WS broadcaster in `finally`. (2) **`--web --model X` was silently ignored.** The CLI override branch only ran in the interactive-REPL path; the `if args.web:` branch loaded config straight from disk and started the server, so `python cheetahclaws.py --web --model custom/qwen2.5-72b` would happily boot but every request handler reloaded `~/.cheetahclaws/config.json` with the previous model name (e.g. `gemma-4-31B-it`), producing a confusing `404: model does not exist` against the new endpoint. Fix: `cheetahclaws.py` now persists `args.model` to config before calling `start_web_server`, matching the documented behavior; `provider:model` β†’ `provider/model` normalization is identical to the REPL path. User-side guide: [`docs/guides/web-ui.md`](docs/guides/web-ui.md) (Troubleshooting + Architecture notes updated). +- May 10, 2026 (latest): **Web Chat UI session organization β€” folders, drag-drop, batch ops, resizable sidebar, ChatGPT-style active-folder context.** Built on top of the slash-fix branch the same day. (1) **Folders.** New `folders` table (per-user, name unique), `chat_sessions.folder_id` nullable FK with `ON DELETE SET NULL` semantics enforced at the repo layer (SQLite `PRAGMA foreign_keys` is off in this engine). Light-touch migration runs in `init_db()`: a `PRAGMA table_info` probe adds the column to existing DBs in place β€” no Alembic, no manual steps for upgraders. New endpoints: `GET/POST /api/folders`, `PATCH/DELETE /api/folders/{id}`, `PATCH /api/sessions/{id}/folder` (body `{folder_id: int|null}`); deleting a folder preserves its sessions and reparents them to "Ungrouped". (2) **Drag-and-drop + Move-to context menu.** Session items are HTML5-draggable; folder rows and the Ungrouped header are drop targets with `drop-target` highlight. Right-click on a session shows a flat `Move to:` section listing every folder, plus `(Ungrouped)` when applicable and `+ New folder…` for create-and-move in one shot. Right-click on a folder header offers Rename / Delete (with a `confirm()` that spells out "sessions become Ungrouped β€” they are NOT deleted"). (3) **Active-folder context (ChatGPT-style).** Click a folder name (not the disclosure arrow) to "enter" that folder β€” the row gets accent highlighting and the topbar grows a `Chat Β· in ` breadcrumb. While a folder is active, **`+ New` and direct-typing auto-create both drop the new session into that folder**, mirroring how OpenAI Projects scope new chats. Switching to any session syncs active-folder to that session's folder so the breadcrumb stays honest. State persists across reloads via `localStorage` (`cc-active-folder`); deleted folders auto-clear. (4) **Batch select.** "Select" button in the sidebar header enters a checkbox mode with a footer action bar: count, Select all (respects the search filter), Delete (single confirm with total-message count), Export (single combined Markdown download with one `## Session: ` block per session, `chats-N-sessions.md`), Cancel. Right-click context menu is suppressed in select mode to avoid mode confusion. (5) **Resizable sidebar.** 4-px drag handle between sidebar and main pane (mouse + touch); width clamped to 200–600 px and persisted to `localStorage` (`cc-sidebar-w`). Double-click resets to default. Hidden under `@media (max-width: 768px)` so the mobile drawer keeps its swipe behavior. **Tests:** +10 new in `test_web_api.py` (folder CRUD, duplicate-409, move, delete-preserves, cross-user isolation, list includes `folder_id`, batch delete, batch delete cross-user, batch export, batch export empty 400) β€” full file at 31 tests, all passing, zero regressions on the existing 21. User-side guide: [`docs/guides/web-ui.md`](docs/guides/web-ui.md) (Layout / Session management / Folders / HTTP API all updated). +- May 10, 2026: **Web Chat UI fixes β€” slash commands no longer reply twice; `--web --model X` actually applies the model.** Two related issues that surfaced when wiring a self-hosted vLLM endpoint into the Chat UI. (1) **Issue #111 β€” slash commands duplicated in Chat UI but not in terminal.** `web/api.py:handle_slash_sync` was both returning events inline in the HTTP response **and** broadcasting the same events to the WS subscribers of the same client; `chat.js` then iterated `data.events` AND fired `_handleEvent` from `ws.onmessage`, rendering every reply twice. Same bug in `handle_slash_stream` for SSE-streamed long commands (`/brainstorm`, `/worker`, `/agent`, `/plan`). Both helpers now deliver events through a single channel β€” HTTP/SSE only β€” so `_handleEvent` runs exactly once per event. Background-thread events (sentinel flows, agent runs) are unaffected: by the time the worker thread emits, `_broadcast` is already restored to the live WS broadcaster in `finally`. (2) **`--web --model X` was silently ignored.** The CLI override branch only ran in the interactive-REPL path; the `if args.web:` branch loaded config straight from disk and started the server, so `python cheetahclaws.py --web --model custom/qwen2.5-72b` would happily boot but every request handler reloaded `~/.cheetahclaws/config.json` with the previous model name (e.g. `gemma-4-31B-it`), producing a confusing `404: model does not exist` against the new endpoint. Fix: `cheetahclaws.py` now persists `args.model` to config before calling `start_web_server`, matching the documented behavior; `provider:model` β†’ `provider/model` normalization is identical to the REPL path. User-side guide: [`docs/guides/web-ui.md`](docs/guides/web-ui.md) (Troubleshooting + Architecture notes updated). - May 10, 2026: **Small-context local models survive large workloads β€” 4-part fix: ctx cap, auto-fanout, stagnation-stop, output paths under `~/.cheetahclaws/`.** Repro that motivated the work: running `/agent β†’ 1 (Research Assistant)` on a 6.6 MB PDF (`AutoRedTeamer.pdf` β€” ~70k tokens of extracted text) with `custom/qwen2.5-72b` (32k ctx). Old behavior: 400 BadRequest "context length 32768"; the agent_runner kept polling the template every 2 s; the model produced **1500+ identical "task complete" summaries** before anything stopped it. New behavior, four cooperating layers: (1) **Per-model context-window registry + dynamic max_tokens cap** (`providers._MODEL_CONTEXT_LIMITS` + `get_model_context_window` + `dynamic_cap_max_tokens`) β€” covers Qwen 2.5/3, Llama 3.x, Mistral/Mixtral, Phi, Gemma, DeepSeek local variants; `_fetch_custom_model_limit` now backfills `PROVIDERS["custom"]["context_limit"]` so compaction sees the live `/v1/models` value; per-call shrink based on actual prompt size keeps `input + output + 1024 safety ≀ ctx`. `compaction.get_context_limit` gains an optional `config` arg so custom-endpoint detection works on the very first turn. (2) **Auto-fanout for oversize tool outputs** (`multi_agent/fanout.py`) β€” when a single tool result (Read on a huge PDF, Grep over a giant tree, WebFetch of a long article) exceeds 0.4 Γ— ctx_window, split into chunks at paragraph boundaries with token-overlap, dispatch parallel sub-LLM map calls (one per chunk, default cap 5 subagents), merge with a single reduce call; substitutes the merged summary in conversation history instead of letting the next API call overflow. Hooked at the tool-result append site in `agent.py`; transparent UX prints `[Auto-fanout: <Tool> returned ~N chars (>threshold) β†’ dispatching K parallel sub-summaries]`. Configurable: `auto_fanout_enabled` / `_threshold` / `_max_subagents` / `_chunk_overlap_tokens`. (3) **Stagnation-stop in `agent_runner.py`** β€” when the model emits the same summary N iterations in a row (default 3, whitespace/case-normalized), stop the loop with a clear notification instead of burning thousands of API calls; configurable via `auto_agent_dup_summary_limit` (0 disables). (4) **Agent output paths under `~/.cheetahclaws/`** β€” `/agent` wizard now resolves relative output filenames (e.g. `research_notes.md`) to absolute paths under `~/.cheetahclaws/agents/<name>/output/` instead of CWD; `AgentRunner` exposes `runner.output_dir`, eagerly mkdir'd; Summary block + post-start info show the resolved path in green; absolute paths pass through unchanged. **Tests:** +47 new (fanout 23, ctx cap 18, dup-stop 13, output paths 8). **Full suite: 2139 passing, zero regressions.** User-side guide: [`docs/guides/extensions.md`](docs/guides/extensions.md). - May 9, 2026: **`fix/agentic-on-every-model` branch β€” make every model produce useful work, and make `/brainstorm` an actual debate.** A single coordinated branch (9 commits, 269 new tests, zero regressions) that lands on weak / non-Claude models specifically. **Prompts:** new `prompts/overlays/qwen.md` overlay for qwen / qwq families plus an explore-first section in `default.md` so any model walks a directory before asking the user to name a file. **Runtime:** `agent.py` auto-nudge (one-shot, when user message contains an absolute path but the model replies text-only); read-only tool dedup (Read/Glob/Grep/WebFetch/WebSearch with identical args within a turn β†’ 2nd call short-circuited, model gets a `[deduped]` reminder); KeyError-on-empty-args hardening in tool dispatch (`Write({}) β†’ KeyError: 'file_path'` is now a friendly "missing required parameter" error the model can self-correct from). **Providers:** new `nim` provider (build.nvidia.com free tier, 10-model curated chain) invoked as `nim/<vendor>/<model>`, with 429 cascade fallback (cap 3 swaps/turn, gated to NIM only). **`/brainstorm` overhaul:** real lead moderator (`--lead <model>`) does opening (sets agenda + bans filler) β†’ personas debate in N rounds (`--rounds N`, default 2) β†’ lead probes after each round β†’ lead synthesizes a structured master plan inline (no main-agent Read needed); round 2+ is **adversarial cross-examination** β€” every persona MUST quote another agent's claim and attack it with a falsifiable counter, "agree-and-extend" is forbidden, lead probes any dodge. New `--models a,b,c` flag distributes different models per persona for epistemic diversity. **`/monitor` + `/research` stability:** `/subscribe` no longer truncates multi-word topics ("Agent OS Benchmark" used to become "Agent"); aggregator no longer deadlocks on a hung source after `as_completed` timeout; REPL Ctrl+C during a slow slash command cancels just that command instead of killing the whole process. Branch: `fix/agentic-on-every-model`. User-side guide: [`docs/guides/brainstorm.md`](docs/guides/brainstorm.md). - May 8, 2026: **Agent-OS layer (`cc_kernel/`) reaches v1.0 β€” 27 RFCs shipped, 1771 tests passing, zero regressions on the legacy REPL/bridges path.** @@ -932,8 +933,11 @@ On first visit to `http://localhost:<port>/chat`, the UI routes you to a **regis |---------|---------| | **Streaming chat** | WebSocket for live prompts + SSE for long-running slash commands | | **Persistent history** | Every session + message lives in SQLite (`~/.cheetahclaws/web.db`). Server restart does not lose state. | -| **Sidebar session management** | Title auto-titled from first user message, relative time ("12m ago"), message count, busy dot, client-side search, right-click menu (Rename / Export Markdown / Delete) | -| **Cross-user isolation** | Each user only sees their own sessions β€” enforced at DB query and in-memory cache | +| **Sidebar session management** | Title auto-titled from first user message, relative time ("12m ago"), message count, busy dot, client-side search, right-click menu (Rename / Export Markdown / Move to / Delete) | +| **Folders + ChatGPT-style Projects** | `+ Folder` button creates per-user folders; drag a session onto a folder header (or right-click β†’ Move to β–Έ) to file it; click a folder name to "enter" β€” `+ New` and direct-typing then auto-drop the new session into that folder, with a `Chat Β· in <Folder>` topbar breadcrumb. Deleting a folder reparents its sessions to "Ungrouped" rather than deleting them. | +| **Batch operations** | "Select" button enters multi-select mode (checkboxes, Select all respects the search filter); a footer action bar batch-deletes (single confirm + total-message count) or batch-exports as a single combined Markdown (`chats-N-sessions.md`). | +| **Resizable sidebar** | Drag the 4-px divider between the sidebar and the chat pane (200–600 px clamp); double-click resets; width persists across reloads. | +| **Cross-user isolation** | Each user only sees their own sessions and folders β€” enforced at DB query and in-memory cache | | **Tool cards** | Collapsible cards show tool name, inputs, outputs, status (running / done / denied) | | **Permission approval** | Inline Allow / Deny buttons | | **45+ slash commands** | `/status`, `/model`, `/brainstorm`, `/ssj`, `/plan`, `/telegram`, `/wechat`, `/slack`, `/voice`, `/image`, etc. | @@ -955,6 +959,9 @@ Browser ──→ /chat ──→ 9 JS modules load from /static/ ──→ /api/prompt (POST) ──→ persists to SQLite, fans events out ──→ /api/events (WS) ──→ real-time text_chunk / tool_* / permission_* ──→ /api/sessions/* ──→ list / get / rename / delete / export + + batch_delete / batch_export + + {id}/folder (move to folder) + ──→ /api/folders ──→ list / create / rename / delete folders ──→ / ──→ xterm.js PTY (password-gated) ──→ /health ──→ { ok, db, uptime_s } (unauthenticated) diff --git a/docs/guides/web-ui.md b/docs/guides/web-ui.md index cc05864..aa483cf 100644 --- a/docs/guides/web-ui.md +++ b/docs/guides/web-ui.md @@ -80,16 +80,17 @@ Every other `/api/*` route requires a valid `ccjwt` cookie β†’ `401 { "error": " All session metadata and message history live in SQLite, not RAM. Server restarts do **not** lose anything. - **DB file:** `~/.cheetahclaws/web.db` (0600). Override with `CHEETAHCLAWS_WEB_DB`. -- **Four tables** (SQLAlchemy 2.x, declared in `web/models.py`): +- **Five tables** (SQLAlchemy 2.x, declared in `web/models.py`): | Table | Columns | Notes | |-------|---------|-------| | `users` | `id`, `username`, `password_hash`, `is_admin`, `created_at` | Username unique + indexed | -| `chat_sessions` | `id` (12-hex pk), `user_id` (fk), `title`, `created_at`, `last_active`, `config_json` | `last_active` indexed | +| `folders` | `id`, `user_id` (fk, cascade), `name`, `created_at` | unique(user_id, name) | +| `chat_sessions` | `id` (12-hex pk), `user_id` (fk), `title`, `created_at`, `last_active`, `config_json`, `folder_id` (fk, nullable) | `last_active` and `folder_id` indexed | | `messages` | `id`, `session_id` (fk, cascade), `role`, `content`, `tool_calls_json`, `created_at` | | | `api_credentials` | `id`, `user_id` (fk), `provider`, `api_key`, unique(user_id, provider) | Future: encrypt at rest | -Schema is bootstrapped on first run via `Base.metadata.create_all`. No Alembic yet β€” if you change models, drop the DB (or migrate by hand) and restart. +Schema is bootstrapped on first run via `Base.metadata.create_all`. **In-place migration for upgraders:** `init_db()` runs a `PRAGMA table_info(chat_sessions)` probe at startup; if `folder_id` is missing it `ALTER TABLE`s the column in place and adds the index. No Alembic yet β€” for any other model change, drop the DB (or migrate by hand) and restart. ### Session lifecycle @@ -127,21 +128,59 @@ This way `app.foo()` call sites don't change when methods move files, and there' ### Layout -- **Left sidebar** β€” session list (title + relative time + message count + busy dot), search box (client-side filter), `+ New` button, footer with current username + Sign out. +- **Left sidebar** β€” folder tree + session list (title + relative time + message count + busy dot), search box (client-side filter), header buttons `+ Folder` / `Select` / `+ New`, optional batch action bar at the bottom (when in select mode), footer with current username + Sign out. - **Center** β€” scrollable chat area with user bubbles, assistant bubbles (Markdown rendered via `marked.js` with `<tag>` stripping for XSS), tool cards, approval cards, activity indicator. -- **Top bar** β€” status dot, theme toggle (β˜€/☾), settings gear (βš™). +- **Top bar** β€” title (with `Β· in <Folder>` breadcrumb when an active folder is selected), status dot, theme toggle (β˜€/☾), settings gear (βš™). +- **Resizable divider** β€” drag the 4-px handle between the sidebar and main panes to set a custom width (200–600 px clamp). Double-click the handle to reset to the default. Width persists across reloads via `localStorage["cc-sidebar-w"]`. Hidden under `@media (max-width: 768px)` so the mobile drawer keeps its swipe behavior. ### Session management | Action | UI | API | |--------|-----|-----| -| List | Sidebar auto-loads | `GET /api/sessions` | +| List | Sidebar auto-loads | `GET /api/sessions` (rows include `folder_id`) | | Switch | Click a session | `GET /api/sessions/{id}` (replays messages) | -| New | `+ New` button | `POST /api/prompt` with empty `session_id` | +| New | `+ New` button (or just type a message) | `POST /api/prompt` with empty `session_id`; if a folder is active, the new session is auto-PATCHed into it | | Rename | Right-click β†’ Rename | `PATCH /api/sessions/{id}` `{ "title": "..." }` | | Delete | Right-click β†’ Delete | `DELETE /api/sessions/{id}` | +| Move to folder | Drag onto a folder row, or right-click β†’ `Move to: ...` | `PATCH /api/sessions/{id}/folder` `{ "folder_id": int\|null }` | | Export | Right-click β†’ Export Markdown | `GET /api/sessions/{id}/export` (downloads `chat-<id>.md`) | | Search | Search box | Client-side over `_sessions` array (title + id) | +| Batch select | "Select" button β†’ click rows β†’ action bar | (per-row HTTP via batch endpoints below) | +| Batch delete | Select mode β†’ Delete | `POST /api/sessions/batch_delete` `{ "ids": [...] }` | +| Batch export | Select mode β†’ Export | `POST /api/sessions/batch_export` `{ "ids": [...] }` (downloads `chats-N-sessions.md`) | +| Select all (filtered) | Select mode β†’ "Select all" link | Honors current search filter | + +### Folders & active-folder context + +Folders are flat (no nesting) and per-user. A session belongs to **at most one** folder; sessions without a folder live in an "Ungrouped" pseudo-section. + +**Creating, renaming, deleting** + +- `+ Folder` button in the sidebar header β†’ prompts for a name. +- Right-click a folder header β†’ `Rename...` or `Delete folder`. Deleting a folder **does not** delete its sessions; they're reparented to Ungrouped (the repo layer NULLs the column explicitly because `PRAGMA foreign_keys` is off in this engine, so `ON DELETE SET NULL` wouldn't fire on its own). +- Folder name uniqueness is enforced per user β€” duplicate creates return `409 Conflict`. + +**Moving sessions** + +Two interactions cover the same `PATCH /api/sessions/{id}/folder` endpoint: + +- **Drag-and-drop** β€” every session row is `draggable="true"`. Folder headers and the Ungrouped header are drop targets and light up in accent colour while the drag is over them. +- **Right-click context menu** β€” each session has a flat `Move to:` section listing every folder, plus `(Ungrouped)` (only when the session is currently in a folder) and `+ New folder…` (creates a folder from a prompt and moves the session in a single click). + +**Active-folder context (ChatGPT-style)** + +Clicking a folder **name** (not the disclosure arrow) "enters" that folder: + +- The folder row gets accent highlighting (`.active-folder`). +- The topbar title grows a `Chat Β· in <Folder>` breadcrumb so you always know which scope you're in. +- **`+ New` and direct-typing auto-create both drop the new session into the active folder** β€” same UX as OpenAI Projects. +- Switching to a session in another folder syncs the active context to that folder. +- Clicking the active folder again (or clicking an Ungrouped session) exits the context. +- The disclosure arrow (`β–Ύ`/`β–Έ`) is wired separately β€” clicking the arrow only toggles collapse, never changes the active folder. + +State persists across reloads (`localStorage["cc-active-folder"]`, `localStorage["cc-collapsed-folders"]`); a deleted folder auto-clears its active reference on next render. + +**Schema migration for upgraders.** `init_db()` runs a one-shot `PRAGMA table_info(chat_sessions)` probe and `ALTER TABLE` adds the `folder_id` column on databases that predate folders. No Alembic; existing rows keep all data and start out as Ungrouped. ### Theme (light / dark / system) @@ -194,11 +233,23 @@ All `/api/*` routes other than `/api/auth/*` and the ops endpoints require a val | Route | Method | Purpose | |-------|--------|---------| -| `/api/sessions` | GET | `{sessions: [{id, title, created_at, last_active, message_count, busy}, ...]}` β€” this user only | +| `/api/sessions` | GET | `{sessions: [{id, title, created_at, last_active, message_count, busy, folder_id}, ...]}` β€” this user only | | `/api/sessions/{id}` | GET | `{id, title, messages, config, busy}` β€” messages include `tool_calls` | | `/api/sessions/{id}` | PATCH | `{title}` β€” rename (returns 400 on empty) | | `/api/sessions/{id}` | DELETE | Remove session + cascade messages | +| `/api/sessions/{id}/folder` | PATCH | `{folder_id: int\|null}` β€” move session into a folder, or set `null` for Ungrouped. Cross-user folders return 404. | | `/api/sessions/{id}/export` | GET | Download conversation as Markdown (`Content-Disposition: attachment; filename="chat-<id>.md"`) | +| `/api/sessions/batch_delete` | POST | `{ids: [...]}` β†’ `{deleted, failed: [...], requested}`. IDs the caller doesn't own are skipped (counted as `failed`), never erased β€” same ownership check as the single-session DELETE. | +| `/api/sessions/batch_export` | POST | `{ids: [...]}` β†’ combined Markdown attachment (`chats-N-sessions.md`). Empty list returns 400; if no requested id is owned by the caller, returns 404. | + +### Folders + +| Route | Method | Purpose | +|-------|--------|---------| +| `/api/folders` | GET | `{folders: [{id, name, created_at, session_count}, ...]}` β€” this user only | +| `/api/folders` | POST | `{name}` β†’ `{id, name, created_at, session_count}`. Duplicate name for the same user returns `409 Conflict`; missing/empty name returns 400. | +| `/api/folders/{id}` | PATCH | `{name}` β†’ `{ok: true, name}` or 404 if not yours / duplicate. | +| `/api/folders/{id}` | DELETE | `{ok: true}` β€” sessions inside are reparented to Ungrouped (`folder_id = NULL`), not deleted. Cross-user delete returns `{ok: false}` (matches the per-session DELETE convention). | ### Config / models @@ -260,7 +311,7 @@ Point Prometheus at `/metrics` β€” it returns v0.0.4 text format. The in-process pytest tests/test_web_api.py -v ``` -21 end-to-end tests spin the real server in a background thread on a random port, truncate the DB between tests, and drive it with `httpx`. No mocks β€” real SQLite, real bcrypt, real JWT. Runs in ~5s. +31 end-to-end tests spin the real server in a background thread on a random port, truncate the DB between tests, and drive it with `httpx`. Coverage includes auth, session CRUD, batch delete/export, folders (CRUD, duplicate name 409, move-into-folder, delete-preserves-as-ungrouped, cross-user isolation), `folder_id` shape on session list, and config/CORS. No mocks β€” real SQLite, real bcrypt, real JWT. Runs in ~10s. --- @@ -299,6 +350,8 @@ Key design choices: - **Pure stdlib HTTP server.** Raw sockets, manual header parsing, RFC 6455 WebSocket implementation. No Flask / FastAPI / aiohttp. The only new runtime deps are the three chat-UI extras (`sqlalchemy`, `bcrypt`, `PyJWT`). - **In-process agent.** The Chat UI runs `agent.run()` directly (no PTY subprocess). A `queue.Queue` fans events out to WS subscribers; a 500-event ring buffer lets late-joining subscribers replay missed events. - **Single-source slash-command events.** `handle_slash_sync` (HTTP POST `/api/prompt`) and `handle_slash_stream` (SSE) deliver synchronous slash-command events through their own response channel only β€” **not** also via the live WS broadcaster. Re-broadcasting would duplicate every reply in the same client (which iterates `data.events` AND fires `_handleEvent` from `ws.onmessage`). Background-thread events (sentinel flows, agent runs spawned from a slash command) still go through `_broadcast` normally because the helpers restore it in `finally` before the worker thread emits anything. +- **In-place schema migration for `folder_id`.** `init_db()` runs a `PRAGMA table_info(chat_sessions)` check after `Base.metadata.create_all` and `ALTER TABLE`s the column in for older databases. SQLite's `PRAGMA foreign_keys` is left **off** (matching the pre-existing engine config), so the `ON DELETE SET NULL` declared on the FK does not fire automatically β€” `repo.delete_folder` instead issues an explicit `UPDATE chat_sessions SET folder_id = NULL` before deleting the folder row. Cascade deletes on `User β†’ ChatSessionRow β†’ Message` continue to work because they're driven by SQLAlchemy ORM `cascade="all, delete-orphan"` rather than DB-level constraints. +- **Two-step session-into-folder placement.** New sessions are still created via the unchanged `POST /api/prompt` (empty body) flow, which always returns a session with `folder_id = NULL`. The Chat UI reads its active-folder context (`localStorage["cc-active-folder"]`) and immediately follows up with `PATCH /api/sessions/{id}/folder`. Two requests, but the contract for non-folder-aware clients (e.g. CLI tooling that POSTs to `/api/prompt`) stays identical. - **Write-through persistence.** Messages live in memory (for fast replay) AND SQLite (for survival). Config changes PATCH both. - **Two cookies on the same origin.** Chat UI uses `ccjwt` (7-day JWT), PTY terminal uses `cctoken` (one-time password). The browser sends both; each route only reads the one it cares about. - **Thread-local request context** for access logs: `_req_ctx` holds method/path/start_ts/user_id/peer. `_send_http` reads it once per response and logs + increments counters. diff --git a/docs/news.md b/docs/news.md index 4ee2bc6..1a87db6 100644 --- a/docs/news.md +++ b/docs/news.md @@ -3,7 +3,11 @@ ## πŸ”₯πŸ”₯πŸ”₯ News (Pacific Time) -- May 10, 2026 (latest): **Small-context local models survive large workloads β€” 4-part fix: ctx cap, auto-fanout, stagnation-stop, output paths under `~/.cheetahclaws/`.** Repro that motivated the work: running `/agent β†’ 1 (Research Assistant)` on a 6.6 MB PDF (`AutoRedTeamer.pdf` β€” ~70k tokens of extracted text) with `custom/qwen2.5-72b` (32k ctx). Old behavior: 400 BadRequest "context length 32768"; the agent_runner kept polling the template every 2 s; the model produced **1500+ identical "task complete" summaries** before anything stopped it. New behavior, four cooperating layers: (1) **Per-model context-window registry + dynamic max_tokens cap** (`providers._MODEL_CONTEXT_LIMITS` + `get_model_context_window` + `dynamic_cap_max_tokens`) β€” covers Qwen 2.5/3, Llama 3.x, Mistral/Mixtral, Phi, Gemma, DeepSeek local variants; `_fetch_custom_model_limit` now backfills `PROVIDERS["custom"]["context_limit"]` so compaction sees the live `/v1/models` value; per-call shrink based on actual prompt size keeps `input + output + 1024 safety ≀ ctx`. `compaction.get_context_limit` gains an optional `config` arg so custom-endpoint detection works on the very first turn. (2) **Auto-fanout for oversize tool outputs** (`multi_agent/fanout.py`) β€” when a single tool result (Read on a huge PDF, Grep over a giant tree, WebFetch of a long article) exceeds 0.4 Γ— ctx_window, split into chunks at paragraph boundaries with token-overlap, dispatch parallel sub-LLM map calls (one per chunk, default cap 5 subagents), merge with a single reduce call; substitutes the merged summary in conversation history instead of letting the next API call overflow. Hooked at the tool-result append site in `agent.py`; transparent UX prints `[Auto-fanout: <Tool> returned ~N chars (>threshold) β†’ dispatching K parallel sub-summaries]`. Configurable: `auto_fanout_enabled` / `_threshold` / `_max_subagents` / `_chunk_overlap_tokens`. (3) **Stagnation-stop in `agent_runner.py`** β€” when the model emits the same summary N iterations in a row (default 3, whitespace/case-normalized), stop the loop with a clear notification instead of burning thousands of API calls; configurable via `auto_agent_dup_summary_limit` (0 disables). (4) **Agent output paths under `~/.cheetahclaws/`** β€” `/agent` wizard now resolves relative output filenames (e.g. `research_notes.md`) to absolute paths under `~/.cheetahclaws/agents/<name>/output/` instead of CWD; `AgentRunner` exposes `runner.output_dir`, eagerly mkdir'd; Summary block + post-start info show the resolved path in green; absolute paths pass through unchanged. **Tests:** +47 new (fanout 23, ctx cap 18, dup-stop 13, output paths 8). **Full suite: 2139 passing, zero regressions.** User-side guide: [`docs/guides/extensions.md`](guides/extensions.md). +- May 10, 2026 (latest): **Web Chat UI session organization β€” folders, drag-drop, batch ops, resizable sidebar, ChatGPT-style active-folder context.** Built on top of the slash-fix branch the same day. (1) **Folders.** New `folders` table (per-user, name unique), `chat_sessions.folder_id` nullable FK with `ON DELETE SET NULL` semantics enforced at the repo layer (SQLite `PRAGMA foreign_keys` is off in this engine). Light-touch migration runs in `init_db()`: a `PRAGMA table_info` probe adds the column to existing DBs in place β€” no Alembic, no manual steps for upgraders. New endpoints: `GET/POST /api/folders`, `PATCH/DELETE /api/folders/{id}`, `PATCH /api/sessions/{id}/folder` (body `{folder_id: int|null}`); deleting a folder preserves its sessions and reparents them to "Ungrouped". (2) **Drag-and-drop + Move-to context menu.** Session items are HTML5-draggable; folder rows and the Ungrouped header are drop targets with `drop-target` highlight. Right-click on a session shows a flat `Move to:` section listing every folder, plus `(Ungrouped)` when applicable and `+ New folder…` for create-and-move in one shot. Right-click on a folder header offers Rename / Delete (with a `confirm()` that spells out "sessions become Ungrouped β€” they are NOT deleted"). (3) **Active-folder context (ChatGPT-style).** Click a folder name (not the disclosure arrow) to "enter" that folder β€” the row gets accent highlighting and the topbar grows a `Chat Β· in <Folder>` breadcrumb. While a folder is active, **`+ New` and direct-typing auto-create both drop the new session into that folder**, mirroring how OpenAI Projects scope new chats. Switching to any session syncs active-folder to that session's folder so the breadcrumb stays honest. State persists across reloads via `localStorage` (`cc-active-folder`); deleted folders auto-clear. (4) **Batch select.** "Select" button in the sidebar header enters a checkbox mode with a footer action bar: count, Select all (respects the search filter), Delete (single confirm with total-message count), Export (single combined Markdown download with one `## Session: <title>` block per session, `chats-N-sessions.md`), Cancel. Right-click context menu is suppressed in select mode to avoid mode confusion. (5) **Resizable sidebar.** 4-px drag handle between sidebar and main pane (mouse + touch); width clamped to 200–600 px and persisted to `localStorage` (`cc-sidebar-w`). Double-click resets to default. Hidden under `@media (max-width: 768px)` so the mobile drawer keeps its swipe behavior. **Tests:** +10 new in `test_web_api.py` (folder CRUD, duplicate-409, move, delete-preserves, cross-user isolation, list includes `folder_id`, batch delete, batch delete cross-user, batch export, batch export empty 400) β€” full file at 31 tests, all passing, zero regressions on the existing 21. User-side guide: [`docs/guides/web-ui.md`](guides/web-ui.md) (Layout / Session management / Folders / HTTP API all updated). + +- May 10, 2026: **Web Chat UI fixes β€” slash commands no longer reply twice; `--web --model X` actually applies the model.** Two related issues that surfaced when wiring a self-hosted vLLM endpoint into the Chat UI. (1) **Issue #111 β€” slash commands duplicated in Chat UI but not in terminal.** `web/api.py:handle_slash_sync` was both returning events inline in the HTTP response **and** broadcasting the same events to the WS subscribers of the same client; `chat.js` then iterated `data.events` AND fired `_handleEvent` from `ws.onmessage`, rendering every reply twice. Same bug in `handle_slash_stream` for SSE-streamed long commands (`/brainstorm`, `/worker`, `/agent`, `/plan`). Both helpers now deliver events through a single channel β€” HTTP/SSE only β€” so `_handleEvent` runs exactly once per event. Background-thread events (sentinel flows, agent runs) are unaffected: by the time the worker thread emits, `_broadcast` is already restored to the live WS broadcaster in `finally`. (2) **`--web --model X` was silently ignored.** The CLI override branch only ran in the interactive-REPL path; the `if args.web:` branch loaded config straight from disk and started the server, so `python cheetahclaws.py --web --model custom/qwen2.5-72b` would happily boot but every request handler reloaded `~/.cheetahclaws/config.json` with the previous model name (e.g. `gemma-4-31B-it`), producing a confusing `404: model does not exist` against the new endpoint. Fix: `cheetahclaws.py` now persists `args.model` to config before calling `start_web_server`, matching the documented behavior; `provider:model` β†’ `provider/model` normalization is identical to the REPL path. User-side guide: [`docs/guides/web-ui.md`](guides/web-ui.md) (Troubleshooting + Architecture notes updated). + +- May 10, 2026: **Small-context local models survive large workloads β€” 4-part fix: ctx cap, auto-fanout, stagnation-stop, output paths under `~/.cheetahclaws/`.** Repro that motivated the work: running `/agent β†’ 1 (Research Assistant)` on a 6.6 MB PDF (`AutoRedTeamer.pdf` β€” ~70k tokens of extracted text) with `custom/qwen2.5-72b` (32k ctx). Old behavior: 400 BadRequest "context length 32768"; the agent_runner kept polling the template every 2 s; the model produced **1500+ identical "task complete" summaries** before anything stopped it. New behavior, four cooperating layers: (1) **Per-model context-window registry + dynamic max_tokens cap** (`providers._MODEL_CONTEXT_LIMITS` + `get_model_context_window` + `dynamic_cap_max_tokens`) β€” covers Qwen 2.5/3, Llama 3.x, Mistral/Mixtral, Phi, Gemma, DeepSeek local variants; `_fetch_custom_model_limit` now backfills `PROVIDERS["custom"]["context_limit"]` so compaction sees the live `/v1/models` value; per-call shrink based on actual prompt size keeps `input + output + 1024 safety ≀ ctx`. `compaction.get_context_limit` gains an optional `config` arg so custom-endpoint detection works on the very first turn. (2) **Auto-fanout for oversize tool outputs** (`multi_agent/fanout.py`) β€” when a single tool result (Read on a huge PDF, Grep over a giant tree, WebFetch of a long article) exceeds 0.4 Γ— ctx_window, split into chunks at paragraph boundaries with token-overlap, dispatch parallel sub-LLM map calls (one per chunk, default cap 5 subagents), merge with a single reduce call; substitutes the merged summary in conversation history instead of letting the next API call overflow. Hooked at the tool-result append site in `agent.py`; transparent UX prints `[Auto-fanout: <Tool> returned ~N chars (>threshold) β†’ dispatching K parallel sub-summaries]`. Configurable: `auto_fanout_enabled` / `_threshold` / `_max_subagents` / `_chunk_overlap_tokens`. (3) **Stagnation-stop in `agent_runner.py`** β€” when the model emits the same summary N iterations in a row (default 3, whitespace/case-normalized), stop the loop with a clear notification instead of burning thousands of API calls; configurable via `auto_agent_dup_summary_limit` (0 disables). (4) **Agent output paths under `~/.cheetahclaws/`** β€” `/agent` wizard now resolves relative output filenames (e.g. `research_notes.md`) to absolute paths under `~/.cheetahclaws/agents/<name>/output/` instead of CWD; `AgentRunner` exposes `runner.output_dir`, eagerly mkdir'd; Summary block + post-start info show the resolved path in green; absolute paths pass through unchanged. **Tests:** +47 new (fanout 23, ctx cap 18, dup-stop 13, output paths 8). **Full suite: 2139 passing, zero regressions.** User-side guide: [`docs/guides/extensions.md`](guides/extensions.md). - May 9, 2026: **Read tool auto-redirects on overflow β€” defense-in-depth for the case where model ignores the template instruction.** Re-running the same `/agent + autodan.pdf` failure showed two real-world problems with the prior fix: (1) The user was running the **pip-installed** binary (`/home/shangdinggu/anaconda3/bin/cheetahclaws`), not the source tree. New tools / templates added to source had no effect. (2) Even if the user reinstalled, qwen2.5-72b would likely still call `Read` instead of `SummarizeLargeFile` β€” models default to familiar tools no matter what the template says. The fix moves the routing decision into the Read tool itself. (a) **New `_maybe_redirect_to_summarize` helper (`tools/files.py`).** When `Read` or `ReadPDF` would return content too large to safely fit in the next API call, it instead returns a **short redirect message** like `[ReadTooLarge: file is too large β€” call SummarizeLargeFile with file_path='X' instead] PREVIEW: …`. The model sees the redirect, calls `SummarizeLargeFile`, gets a chunked-and-merged summary back. The raw content never enters the API call. (b) **CJK-aware token estimation.** CJK content tokenizes at ~1 token per character (vs ~2.8 chars/token for English). New `_is_cjk_heavy()` heuristic: β‰₯20% CJK characters β†’ use 1:1 char-to-token estimate. A 24K-char Chinese file is 24K tokens, not 8.6K, and now triggers redirect on a 32K-context model. (c) **Conservative ceiling for unreliable provider declarations.** `custom/<model>` provider declares 128K context by default but the underlying model is often 32K (qwen2.5-72b, llama 3 8B, etc.). New `safe_ctx = min(declared_ctx, 30000)` caps the threshold at 30K tokens regardless of provider claims β€” the redirect now fires on the user's exact ~25K-token PDF case (would NOT have fired with the unconditional 128K ceiling, which is exactly the bug). (d) **Wrapped Read registration (`tools/__init__.py`).** New `_read_with_overflow_check` lambda calls `_maybe_redirect_to_summarize` after `_read` returns; for results <8KB it skips (not worth the check). ReadPDF gets the same treatment inline in `_read_pdf`. **Why this works even on the old install**: as soon as the user updates `tools/files.py` and `tools/__init__.py`, the redirect fires regardless of whether SummarizeLargeFile / template changes are present. The redirect's prose tells the model exactly which tool to call and with what args. Tests: 14 new pytest cases (`tests/test_read_overflow_redirect.py`) β€” CJK detection (English / Chinese / Japanese / mixed-minority / empty), threshold logic (small file β†’ no redirect; user's exact failure case β†’ redirect with right pointer; CJK at lower char count triggers vs same chars in English; conservative ceiling protects against overconfident provider; preview included for context). Plus 2 integration tests via `execute_tool("Read", ...)` confirming the wrapper applies the redirect end-to-end. **2077 targeted regression tests pass** (2063 prior + 14 new), zero regressions across the whole repo. diff --git a/tests/test_web_api.py b/tests/test_web_api.py index dbde0d1..6489535 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -258,6 +258,193 @@ def test_export_session_markdown(server_url): assert "# " in r.text # has a heading +def test_batch_delete_sessions(server_url): + with _client(server_url) as c: + _register(c, "alice") + sids = [ + c.post("/api/prompt", + json={"prompt": "", "session_id": ""}).json()["session_id"] + for _ in range(3) + ] + # Delete first two + r = c.post("/api/sessions/batch_delete", json={"ids": sids[:2]}) + assert r.status_code == 200 + body = r.json() + assert body["deleted"] == 2 + assert body["failed"] == [] + assert body["requested"] == 2 + remaining = c.get("/api/sessions").json()["sessions"] + assert {s["id"] for s in remaining} == {sids[2]} + + +def test_batch_delete_skips_other_users_sessions(server_url): + with _client(server_url) as ca: + _register(ca, "alice") + a_sid = ca.post("/api/prompt", + json={"prompt": "", "session_id": ""} + ).json()["session_id"] + with _client(server_url) as cb: + _register(cb, "bob") + b_sid = cb.post("/api/prompt", + json={"prompt": "", "session_id": ""} + ).json()["session_id"] + # Bob attempts to batch-delete Alice's session along with his own + r = cb.post("/api/sessions/batch_delete", + json={"ids": [a_sid, b_sid]}) + assert r.status_code == 200 + body = r.json() + assert body["deleted"] == 1 # only his own + assert body["failed"] == [a_sid] + # Alice's session still listed for Alice + with _client(server_url) as ca: + _login(ca, "alice") + ls = ca.get("/api/sessions").json()["sessions"] + assert any(s["id"] == a_sid for s in ls) + + +def test_batch_export_sessions_markdown(server_url): + with _client(server_url) as c: + _register(c, "alice") + sids = [ + c.post("/api/prompt", + json={"prompt": "", "session_id": ""}).json()["session_id"] + for _ in range(2) + ] + c.patch(f"/api/sessions/{sids[0]}", json={"title": "First Topic"}) + c.patch(f"/api/sessions/{sids[1]}", json={"title": "Second Topic"}) + r = c.post("/api/sessions/batch_export", json={"ids": sids}) + assert r.status_code == 200 + assert r.headers["content-type"].startswith("text/markdown") + assert "Chat Export" in r.text + assert "First Topic" in r.text + assert "Second Topic" in r.text + for sid in sids: + assert sid in r.text + # Filename hints the session count + cd = r.headers.get("content-disposition", "") + assert "chats-2-sessions.md" in cd + + +def test_batch_export_empty_ids_returns_400(server_url): + with _client(server_url) as c: + _register(c, "alice") + r = c.post("/api/sessions/batch_export", json={"ids": []}) + assert r.status_code == 400 + + +def test_folder_create_list_rename_delete(server_url): + with _client(server_url) as c: + _register(c, "alice") + # Create + r = c.post("/api/folders", json={"name": "Trading"}) + assert r.status_code == 200 + f = r.json() + assert f["name"] == "Trading" + assert f["session_count"] == 0 + fid = f["id"] + # List + r = c.get("/api/folders") + assert r.status_code == 200 + assert any(x["id"] == fid for x in r.json()["folders"]) + # Rename + r = c.patch(f"/api/folders/{fid}", json={"name": "Crypto Trading"}) + assert r.status_code == 200 + assert r.json()["name"] == "Crypto Trading" + # Delete + r = c.request("DELETE", f"/api/folders/{fid}") + assert r.status_code == 200 + assert r.json() == {"ok": True} + assert c.get("/api/folders").json()["folders"] == [] + + +def test_folder_duplicate_name_409(server_url): + with _client(server_url) as c: + _register(c, "alice") + c.post("/api/folders", json={"name": "Research"}) + r = c.post("/api/folders", json={"name": "Research"}) + assert r.status_code == 409 + + +def test_move_session_to_folder(server_url): + with _client(server_url) as c: + _register(c, "alice") + sid = c.post("/api/prompt", + json={"prompt": "", "session_id": ""}).json()["session_id"] + fid = c.post("/api/folders", json={"name": "Notes"}).json()["id"] + # Move into folder + r = c.patch(f"/api/sessions/{sid}/folder", json={"folder_id": fid}) + assert r.status_code == 200 + assert r.json()["folder_id"] == fid + ls = c.get("/api/sessions").json()["sessions"] + assert next(s for s in ls if s["id"] == sid)["folder_id"] == fid + # Move out (back to ungrouped) + r = c.patch(f"/api/sessions/{sid}/folder", json={"folder_id": None}) + assert r.status_code == 200 + assert r.json()["folder_id"] is None + ls = c.get("/api/sessions").json()["sessions"] + assert next(s for s in ls if s["id"] == sid)["folder_id"] is None + + +def test_delete_folder_preserves_sessions_as_ungrouped(server_url): + with _client(server_url) as c: + _register(c, "alice") + sid = c.post("/api/prompt", + json={"prompt": "", "session_id": ""}).json()["session_id"] + fid = c.post("/api/folders", json={"name": "Temp"}).json()["id"] + c.patch(f"/api/sessions/{sid}/folder", json={"folder_id": fid}) + # Delete folder + r = c.request("DELETE", f"/api/folders/{fid}") + assert r.status_code == 200 + # Session still listed, now ungrouped + ls = c.get("/api/sessions").json()["sessions"] + s = next(x for x in ls if x["id"] == sid) + assert s["folder_id"] is None + + +def test_folder_cross_user_isolation(server_url): + with _client(server_url) as ca: + _register(ca, "alice") + a_fid = ca.post("/api/folders", + json={"name": "AlicePrivate"}).json()["id"] + a_sid = ca.post("/api/prompt", + json={"prompt": "", "session_id": ""} + ).json()["session_id"] + with _client(server_url) as cb: + _register(cb, "bob") + b_sid = cb.post("/api/prompt", + json={"prompt": "", "session_id": ""} + ).json()["session_id"] + # Bob cannot see Alice's folder + assert cb.get("/api/folders").json() == {"folders": []} + # Bob cannot move his session into Alice's folder + r = cb.patch(f"/api/sessions/{b_sid}/folder", + json={"folder_id": a_fid}) + assert r.status_code == 404 + # Bob cannot rename or delete Alice's folder + assert cb.patch(f"/api/folders/{a_fid}", + json={"name": "BobOwned"}).status_code == 404 + assert cb.request("DELETE", + f"/api/folders/{a_fid}").json() == {"ok": False} + # Alice's folder still intact + with _client(server_url) as ca: + _login(ca, "alice") + folders = ca.get("/api/folders").json()["folders"] + assert any(f["id"] == a_fid and f["name"] == "AlicePrivate" + for f in folders) + + +def test_session_list_includes_folder_id(server_url): + with _client(server_url) as c: + _register(c, "alice") + sid = c.post("/api/prompt", + json={"prompt": "", "session_id": ""}).json()["session_id"] + ls = c.get("/api/sessions").json()["sessions"] + # New sessions are ungrouped + s = next(x for x in ls if x["id"] == sid) + assert "folder_id" in s + assert s["folder_id"] is None + + def test_cross_user_isolation(server_url): with _client(server_url) as ca: _register(ca, "alice") diff --git a/web/api.py b/web/api.py index b336adb..4fd042f 100644 --- a/web/api.py +++ b/web/api.py @@ -984,6 +984,74 @@ def remove_chat_session(sid: str, user_id: int) -> bool: return deleted +def list_folders(user_id: int) -> list[dict]: + from web import db as _db + return _db.repo.list_folders(user_id) + + +def create_folder(user_id: int, name: str) -> Optional[dict]: + from web import db as _db + return _db.repo.create_folder(user_id, name) + + +def rename_folder(folder_id: int, user_id: int, name: str) -> bool: + from web import db as _db + return _db.repo.rename_folder(folder_id, user_id, name) + + +def remove_folder(folder_id: int, user_id: int) -> bool: + from web import db as _db + return _db.repo.delete_folder(folder_id, user_id) + + +def move_session_to_folder(sid: str, user_id: int, + folder_id: Optional[int]) -> bool: + from web import db as _db + return _db.repo.move_session_to_folder(sid, user_id, folder_id) + + +def batch_remove_chat_sessions(sids: list, user_id: int) -> dict: + """Delete multiple sessions for a user. Cross-user IDs are silently + skipped (delete_session enforces ownership). Returns counts.""" + deleted = 0 + failed: list[str] = [] + for sid in sids: + try: + if remove_chat_session(sid, user_id): + deleted += 1 + else: + failed.append(sid) + except Exception: # noqa: BLE001 + failed.append(sid) + return {"deleted": deleted, "failed": failed, "requested": len(sids)} + + +def batch_export_chat_sessions_markdown(sids: list, + user_id: int) -> Optional[str]: + """Combine multiple sessions into a single markdown document. Returns + None when no requested session belongs to the user.""" + parts: list[str] = [] + rendered = 0 + for sid in sids: + md = export_chat_session_markdown(sid, user_id) + if md is None: + continue + rendered += 1 + if parts: + parts.append("\n\n---\n\n") + parts.append(md) + if rendered == 0: + return None + import datetime as _dt + header = ( + f"# Chat Export β€” {rendered} session" + f"{'s' if rendered != 1 else ''}\n\n" + f"- Exported: {_dt.datetime.now():%Y-%m-%d %H:%M}\n" + f"- User ID: {user_id}\n\n---\n\n" + ) + return header + "".join(parts) + + def rename_chat_session(sid: str, user_id: int, title: str) -> bool: try: from web import db as _db diff --git a/web/chat.html b/web/chat.html index 112e822..1ca9e73 100644 --- a/web/chat.html +++ b/web/chat.html @@ -113,6 +113,106 @@ border-radius:var(--radius-sm); font-weight:600; font-size:12px; cursor:pointer; } #sidebar .new-btn:hover { opacity:.85; } +#sidebar .hdr-buttons { display:flex; gap:6px; } +#sidebar .select-btn { + background:transparent; color:var(--text-dim); border:1px solid var(--border); + padding:5px 10px; border-radius:var(--radius-sm); font-weight:600; font-size:12px; + cursor:pointer; transition:background .15s, color .15s; +} +#sidebar .select-btn:hover { background:var(--panel); color:var(--text); } +#sidebar .select-btn.active { + background:var(--accent); color:#000; border-color:var(--accent); +} +#sidebar .folder-btn { + background:transparent; color:var(--text-dim); border:1px solid var(--border); + padding:5px 10px; border-radius:var(--radius-sm); font-weight:600; font-size:12px; + cursor:pointer; transition:background .15s, color .15s; +} +#sidebar .folder-btn:hover { background:var(--panel); color:var(--text); } + +/* Folder tree */ +.folder-row { + display:flex; align-items:center; gap:6px; padding:7px 8px; + font-size:12px; color:var(--text-dim); font-weight:600; + cursor:pointer; user-select:none; border-radius:var(--radius-sm); + margin-top:4px; border:1px solid transparent; +} +.folder-row:hover { background:var(--panel); color:var(--text); } +.folder-row.drop-target { + border-color:var(--accent); background:var(--blue-dim); color:var(--text); +} +.folder-row.active-folder { + border-color:var(--accent); background:var(--blue-dim); color:var(--text); +} +.folder-row.active-folder .folder-name { + color:var(--accent); +} +#topbar .title-folder { + color:var(--accent); font-weight:500; font-size:13px; + margin-left:6px; opacity:.8; +} +.folder-row .arrow { + display:inline-block; width:10px; transition:transform .15s; flex-shrink:0; +} +.folder-row.collapsed .arrow { transform:rotate(-90deg); } +.folder-row .folder-name { + flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; +} +.folder-row .folder-count { + color:var(--text-muted); font-size:11px; font-weight:500; flex-shrink:0; +} +.folder-children { + margin-left:14px; padding-left:6px; border-left:1px dashed var(--border); +} +.folder-children.hidden { display:none; } +.folder-row.ungrouped { color:var(--text-muted); } +.sess-item.dragging { opacity:.5; } +.sess-checkbox { + display:inline-block; width:14px; height:14px; border:1.5px solid var(--text-muted); + border-radius:3px; flex-shrink:0; box-sizing:border-box; position:relative; + background:var(--panel); +} +.sess-item.selected { background:var(--blue-dim); border-color:var(--blue); } +.sess-item.selected .sess-checkbox { + background:var(--blue); border-color:var(--blue); +} +.sess-item.selected .sess-checkbox::after { + content:""; position:absolute; left:3px; top:0px; width:4px; height:8px; + border:solid #fff; border-width:0 2px 2px 0; transform:rotate(45deg); +} +#batch-bar { + border-top:1px solid var(--border); padding:10px 12px; + display:flex; flex-direction:column; gap:6px; background:var(--panel); + font-size:12px; +} +#batch-bar .batch-count { + color:var(--text-dim); font-size:11px; + display:flex; justify-content:space-between; align-items:center; +} +#batch-bar .batch-link { + background:none; border:none; color:var(--accent); cursor:pointer; + font-size:11px; padding:2px 4px; font-weight:600; +} +#batch-bar .batch-link:hover { text-decoration:underline; } +#batch-bar .batch-link:disabled { + color:var(--text-muted); cursor:not-allowed; text-decoration:none; +} +#batch-bar .batch-actions { display:flex; gap:6px; } +#batch-bar .batch-actions button { + flex:1; padding:6px 10px; border-radius:var(--radius-sm); + font-size:12px; font-weight:600; cursor:pointer; border:1px solid var(--border); + background:var(--surface); color:var(--text); transition:background .15s; +} +#batch-bar .batch-actions button:hover { background:var(--panel); } +#batch-bar .batch-actions button:disabled { + opacity:.5; cursor:not-allowed; +} +#batch-bar .batch-actions .btn-delete:hover { + background:var(--red-dim); color:var(--red); border-color:var(--red); +} +#batch-bar .batch-actions .btn-export:hover { + background:var(--blue-dim); color:var(--blue); border-color:var(--blue); +} .sess-search { padding:8px 10px 4px; } .sess-search input { width:100%; box-sizing:border-box; padding:6px 10px; @@ -163,6 +263,17 @@ height:1px; background:var(--border); margin:4px 6px; } +/* ── Sidebar resizer ──────────────────────────────────────────── */ +#sidebar-resizer { + width:4px; flex:0 0 4px; cursor:col-resize; + background:transparent; border-left:1px solid var(--border); + transition:background .15s, border-color .15s; +} +#sidebar-resizer:hover, #sidebar-resizer.dragging { + background:var(--accent); border-left-color:var(--accent); +} +body.resizing { cursor:col-resize; user-select:none; } + /* ── Main area ────────────────────────────────────────────────── */ #main { flex:1; display:flex; flex-direction:column; min-width:0; } #topbar { @@ -432,6 +543,7 @@ transform:translateX(-100%); } #sidebar.open { transform:translateX(0); } + #sidebar-resizer { display:none; } #topbar .menu-btn { display:block; } .msg { max-width:95%; } .msg.user { max-width:90%; } @@ -471,19 +583,29 @@ <h2 style="color:var(--accent);margin-bottom:.25rem;font-size:1.25rem;">CheetahC <aside id="sidebar"> <div class="hdr"> <h2>CheetahClaws</h2> - <button class="new-btn" onclick="app.newSession()">+ New</button> + <div class="hdr-buttons"> + <button class="folder-btn" onclick="app.newFolder()" + title="Create a folder">+ Folder</button> + <button class="select-btn" id="select-btn" onclick="app.toggleSelectMode()" + title="Select multiple sessions">Select</button> + <button class="new-btn" onclick="app.newSession()">+ New</button> + </div> </div> <div class="sess-search"> <input id="sess-search-input" type="text" placeholder="Search sessions..." oninput="app._renderSessionList()" autocomplete="off"> </div> <div id="session-list"></div> + <div id="batch-bar" style="display:none"></div> <div id="sidebar-foot"> <span id="sidebar-user">β€”</span> <button class="link-btn" onclick="app.logout()" title="Sign out">Sign out</button> </div> </aside> +<!-- Resizable divider between sidebar and main --> +<div id="sidebar-resizer" title="Drag to resize"></div> + <!-- Session context menu (right-click) --> <div id="sess-menu" style="display:none;position:fixed;z-index:400;background:var(--surface); border:1px solid var(--border);border-radius:8px;padding:4px;min-width:160px; diff --git a/web/db.py b/web/db.py index 47978d2..958a25d 100644 --- a/web/db.py +++ b/web/db.py @@ -24,7 +24,7 @@ ) from exc from web.models import ( - ApiCredential, Base, ChatSessionRow, Message, User, + ApiCredential, Base, ChatSessionRow, Folder, Message, User, ) @@ -59,6 +59,23 @@ def init_db(db_path: Optional[Path] = None) -> None: future=True, ) Base.metadata.create_all(_engine) + # Light-touch migration for existing DBs that predate folders: + # add chat_sessions.folder_id if missing. SQLite ALTER TABLE is + # limited but ADD COLUMN with NULL default works fine. + from sqlalchemy import text + with _engine.begin() as conn: + cols = {row[1] for row in conn.exec_driver_sql( + "PRAGMA table_info(chat_sessions)" + ).fetchall()} + if "folder_id" not in cols: + conn.exec_driver_sql( + "ALTER TABLE chat_sessions ADD COLUMN folder_id INTEGER" + " REFERENCES folders(id) ON DELETE SET NULL" + ) + conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_chat_sessions_folder_id" + " ON chat_sessions(folder_id)" + ) _SessionLocal = sessionmaker(bind=_engine, autoflush=False, expire_on_commit=False, future=True) # Tighten file permissions β€” the DB now holds password hashes & API keys. @@ -171,6 +188,7 @@ def list_sessions(user_id: int) -> list[dict]: "created_at": r.ChatSessionRow.created_at, "last_active": r.ChatSessionRow.last_active, "message_count": int(r.msg_count or 0), + "folder_id": r.ChatSessionRow.folder_id, } for r in rows ] @@ -218,6 +236,108 @@ def touch_session(session_id: str) -> None: if row: row.last_active = time.time() + @staticmethod + def move_session_to_folder(session_id: str, user_id: int, + folder_id: Optional[int]) -> bool: + """Set or clear a session's folder. None means ungrouped. + + Verifies the folder (when given) belongs to the same user β€” silently + rejects cross-user moves the same way other ownership checks do. + """ + with session_scope() as db: + row = db.get(ChatSessionRow, session_id) + if not row or row.user_id != user_id: + return False + if folder_id is not None: + fld = db.get(Folder, folder_id) + if not fld or fld.user_id != user_id: + return False + row.folder_id = folder_id + return True + + # ── Folders ──────────────────────────────────────────────────────── + + @staticmethod + def list_folders(user_id: int) -> list[dict]: + with session_scope() as db: + rows = db.execute( + select( + Folder, + func.count(ChatSessionRow.id).label("sess_count"), + ) + .outerjoin(ChatSessionRow, + ChatSessionRow.folder_id == Folder.id) + .where(Folder.user_id == user_id) + .group_by(Folder.id) + .order_by(Folder.created_at) + ).all() + return [ + { + "id": r.Folder.id, + "name": r.Folder.name, + "created_at": r.Folder.created_at, + "session_count": int(r.sess_count or 0), + } + for r in rows + ] + + @staticmethod + def create_folder(user_id: int, name: str) -> Optional[dict]: + """Create a folder. Returns None if the name already exists for + this user (UniqueConstraint violation).""" + from sqlalchemy.exc import IntegrityError + name = (name or "").strip()[:120] + if not name: + return None + with session_scope() as db: + f = Folder(user_id=user_id, name=name) + db.add(f) + try: + db.flush() + except IntegrityError: + db.rollback() + return None + return {"id": f.id, "name": f.name, + "created_at": f.created_at, "session_count": 0} + + @staticmethod + def rename_folder(folder_id: int, user_id: int, name: str) -> bool: + from sqlalchemy.exc import IntegrityError + name = (name or "").strip()[:120] + if not name: + return False + with session_scope() as db: + f = db.get(Folder, folder_id) + if not f or f.user_id != user_id: + return False + f.name = name + try: + db.flush() + except IntegrityError: + db.rollback() + return False + return True + + @staticmethod + def delete_folder(folder_id: int, user_id: int) -> bool: + """Delete a folder. Sessions inside it are preserved and become + ungrouped. We NULL out folder_id explicitly because SQLite's + PRAGMA foreign_keys is off in this engine, so the ON DELETE SET NULL + wouldn't fire on its own.""" + from sqlalchemy import update + with session_scope() as db: + f = db.get(Folder, folder_id) + if not f or f.user_id != user_id: + return False + db.execute( + update(ChatSessionRow) + .where(ChatSessionRow.folder_id == folder_id, + ChatSessionRow.user_id == user_id) + .values(folder_id=None) + ) + db.delete(f) + return True + # ── Messages ─────────────────────────────────────────────────────── @staticmethod diff --git a/web/models.py b/web/models.py index 917ff9e..3078e3f 100644 --- a/web/models.py +++ b/web/models.py @@ -49,6 +49,27 @@ class User(Base): ) +class Folder(Base): + """User-scoped folder for grouping chat sessions (flat hierarchy).""" + __tablename__ = "folders" + __table_args__ = ( + UniqueConstraint("user_id", "name", name="uq_user_folder_name"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), + index=True, nullable=False, + ) + name: Mapped[str] = mapped_column(String(120), nullable=False) + created_at: Mapped[float] = mapped_column(Float, default=time.time, + nullable=False) + + sessions: Mapped[list["ChatSessionRow"]] = relationship( + back_populates="folder" + ) + + class ChatSessionRow(Base): """Persistent metadata for a chat session. @@ -66,8 +87,13 @@ class ChatSessionRow(Base): last_active: Mapped[float] = mapped_column(Float, default=time.time, nullable=False, index=True) config_json: Mapped[str] = mapped_column(Text, default="{}", nullable=False) + folder_id: Mapped[int | None] = mapped_column( + ForeignKey("folders.id", ondelete="SET NULL"), + nullable=True, index=True, + ) user: Mapped[User] = relationship(back_populates="sessions") + folder: Mapped["Folder | None"] = relationship(back_populates="sessions") messages: Mapped[list["Message"]] = relationship( back_populates="session", cascade="all, delete-orphan", diff --git a/web/server.py b/web/server.py index affff83..8964b61 100644 --- a/web/server.py +++ b/web/server.py @@ -1500,6 +1500,77 @@ def _sse_callback(evt_dict): sock.close() return + # ── /api/folders β€” list / create / rename / delete ────────── + if path.startswith("/api/folders"): + uid = _require_user(sock, cookie, origin) + if uid is None: + return + from web.api import (list_folders, create_folder, + rename_folder, remove_folder) + parts_f = path.rstrip("/").split("/") + # GET /api/folders + if path == "/api/folders" and method == "GET": + _send_json(sock, {"folders": list_folders(uid)}, + request_origin=origin) + sock.close() + return + # POST /api/folders body: {name} + if path == "/api/folders" and method == "POST": + name = (body_json.get("name") or "").strip() + if not name: + _send_http(sock, "400 Bad Request", "application/json", + b'{"error":"name required"}', + request_origin=origin) + sock.close() + return + folder = create_folder(uid, name) + if folder is None: + _send_http(sock, "409 Conflict", "application/json", + b'{"error":"folder name already exists"}', + request_origin=origin) + else: + _send_json(sock, folder, request_origin=origin) + sock.close() + return + # /api/folders/{id} + if len(parts_f) == 4: + try: + fid = int(parts_f[3]) + except ValueError: + _send_http(sock, "404 Not Found", "text/plain", + b"Not Found", request_origin=origin) + sock.close() + return + if method == "PATCH": + name = (body_json.get("name") or "").strip() + if not name: + _send_http(sock, "400 Bad Request", + "application/json", + b'{"error":"name required"}', + request_origin=origin) + sock.close() + return + ok = rename_folder(fid, uid, name) + if ok: + _send_json(sock, {"ok": True, "name": name[:120]}, + request_origin=origin) + else: + _send_http(sock, "404 Not Found", + "application/json", + b'{"error":"not found or duplicate name"}', + request_origin=origin) + sock.close() + return + if method == "DELETE": + ok = remove_folder(fid, uid) + _send_json(sock, {"ok": ok}, request_origin=origin) + sock.close() + return + _send_http(sock, "404 Not Found", "text/plain", b"Not Found", + request_origin=origin) + sock.close() + return + # ── /api/sessions β€” list / get / rename / delete / export ─── if path.startswith("/api/sessions"): uid = _require_user(sock, cookie, origin) @@ -1507,8 +1578,49 @@ def _sse_callback(evt_dict): return from web.api import (list_chat_sessions, get_chat_session, rename_chat_session, remove_chat_session, - export_chat_session_markdown) + export_chat_session_markdown, + batch_remove_chat_sessions, + batch_export_chat_sessions_markdown, + move_session_to_folder) from cc_config import load_config + # POST /api/sessions/batch_delete body: {ids: [...]} + if path == "/api/sessions/batch_delete" and method == "POST": + ids = body_json.get("ids") or [] + if not isinstance(ids, list): + _send_http(sock, "400 Bad Request", "application/json", + b'{"error":"ids must be a list"}', + request_origin=origin) + sock.close() + return + result = batch_remove_chat_sessions( + [str(i) for i in ids], uid) + _send_json(sock, result, request_origin=origin) + sock.close() + return + # POST /api/sessions/batch_export body: {ids: [...]} + if path == "/api/sessions/batch_export" and method == "POST": + ids = body_json.get("ids") or [] + if not isinstance(ids, list) or not ids: + _send_http(sock, "400 Bad Request", "application/json", + b'{"error":"ids must be a non-empty list"}', + request_origin=origin) + sock.close() + return + md = batch_export_chat_sessions_markdown( + [str(i) for i in ids], uid) + if md is None: + _send_http(sock, "404 Not Found", "text/plain", + b"no sessions found", request_origin=origin) + else: + fname = f"chats-{len(ids)}-sessions.md" + cd = (f"Content-Disposition: attachment; " + f"filename=\"{fname}\"\r\n") + _send_http(sock, "200 OK", + "text/markdown; charset=utf-8", + md.encode("utf-8"), + extra_headers=cd, request_origin=origin) + sock.close() + return parts_path = path.rstrip("/").split("/") # GET /api/sessions if len(parts_path) == 3 and method == "GET": @@ -1558,6 +1670,32 @@ def _sse_callback(evt_dict): _send_json(sock, {"ok": ok}, request_origin=origin) sock.close() return + # PATCH /api/sessions/{id}/folder body: {folder_id: int|null} + if (len(parts_path) == 5 and parts_path[4] == "folder" + and method == "PATCH"): + sid = parts_path[3] + fid_raw = body_json.get("folder_id", None) + fid = None + if fid_raw is not None: + try: + fid = int(fid_raw) + except (TypeError, ValueError): + _send_http(sock, "400 Bad Request", + "application/json", + b'{"error":"folder_id must be int or null"}', + request_origin=origin) + sock.close() + return + ok = move_session_to_folder(sid, uid, fid) + if ok: + _send_json(sock, {"ok": True, "folder_id": fid}, + request_origin=origin) + else: + _send_http(sock, "404 Not Found", "application/json", + b'{"error":"session or folder not found"}', + request_origin=origin) + sock.close() + return # GET /api/sessions/{id}/export if (len(parts_path) == 5 and parts_path[4] == "export" and method == "GET"): diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 9f93b67..4c795ba 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -47,6 +47,18 @@ class ChatApp { return; } this.sessionId = data.session_id; + // If user is "in" a folder, drop the auto-created session there. + const fid = this._getActiveFolderId && this._getActiveFolderId(); + if (fid) { + try { + await this._fetchAuth( + `/api/sessions/${data.session_id}/folder`, { + method: 'PATCH', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({folder_id: fid}), + }); + } catch(e) { /* non-fatal */ } + } this._connectWS(this.sessionId); this.loadSessions(); } diff --git a/web/static/js/init.js b/web/static/js/init.js index 38f273c..1bd84a2 100644 --- a/web/static/js/init.js +++ b/web/static/js/init.js @@ -21,3 +21,53 @@ promptInput.addEventListener('input', () => { document.getElementById('main').addEventListener('click', () => { document.getElementById('sidebar').classList.remove('open'); }); + +/* ── Sidebar resizer ───────────────────────────────────────────── */ +(function initSidebarResizer() { + const sidebar = document.getElementById('sidebar'); + const resizer = document.getElementById('sidebar-resizer'); + if (!sidebar || !resizer) return; + const MIN = 200, MAX = 600; + // Restore saved width + const saved = parseInt(localStorage.getItem('cc-sidebar-w') || '0', 10); + if (saved >= MIN && saved <= MAX) { + sidebar.style.width = saved + 'px'; + sidebar.style.minWidth = saved + 'px'; + } + let startX = 0, startW = 0, dragging = false; + const onMove = (e) => { + if (!dragging) return; + const x = e.touches ? e.touches[0].clientX : e.clientX; + const w = Math.min(MAX, Math.max(MIN, startW + (x - startX))); + sidebar.style.width = w + 'px'; + sidebar.style.minWidth = w + 'px'; + }; + const onUp = () => { + if (!dragging) return; + dragging = false; + resizer.classList.remove('dragging'); + document.body.classList.remove('resizing'); + const w = Math.round(sidebar.getBoundingClientRect().width); + localStorage.setItem('cc-sidebar-w', String(w)); + }; + const onDown = (e) => { + dragging = true; + startX = e.touches ? e.touches[0].clientX : e.clientX; + startW = sidebar.getBoundingClientRect().width; + resizer.classList.add('dragging'); + document.body.classList.add('resizing'); + e.preventDefault(); + }; + resizer.addEventListener('mousedown', onDown); + resizer.addEventListener('touchstart', onDown, {passive: false}); + document.addEventListener('mousemove', onMove); + document.addEventListener('touchmove', onMove, {passive: false}); + document.addEventListener('mouseup', onUp); + document.addEventListener('touchend', onUp); + // Double-click resets to default + resizer.addEventListener('dblclick', () => { + sidebar.style.width = ''; + sidebar.style.minWidth = ''; + localStorage.removeItem('cc-sidebar-w'); + }); +})(); diff --git a/web/static/js/sidebar.js b/web/static/js/sidebar.js index aa73d1b..34c69ab 100644 --- a/web/static/js/sidebar.js +++ b/web/static/js/sidebar.js @@ -4,68 +4,463 @@ Object.assign(ChatApp.prototype, { async loadSessions() { + // Fetch sessions and folders independently. If /api/folders is missing + // (older server) or errors, the session list still renders flat. try { const r = await this._fetchAuth('/api/sessions'); - const data = await r.json(); - this._sessions = data.sessions || []; - this._renderSessionList(); - } catch(e) { console.error('loadSessions:', e); } + if (r.ok) { + const sd = await r.json(); + this._sessions = sd.sessions || []; + } + } catch(e) { console.error('loadSessions sessions:', e); } + try { + const r = await this._fetchAuth('/api/folders'); + if (r.ok) { + const fd = await r.json(); + this._folders = fd.folders || []; + } else { + this._folders = []; + } + } catch(e) { + console.warn('loadSessions folders (non-fatal):', e); + this._folders = []; + } + this._renderSessionList(); + }, + + _collapsedFolders() { + if (!this.__collapsedFolders) { + try { + const raw = localStorage.getItem('cc-collapsed-folders') || '[]'; + this.__collapsedFolders = new Set(JSON.parse(raw)); + } catch(e) { this.__collapsedFolders = new Set(); } + } + return this.__collapsedFolders; + }, + + _saveCollapsed() { + try { + localStorage.setItem('cc-collapsed-folders', + JSON.stringify([...this._collapsedFolders()])); + } catch(e) {} + }, + + _toggleCollapse(key) { + const set = this._collapsedFolders(); + if (set.has(key)) set.delete(key); else set.add(key); + this._saveCollapsed(); + this._renderSessionList(); + }, + + _getActiveFolderId() { + if (this._activeFolderId === undefined) { + const raw = localStorage.getItem('cc-active-folder'); + this._activeFolderId = raw ? parseInt(raw, 10) : null; + } + return this._activeFolderId || null; + }, + + _setActiveFolder(fid) { + this._activeFolderId = fid || null; + if (fid) { + localStorage.setItem('cc-active-folder', String(fid)); + // Expand the folder so its sessions are visible + this._collapsedFolders().delete(`f:${fid}`); + this._saveCollapsed(); + } else { + localStorage.removeItem('cc-active-folder'); + } + this._renderSessionList(); + this._updateTopbarFolder(); + }, + + _updateTopbarFolder() { + const titleEl = document.querySelector('#topbar .title'); + if (!titleEl) return; + let badge = document.querySelector('#topbar .title-folder'); + const fid = this._getActiveFolderId(); + const folder = (this._folders || []).find(f => f.id === fid); + if (folder) { + if (!badge) { + badge = document.createElement('span'); + badge.className = 'title-folder'; + titleEl.parentNode.insertBefore(badge, titleEl.nextSibling); + } + badge.textContent = `Β· in ${folder.name}`; + } else if (badge) { + badge.remove(); + } }, _renderSessionList() { const list = document.getElementById('session-list'); if (!list) return; + if (!this._selectedIds) this._selectedIds = new Set(); + if (!this._folders) this._folders = []; + const sel = this._selectMode; const q = (document.getElementById('sess-search-input')?.value || '') .trim().toLowerCase(); - const items = (this._sessions || []).filter(s => + const allSessions = (this._sessions || []).filter(s => !q || (s.title || '').toLowerCase().includes(q) || s.id.includes(q) ); list.innerHTML = ''; - if (items.length === 0) { + if (allSessions.length === 0 && this._folders.length === 0) { const empty = document.createElement('div'); empty.className = 'sess-empty'; empty.textContent = q - ? 'No sessions match.' : 'No sessions yet β€” click + New.'; + ? 'No sessions match.' + : 'No sessions yet β€” click + New.'; list.appendChild(empty); + this._renderBatchBar(); return; } - items.forEach(s => { - const el = document.createElement('div'); - el.className = 'sess-item' + (s.id === this.sessionId ? ' active' : ''); - const title = s.title && s.title !== 'New chat' - ? s.title : `Untitled (${s.id.slice(0, 6)})`; - el.innerHTML = ` - <div class="sess-title"> - <span class="sess-dot ${s.busy ? '' : 'idle'}"></span> - <span>${this._escapeHtml(title)}</span> - </div> - <div class="sess-info"> - <span>${s.message_count || 0} msg</span> - <span>${this._fmtRelTime(s.last_active)}</span> - </div>`; - el.onclick = () => this.switchSession(s.id); - el.oncontextmenu = (e) => { + // Group sessions by folder_id + const byFolder = new Map(); + const ungrouped = []; + allSessions.forEach(s => { + if (s.folder_id == null) ungrouped.push(s); + else { + if (!byFolder.has(s.folder_id)) byFolder.set(s.folder_id, []); + byFolder.get(s.folder_id).push(s); + } + }); + const collapsed = this._collapsedFolders(); + const activeFid = this._getActiveFolderId(); + // Drop stale active reference if folder was deleted + if (activeFid && !this._folders.some(f => f.id === activeFid)) { + this._activeFolderId = null; + localStorage.removeItem('cc-active-folder'); + } + // Render each named folder + this._folders.forEach(f => { + const inside = byFolder.get(f.id) || []; + const isCollapsed = collapsed.has(`f:${f.id}`); + const isActive = this._getActiveFolderId() === f.id; + const row = document.createElement('div'); + row.className = 'folder-row' + + (isCollapsed ? ' collapsed' : '') + + (isActive ? ' active-folder' : ''); + row.dataset.folderId = String(f.id); + row.innerHTML = ` + <span class="arrow">${isCollapsed ? 'β–Έ' : 'β–Ύ'}</span> + <span class="folder-name">${this._escapeHtml(f.name)}</span> + <span class="folder-count">${inside.length}</span>`; + row.onclick = (e) => { + // Click on the arrow: only toggle collapse. Click anywhere else on + // the row: enter the folder (set as active). Mirrors how IDE-style + // tree views separate the disclosure triangle from the row body. + if (e.target.classList.contains('arrow')) { + this._toggleCollapse(`f:${f.id}`); + } else if (this._getActiveFolderId() === f.id) { + // Already active β†’ exit folder context (clear active) + this._setActiveFolder(null); + } else { + this._setActiveFolder(f.id); + } + }; + row.oncontextmenu = (e) => { e.preventDefault(); - this._showSessMenu(e.clientX, e.clientY, s); + this._showFolderMenu(e.clientX, e.clientY, f); }; - list.appendChild(el); + this._wireDropTarget(row, f.id); + list.appendChild(row); + const wrap = document.createElement('div'); + wrap.className = 'folder-children' + (isCollapsed ? ' hidden' : ''); + inside.forEach(s => wrap.appendChild(this._renderSessItem(s, sel))); + list.appendChild(wrap); }); + // Render Ungrouped header (always shown, even empty, when folders exist) + const showUngrouped = this._folders.length > 0 + ? true // header always shown as a drop target + : ungrouped.length > 0; // no folders β†’ just sessions, no header + if (showUngrouped && this._folders.length > 0) { + const isCollapsed = collapsed.has('ungrouped'); + const row = document.createElement('div'); + row.className = 'folder-row ungrouped' + + (isCollapsed ? ' collapsed' : ''); + row.innerHTML = ` + <span class="arrow">${isCollapsed ? 'β–Έ' : 'β–Ύ'}</span> + <span class="folder-name">Ungrouped</span> + <span class="folder-count">${ungrouped.length}</span>`; + row.onclick = () => this._toggleCollapse('ungrouped'); + this._wireDropTarget(row, null); + list.appendChild(row); + const wrap = document.createElement('div'); + wrap.className = 'folder-children' + (isCollapsed ? ' hidden' : ''); + ungrouped.forEach(s => wrap.appendChild(this._renderSessItem(s, sel))); + list.appendChild(wrap); + } else { + // No folders at all β†’ render sessions flat (legacy layout) + ungrouped.forEach(s => + list.appendChild(this._renderSessItem(s, sel))); + } + this._renderBatchBar(); + this._updateTopbarFolder(); + }, + + _renderSessItem(s, sel) { + const checked = sel && this._selectedIds.has(s.id); + const el = document.createElement('div'); + el.className = 'sess-item' + + (s.id === this.sessionId && !sel ? ' active' : '') + + (checked ? ' selected' : ''); + el.dataset.sessionId = s.id; + if (!sel) el.draggable = true; // drag disabled in batch-select mode + const title = s.title && s.title !== 'New chat' + ? s.title : `Untitled (${s.id.slice(0, 6)})`; + const checkboxHtml = sel ? '<span class="sess-checkbox"></span>' : ''; + el.innerHTML = ` + <div class="sess-title"> + ${checkboxHtml} + <span class="sess-dot ${s.busy ? '' : 'idle'}"></span> + <span>${this._escapeHtml(title)}</span> + </div> + <div class="sess-info"> + <span>${s.message_count || 0} msg</span> + <span>${this._fmtRelTime(s.last_active)}</span> + </div>`; + el.onclick = () => { + if (this._selectMode) this._toggleSelected(s.id); + else this.switchSession(s.id); + }; + el.oncontextmenu = (e) => { + e.preventDefault(); + if (this._selectMode) return; + this._showSessMenu(e.clientX, e.clientY, s); + }; + el.ondragstart = (e) => { + e.dataTransfer.setData('text/cc-session-id', s.id); + e.dataTransfer.effectAllowed = 'move'; + el.classList.add('dragging'); + }; + el.ondragend = () => el.classList.remove('dragging'); + return el; + }, + + _wireDropTarget(row, folderId) { + row.ondragover = (e) => { + const sid = e.dataTransfer.types.includes('text/cc-session-id'); + if (!sid) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + row.classList.add('drop-target'); + }; + row.ondragleave = () => row.classList.remove('drop-target'); + row.ondrop = (e) => { + e.preventDefault(); + row.classList.remove('drop-target'); + const sid = e.dataTransfer.getData('text/cc-session-id'); + if (sid) this.moveSessionToFolder(sid, folderId); + }; + }, + + toggleSelectMode() { + this._selectMode = !this._selectMode; + if (!this._selectedIds) this._selectedIds = new Set(); + if (!this._selectMode) this._selectedIds.clear(); + const btn = document.getElementById('select-btn'); + if (btn) btn.classList.toggle('active', this._selectMode); + this._renderSessionList(); + }, + + _toggleSelected(sid) { + if (!this._selectedIds) this._selectedIds = new Set(); + if (this._selectedIds.has(sid)) this._selectedIds.delete(sid); + else this._selectedIds.add(sid); + this._renderSessionList(); + }, + + _renderBatchBar() { + const bar = document.getElementById('batch-bar'); + if (!bar) return; + if (!this._selectMode) { + bar.style.display = 'none'; + bar.innerHTML = ''; + return; + } + const sel = this._selectedIds || new Set(); + const visibleIds = this._visibleSessionIds(); + const allSelected = visibleIds.length > 0 + && visibleIds.every(id => sel.has(id)); + const toggleLabel = allSelected ? 'Deselect all' : 'Select all'; + const dis = sel.size === 0 ? 'disabled' : ''; + bar.style.display = ''; + bar.innerHTML = ` + <div class="batch-count"> + <span>${sel.size} selected</span> + <button class="batch-link" onclick="app._toggleSelectAll()" + ${visibleIds.length === 0 ? 'disabled' : ''}>${toggleLabel}</button> + </div> + <div class="batch-actions"> + <button class="btn-delete" ${dis} onclick="app.batchDelete()">Delete</button> + <button class="btn-export" ${dis} onclick="app.batchExport()">Export</button> + <button onclick="app.toggleSelectMode()">Cancel</button> + </div>`; + }, + + _visibleSessionIds() { + const q = (document.getElementById('sess-search-input')?.value || '') + .trim().toLowerCase(); + return (this._sessions || []) + .filter(s => !q + || (s.title || '').toLowerCase().includes(q) + || s.id.includes(q)) + .map(s => s.id); + }, + + _toggleSelectAll() { + if (!this._selectedIds) this._selectedIds = new Set(); + const visible = this._visibleSessionIds(); + if (visible.length === 0) return; + const allSelected = visible.every(id => this._selectedIds.has(id)); + if (allSelected) { + visible.forEach(id => this._selectedIds.delete(id)); + } else { + visible.forEach(id => this._selectedIds.add(id)); + } + this._renderSessionList(); + }, + + async batchDelete() { + const ids = Array.from(this._selectedIds || []); + if (ids.length === 0) return; + const totalMsgs = (this._sessions || []) + .filter(s => this._selectedIds.has(s.id)) + .reduce((sum, s) => sum + (s.message_count || 0), 0); + if (!confirm( + `Delete ${ids.length} session${ids.length === 1 ? '' : 's'}?\n\n` + + `This removes ${totalMsgs} messages permanently.` + )) return; + try { + const r = await this._fetchAuth('/api/sessions/batch_delete', { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ids}), + }); + const data = await r.json(); + if (!r.ok) { + alert(data.error || `Server error (${r.status})`); + return; + } + // If we deleted the currently active session, clear chat + if (this.sessionId && this._selectedIds.has(this.sessionId)) { + this._disconnectWS(); + this.sessionId = null; + this._clearChat(); + this._showWelcome(); + } + this._selectedIds.clear(); + this._selectMode = false; + const btn = document.getElementById('select-btn'); + if (btn) btn.classList.remove('active'); + this.loadSessions(); + } catch(e) { alert('Delete failed: ' + e.message); } + }, + + async batchExport() { + const ids = Array.from(this._selectedIds || []); + if (ids.length === 0) return; + try { + const r = await this._fetchAuth('/api/sessions/batch_export', { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ids}), + }); + if (!r.ok) { + const data = await r.json().catch(() => ({})); + alert(data.error || `Server error (${r.status})`); + return; + } + const blob = await r.blob(); + const cd = r.headers.get('Content-Disposition') || ''; + const m = cd.match(/filename="?([^"]+)"?/); + const fname = (m && m[1]) || `chats-${ids.length}-sessions.md`; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fname; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch(e) { alert('Export failed: ' + e.message); } }, _showSessMenu(x, y, sess) { const menu = document.getElementById('sess-menu'); + const folders = this._folders || []; + let moveItems = ''; + folders.forEach(f => { + if (f.id === sess.folder_id) return; // already in this folder + moveItems += `<div class="menu-item" data-act="move:${f.id}">` + + `  ${this._escapeHtml(f.name)}</div>`; + }); + if (sess.folder_id != null) { + moveItems += '<div class="menu-item" data-act="move:null">' + + '  (Ungrouped)</div>'; + } + moveItems += '<div class="menu-item" data-act="move:new">' + + '  + New folder…</div>'; menu.innerHTML = ` <div class="menu-item" data-act="rename">Rename...</div> <div class="menu-item" data-act="export">Export Markdown</div> <div class="menu-sep"></div> + <div class="menu-item" data-act="movehdr" + style="cursor:default;color:var(--text-muted);font-size:11px;"> + Move to:</div> + ${moveItems} + <div class="menu-sep"></div> <div class="menu-item danger" data-act="delete">Delete</div>`; menu.querySelectorAll('.menu-item').forEach(item => { - item.onclick = () => { + const act = item.dataset.act; + if (act === 'movehdr') return; // header, not clickable + item.onclick = async () => { menu.style.display = 'none'; - const act = item.dataset.act; if (act === 'rename') this.renameSession(sess); else if (act === 'export') this.exportSession(sess); else if (act === 'delete') this.deleteSession(sess); + else if (act && act.startsWith('move:')) { + const target = act.slice(5); + if (target === 'new') { + const name = prompt('New folder name:'); + if (!name || !name.trim()) return; + const f = await this._createFolder(name.trim()); + if (f) await this.moveSessionToFolder(sess.id, f.id); + } else if (target === 'null') { + await this.moveSessionToFolder(sess.id, null); + } else { + await this.moveSessionToFolder(sess.id, parseInt(target, 10)); + } + } + }; + }); + menu.style.display = 'block'; + const rect = menu.getBoundingClientRect(); + const px = Math.min(x, window.innerWidth - rect.width - 8); + const py = Math.min(y, window.innerHeight - rect.height - 8); + menu.style.left = px + 'px'; + menu.style.top = py + 'px'; + const dismiss = (ev) => { + if (!menu.contains(ev.target)) { + menu.style.display = 'none'; + document.removeEventListener('click', dismiss); + } + }; + setTimeout(() => document.addEventListener('click', dismiss), 0); + }, + + _showFolderMenu(x, y, folder) { + const menu = document.getElementById('sess-menu'); + menu.innerHTML = ` + <div class="menu-item" data-act="rename">Rename...</div> + <div class="menu-sep"></div> + <div class="menu-item danger" data-act="delete">Delete folder</div>`; + menu.querySelectorAll('.menu-item').forEach(item => { + item.onclick = () => { + menu.style.display = 'none'; + const act = item.dataset.act; + if (act === 'rename') this.renameFolder(folder); + else if (act === 'delete') this.deleteFolder(folder); }; }); menu.style.display = 'block'; @@ -83,6 +478,81 @@ Object.assign(ChatApp.prototype, { setTimeout(() => document.addEventListener('click', dismiss), 0); }, + async _createFolder(name) { + try { + const r = await this._fetchAuth('/api/folders', { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({name}), + }); + const data = await r.json(); + if (!r.ok) { + alert(data.error || `Server error (${r.status})`); + return null; + } + return data; + } catch(e) { alert('Create folder failed: ' + e.message); return null; } + }, + + async newFolder() { + const name = prompt('New folder name:'); + if (!name || !name.trim()) return; + const f = await this._createFolder(name.trim()); + if (f) this.loadSessions(); + }, + + async renameFolder(folder) { + const name = prompt('Rename folder:', folder.name); + if (name === null) return; + const t = name.trim(); + if (!t || t === folder.name) return; + try { + const r = await this._fetchAuth(`/api/folders/${folder.id}`, { + method: 'PATCH', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({name: t}), + }); + const data = await r.json(); + if (!r.ok) { alert(data.error || `Server error (${r.status})`); return; } + this.loadSessions(); + } catch(e) { alert('Rename failed: ' + e.message); } + }, + + async deleteFolder(folder) { + if (!confirm( + `Delete folder "${folder.name}"?\n\n` + + `Sessions inside (${folder.session_count || 0}) will become Ungrouped β€” ` + + `they are NOT deleted.` + )) return; + try { + const r = await this._fetchAuth(`/api/folders/${folder.id}`, { + method: 'DELETE', + }); + if (!r.ok) { + const data = await r.json().catch(() => ({})); + alert(data.error || `Server error (${r.status})`); + return; + } + this.loadSessions(); + } catch(e) { alert('Delete failed: ' + e.message); } + }, + + async moveSessionToFolder(sid, folderId) { + try { + const r = await this._fetchAuth(`/api/sessions/${sid}/folder`, { + method: 'PATCH', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({folder_id: folderId}), + }); + if (!r.ok) { + const data = await r.json().catch(() => ({})); + alert(data.error || `Server error (${r.status})`); + return; + } + this.loadSessions(); + } catch(e) { alert('Move failed: ' + e.message); } + }, + async renameSession(sess) { const title = prompt('Rename session:', sess.title || ''); if (title === null) return; @@ -135,6 +605,18 @@ Object.assign(ChatApp.prototype, { const data = await r.json(); if (r.ok && data.session_id) { this.sessionId = data.session_id; + // If the user is "in" a folder, drop the new session into it. + const fid = this._getActiveFolderId(); + if (fid) { + try { + await this._fetchAuth( + `/api/sessions/${data.session_id}/folder`, { + method: 'PATCH', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({folder_id: fid}), + }); + } catch(e) { /* non-fatal β€” session still exists, just ungrouped */ } + } this._connectWS(this.sessionId); this._showWelcome(); } @@ -146,6 +628,18 @@ Object.assign(ChatApp.prototype, { if (sid === this.sessionId) return; this._disconnectWS(); this.sessionId = sid; + // Sync active folder to the session's folder so subsequent + New stays + // in the user's current context (ChatGPT-style follow-the-breadcrumb). + const s = (this._sessions || []).find(x => x.id === sid); + if (s) { + const fid = s.folder_id || null; + if (fid !== this._getActiveFolderId()) { + this._activeFolderId = fid; + if (fid) localStorage.setItem('cc-active-folder', String(fid)); + else localStorage.removeItem('cc-active-folder'); + this._updateTopbarFolder(); + } + } this._clearChat(); try { const r = await this._fetchAuth(`/api/sessions/${sid}`);