From c167da78474ecfe3314dce3c53c84bed845cb7dd Mon Sep 17 00:00:00 2001 From: leipeng Date: Sun, 23 Aug 2026 13:56:24 +0800 Subject: [PATCH] feat(web): add manual context compaction --- docs/architecture/backend.md | 1 + docs/architecture/data-flow.md | 25 ++++++ docs/architecture/frontend.md | 2 +- internal/rpc/client.go | 8 ++ internal/rpc/client_test.go | 11 +++ internal/rpc/worker.go | 19 +++++ internal/rpc/worker_test.go | 34 ++++++++ internal/server/chat.go | 42 ++++++++++ internal/server/chat_test.go | 58 +++++++++++++ internal/server/server.go | 1 + internal/ui/embedded/styles/session.css | 27 ++++++ internal/workers/manager.go | 11 +++ internal/workers/manager_test.go | 27 ++++++ .../components/session/ChatComposer.svelte | 2 +- .../session/chat/ContextUsage.svelte | 14 +++- .../session/chat/ContextUsage.test.js | 10 +++ .../session/chat/chat-composer-runtime.js | 1 + .../components/session/chat/chat-submit.js | 38 ++++++++- .../session/chat/chat-submit.test.js | 84 ++++++++++++++++++- .../session/chat/context-popover.js | 20 ++++- .../session/chat/context-popover.test.js | 34 ++++++++ .../components/session/chat/context-usage.js | 1 + .../session/chat/context-usage.test.js | 13 +++ web/src/session/chat/chat-api.js | 12 +++ web/src/session/chat/chat-api.test.js | 15 +++- web/src/shared/icons.js | 2 + web/src/shared/locales/en.js | 4 + 27 files changed, 504 insertions(+), 12 deletions(-) diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index 6815c5c8..6655de45 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -255,6 +255,7 @@ type piRPCWorker struct { | `/api/sessions` | GET | `handleApiSessions` | JSON list of session summaries | | `/api/chat` | POST | `handleChat` | Send chat message (multipart) | | `/api/chat/cancel` | POST | `handleCancelChat` | Abort running chat worker | +| `/api/compact` | POST | `handleCompact` | Compact the current session through its RPC worker | | `/api/set-model` | POST | `handleSetModel` | Change model for session | | `/api/set-thinking-level` | POST | `handleSetThinkingLevel` | Change thinking level | | `/api/models` | GET | `handleAvailableModels` | List available AI models | diff --git a/docs/architecture/data-flow.md b/docs/architecture/data-flow.md index 48f442ef..4867c89e 100644 --- a/docs/architecture/data-flow.md +++ b/docs/architecture/data-flow.md @@ -143,6 +143,31 @@ Browser POST /api/chat?id= └──▶ Return {"ok": true, "status": "accepted"} ``` +## Data Flow: Compact Current Session + +``` +Browser POST /api/compact?id= + │ + ▼ + server.handleCompact + │ + ├──▶ sessions.ResolveByID → Session + Path + ├──▶ workers.Manager.Compact(ctx, sessionID, sessionPath, instructions) + │ ├──▶ Reuse or create the session's ChatWorker + │ └──▶ worker.Compact + │ ├──▶ Reject while the worker is running + │ ├──▶ Write {"type":"compact"} JSONL to pi stdin + │ └──▶ Await the correlated RPC response + ├──▶ Broadcast "reload" to the session SSE clients + └──▶ Return {"ok": true, "status": "compacted"} +``` + +The context popover button sends an empty instruction string so Pi or an installed +compaction extension can apply its defaults. An attachment-free composer message +matching `/compact [instructions]` is intercepted and sent to this endpoint; it is +never forwarded as a normal prompt. With pi-vcc installed, `keep:N` is supported by +passing it as `customInstructions`. + ## Data Flow: Rename Session ``` diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 82aeb039..f4fda591 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -50,7 +50,7 @@ The message pane is rendered by Svelte components (no string-building renderer): - `data/` — payload decoding + the reactive `SessionDataModel` (`session-data.svelte.js`, the single source of truth: entries/lookups/tree/active-path/view-state, `reconcile()`) - `tree/`, `render/`, `navigation/` — **pure** tree/format/markdown/navigation helpers consumed by the Svelte components (and the export). The message renderer is now ``/``; `render/` keeps `session-format`, `markdown`, `entry-format`, `session-entry-actions` (download/share/copy) - `session-globals.js`, `session-content-runtime.js`, `lazy-highlight.js` — the relocated live glue (see above) -- `chat/` — **pure/shared helpers**: `chat-api` + `git-api` (fetch wrappers), `chat-selectors` (pure model/thinking helpers), `done-notifier` (shared notification/sound/push util, also used by the settings page). Live composer DOM helpers live under `web/src/components/session/chat/`, wired together by `chat-composer-runtime.js` (`runChatComposer`, mounted by ``). +- `chat/` — **pure/shared helpers**: `chat-api` + `git-api` (fetch wrappers), `chat-selectors` (pure model/thinking helpers), `done-notifier` (shared notification/sound/push util, also used by the settings page). Live composer DOM helpers live under `web/src/components/session/chat/`, wired together by `chat-composer-runtime.js` (`runChatComposer`, mounted by ``). The context popover can manually compact the active session, and an attachment-free `/compact [instructions]` submission is intercepted locally and routed to the same `/api/compact` endpoint instead of becoming a prompt. - `live/` — live-only helpers used by ``: `live-connection.js` (SSE connection/reconnect lifecycle), `live-events.js` (SSE/reload primitives), `live-scroll.js` (low-level scroll primitives), `live-follow.js` (`createFollowScrollController` — follow-mode decision state + follow button), `live-stats.js` (header stats), and `chat-preview.js` (streaming-preview helper, also used by ``) - `ui/` — sidebar/search/toggle/session-ui-runner helpers used by `setupSessionUi` and `RightSidebar` - `artifacts/`, `annotations/` — pure registries/filters/ranges + the fetch API wrappers; the panels themselves are `ArtifactPanel.svelte`/`AnnotationLayer.svelte` diff --git a/internal/rpc/client.go b/internal/rpc/client.go index 8bdc1643..e93f41be 100644 --- a/internal/rpc/client.go +++ b/internal/rpc/client.go @@ -54,6 +54,14 @@ func BuildAbortCommand(id string) map[string]any { return map[string]any{"id": id, "type": "abort"} } +func BuildCompactCommand(id, customInstructions string) map[string]any { + cmd := map[string]any{"id": id, "type": "compact"} + if customInstructions != "" { + cmd["customInstructions"] = customInstructions + } + return cmd +} + func BuildSetThinkingLevelCommand(id, level string) map[string]any { return map[string]any{"id": id, "type": "set_thinking_level", "level": level} } diff --git a/internal/rpc/client_test.go b/internal/rpc/client_test.go index d9cc946b..58c43476 100644 --- a/internal/rpc/client_test.go +++ b/internal/rpc/client_test.go @@ -59,6 +59,17 @@ func TestBuildGetCommandsCommand(t *testing.T) { } } +func TestBuildCompactCommand(t *testing.T) { + cmd := BuildCompactCommand("req-8", "keep:3") + if cmd["id"] != "req-8" || cmd["type"] != "compact" || cmd["customInstructions"] != "keep:3" { + t.Fatalf("cmd = %#v", cmd) + } + cmd = BuildCompactCommand("req-9", "") + if _, ok := cmd["customInstructions"]; ok { + t.Fatalf("customInstructions present for default compact command") + } +} + func TestWriteRPCCommandWritesJSONLine(t *testing.T) { var buf bytes.Buffer if err := WriteCommand(&buf, map[string]any{"type": "get_state"}); err != nil { diff --git a/internal/rpc/worker.go b/internal/rpc/worker.go index 1f2298e4..7d41b9a0 100644 --- a/internal/rpc/worker.go +++ b/internal/rpc/worker.go @@ -127,6 +127,25 @@ func (w *piRPCWorker) Prompt(ctx context.Context, chat chat.Request) error { return nil } +func (w *piRPCWorker) Compact(ctx context.Context, customInstructions string) error { + w.touch() + w.mu.Lock() + if w.status.State == workers.WorkerStateRunning || w.hasRecentStreamActivityLocked(time.Now()) { + w.mu.Unlock() + return workers.ErrWorkerBusy + } + w.status.State = workers.WorkerStateRunning + w.status.Error = "" + w.mu.Unlock() + + err := w.sendAndAwait(ctx, BuildCompactCommand(w.nextID(), customInstructions)) + w.mu.Lock() + w.status.State = workers.WorkerStateIdle + w.status.Error = "" + w.mu.Unlock() + return err +} + func (w *piRPCWorker) SetModel(ctx context.Context, provider, modelID string) error { w.touch() id := w.nextID() diff --git a/internal/rpc/worker_test.go b/internal/rpc/worker_test.go index 406775da..29e9c632 100644 --- a/internal/rpc/worker_test.go +++ b/internal/rpc/worker_test.go @@ -30,6 +30,40 @@ func waitForPending(t *testing.T, w *piRPCWorker, id string) { t.Fatalf("pending request %q never registered", id) } +func TestCompactWritesRPCCommandAndReturnsIdle(t *testing.T) { + var buf bytes.Buffer + w := &piRPCWorker{ + stdin: nopWriteCloser{&buf}, + status: workers.WorkerStatus{State: workers.WorkerStateIdle}, + pending: make(map[string]chan response), + } + done := make(chan error, 1) + go func() { done <- w.Compact(context.Background(), "keep:3") }() + + waitForPending(t, w, "req-1") + w.handleRPCLine(`{"type":"response","id":"req-1","command":"compact","success":true,"data":{}}`) + + if err := <-done; err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(buf.String()); got != `{"customInstructions":"keep:3","id":"req-1","type":"compact"}` { + t.Fatalf("command = %s", got) + } + if got := w.Status(); got.State != workers.WorkerStateIdle { + t.Fatalf("status = %q, want idle", got.State) + } +} + +func TestCompactRejectsRunningWorker(t *testing.T) { + w := &piRPCWorker{ + status: workers.WorkerStatus{State: workers.WorkerStateRunning}, + pending: make(map[string]chan response), + } + if err := w.Compact(context.Background(), ""); err != workers.ErrWorkerBusy { + t.Fatalf("error = %v, want ErrWorkerBusy", err) + } +} + func TestStatusReportsRunningDuringRecentStreamActivity(t *testing.T) { w := &piRPCWorker{ status: workers.WorkerStatus{State: workers.WorkerStateIdle}, diff --git a/internal/server/chat.go b/internal/server/chat.go index a678a208..2af2d870 100644 --- a/internal/server/chat.go +++ b/internal/server/chat.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "pi-web/internal/chat" @@ -17,6 +18,7 @@ import ( type ChatSender interface { Send(ctx context.Context, sessionID, sessionPath string, chat chat.Request) error + Compact(ctx context.Context, sessionID, sessionPath, customInstructions string) error SetModel(ctx context.Context, sessionID, sessionPath, provider, modelID string) error SetThinkingLevel(ctx context.Context, sessionID, sessionPath, level string) error Abort(ctx context.Context, sessionID string) error @@ -134,6 +136,46 @@ func (s *Server) handleCancelChat(w http.ResponseWriter, r *http.Request) { writeJSON(w, 0, map[string]any{"ok": true, "status": "cancelled"}) } +func (s *Server) handleCompact(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + resolved, err := sessions.ResolveByID(s.sessionsDir, r.URL.Query().Get("id")) + if resolveOrWriteError(w, err) { + return + } + if !resolved.Session.ChatAvailable { + writeJSONError(w, http.StatusConflict, resolved.Session.ChatDisabledReason) + return + } + if s.chatSender == nil { + writeJSONError(w, http.StatusServiceUnavailable, "chat unavailable") + return + } + var body struct { + CustomInstructions string `json:"customInstructions"` + } + if !decodeJSONBody(w, r, &body) { + return + } + if err := s.chatSender.Compact( + r.Context(), + resolved.Session.ID, + resolved.Path, + strings.TrimSpace(body.CustomInstructions), + ); err != nil { + if errors.Is(err, workers.ErrWorkerBusy) { + writeJSONError(w, http.StatusConflict, err.Error()) + return + } + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + s.broadcast(resolved.Session.ID, "reload") + writeJSON(w, 0, map[string]any{"ok": true, "status": "compacted"}) +} + func (s *Server) handleWorkerStatus(w http.ResponseWriter, r *http.Request) { sessionID := r.URL.Query().Get("id") diff --git a/internal/server/chat_test.go b/internal/server/chat_test.go index 21eca3af..16593440 100644 --- a/internal/server/chat_test.go +++ b/internal/server/chat_test.go @@ -47,6 +47,10 @@ type fakeSender struct { setModelID string setThinkingSessionID string setThinkingLevel string + compactSessionID string + compactSessionPath string + compactInstructions string + compactErr error getCommandsCalls int } @@ -79,6 +83,15 @@ func (f *fakeSender) SetThinkingLevel(ctx context.Context, sessionID, sessionPat return nil } +func (f *fakeSender) Compact(ctx context.Context, sessionID, sessionPath, customInstructions string) error { + f.mu.Lock() + f.compactSessionID = sessionID + f.compactSessionPath = sessionPath + f.compactInstructions = customInstructions + f.mu.Unlock() + return f.compactErr +} + func (f *fakeSender) Abort(ctx context.Context, sessionID string) error { return nil } @@ -155,6 +168,12 @@ func (f *fakeSender) thinkingSessionID() string { return f.setThinkingSessionID } +func (f *fakeSender) compactInfo() (sessionID, sessionPath, instructions string) { + f.mu.Lock() + defer f.mu.Unlock() + return f.compactSessionID, f.compactSessionPath, f.compactInstructions +} + func TestHandleChatQueuesResolvedSession(t *testing.T) { root := t.TempDir() wantPath := writeSessionFile(t, root, "--tmp--project--", "session.jsonl") @@ -230,6 +249,45 @@ func TestHandleChatRejectsBrokenSession(t *testing.T) { } } +func TestHandleCompactUsesResolvedSession(t *testing.T) { + root := t.TempDir() + wantPath := writeSessionFile(t, root, "--tmp--project--", "session.jsonl") + fake := &fakeSender{} + s := &Server{sessionsDir: root, chatSender: fake} + req := httptest.NewRequest( + http.MethodPost, + "/api/compact?id=session.jsonl", + strings.NewReader(`{"customInstructions":" keep:3 "}`), + ) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + s.handleCompact(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + gotID, gotPath, gotInstructions := fake.compactInfo() + if gotID != "session.jsonl" || gotPath != wantPath || gotInstructions != "keep:3" { + t.Fatalf("compact id=%q path=%q instructions=%q, want path %q", gotID, gotPath, gotInstructions, wantPath) + } +} + +func TestHandleCompactRejectsBusyWorker(t *testing.T) { + root := t.TempDir() + writeSessionFile(t, root, "--tmp--project--", "session.jsonl") + s := &Server{sessionsDir: root, chatSender: &fakeSender{compactErr: workers.ErrWorkerBusy}} + req := httptest.NewRequest(http.MethodPost, "/api/compact?id=session.jsonl", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + s.handleCompact(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409; body=%s", w.Code, w.Body.String()) + } +} + func TestHandleWorkerStatusDefaultsIdle(t *testing.T) { s := &Server{sessionsDir: t.TempDir(), chatSender: &fakeSender{}, now: time.Now} req := httptest.NewRequest(http.MethodGet, "/api/worker-status?id=session.jsonl", nil) diff --git a/internal/server/server.go b/internal/server/server.go index 4b8bfa28..61912c04 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -335,6 +335,7 @@ func (s *Server) Register(mux *http.ServeMux) { mux.HandleFunc("/api/sessions", s.auth.Wrap(s.handleApiSessions)) mux.HandleFunc("/api/chat", s.auth.Wrap(s.handleChat)) mux.HandleFunc("/api/chat/cancel", s.auth.Wrap(s.handleCancelChat)) + mux.HandleFunc("/api/compact", s.auth.Wrap(s.handleCompact)) mux.HandleFunc("/api/set-model", s.auth.Wrap(s.handleSetModel)) mux.HandleFunc("/api/set-thinking-level", s.auth.Wrap(s.handleSetThinkingLevel)) mux.HandleFunc("/api/models", s.auth.Wrap(s.handleAvailableModels)) diff --git a/internal/ui/embedded/styles/session.css b/internal/ui/embedded/styles/session.css index e2892975..c9edaa93 100644 --- a/internal/ui/embedded/styles/session.css +++ b/internal/ui/embedded/styles/session.css @@ -6469,6 +6469,33 @@ color: var(--error, #cc6666); } + .pi-chat-context-popover .pi-context-compact { + width: 100%; + min-height: 30px; + margin-top: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--dim)); + border-radius: 6px; + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--text); + font-size: 10px; + font-weight: 600; + cursor: pointer; + } + + .pi-chat-context-popover .pi-context-compact:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 20%, transparent); + border-color: var(--accent); + } + + .pi-chat-context-popover .pi-context-compact:disabled { + opacity: 0.45; + cursor: not-allowed; + } + /* =================================================================== Cat Gatekeeper — focus/break + bedtime overlay (live app only). The overlay DOM is created by web/src/session/cat-gatekeeper/. diff --git a/internal/workers/manager.go b/internal/workers/manager.go index d4217c76..9b0f1113 100644 --- a/internal/workers/manager.go +++ b/internal/workers/manager.go @@ -9,6 +9,8 @@ import ( "pi-web/internal/chat" ) +var ErrWorkerBusy = errors.New("worker is busy") + type State string const ( @@ -57,6 +59,7 @@ type inspector interface { type ChatWorker interface { Prompt(ctx context.Context, chat chat.Request) error + Compact(ctx context.Context, customInstructions string) error SetModel(ctx context.Context, provider, modelID string) error SetThinkingLevel(ctx context.Context, level string) error Abort(ctx context.Context) error @@ -236,6 +239,14 @@ func (m *Manager) SetModel(ctx context.Context, sessionID, sessionPath, provider return worker.SetModel(ctx, provider, modelID) } +func (m *Manager) Compact(ctx context.Context, sessionID, sessionPath, customInstructions string) error { + worker, err := m.workerFor(sessionID, sessionPath) + if err != nil { + return err + } + return worker.Compact(ctx, customInstructions) +} + func (m *Manager) SetThinkingLevel(ctx context.Context, sessionID, sessionPath, level string) error { worker, err := m.workerFor(sessionID, sessionPath) if err != nil { diff --git a/internal/workers/manager_test.go b/internal/workers/manager_test.go index 733c0a31..0d136f1a 100644 --- a/internal/workers/manager_test.go +++ b/internal/workers/manager_test.go @@ -14,6 +14,7 @@ type fakeChatWorker struct { mu sync.Mutex streaming bool prompts []map[string]any + compactions []string commands []SlashCommand getCommandsCall int } @@ -39,6 +40,13 @@ func (f *fakeChatWorker) Status() WorkerStatus { return WorkerStatus{State: WorkerStateIdle} } +func (f *fakeChatWorker) Compact(ctx context.Context, customInstructions string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.compactions = append(f.compactions, customInstructions) + return nil +} + func (f *fakeChatWorker) SetModel(ctx context.Context, provider, modelID string) error { return nil } func (f *fakeChatWorker) SetThinkingLevel(ctx context.Context, level string) error { return nil } @@ -78,6 +86,21 @@ func TestManagerCreatesOneWorkerPerSession(t *testing.T) { } } +func TestManagerCompactsCurrentSessionWorker(t *testing.T) { + worker := &fakeChatWorker{} + manager := NewManager(func(string, string) (ChatWorker, error) { return worker, nil }) + + if err := manager.Compact(context.Background(), "a.jsonl", "/tmp/a.jsonl", "keep:3"); err != nil { + t.Fatal(err) + } + + worker.mu.Lock() + defer worker.mu.Unlock() + if len(worker.compactions) != 1 || worker.compactions[0] != "keep:3" { + t.Fatalf("compactions = %#v", worker.compactions) + } +} + func TestManagerGetCommandsPeeksWithoutSpawning(t *testing.T) { created := 0 manager := NewManager(func(string, string) (ChatWorker, error) { @@ -206,6 +229,7 @@ type reapableWorker struct { } func (r *reapableWorker) Prompt(ctx context.Context, chat chat.Request) error { return nil } +func (r *reapableWorker) Compact(ctx context.Context, customInstructions string) error { return nil } func (r *reapableWorker) SetModel(ctx context.Context, provider, modelID string) error { return nil } func (r *reapableWorker) SetThinkingLevel(ctx context.Context, level string) error { return nil } func (r *reapableWorker) Abort(ctx context.Context) error { return nil } @@ -270,6 +294,7 @@ func TestManagerDoesNotReapRunningWorker(t *testing.T) { type runningReapable struct{} func (runningReapable) Prompt(ctx context.Context, chat chat.Request) error { return nil } +func (runningReapable) Compact(ctx context.Context, customInstructions string) error { return nil } func (runningReapable) SetModel(ctx context.Context, provider, modelID string) error { return nil } func (runningReapable) SetThinkingLevel(ctx context.Context, level string) error { return nil } func (runningReapable) Abort(ctx context.Context) error { return nil } @@ -284,6 +309,7 @@ func (runningReapable) IdleSince(now time.Time) time.Duration type erroredWorker struct{} func (erroredWorker) Prompt(ctx context.Context, chat chat.Request) error { return nil } +func (erroredWorker) Compact(ctx context.Context, customInstructions string) error { return nil } func (erroredWorker) SetModel(ctx context.Context, provider, modelID string) error { return nil } func (erroredWorker) SetThinkingLevel(ctx context.Context, level string) error { return nil } func (erroredWorker) Abort(ctx context.Context) error { return nil } @@ -306,6 +332,7 @@ type inspectableWorker struct { } func (w *inspectableWorker) Prompt(context.Context, chat.Request) error { return nil } +func (w *inspectableWorker) Compact(context.Context, string) error { return nil } func (w *inspectableWorker) SetModel(context.Context, string, string) error { return nil } func (w *inspectableWorker) SetThinkingLevel(context.Context, string) error { return nil } func (w *inspectableWorker) Abort(context.Context) error { return nil } diff --git a/web/src/components/session/ChatComposer.svelte b/web/src/components/session/ChatComposer.svelte index 917b8e31..fe9e7d2a 100644 --- a/web/src/components/session/ChatComposer.svelte +++ b/web/src/components/session/ChatComposer.svelte @@ -127,7 +127,7 @@
- + diff --git a/web/src/components/session/chat/ContextUsage.svelte b/web/src/components/session/chat/ContextUsage.svelte index b58d8ac4..b3f2164a 100644 --- a/web/src/components/session/chat/ContextUsage.svelte +++ b/web/src/components/session/chat/ContextUsage.svelte @@ -1,8 +1,8 @@ @@ -66,6 +66,16 @@ 0 + {/if} diff --git a/web/src/components/session/chat/ContextUsage.test.js b/web/src/components/session/chat/ContextUsage.test.js index 2c493b19..fa0da05b 100644 --- a/web/src/components/session/chat/ContextUsage.test.js +++ b/web/src/components/session/chat/ContextUsage.test.js @@ -24,5 +24,15 @@ describe('ContextUsage', () => { expect(document.getElementById('pi-popover-val-cache-write')?.textContent).toBe('0'); expect(document.getElementById('pi-popover-val-output')?.textContent).toBe('0'); expect(document.getElementById('pi-popover-val-total')?.textContent).toBe('0'); + expect(document.getElementById('pi-context-compact')?.textContent).toContain('Compact context'); + }); + + it('disables compact when chat is unavailable or the worker is running', () => { + const { unmount } = render(ContextUsage, { props: { popover: true, chatAvailable: false } }); + expect(document.getElementById('pi-context-compact')).toBeDisabled(); + unmount(); + + render(ContextUsage, { props: { popover: true, isRunning: true } }); + expect(document.getElementById('pi-context-compact')).toBeDisabled(); }); }); diff --git a/web/src/components/session/chat/chat-composer-runtime.js b/web/src/components/session/chat/chat-composer-runtime.js index 13c172c9..45b19765 100644 --- a/web/src/components/session/chat/chat-composer-runtime.js +++ b/web/src/components/session/chat/chat-composer-runtime.js @@ -247,6 +247,7 @@ export function runChatComposer({ documentImpl: document, windowImpl: window, updateContextUsage, + onCompact: () => submission.compactSession(''), }); positionPopover = contextPopover.position; diff --git a/web/src/components/session/chat/chat-submit.js b/web/src/components/session/chat/chat-submit.js index fd8f84bb..d3ad733d 100644 --- a/web/src/components/session/chat/chat-submit.js +++ b/web/src/components/session/chat/chat-submit.js @@ -1,3 +1,12 @@ +import { t } from '../../../shared/i18n.js'; + +export function parseCompactCommand(message) { + if (typeof message !== 'string') return null; + const match = message.trim().match(/^\/compact(?:\s+([\s\S]*))?$/i); + if (!match) return null; + return { customInstructions: (match[1] || '').trim() }; +} + export function setupChatSubmission({ windowImpl = window, form, @@ -61,11 +70,35 @@ export function setupChatSubmission({ } } + async function compactSession(customInstructions = '') { + sendButton.dataset.sending = '1'; + sendButton.disabled = true; + setStatus(t('composer.compacting'), 'running'); + try { + const response = await chatApi.compactSession(sessionId, { customInstructions }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || 'compact request failed'); + setStatus(t('composer.compacted'), ''); + return true; + } catch (error) { + setStatus(error.message || String(error), 'error'); + return false; + } finally { + delete sendButton.dataset.sending; + sendButton.disabled = false; + updateSendEnabled(); + } + } + form?.addEventListener('submit', async (event) => { event.preventDefault(); const typed = textarea.value.trim(); const filesToSend = attachments.files().slice(); const textAttachmentsToSend = attachments.textAttachments().slice(); + const compactCommand = + filesToSend.length === 0 && textAttachmentsToSend.length === 0 + ? parseCompactCommand(typed) + : null; const message = attachments.composeMessage(typed); if (!message && filesToSend.length === 0) { setStatus('message or image required', 'error'); @@ -79,7 +112,9 @@ export function setupChatSubmission({ autoResizeTextarea(); updateSendEnabled(); - const sent = await sendChatMessage(message, filesToSend); + const sent = compactCommand + ? await compactSession(compactCommand.customInstructions) + : await sendChatMessage(message, filesToSend); if (!sent) { textarea.value = typed; attachments.restore({ files: filesToSend, textAttachments: textAttachmentsToSend }); @@ -90,6 +125,7 @@ export function setupChatSubmission({ return { sendChatMessage, + compactSession, setRefreshWorkerStatus: (fn) => { refreshWorkerStatus = typeof fn === 'function' ? fn : async () => {}; }, diff --git a/web/src/components/session/chat/chat-submit.test.js b/web/src/components/session/chat/chat-submit.test.js index 68ba956d..e22f0520 100644 --- a/web/src/components/session/chat/chat-submit.test.js +++ b/web/src/components/session/chat/chat-submit.test.js @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { JSDOM } from 'jsdom'; -import { setupChatSubmission } from './chat-submit.js'; +import { setupChatSubmission, parseCompactCommand } from './chat-submit.js'; function setupDom() { const dom = new JSDOM( @@ -27,6 +27,16 @@ function createAttachments({ files = [], textAttachments = [], message = '' } = } describe('chat submit', () => { + it('parses only complete compact commands', () => { + expect(parseCompactCommand('/compact')).toEqual({ customInstructions: '' }); + expect(parseCompactCommand('/compact keep:3')).toEqual({ customInstructions: 'keep:3' }); + expect(parseCompactCommand('/compact focus on changes')).toEqual({ + customInstructions: 'focus on changes', + }); + expect(parseCompactCommand('/compact-now')).toBeNull(); + expect(parseCompactCommand('please /compact')).toBeNull(); + }); + it('sends a composed message and dispatches the live preview event', async () => { const { dom, form, textarea, sendButton, cancelButton } = setupDom(); textarea.value = ' hello '; @@ -70,6 +80,78 @@ describe('chat submit', () => { expect(updateSendEnabled).toHaveBeenCalled(); }); + it('routes /compact through the compact API instead of chat', async () => { + const { dom, form, textarea, sendButton, cancelButton } = setupDom(); + textarea.value = '/compact keep:3'; + const attachments = createAttachments({ message: '/compact keep:3' }); + const compactSession = vi.fn(() => + Promise.resolve(new Response('{"status":"compacted"}', { status: 200 })), + ); + const sendChat = vi.fn(); + const setStatus = vi.fn(); + + setupChatSubmission({ + windowImpl: dom.window, + form, + textarea, + sendButton, + cancelButton, + attachments, + chatApi: { sendChat, compactSession, cancelChat: vi.fn() }, + sessionId: 's1', + setStatus, + autoResizeTextarea: vi.fn(), + updateSendEnabled: vi.fn(), + FormDataImpl: dom.window.FormData, + CustomEventImpl: dom.window.CustomEvent, + }); + + form.dispatchEvent(new dom.window.Event('submit', { bubbles: true, cancelable: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(compactSession).toHaveBeenCalledWith('s1', { customInstructions: 'keep:3' }); + expect(sendChat).not.toHaveBeenCalled(); + expect(textarea.value).toBe(''); + expect(setStatus).toHaveBeenCalledWith('compacting', 'running'); + expect(setStatus).toHaveBeenCalledWith('compacted', ''); + }); + + it('keeps /compact as chat when attachments are present', async () => { + const { dom, form, textarea, sendButton, cancelButton } = setupDom(); + textarea.value = '/compact'; + const textAttachment = { original: 'quote', note: '' }; + const attachments = createAttachments({ + textAttachments: [textAttachment], + message: '> quote\n\n/compact', + }); + const sendChat = vi.fn(() => + Promise.resolve(new Response('{"status":"queued"}', { status: 200 })), + ); + const compactSession = vi.fn(); + + setupChatSubmission({ + windowImpl: dom.window, + form, + textarea, + sendButton, + cancelButton, + attachments, + chatApi: { sendChat, compactSession, cancelChat: vi.fn() }, + sessionId: 's1', + setStatus: vi.fn(), + autoResizeTextarea: vi.fn(), + updateSendEnabled: vi.fn(), + FormDataImpl: dom.window.FormData, + CustomEventImpl: dom.window.CustomEvent, + }); + + form.dispatchEvent(new dom.window.Event('submit', { bubbles: true, cancelable: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(sendChat).toHaveBeenCalled(); + expect(compactSession).not.toHaveBeenCalled(); + }); + it('restores the draft and attachments when send fails', async () => { const { dom, form, textarea, sendButton, cancelButton } = setupDom(); textarea.value = ' retry '; diff --git a/web/src/components/session/chat/context-popover.js b/web/src/components/session/chat/context-popover.js index 2c6fcbfc..4185e921 100644 --- a/web/src/components/session/chat/context-popover.js +++ b/web/src/components/session/chat/context-popover.js @@ -2,9 +2,11 @@ export function setupContextPopover({ documentImpl = document, windowImpl = window, updateContextUsage = () => {}, + onCompact = async () => false, } = {}) { const usageCapsule = documentImpl.getElementById('pi-chat-context-usage'); const popover = documentImpl.getElementById('pi-chat-context-popover'); + const compactButton = documentImpl.getElementById('pi-context-compact'); function position() { if (!usageCapsule || !popover) return; @@ -58,8 +60,22 @@ export function setupContextPopover({ else show(); }; - const onPopoverClick = (event) => { - if (event.target.closest('.pi-popover-close')) hide(); + const onPopoverClick = async (event) => { + if (event.target.closest('.pi-popover-close')) { + hide(); + event.stopPropagation(); + return; + } + if (event.target.closest('#pi-context-compact')) { + event.preventDefault(); + event.stopPropagation(); + if (!compactButton || compactButton.disabled) return; + compactButton.disabled = true; + const compacted = await onCompact(); + compactButton.disabled = false; + if (compacted) hide(); + return; + } event.stopPropagation(); }; diff --git a/web/src/components/session/chat/context-popover.test.js b/web/src/components/session/chat/context-popover.test.js index 4dfdc5d9..70b7c500 100644 --- a/web/src/components/session/chat/context-popover.test.js +++ b/web/src/components/session/chat/context-popover.test.js @@ -11,6 +11,7 @@ function renderDom() { @@ -68,6 +69,39 @@ describe('setupContextPopover', () => { expect(popover.style.display).toBe('none'); }); + it('runs compaction once and closes after success', async () => { + renderDom(); + const onCompact = vi.fn(() => Promise.resolve(true)); + setupContextPopover({ documentImpl: document, windowImpl: window, onCompact }); + const popover = document.getElementById('pi-chat-context-popover'); + const button = document.getElementById('pi-context-compact'); + document.getElementById('pi-chat-context-usage').click(); + + button.click(); + expect(button.disabled).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(onCompact).toHaveBeenCalledTimes(1); + expect(button.disabled).toBe(false); + expect(popover.style.display).toBe('none'); + }); + + it('keeps the popover open when compaction fails', async () => { + renderDom(); + setupContextPopover({ + documentImpl: document, + windowImpl: window, + onCompact: () => Promise.resolve(false), + }); + const popover = document.getElementById('pi-chat-context-popover'); + document.getElementById('pi-chat-context-usage').click(); + + document.getElementById('pi-context-compact').click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(popover.style.display).toBe('block'); + }); + it('repositions while visible on resize', () => { renderDom(); setupContextPopover({ documentImpl: document, windowImpl: window }); diff --git a/web/src/components/session/chat/context-usage.js b/web/src/components/session/chat/context-usage.js index 12f2a3c5..6f920d4e 100644 --- a/web/src/components/session/chat/context-usage.js +++ b/web/src/components/session/chat/context-usage.js @@ -77,6 +77,7 @@ export function collectContextUsage(entries = []) { let contextTokens = 0; for (let i = entries.length - 1; i >= 0; i -= 1) { const entry = entries[i]; + if (entry?.type === 'compaction') break; if (entry?.type !== 'message' || !entry.message) continue; const msg = entry.message; if (msg.role === 'assistant' && msg.usage) { diff --git a/web/src/components/session/chat/context-usage.test.js b/web/src/components/session/chat/context-usage.test.js index 4580bbe0..89e5cf7d 100644 --- a/web/src/components/session/chat/context-usage.test.js +++ b/web/src/components/session/chat/context-usage.test.js @@ -73,6 +73,19 @@ describe('context usage helpers', () => { expect(usage.totalIOTokens).toBe(4300); expect(usage.contextTokens).toBe(1800); }); + + it('clears stale context pressure after compaction until the next assistant response', () => { + const usage = collectContextUsage([ + { + type: 'message', + message: { role: 'assistant', usage: { input: 90000, output: 1000 } }, + }, + { type: 'compaction', tokensBefore: 91000 }, + ]); + + expect(usage.totalIOTokens).toBe(91000); + expect(usage.contextTokens).toBe(0); + }); }); describe('updateContextUsage', () => { diff --git a/web/src/session/chat/chat-api.js b/web/src/session/chat/chat-api.js index 97216efe..914fa6a8 100644 --- a/web/src/session/chat/chat-api.js +++ b/web/src/session/chat/chat-api.js @@ -6,6 +6,18 @@ export function cancelChat(sessionId, { fetchImpl = fetch } = {}) { return fetchImpl(chatUrl('/api/chat/cancel', sessionId), { method: 'POST' }); } +export function compactSession( + sessionId, + { customInstructions = '' } = {}, + { fetchImpl = fetch } = {}, +) { + return fetchImpl(chatUrl('/api/compact', sessionId), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ customInstructions }), + }); +} + export function sendChat(sessionId, body, { fetchImpl = fetch } = {}) { return fetchImpl(chatUrl('/api/chat', sessionId), { method: 'POST', body }); } diff --git a/web/src/session/chat/chat-api.test.js b/web/src/session/chat/chat-api.test.js index 731af69c..0f5ff76d 100644 --- a/web/src/session/chat/chat-api.test.js +++ b/web/src/session/chat/chat-api.test.js @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { cancelChat, chatUrl, + compactSession, getCommands, getFiles, getWorkerStatus, @@ -22,6 +23,7 @@ describe('chat api helpers', () => { await sendChat('s.jsonl', body, { fetchImpl }); await cancelChat('s.jsonl', { fetchImpl }); + await compactSession('s.jsonl', { customInstructions: 'keep:3' }, { fetchImpl }); await getWorkerStatus('s.jsonl', { fetchImpl }); await listModels({ fetchImpl }); await setModel('s.jsonl', { provider: 'p', modelId: 'm' }, { fetchImpl }); @@ -29,14 +31,19 @@ describe('chat api helpers', () => { expect(fetchImpl).toHaveBeenNthCalledWith(1, '/api/chat?id=s.jsonl', { method: 'POST', body }); expect(fetchImpl).toHaveBeenNthCalledWith(2, '/api/chat/cancel?id=s.jsonl', { method: 'POST' }); - expect(fetchImpl).toHaveBeenNthCalledWith(3, '/api/worker-status?id=s.jsonl'); - expect(fetchImpl).toHaveBeenNthCalledWith(4, '/api/models'); - expect(fetchImpl).toHaveBeenNthCalledWith(5, '/api/set-model?id=s.jsonl', { + expect(fetchImpl).toHaveBeenNthCalledWith(3, '/api/compact?id=s.jsonl', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ customInstructions: 'keep:3' }), + }); + expect(fetchImpl).toHaveBeenNthCalledWith(4, '/api/worker-status?id=s.jsonl'); + expect(fetchImpl).toHaveBeenNthCalledWith(5, '/api/models'); + expect(fetchImpl).toHaveBeenNthCalledWith(6, '/api/set-model?id=s.jsonl', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: 'p', modelId: 'm' }), }); - expect(fetchImpl).toHaveBeenNthCalledWith(6, '/api/set-thinking-level?id=s.jsonl', { + expect(fetchImpl).toHaveBeenNthCalledWith(7, '/api/set-thinking-level?id=s.jsonl', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ level: 'medium' }), diff --git a/web/src/shared/icons.js b/web/src/shared/icons.js index 2fe567b7..3381357c 100644 --- a/web/src/shared/icons.js +++ b/web/src/shared/icons.js @@ -34,6 +34,7 @@ import { ListTree, Loader, Maximize2, + Minimize2, Moon, MoreHorizontal, PanelLeft, @@ -171,6 +172,7 @@ export { ListTree, Loader, Maximize2, + Minimize2, Moon, MoreHorizontal, PanelLeft, diff --git a/web/src/shared/locales/en.js b/web/src/shared/locales/en.js index 56aa1c1a..1bd20811 100644 --- a/web/src/shared/locales/en.js +++ b/web/src/shared/locales/en.js @@ -302,6 +302,10 @@ export default { 'composer.focusShortcut': 'Shift + i to focus', 'composer.cancelRunning': 'Cancel running response', 'composer.contextDetails': 'Click for details', + 'composer.compactContext': 'Compact context', + 'composer.compactContextHint': 'Summarize older messages and keep recent context', + 'composer.compacting': 'compacting', + 'composer.compacted': 'compacted', 'composer.pathCopied': 'Path copied', // ── Share / export ──