diff --git a/.gitignore b/.gitignore index 3a44c749..ff9f4bf1 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ # next.js /.next/ +/.next-dev/ /out/ # production diff --git a/AGENTS.md b/AGENTS.md index ad4735ec..734d223e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,6 +147,17 @@ On `ChatWindow` mount, `GET /api/agent/[id]` is called. If `state.isStreaming == ### Compaction SSE events Newer pi emits `compaction_start` / `compaction_end`; older versions emitted `auto_compaction_start` / `auto_compaction_end`. `handleAgentEvent` accepts both sets to keep `isCompacting` in sync. Manual compact is a blocking POST — the button stays disabled until the response returns. +### Queue persistence & crash recovery (`lib/queue-store.ts`) +pi keeps steer/follow-up queues in memory only — a server restart would lose them. The wrapper mirrors every queue mutation to a per-session sidecar `.jsonl.queue.json` (atomic tmp+rename). **The sidecar only saves — it never auto-delivers.** + +- Wrapper state: `queueMirror` (live queue w/ images), `queueRecovery` (orphans awaiting user decision), `pendingQueueHints` (FIFO image hints — pi reports queue items by text only and template expansion can rewrite text, so hints are consumed FIFO per kind). +- Mirror sync: `queue_update` events fire on enqueue / drain (message_start) / clear — `reconcileQueue()` rebuilds the mirror from pi's text arrays. +- Enqueue points: `send("prompt")` with `streamingBehavior` while streaming, `send("steer")`, `send("follow_up")`, `clear_queue`. +- After a restart (or wrapper idle-destroy), every sidecar entry becomes a `pendingRecovery` item exposed via `get_state.pendingRecovery` and `GET /api/sessions/[id]/queue-recovery` (lightweight file read — does NOT create an AgentSession; when a wrapper is alive its in-memory recovery list wins). +- `POST /api/agent/[id]` commands: `resolve_recovery {keep, discard, continueRun}` (keep re-queues via `inner.steer()/followUp()`; `continueRun` invokes `this.inner.agent.continue()` directly — AgentSession has no public continue; events/persistence still flow via its subscriptions), `export_queue`, `import_queue`. +- UI: `QueueRecoveryDialog` (checklist + re-queue / re-queue & continue / discard / export .md/.json / import / dismiss), export/import buttons in the ChatInput queued banner. `lib/queue-export.ts` is client-safe (blob download + `parseQueueImport` for the round-trip). +- Deleting a session removes the sidecar (`removeQueue`). Image-only queued messages never leave pi's `_steeringMessages` (contentText("") is falsy) — they may surface as already-delivered in recovery; user discards manually. + ### Running state polling + reconciliation - The sidebar polls `/api/agent/running` every 2.5 seconds while the tab is visible and pauses polling in background tabs. The session-list response remains the initial fallback. - `useAgentSession` treats per-session SSE as primary for chat events and opens it before each prompt. `prompt_done` completes the current UI stage and notification immediately, but the idle SSE stays open for a 30-second grace window and is reused by the next prompt. `agent_start` cancels that close timer; `agent_settled` finishes extension-injected runs that have no wrapper-level `prompt_done` and starts a fresh grace window. Do not close on the first `agent_end`: retries, compaction, and extension-queued messages can continue the same logical prompt. diff --git a/app/api/sessions/[id]/queue-recovery/route.ts b/app/api/sessions/[id]/queue-recovery/route.ts new file mode 100644 index 00000000..a0e585ff --- /dev/null +++ b/app/api/sessions/[id]/queue-recovery/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server"; +import { getRpcSession } from "@/lib/rpc-manager"; +import { resolveSessionPath } from "@/lib/session-reader"; +import { + loadQueue, + type PendingRecoveryItem, + type QueueEntry, +} from "@/lib/queue-store"; + +function toPublicView(entry: QueueEntry): PendingRecoveryItem { + return { + id: entry.id, + kind: entry.kind, + text: entry.text, + hasImages: Boolean(entry.images?.length), + queuedAt: entry.queuedAt, + }; +} + +/** + * GET /api/sessions/[id]/queue-recovery + * + * Lists queued messages that survived a server restart and were never + * processed (the "pending recovery" list). Deliberately lightweight: it reads + * the sidecar file directly and does NOT create an AgentSession. + * + * When a live wrapper exists, its in-memory recovery list is authoritative + * (it excludes messages currently in the live queue). + */ +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + try { + const filePath = await resolveSessionPath(id); + if (!filePath) { + return NextResponse.json({ error: "Session not found" }, { status: 404 }); + } + + const rpc = getRpcSession(id); + if (rpc?.isAlive()) { + const state = await rpc.send({ type: "get_state" }) as { pendingRecovery?: PendingRecoveryItem[] }; + return NextResponse.json({ items: state.pendingRecovery ?? [] }); + } + + const items = loadQueue(filePath).map(toPublicView); + return NextResponse.json({ items }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/app/api/sessions/[id]/route.ts b/app/api/sessions/[id]/route.ts index 9303a66a..c94f89a3 100644 --- a/app/api/sessions/[id]/route.ts +++ b/app/api/sessions/[id]/route.ts @@ -12,6 +12,7 @@ import { } from "@/lib/session-reader"; import { sessionPathKey } from "@/lib/session-path"; import { getRpcSession } from "@/lib/rpc-manager"; +import { removeQueue } from "@/lib/queue-store"; // BranchNavigator still traverses recursively, so keep the response tree shallow. const MAX_PROJECTED_TREE_DEPTH = 200; @@ -239,6 +240,7 @@ export async function DELETE( await getRpcSession(id)?.shutdown(); unlinkSync(filePath); + removeQueue(filePath); invalidateSessionPathCache(id); invalidateSessionListCache(); return NextResponse.json({ ok: true }); diff --git a/components/AppShell.tsx b/components/AppShell.tsx index d4d827fa..e1c5b407 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -1223,7 +1223,7 @@ export function AppShell() { {costStr} )} - {ctxStr && ( + {!isMobile && ctxStr && ( diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 35fa00bd..bcf102bd 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -2,6 +2,8 @@ import React, { useRef, useState, useCallback, useEffect, useImperativeHandle, forwardRef, KeyboardEvent } from "react"; import type { BuiltinSlashCommandResult, CompactResultInfo, QueuedMessages, SlashCommandInfo } from "@/hooks/useAgentSession"; +import type { QueueEntry, QueueEntryInput } from "@/lib/queue-store"; +import { downloadQueueExport, parseQueueImport } from "@/lib/queue-export"; import type { SkillsResponse } from "@/lib/api-types"; import { clearDraft, getDraft, setDraft, type ChatDraftImage } from "@/lib/draft-store"; import { @@ -49,6 +51,12 @@ interface Props { isCompacting?: boolean; compactError?: string | null; compactResult?: CompactResultInfo | null; + /** Manual compaction was queued while the agent was running. */ + compactQueued?: boolean; + onCancelCompactQueue?: () => void; + /** A model switch was made mid-run; applies next turn. Switching back to the + * current run's model cancels it (null). */ + modelSwitchPending?: { provider: string; modelId: string } | null; toolPreset?: "none" | "default" | "full"; onToolPresetChange?: (preset: "none" | "default" | "full") => void; thinkingLevel?: "auto" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; @@ -59,6 +67,20 @@ interface Props { queuedMessages?: QueuedMessages | null; inputHistory?: string[]; onRecallQueue?: () => void; + /** Fetch full queue entries (live + recovery) for export. */ + onExportQueue?: () => Promise<{ live: QueueEntry[]; recovery: QueueEntry[] } | null>; + /** Re-queue entries parsed from an imported .json file. Returns count. */ + onImportQueue?: (entries: QueueEntryInput[]) => Promise; + /** Stage imported entries as pending recovery (pops the recovery dialog). */ + onStageImport?: (entries: QueueEntryInput[]) => Promise; + /** Move a queued message within its queue (clear + re-enqueue). */ + onMoveQueue?: (kind: "steer" | "followUp", fromIndex: number, toIndex: number) => Promise; + /** Pull one queued message back to the input box; returns its text + images. */ + onRecallOne?: (kind: "steer" | "followUp", index: number) => Promise<{ text: string; images?: ChatDraftImage[] } | null>; + /** Insert an edited message back into the queue at its original position. */ + onRequeueAt?: (kind: "steer" | "followUp", index: number, text: string, images?: ChatDraftImage[]) => Promise; + /** Remove a single queued message. */ + onRemoveQueueItem?: (kind: "steer" | "followUp", index: number) => Promise; slashCommands?: SlashCommandInfo[]; slashCommandsLoading?: boolean; onLoadSlashCommands?: () => Promise | SlashCommandInfo[]; @@ -217,10 +239,134 @@ function revokeImagePreview(image: AttachedImage): void { } } -function QueuedMessageRow({ kind, text }: { kind: "steer" | "follow-up"; text: string }) { +function QueuedMessageRow({ kind, text, index, total, onMove, onRecall, onRemove, onDragStart, onDragOver, onDrop, dragging, onTouchMoveTo }: { + kind: "steer" | "follow-up"; + text: string; + index: number; + total: number; + onMove?: (dir: -1 | 1) => void; + onRecall?: () => void; + onRemove?: () => void; + onDragStart?: (index: number) => void; + onDragOver?: (index: number) => void; + onDrop?: (targetIndex: number) => void; + dragging?: boolean; + /** Touch drag on mobile: move this row to the given target index. */ + onTouchMoveTo?: (targetIndex: number) => void; +}) { + const { t } = useI18n(); + const canUp = onMove && index > 0; + const canDown = onMove && index < total - 1; + // Disable the HTML5 drag source on coarse-pointer (touch) devices: they get + // the dedicated touch-drag implementation below instead. + const [isCoarsePointer, setIsCoarsePointer] = useState(false); + useEffect(() => { + setIsCoarsePointer(typeof window !== "undefined" && window.matchMedia("(pointer: coarse)").matches); + }, []); + const iconBtn: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 20, + height: 20, + padding: 0, + border: "none", + borderRadius: 5, + background: "transparent", + color: "var(--text-dim)", + cursor: "pointer", + flexShrink: 0, + }; + // Touch drag state (mobile). touchOverEl is the row currently highlighted as + // the drop target; styled via direct DOM writes to avoid per-frame re-renders. + const touchDragRef = useRef<{ y: number; moved: boolean } | null>(null); + const touchOverElRef = useRef(null); + const clearTouchOver = () => { + if (touchOverElRef.current) { + touchOverElRef.current.style.background = ""; + touchOverElRef.current.style.borderRadius = ""; + touchOverElRef.current = null; + } + }; + const handleTouchStart = (e: React.TouchEvent) => { + // Buttons are taps, not drags; stop propagation so the bottom-panel swipe + // gesture (full → queueHidden → minimal) does not fight the row drag. + if (e.target instanceof Element && e.target.closest("button")) return; + e.stopPropagation(); + touchDragRef.current = { y: e.touches[0].clientY, moved: false }; + }; + const handleTouchMove = (e: React.TouchEvent) => { + const d = touchDragRef.current; + if (!d) return; + const t = e.touches[0]; + const dy = t.clientY - d.y; + if (!d.moved) { + if (Math.abs(dy) < 8) return; + d.moved = true; + const el = e.currentTarget as HTMLElement; + el.style.opacity = "0.45"; + el.style.background = "color-mix(in srgb, var(--accent) 8%, transparent)"; + el.style.borderRadius = "6px"; + } + e.preventDefault(); + e.stopPropagation(); + const el = document.elementFromPoint(t.clientX, t.clientY); + const row = el?.closest?.("[data-queue-row]"); + if (row && row !== e.currentTarget) { + const target = row as HTMLElement; + if (touchOverElRef.current !== target) { + clearTouchOver(); + touchOverElRef.current = target; + target.style.background = "color-mix(in srgb, var(--accent) 14%, transparent)"; + target.style.borderRadius = "6px"; + } + } else if (touchOverElRef.current) { + clearTouchOver(); + } + }; + const handleTouchEnd = (e: React.TouchEvent) => { + const d = touchDragRef.current; + touchDragRef.current = null; + const rowEl = e.currentTarget as HTMLElement; + rowEl.style.opacity = ""; + rowEl.style.background = ""; + rowEl.style.borderRadius = ""; + clearTouchOver(); + if (!d?.moved) return; + e.stopPropagation(); + const el = document.elementFromPoint(e.changedTouches[0].clientX, e.changedTouches[0].clientY); + const row = el?.closest?.("[data-queue-row]") as HTMLElement | null; + if (!row || row === e.currentTarget) return; + const to = Number(row.dataset.queueIndex); + if (!Number.isNaN(to) && to !== index && onTouchMoveTo) onTouchMoveTo(to); + }; return (
{ + onDragStart?.(index); + e.dataTransfer.effectAllowed = "move"; + try { e.dataTransfer.setData("text/plain", String(index)); } catch { /* ignore */ } + }} + onDragOver={(e) => { + if (!onDragOver) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + onDragOver(index); + }} + onDrop={(e) => { + if (!onDrop) return; + e.preventDefault(); + onDrop(index); + }} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + onTouchCancel={handleTouchEnd} style={{ display: "flex", alignItems: "center", @@ -229,6 +375,11 @@ function QueuedMessageRow({ kind, text }: { kind: "steer" | "follow-up"; text: s fontSize: 12, color: "var(--text-muted)", minWidth: 0, + touchAction: "none", + cursor: onDragStart && !isCoarsePointer ? "grab" : "default", + ...(dragging + ? { opacity: 0.45, background: "color-mix(in srgb, var(--accent) 8%, transparent)", borderRadius: 6 } + : {}), }} > {kind} - {text} + {text} + {onMove && total > 1 && ( + + + + + )} + {onRecall && ( + + )} + {onRemove && ( + + )}
); } +/** Windows-10 "show desktop"-style thin vertical bar cycling the bottom panel states. + * The visible line stays thin, but the tap target is 32px wide (mobile-friendly). */ +function BottomModeBar({ mode, onClick, height = 32, tapWidth = 32 }: { + mode: "full" | "queueHidden" | "minimal"; + onClick: () => void; + height?: number; + tapWidth?: number; +}) { + const { t } = useI18n(); + const label = mode === "full" ? t("chat.minimizeQueue") : mode === "queueHidden" ? t("chat.minimizeInput") : t("chat.restoreBottom"); + return ( + + ); +} + function ModelNoticeBanner({ tone, title, body }: { tone: "error" | "warning"; title: string; body: string }) { const color = tone === "error" ? "239,68,68" : "234,179,8"; return ( @@ -313,9 +575,10 @@ export function ModelScopeWarningBanner({ warnings }: { warnings?: string[] }) { export const ChatInput = forwardRef(function ChatInput({ onSend, onAbort, onSteer, onFollowUp, isStreaming, model, isAutoModelSelection, modelNames, modelList, modelError, modelScopeWarnings, onModelChange, - onCompact, onAbortCompaction, isCompacting, compactError, compactResult, toolPreset, onToolPresetChange, + onCompact, onAbortCompaction, isCompacting, compactError, compactResult, compactQueued, onCancelCompactQueue, modelSwitchPending, toolPreset, onToolPresetChange, thinkingLevel, onThinkingLevelChange, availableThinkingLevels, thinkingLevelMap, retryInfo, queuedMessages, inputHistory = [], onRecallQueue, + onExportQueue, onImportQueue, onStageImport, onMoveQueue, onRecallOne, onRequeueAt, onRemoveQueueItem, slashCommands, slashCommandsLoading, onLoadSlashCommands, onBuiltinCommand, soundEnabled, onSoundToggle, onAudioUnlock, @@ -364,6 +627,147 @@ export const ChatInput = forwardRef(function ChatInput({ const controlsMenuRef = useRef(null); const historyMenuRef = useRef(null); const fileInputRef = useRef(null); + const queueImportFileRef = useRef(null); + // Mobile-only: cycle the bottom panels (queue banner + input + toolbar) to + // leave more room for the conversation during long runs. Persisted so the + // choice survives reloads. + // full → everything visible + // queueHidden → queue banner hidden, input + toolbar visible + // minimal → only a slim capsule bar (with the cycle button) remains + type BottomMode = "full" | "queueHidden" | "minimal"; + const [bottomMode, setBottomMode] = useState(() => { + if (typeof window === "undefined") return "full"; + try { + const v = window.localStorage.getItem("pi-chat-bottom-mode"); + return v === "queueHidden" || v === "minimal" ? v : "full"; + } catch { return "full"; } + }); + const bottomModeRef = useRef(bottomMode); + useEffect(() => { bottomModeRef.current = bottomMode; }, [bottomMode]); + const cycleBottomMode = useCallback((dir: 1 | -1 = 1) => { + const prev = bottomModeRef.current; + const order: BottomMode[] = ["full", "queueHidden", "minimal"]; + const next = order[(order.indexOf(prev) + dir + order.length) % order.length]; + try { window.localStorage.setItem("pi-chat-bottom-mode", next); } catch { /* ignore */ } + if (next === "minimal") { + setControlsMenuOpen(false); + setModelDropdownOpen(false); + } + setBottomMode(next); + }, []); + + // Swipe gestures on the bottom panels: swipe down collapses one level + // (full → queueHidden → minimal), swipe up expands one level back. + const SWIPE_THRESHOLD = 44; + const touchStartRef = useRef<{ y: number; x: number; active: boolean } | null>(null); + const [swipeHint, setSwipeHint] = useState<"up" | "down" | null>(null); + const handleTouchStart = useCallback((e: React.TouchEvent) => { + if (!isMobile) return; + // Text editing / scrolling inside the textarea takes priority. + if (e.target instanceof Element && e.target.closest("textarea")) { + touchStartRef.current = null; + return; + } + const t = e.touches[0]; + touchStartRef.current = { y: t.clientY, x: t.clientX, active: true }; + }, [isMobile]); + const handleTouchMove = useCallback((e: React.TouchEvent) => { + const s = touchStartRef.current; + if (!s || !s.active || !isMobile) return; + const t = e.touches[0]; + const dy = t.clientY - s.y; + const dx = t.clientX - s.x; + // Horizontal-dominant gesture: cancel (edge swipes / other horizontal UX). + if (Math.abs(dx) > Math.abs(dy) * 1.5 && Math.abs(dx) > 24) { + touchStartRef.current = null; + setSwipeHint(null); + return; + } + if (dy > SWIPE_THRESHOLD) setSwipeHint("down"); + else if (dy < -SWIPE_THRESHOLD) setSwipeHint("up"); + else setSwipeHint(null); + }, [isMobile]); + const handleTouchEnd = useCallback((e: React.TouchEvent) => { + setSwipeHint(null); + const s = touchStartRef.current; + touchStartRef.current = null; + if (!s || !s.active || !isMobile) return; + const t = e.changedTouches[0]; + const dy = t.clientY - s.y; + if (Math.abs(dy) < SWIPE_THRESHOLD) return; + // Swipe down = collapse further (full→queueHidden→minimal), swipe up = expand back. + cycleBottomMode(dy > 0 ? 1 : -1); + }, [isMobile, cycleBottomMode]); + // Queue area collapse: null = auto (fold when many messages), otherwise the + // user's explicit choice. Mobile is more aggressive to save half-screen space. + const [queueCollapsedUser, setQueueCollapsedUser] = useState(null); + const [queueNotice, setQueueNotice] = useState<{ text: string; ok: boolean } | null>(null); + const queueNoticeTimerRef = useRef | null>(null); + const showQueueNotice = useCallback((text: string, ok: boolean) => { + setQueueNotice({ text, ok }); + if (queueNoticeTimerRef.current) clearTimeout(queueNoticeTimerRef.current); + queueNoticeTimerRef.current = setTimeout(() => setQueueNotice(null), 3000); + }, []); + const queueCount = (queuedMessages?.steering.length ?? 0) + (queuedMessages?.followUp.length ?? 0); + const queueCollapsed = queueCollapsedUser ?? queueCount > (isMobile ? 1 : 3); + const toggleQueueCollapsed = useCallback(() => { + setQueueCollapsedUser((prev) => !(prev ?? queueCount > (isMobile ? 1 : 3))); + }, [queueCount, isMobile]); + const handleMoveQueue = useCallback(async (kind: "steer" | "followUp", index: number, dir: -1 | 1) => { + if (!onMoveQueue) return; + const ok = await onMoveQueue(kind, index, index + dir); + if (ok) setQueueCollapsedUser(null); + }, [onMoveQueue]); + const handleRemoveQueueItem = useCallback(async (kind: "steer" | "followUp", index: number) => { + if (!onRemoveQueueItem) return; + const ok = await onRemoveQueueItem(kind, index); + if (ok) setQueueCollapsedUser(null); + }, [onRemoveQueueItem]); + // Entry pulled out for editing; sending re-inserts it at its original spot. + const recalledRef = useRef<{ kind: "steer" | "followUp"; index: number; text: string; images?: ChatDraftImage[] } | null>(null); + const [recalledVisible, setRecalledVisible] = useState(false); + const handleRecallOne = useCallback(async (kind: "steer" | "followUp", index: number) => { + if (!onRecallOne) return; + const entry = await onRecallOne(kind, index); + if (!entry) return; + recalledRef.current = { kind, index, text: entry.text, images: entry.images }; + setRecalledVisible(true); + const ta = textareaRef.current; + const current = ta ? ta.value : value; + const combined = [entry.text, current].filter((t) => t.trim()).join("\n\n"); + setValue(combined); + setAtQuery(null); + requestAnimationFrame(() => { + if (!ta) return; + ta.focus(); + ta.setSelectionRange(combined.length, combined.length); + ta.style.height = "auto"; + ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; + }); + setQueueCollapsedUser(null); + }, [onRecallOne, value]); + const cancelRecall = useCallback(() => { + recalledRef.current = null; + setRecalledVisible(false); + }, []); + // Drag & drop reordering (desktop); up/down buttons remain for mobile. + const dragFromRef = useRef<{ kind: "steer" | "followUp"; index: number } | null>(null); + const [dragOverIndex, setDragOverIndex] = useState<{ kind: "steer" | "followUp"; index: number } | null>(null); + const handleDragStart = useCallback((kind: "steer" | "followUp", index: number) => { + dragFromRef.current = { kind, index }; + }, []); + const handleDragOver = useCallback((kind: "steer" | "followUp", index: number) => { + setDragOverIndex({ kind, index }); + }, []); + const handleDrop = useCallback((kind: "steer" | "followUp", targetIndex: number) => { + const from = dragFromRef.current; + dragFromRef.current = null; + setDragOverIndex(null); + if (!from || from.kind !== kind || from.index === targetIndex || !onMoveQueue) return; + void onMoveQueue(kind, from.index, targetIndex).then((ok) => { + if (ok) setQueueCollapsedUser(null); + }); + }, [onMoveQueue]); const isComposingRef = useRef(false); const lastCompositionEndAtRef = useRef(0); const slashCommandsRequestedRef = useRef(false); @@ -552,6 +956,23 @@ export const ChatInput = forwardRef(function ChatInput({ if (!msg && !attachedImages.length) return; if (isStreaming) return; onAudioUnlock?.(); + // Edited entry pulled out of the queue: sending puts it back at its + // original position instead of dispatching it as a new prompt. + const recalled = recalledRef.current; + if (recalled) { + recalledRef.current = null; + setRecalledVisible(false); + if (onRequeueAt) { + const ok = await onRequeueAt(recalled.kind, recalled.index, msg, attachedImages.length ? attachedImages : recalled.images); + if (!ok) { + // Failed to requeue: restore the edit state so the user can retry. + recalledRef.current = { ...recalled, text: msg }; + setRecalledVisible(true); + } + clearInput(); + return; + } + } if (!attachedImages.length && msg.startsWith("/") && onBuiltinCommand) { const result = await onBuiltinCommand(msg); if (result.handled) { @@ -561,7 +982,7 @@ export const ChatInput = forwardRef(function ChatInput({ } onSend(msg, attachedImages.length ? attachedImages : undefined); clearInput(); - }, [value, attachedImages, isStreaming, onBuiltinCommand, onSend, clearInput, onAudioUnlock]); + }, [value, attachedImages, isStreaming, onBuiltinCommand, onSend, clearInput, onAudioUnlock, onRequeueAt]); const slashQuery = value.startsWith("/") && !/\s/.test(value.slice(1)) ? value.slice(1).toLowerCase() @@ -1114,12 +1535,49 @@ export const ChatInput = forwardRef(function ChatInput({ return (
+ {/* Swipe direction hint (mobile) */} + {swipeHint && ( +
+
+ + + + {swipeHint === "down" ? t("chat.swipeCollapse") : t("chat.swipeExpand")} +
+
+ )} {/* Hidden file input */} (function ChatInput({ e.target.value = ""; }} /> + {/* Hidden queue-import file input */} + { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file || !onStageImport) return; + try { + const entries = parseQueueImport(await file.text()); + if (entries.length === 0) { + showQueueNotice(t("chat.queueImportEmpty"), false); + return; + } + const staged = await onStageImport(entries); + if (staged !== null && staged > 0) { + showQueueNotice(t("chat.queueImported", { count: String(staged) }), true); + } + } catch { + showQueueNotice(t("chat.queueImportEmpty"), false); + } + }} + />
+ {/* Queue import/export feedback — rendered outside the banner so it is + visible even when the queue is empty (import history entry point). */} + {queueNotice && ( +
+ + + {queueNotice.ok + ? <> + : <>} + + {queueNotice.text} + +
+ )} {/* Queued steering / follow-up messages (delivered by pi on upcoming turns) */} - {((queuedMessages?.steering.length ?? 0) + (queuedMessages?.followUp.length ?? 0)) > 0 && ( + {bottomMode === "full" && ((queuedMessages?.steering.length ?? 0) + (queuedMessages?.followUp.length ?? 0)) > 0 && (
+
- + + + + + {t("chat.queued", { count: (queuedMessages?.steering.length ?? 0) + (queuedMessages?.followUp.length ?? 0) })} + + +
+
+ {onRecallQueue && ( + + )} + {(onExportQueue || onImportQueue) && ( + <> + {onExportQueue && ( + + )} + {onImportQueue && ( + + )} + + )} +
+
+ {queueCollapsed && queueCount > 0 && ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleQueueCollapsed(); + } + }} + style={{ + display: "flex", + alignItems: "center", + gap: 6, + margin: "0 6px 4px", + padding: "3px 6px", + borderRadius: 6, + fontSize: 11.5, color: "var(--text-dim)", - textTransform: "uppercase", - letterSpacing: 0.4, - }}> - {t("chat.queued", { count: (queuedMessages?.steering.length ?? 0) + (queuedMessages?.followUp.length ?? 0) })} + whiteSpace: "nowrap", + overflow: "hidden", + cursor: "pointer", + transition: "background 0.12s", + }} + onMouseEnter={(e) => { + e.currentTarget.style.background = "var(--bg-hover)"; + e.currentTarget.style.color = "var(--text)"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = ""; + e.currentTarget.style.color = "var(--text-dim)"; + }} + > + + + + + {(queuedMessages?.steering?.[0] ?? queuedMessages?.followUp?.[0] ?? "")} - {onRecallQueue && ( - + {queueCount > 1 && ( + + +{queueCount - 1} + )}
- {queuedMessages?.steering.map((text, i) => ( - + )} + {!queueCollapsed && queuedMessages?.steering.map((text, i) => ( + void handleMoveQueue("steer", i, dir)} + onRecall={() => void handleRecallOne("steer", i)} + onRemove={() => void handleRemoveQueueItem("steer", i)} + onDragStart={(idx) => handleDragStart("steer", idx)} + onDragOver={(idx) => handleDragOver("steer", idx)} + onDrop={(idx) => handleDrop("steer", idx)} + onTouchMoveTo={(to) => void handleMoveQueue("steer", i, (to - i) as 1 | -1)} + dragging={dragOverIndex?.kind === "steer" && dragOverIndex.index === i} + /> ))} - {queuedMessages?.followUp.map((text, i) => ( - + {!queueCollapsed && queuedMessages?.followUp.map((text, i) => ( + void handleMoveQueue("followUp", i, dir)} + onRecall={() => void handleRecallOne("followUp", i)} + onRemove={() => void handleRemoveQueueItem("followUp", i)} + onDragStart={(idx) => handleDragStart("followUp", idx)} + onDragOver={(idx) => handleDragOver("followUp", idx)} + onDrop={(idx) => handleDrop("followUp", idx)} + onTouchMoveTo={(to) => void handleMoveQueue("followUp", i, (to - i) as 1 | -1)} + dragging={dragOverIndex?.kind === "followUp" && dragOverIndex.index === i} + /> ))}
)} @@ -1613,6 +2307,107 @@ export const ChatInput = forwardRef(function ChatInput({
); })()} + {bottomMode !== "minimal" && recalledVisible && recalledRef.current && ( +
+ + + + + + {t("chat.recalledEditing", { pos: String(recalledRef.current.index + 1) })} + + + +
+ )} + {bottomMode === "minimal" ? ( +
+ {queueCount > 0 && ( + + )} + cycleBottomMode()} height={44} /> +
+ ) : (
(function ChatInput({ )}
+ )}
{/* Bash mode status label */} - {bashMode && ( + {bottomMode !== "minimal" && bashMode && (
{t("chat.shell")} · {bashExcluded ? t("chat.outputLocal") : t("chat.outputModel")}
)} {/* Bottom bar: left | center (context) | right */} + {bottomMode !== "minimal" && (
(function ChatInput({ return !open; }); }} - disabled={isStreaming} style={{ display: "flex", alignItems: "center", gap: 6, justifyContent: isMobile ? "flex-start" : undefined, @@ -1832,13 +2628,12 @@ export const ChatInput = forwardRef(function ChatInput({ border: "none", borderRadius: 9, color: "var(--text-muted)", - cursor: isStreaming ? "not-allowed" : "pointer", + cursor: "pointer", fontSize: 12, - opacity: isStreaming ? 0.5 : 1, + opacity: 1, transition: "background 0.12s, color 0.12s", }} onMouseEnter={(e) => { - if (isStreaming) return; e.currentTarget.style.background = "var(--bg-hover)"; e.currentTarget.style.color = "var(--text)"; }} @@ -1846,7 +2641,7 @@ export const ChatInput = forwardRef(function ChatInput({ e.currentTarget.style.background = modelDropdownOpen ? "var(--bg-hover)" : "none"; e.currentTarget.style.color = "var(--text-muted)"; }} - title={modelOptions.length > 0 ? "Change model" : "No available models"} + title={modelOptions.length > 0 ? (isStreaming ? t("chat.modelSwitchStreaming") : t("chat.changeModel")) : t("chat.noModels")} > @@ -1859,6 +2654,12 @@ export const ChatInput = forwardRef(function ChatInput({ {currentName ?? (modelOptions.length > 0 ? "Select model" : "No models")} + {modelSwitchPending && ( + + + {t("chat.modelSwitchPendingBadge")} + + )} {modelDropdownOpen && modelDropdownRect && (() => { const viewportHeight = window.visualViewport?.height ?? window.innerHeight; @@ -2209,38 +3010,43 @@ export const ChatInput = forwardRef(function ChatInput({
)} - {!isStreaming && onCompact && ( + {onCompact && (
)} + {/* Import queue history — always available, even with an empty queue. */} + {onImportQueue !== undefined && ( + + )} {isMobile && controlsMenuOpen && ( )}
+ {isMobile && cycleBottomMode()} />} + + )} - ); }); diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 794a34cf..bd0c0a11 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -7,6 +7,7 @@ import { asBracketedPaste, toTerminalKeyData } from "@/lib/terminal-input"; import { countToolCallBlocks, getAssistantErrorMessage, getDisplayableAssistantBlocks, splitFinalAssistantBlocks } from "@/lib/message-display"; import { MessageView } from "./MessageView"; import { ChatInput, type ChatInputHandle } from "./ChatInput"; +import { QueueRecoveryDialog } from "./QueueRecoveryDialog"; import { ChatMinimap, useMessageRefs } from "./ChatMinimap"; import { ExtensionStatusBar } from "./ExtensionStatusBar"; import { useI18n } from "@/hooks/useI18n"; @@ -200,8 +201,9 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate loading, error, messages, entryIds, streamState, agentRunning, bashRunning, pendingBash, modelNames, modelList, modelError, modelScopeWarnings, modelThinkingLevels, modelThinkingLevelMaps, toolPreset, thinkingLevel, retryInfo, contextUsage, forkingEntryId, - isCompacting, compactError, compactResult, displayModel: displayModelValue, sessionStats, + isCompacting, compactError, compactResult, compactQueued, cancelCompactQueue, modelSwitchPending, displayModel: displayModelValue, sessionStats, slashCommands, slashCommandsLoading, queuedMessages, + pendingRecovery, recoveryIsImport, resolveRecovery, exportQueueData, importQueueData, stageImport, moveQueuedMessage, recallQueuedMessage, requeueAt, removeQueuedMessage, notices, extensionDialog, extensionCustomUi, extensionStatuses, extensionWidgets, respondToExtensionUi, sendExtensionCustomInput, isAutoModelSelection, agentPhase, @@ -218,6 +220,12 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSessionStatsPanelOpen, }); const sessionBusy = agentRunning || bashRunning; + const [recoveryDismissed, setRecoveryDismissed] = useState(false); + + // Reset dismissal whenever the session or its pending list changes identity. + useEffect(() => { + setRecoveryDismissed(false); + }, [session?.id]); useEffect(() => { if (!extensionDialog || soundedExtensionDialogIdRef.current === extensionDialog.id) return; @@ -361,6 +369,9 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate isCompacting={isCompacting} compactError={compactError} compactResult={compactResult} + compactQueued={compactQueued} + modelSwitchPending={modelSwitchPending} + onCancelCompactQueue={cancelCompactQueue} toolPreset={toolPreset} onToolPresetChange={session || isNew ? handleToolPresetChange : undefined} thinkingLevel={thinkingLevel} @@ -371,6 +382,13 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate queuedMessages={queuedMessages} inputHistory={inputHistory} onRecallQueue={handleRecallQueue} + onExportQueue={exportQueueData} + onImportQueue={importQueueData} + onStageImport={stageImport} + onMoveQueue={moveQueuedMessage} + onRecallOne={recallQueuedMessage} + onRequeueAt={requeueAt} + onRemoveQueueItem={removeQueuedMessage} slashCommands={slashCommands} slashCommandsLoading={slashCommandsLoading} onLoadSlashCommands={loadSlashCommands} @@ -457,6 +475,18 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate /> )} + {!isNew && pendingRecovery.length > 0 && !recoveryDismissed && ( + setRecoveryDismissed(true)} + mode={recoveryIsImport ? "import" : "recovery"} + /> + )} + {isEmptyNew ? (
diff --git a/components/QueueRecoveryDialog.tsx b/components/QueueRecoveryDialog.tsx new file mode 100644 index 00000000..0c1b3140 --- /dev/null +++ b/components/QueueRecoveryDialog.tsx @@ -0,0 +1,543 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { useI18n } from "@/hooks/useI18n"; +import { useIsMobile } from "@/hooks/useIsMobile"; +import type { PendingRecoveryItem, QueueEntry, QueueEntryInput } from "@/lib/queue-store"; +import { + downloadQueueExport, + parseQueueImport, +} from "@/lib/queue-export"; + +/** + * Modal shown when a session has queued messages that survived a server + * restart. Nothing is delivered automatically — the user selects which to + * re-queue, discard, export, or import. + */ +export function QueueRecoveryDialog({ + items, + sessionId, + onResolve, + onExport, + onImport, + onDismiss, + mode = "recovery", +}: { + items: PendingRecoveryItem[]; + sessionId?: string; + onResolve: (keep: string[], discard: string[], continueRun: boolean) => Promise; + onExport: () => Promise<{ live: QueueEntry[]; recovery: QueueEntry[] } | null>; + onImport: (entries: QueueEntryInput[]) => Promise; + onDismiss: () => void; + /** "recovery" = crash-recovery copy; "import" = imported-queue copy. */ + mode?: "recovery" | "import"; +}) { + const { t } = useI18n(); + const isMobile = useIsMobile(); + const [selected, setSelected] = useState>(() => new Set(items.map((item) => item.id))); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState<{ text: string; ok: boolean } | null>(null); + const importFileRef = useRef(null); + + const toggle = useCallback((id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const selectAll = useCallback(() => { + setSelected(new Set(items.map((item) => item.id))); + }, [items]); + + const selectNone = useCallback(() => { + setSelected(new Set()); + }, []); + + const selectedIds = items.filter((item) => selected.has(item.id)); + const allSelected = items.length > 0 && selectedIds.length === items.length; + + const doResolve = useCallback(async (continueRun: boolean) => { + if (selectedIds.length === 0) return; + setBusy(true); + setStatus(null); + try { + await onResolve( + selectedIds.map((item) => item.id), + [], + continueRun, + ); + } catch (error) { + setStatus({ text: error instanceof Error ? error.message : String(error), ok: false }); + } finally { + setBusy(false); + } + }, [selectedIds, onResolve]); + + const doDiscard = useCallback(async () => { + if (selectedIds.length === 0) return; + setBusy(true); + setStatus(null); + try { + await onResolve([], selectedIds.map((item) => item.id), false); + } catch (error) { + setStatus({ text: error instanceof Error ? error.message : String(error), ok: false }); + } finally { + setBusy(false); + } + }, [selectedIds, onResolve]); + + const doExport = useCallback(async (format: "md" | "json") => { + const data = await onExport(); + if (!data) return; + const entries = data.recovery.filter((entry) => selected.has(entry.id)); + downloadQueueExport(entries, { sessionId, source: "recovery" }, format); + if (entries.length > 0) { + setStatus({ text: t("chat.queueExported", { count: String(entries.length) }), ok: true }); + } + }, [onExport, selected, sessionId, t]); + + const doImport = useCallback(async (file: File) => { + try { + const content = await file.text(); + const entries = parseQueueImport(content); + if (entries.length === 0) { + setStatus({ text: t("chat.queueImportEmpty"), ok: false }); + return; + } + const imported = await onImport(entries); + if (imported !== null) setStatus({ text: t("chat.queueImported", { count: String(imported) }), ok: true }); + } catch { + setStatus({ text: t("chat.queueImportEmpty"), ok: false }); + } + }, [onImport, t]); + + // Shared button styles + const btnBase: React.CSSProperties = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + gap: 6, + padding: "6px 12px", + borderRadius: 7, + fontSize: 12.5, + fontWeight: 550, + cursor: "pointer", + transition: "background 0.12s, border-color 0.12s, color 0.12s, opacity 0.12s", + whiteSpace: "nowrap", + }; + const btnDanger: React.CSSProperties = { + ...btnBase, + color: "#ef4444", + background: "transparent", + border: "1px solid color-mix(in srgb, #ef4444 35%, var(--border))", + }; + const btnPrimary: React.CSSProperties = { + ...btnBase, + color: "var(--accent)", + background: "color-mix(in srgb, var(--accent) 10%, transparent)", + border: "1px solid color-mix(in srgb, var(--accent) 45%, var(--border))", + }; + const btnPrimarySolid: React.CSSProperties = { + ...btnBase, + color: "#fff", + background: "var(--accent)", + border: "1px solid var(--accent)", + }; + + const disabled = busy || selectedIds.length === 0; + const dimButton = (style: React.CSSProperties): React.CSSProperties => ({ + ...style, + opacity: disabled ? 0.45 : 1, + cursor: disabled ? "not-allowed" : "pointer", + }); + + return ( +
+
+ {/* Header */} +
+
+ + + + +
+
+
+ {mode === "import" ? t("chat.queueImportTitle", { count: String(items.length) }) : t("chat.queueRecoveryTitle", { count: String(items.length) })} +
+
+ {mode === "import" + ? (isMobile ? t("chat.queueImportDescShort") : t("chat.queueImportDesc")) + : (isMobile ? t("chat.queueRecoveryDescShort") : t("chat.queueRecoveryDesc"))} +
+
+ {/* Header utility actions (compact icon buttons) — wraps to its own row on small screens */} +
+ +
+ + + +
+ +
+
+ + {/* Item list */} +
+ {items.map((item) => { + const checked = selected.has(item.id); + return ( + + ); + })} +
+ + {/* Footer actions — decision row only */} +
+
+ + + + +
+ {status && ( +
+ + {status.ok + ? <> + : <>} + + {status.text} +
+ )} +
+
+ + { + const file = e.target.files?.[0]; + if (file) void doImport(file); + e.target.value = ""; + }} + /> +
+ ); +} diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index a482f712..3da0018c 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -11,6 +11,8 @@ import type { } from "@/lib/types"; import { normalizeToolCalls } from "@/lib/normalize"; import { sendAgentCommand } from "@/lib/agent-client"; +import type { ChatDraftImage } from "@/lib/draft-store"; +import type { PendingRecoveryItem, QueueEntry, QueueEntryInput } from "@/lib/queue-store"; import { getToolNamesForPreset, type ToolEntry } from "@/lib/tool-presets"; import type { SessionStatsInfo } from "@/lib/pi-types"; @@ -77,6 +79,7 @@ type AgentStateResponse = { extensionStatuses?: ExtensionStatusItem[]; extensionWidgets?: ExtensionWidgetItem[]; queuedMessages?: { steering?: string[]; followUp?: string[] } | null; + pendingRecovery?: PendingRecoveryItem[] | null; }; export interface QueuedMessages { @@ -354,6 +357,11 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const [modelScopeWarnings, setModelScopeWarnings] = useState([]); const [modelThinkingLevels, setModelThinkingLevels] = useState>({}); const [modelThinkingLevelMaps, setModelThinkingLevelMaps] = useState>>({}); + /** A model switch was made while the agent was running — applies next turn. + * Stores the target model; switching back to the current run's model cancels. */ + const [modelSwitchPending, setModelSwitchPending] = useState<{ provider: string; modelId: string } | null>(null); + /** Model the current agent run started with (agent_start / running transition). */ + const agentRunModelRef = useRef<{ provider: string; modelId: string } | null>(null); const [newSessionModel, setNewSessionModel] = useState(null); const [newSessionDefaultModel, setNewSessionDefaultModel] = useState(null); const [toolPreset, setToolPreset] = useState<"none" | "default" | "full">("default"); @@ -367,6 +375,9 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const [isCompacting, setIsCompacting] = useState(false); const [compactError, setCompactError] = useState(null); const [compactResult, setCompactResult] = useState(null); + /** Manual compaction queued while the agent was running; fires on next idle. */ + const [compactQueued, setCompactQueued] = useState(false); + const pendingCompactRef = useRef(false); const [agentPhase, setAgentPhase] = useState(null); const [slashCommands, setSlashCommands] = useState([]); const [slashCommandsLoading, setSlashCommandsLoading] = useState(false); @@ -377,6 +388,10 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const [extensionStatuses, setExtensionStatuses] = useState([]); const [extensionWidgets, setExtensionWidgets] = useState([]); const [queuedMessages, setQueuedMessages] = useState({ steering: [], followUp: [] }); + const [pendingRecovery, setPendingRecovery] = useState([]); + /** True when the current pending-recovery list came from an import (not a + * crash recovery) — drives the dialog's copy. Resets when the list empties. */ + const [recoveryIsImport, setRecoveryIsImport] = useState(false); const eventSourceRef = useRef(null); const eventSourceSessionIdRef = useRef(null); @@ -498,6 +513,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (liveState.extensionStatuses !== undefined) setExtensionStatuses(liveState.extensionStatuses ?? []); if (liveState.extensionWidgets !== undefined) setExtensionWidgets(liveState.extensionWidgets ?? []); if (liveState.queuedMessages !== undefined) setQueuedMessages(normalizeQueuedMessages(liveState.queuedMessages)); + if (liveState.pendingRecovery !== undefined) setPendingRecovery(liveState.pendingRecovery ?? []); } else if (!agentState.running) { setQueuedMessages({ steering: [], followUp: [] }); } @@ -994,6 +1010,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { // (wrapper destroyed) means nothing is compacting. setIsCompacting(state?.isCompacting ?? false); setQueuedMessages(normalizeQueuedMessages(state?.queuedMessages)); + setPendingRecovery(state?.pendingRecovery ?? []); const busy = data.running && state && (state.isStreaming || state.isPromptRunning || state.isCompacting); if (busy || !agentRunningRef.current) return; @@ -1067,6 +1084,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { // Aborted turns can leave messages queued in pi (delivered with the // next turn); dead wrapper (no state) means the queue is gone. setQueuedMessages(normalizeQueuedMessages(d.state?.queuedMessages)); + setPendingRecovery(d.state?.pendingRecovery ?? []); }) .catch(() => {}); } @@ -1156,6 +1174,21 @@ export function useAgentSession(opts: UseAgentSessionOptions) { return [...prev, delivered]; }); } else if (completed) { + // If this assistant reply was actually produced by the model the user + // switched to mid-run, the switch has now taken effect — clear the + // "applies next turn" hint. (The assistant message carries the real + // model/provider that generated it.) + if (completed.role === "assistant") { + const msgModel = (completed as unknown as { model?: string; provider?: string }).model; + const msgProvider = (completed as unknown as { model?: string; provider?: string }).provider; + if (msgModel && msgProvider) { + setModelSwitchPending((prev) => + prev && prev.modelId === msgModel && prev.provider === msgProvider + ? null + : prev, + ); + } + } setMessages((prev) => [...prev, normalizeToolCalls(completed)]); } dispatch({ type: "reset" }); @@ -1437,14 +1470,26 @@ export function useAgentSession(opts: UseAgentSessionOptions) { try { await sendAgentCommand(sid, { type: "set_model", provider, modelId }); setCurrentModelOverride({ provider, modelId }); + // Switching mid-run only takes effect next turn (the in-flight request + // still uses the old model). Surface this so the user isn't confused by + // the model name updating immediately. Switching back to the model the + // current run is using cancels the pending switch instead. + if (agentRunning) { + const runModel = agentRunModelRef.current; + if (runModel && runModel.provider === provider && runModel.modelId === modelId) { + setModelSwitchPending(null); + } else { + setModelSwitchPending({ provider, modelId }); + } + } } catch (e) { console.error("Failed to set model:", e); } - }, [isNew, setNewSessionModel]); + }, [isNew, setNewSessionModel, agentRunning]); - const handleCompact = useCallback(async () => { + const runCompactNow = useCallback(async () => { const sid = sessionIdRef.current; - if (!sid || isCompacting) return; + if (!sid) return; setIsCompacting(true); setCompactError(null); setCompactResult(null); @@ -1458,7 +1503,55 @@ export function useAgentSession(opts: UseAgentSessionOptions) { } finally { setIsCompacting(false); } - }, [isCompacting, loadSession]); + }, [loadSession]); + + const handleCompact = useCallback(async () => { + const sid = sessionIdRef.current; + if (!sid || isCompacting) return; + // Agent running (streaming OR executing a tool call): compacting now + // would race the active agent loop (both rewrite context + session file). + // Queue it for the next idle window. + if (agentRunning) { + pendingCompactRef.current = true; + setCompactQueued(true); + setCompactError(null); + return; + } + await runCompactNow(); + }, [isCompacting, agentRunning, runCompactNow]); + + // Record the model the current run started with, then clear the "applies + // next turn" hint once the agent finishes the run. + useEffect(() => { + if (agentRunning) { + if (currentModel) { + agentRunModelRef.current = { provider: currentModel.provider, modelId: currentModel.modelId }; + } + } else { + agentRunModelRef.current = null; + setModelSwitchPending(null); + } + // Intentionally only reacts to agentRunning: currentModel may change mid-run + // when the user switches, which must NOT overwrite the run's start model. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [agentRunning]); + + // Drain a queued compaction as soon as the agent is truly idle (covers SSE + // events, reconcile, and visibility/online recovery — all paths that clear + // isStreaming). prompt_done alone is not enough: retries, auto-compaction, + // and queued follow-ups can extend a logical run. + useEffect(() => { + if (pendingCompactRef.current && !agentRunning && !isCompacting) { + pendingCompactRef.current = false; + setCompactQueued(false); + void runCompactNow(); + } + }, [agentRunning, isCompacting, runCompactNow]); + + const cancelCompactQueue = useCallback(() => { + pendingCompactRef.current = false; + setCompactQueued(false); + }, []); const loadModels = useCallback(async (signal?: AbortSignal) => { const modelCwd = newSessionCwd ?? session?.cwd ?? ""; @@ -1653,6 +1746,220 @@ export function useAgentSession(opts: UseAgentSessionOptions) { } }, [opts.chatInputRef, addNotice]); + /** + * Decide what to do with pending recovery items: keep (re-queue, optionally + * continuing the transcript) and/or discard. Returns the remaining items. + */ + // Reset the import flag once the recovery list is fully drained so the + // next crash recovery shows recovery copy again. + useEffect(() => { + if (pendingRecovery.length === 0) setRecoveryIsImport(false); + }, [pendingRecovery.length]); + const resolveRecovery = useCallback(async ( + keep: string[], + discard: string[], + continueRun = false, + ): Promise => { + const sid = sessionIdRef.current; + if (!sid) return pendingRecovery; + try { + const result = await sendAgentCommand<{ remaining?: PendingRecoveryItem[] }>(sid, { + type: "resolve_recovery", + keep, + discard, + continueRun, + }); + const remaining = result?.remaining ?? []; + setPendingRecovery(remaining); + if (continueRun && keep.length > 0) { + // The run was started server-side, possibly before any SSE was + // connected (e.g. right after a restart) — pick it up so streaming, + // agent_end refresh, and settlement all work as usual. + void (async () => { + try { + const stateRes = await fetch(`/api/agent/${encodeURIComponent(sid)}`); + if (!stateRes.ok) return; + const agentState = await stateRes.json() as { running?: boolean; state?: AgentStateResponse }; + const st = agentState.state; + if (agentState.running && st && (st.isStreaming || st.isPromptRunning)) { + sdkAgentActiveRef.current = Boolean(st.isStreaming); + rpcPromptPendingRef.current = Boolean(st.isPromptRunning); + agentRunningRef.current = true; + setAgentRunning(true); + setAgentPhase(st.isStreaming ? { kind: "waiting_model" } : { kind: "running_command" }); + dispatch({ type: "start" }); + void connectEvents(sid); + if (!st.isStreaming && st.isPromptRunning) { + void waitForPromptSettlement(sid); + } + } + } catch { /* non-critical */ } + })(); + } + return remaining; + } catch (e) { + console.error("Failed to resolve queued message recovery:", e); + addNotice({ type: "error", message: "Failed to resolve queued message recovery" }); + return pendingRecovery; + } + }, [pendingRecovery, addNotice, connectEvents, waitForPromptSettlement]); + + /** Fetch the full live queue + recovery entries (with images) for export. */ + const exportQueueData = useCallback(async (): Promise<{ live: QueueEntry[]; recovery: QueueEntry[] } | null> => { + const sid = sessionIdRef.current; + if (!sid) return null; + try { + return await sendAgentCommand<{ live: QueueEntry[]; recovery: QueueEntry[] }>(sid, { type: "export_queue" }); + } catch (e) { + console.error("Failed to export queue:", e); + addNotice({ type: "error", message: "Failed to export queue" }); + return null; + } + }, [addNotice]); + + /** Re-queue entries parsed from an exported .json file. Returns count. */ + const importQueueData = useCallback(async (entries: QueueEntryInput[]): Promise => { + const sid = sessionIdRef.current; + if (!sid) return null; + try { + const result = await sendAgentCommand<{ imported?: number; steering?: string[]; followUp?: string[] }>(sid, { + type: "import_queue", + entries, + }); + // Keep the UI in sync with the freshly imported queue (SSE is idle here, + // so the banner would otherwise stay hidden until a reload). + if (result) setQueuedMessages(normalizeQueuedMessages(result)); + return result?.imported ?? 0; + } catch (e) { + console.error("Failed to import queue:", e); + addNotice({ type: "error", message: "Failed to import queue" }); + return null; + } + }, [addNotice]); + + /** Stage imported entries as pending recovery so the recovery dialog pops + * up and the user chooses re-queue / re-queue & continue / discard. */ + const stageImport = useCallback(async (entries: QueueEntryInput[]): Promise => { + const sid = sessionIdRef.current; + if (!sid) return null; + try { + const result = await sendAgentCommand<{ staged?: number; pendingRecovery?: PendingRecoveryItem[] }>(sid, { + type: "stage_recovery", + entries, + }); + if (result?.pendingRecovery) setPendingRecovery(result.pendingRecovery); + if ((result?.staged ?? 0) > 0) setRecoveryIsImport(true); + return result?.staged ?? 0; + } catch (e) { + console.error("Failed to stage import:", e); + addNotice({ type: "error", message: "Failed to stage imported queue" }); + return null; + } + }, [addNotice]); + + /** Move a queued message within its queue (clear + re-enqueue server-side). */ + const moveQueuedMessage = useCallback(async ( + kind: "steer" | "followUp", + fromIndex: number, + toIndex: number, + ): Promise => { + const sid = sessionIdRef.current; + if (!sid) return false; + try { + const result = await sendAgentCommand<{ steering?: string[]; followUp?: string[] }>(sid, { + type: "move_queue", + kind, + fromIndex, + toIndex, + }); + if (result) setQueuedMessages(normalizeQueuedMessages(result)); + return true; + } catch (e) { + console.error("Failed to move queued message:", e); + addNotice({ type: "error", message: e instanceof Error ? e.message : "Failed to move queued message" }); + return false; + } + }, [addNotice]); + + /** Pull one queued message out (clear + re-enqueue the rest); returns its text + images. */ + const recallQueuedMessage = useCallback(async ( + kind: "steer" | "followUp", + index: number, + ): Promise<{ text: string; images?: ChatDraftImage[] } | null> => { + const sid = sessionIdRef.current; + if (!sid) return null; + try { + const result = await sendAgentCommand<{ entry?: { text: string; images?: Array<{ type: "image"; data: string; mimeType: string }> }; steering?: string[]; followUp?: string[] }>(sid, { + type: "recall_queue_item", + kind, + index, + }); + if (result && (result.steering !== undefined || result.followUp !== undefined)) { + setQueuedMessages(normalizeQueuedMessages(result)); + } + if (!result?.entry) return null; + return { + text: result.entry.text, + images: result.entry.images?.map((img) => ({ + data: img.data, + mimeType: img.mimeType, + })), + }; + } catch (e) { + console.error("Failed to recall queued message:", e); + addNotice({ type: "error", message: e instanceof Error ? e.message : "Failed to recall queued message" }); + return null; + } + }, [addNotice]); + + /** Insert an edited message back into the queue at its original position. */ + const requeueAt = useCallback(async ( + kind: "steer" | "followUp", + index: number, + text: string, + images?: ChatDraftImage[], + ): Promise => { + const sid = sessionIdRef.current; + if (!sid) return false; + try { + const result = await sendAgentCommand<{ steering?: string[]; followUp?: string[] }>(sid, { + type: "requeue_at", + kind, + index, + text, + images: images?.map((img) => ({ type: "image" as const, data: img.data, mimeType: img.mimeType })), + }); + if (result) setQueuedMessages(normalizeQueuedMessages(result)); + return true; + } catch (e) { + console.error("Failed to requeue message:", e); + addNotice({ type: "error", message: e instanceof Error ? e.message : "Failed to requeue message" }); + return false; + } + }, [addNotice]); + + /** Remove a single queued message (clear + re-enqueue the rest). */ + const removeQueuedMessage = useCallback(async ( + kind: "steer" | "followUp", + index: number, + ): Promise => { + const sid = sessionIdRef.current; + if (!sid) return false; + try { + const result = await sendAgentCommand<{ steering?: string[]; followUp?: string[] }>(sid, { + type: "remove_queue_item", + kind, + index, + }); + if (result) setQueuedMessages(normalizeQueuedMessages(result)); + return true; + } catch (e) { + console.error("Failed to remove queued message:", e); + addNotice({ type: "error", message: e instanceof Error ? e.message : "Failed to remove queued message" }); + return false; + } + }, [addNotice]); + const handleThinkingLevelChange = useCallback(async (level: ThinkingLevelOption) => { setThinkingLevel(level); if (isNew && !sessionIdRef.current) { @@ -1742,7 +2049,18 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (agentState.state.extensionStatuses !== undefined) setExtensionStatuses(agentState.state.extensionStatuses ?? []); if (agentState.state.extensionWidgets !== undefined) setExtensionWidgets(agentState.state.extensionWidgets ?? []); if (agentState.state.queuedMessages !== undefined) setQueuedMessages(normalizeQueuedMessages(agentState.state.queuedMessages)); + if (agentState.state.pendingRecovery !== undefined) setPendingRecovery(agentState.state.pendingRecovery ?? []); } + // After a restart there may be no wrapper yet; fetch the sidecar-based + // recovery list directly (no AgentSession is created for this read). + void fetch(`/api/sessions/${encodeURIComponent(session.id)}/queue-recovery`) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { items?: PendingRecoveryItem[] } | null) => { + if (data?.items && sessionIdRef.current === session.id) { + setPendingRecovery(data.items); + } + }) + .catch(() => { /* non-critical */ }); }); } return () => { @@ -1840,8 +2158,10 @@ export function useAgentSession(opts: UseAgentSessionOptions) { data, loading, error, activeLeafId, messages, entryIds, streamState, agentRunning, modelNames, modelList, modelError, modelScopeWarnings, modelThinkingLevels, modelThinkingLevelMaps, newSessionModel, toolPreset, thinkingLevel, retryInfo, contextUsage, systemPrompt, forkingEntryId, - isCompacting, compactError, compactResult, currentModel, displayModel, sessionStats, + isCompacting, compactError, compactResult, compactQueued, cancelCompactQueue, modelSwitchPending, currentModel, displayModel, sessionStats, slashCommands, slashCommandsLoading, queuedMessages, + pendingRecovery, recoveryIsImport, resolveRecovery, exportQueueData, importQueueData, stageImport, + moveQueuedMessage, recallQueuedMessage, requeueAt, removeQueuedMessage, notices: noticeState.visible, extensionDialog, extensionCustomUi, extensionStatuses, extensionWidgets, respondToExtensionUi, sendExtensionCustomInput, isAutoModelSelection: isNew && newSessionModel === null, agentPhase, diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 156cb961..957ff1ab 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -176,8 +176,42 @@ export const enLocale: LocalePlugin = { "chat.extensionPanel": "Extension panel", "chat.close": "Close", "chat.queued": "Queued · {count}", + "chat.queueCollapse": "Collapse queued messages", + "chat.queueExpand": "Expand queued messages", "chat.recall": "Recall to input", "chat.recallTitle": "Remove all queued messages and put them back into the input box for editing", + "chat.queueExport": "Export queue", + "chat.queueImport": "Import queue", + "chat.queueRemove": "Remove this message", + "chat.queueMoveUp": "Move up", + "chat.queueMoveDown": "Move down", + "chat.queueRecallOne": "Recall to input box", + "chat.queueImportEmpty": "No valid entries found in that file", + "chat.queueImported": "Imported {count} message(s) to the queue", + "chat.queueExported": "Exported {count} message(s)", + "chat.queueRecoveryTitle": "Pending queued messages · {count}", + "chat.queueRecoveryDesc": "These messages were in the queue when the server restarted and were never processed. Nothing is delivered automatically — pick what to do with each one.", + "chat.queueRecoveryDescShort": "Messages left in the queue after a restart.", + "chat.queueImportTitle": "Imported queued messages · {count}", + "chat.queueImportDesc": "These messages came from an imported file. They won't run automatically — decide what to do with each.", + "chat.queueImportDescShort": "Imported queue messages, decide what to do.", + "chat.queueRecoveryKindSteer": "steer", + "chat.queueRecoveryKindFollowUp": "follow-up", + "chat.queueRecoveryImages": "contains images", + "chat.queueRecoveryNoText": "(no text)", + "chat.queueRecoverySelectAll": "Select all", + "chat.queueRecoverySelectNone": "Select none", + "chat.queueRecoveryRequeue": "Re-queue selected", + "chat.queueRecoveryRequeueContinue": "Re-queue & continue", + "chat.queueRecoveryRequeueContinueTitle": "Re-queue and let the agent process them now", + "chat.queueRecoveryDiscard": "Discard selected", + "chat.queueRecoveryExportMd": "Export .md", + "chat.queueRecoveryExportJson": "Export .json", + "chat.queueRecoveryDismiss": "Later", + "chat.queueRecoveryDismissTitle": "Keep them pending and ask again next time", + "chat.recalledEditing": "Editing a message pulled from the queue — sending puts it back at position {pos}", + "chat.recalledRequeue": "Re-queue", + "chat.recalledCancel": "Cancel", "chat.retrying": "Retrying ({attempt}/{max})…", "chat.loadingCommands": "Loading commands...", "chat.slashCommands": "Slash commands · {label}", @@ -204,6 +238,12 @@ export const enLocale: LocalePlugin = { "chat.noMatchingModels": "No matching models", "chat.moreControls": "More controls", "chat.collapseControls": "Collapse controls", + "chat.minimizeQueue": "Collapse queue panel", + "chat.minimizeInput": "Collapse input panel", + "chat.restoreBottom": "Expand all", + "chat.bottomInputCollapsed": "Input collapsed, tap the bar on the right to expand", + "chat.swipeExpand": "Keep swiping up to expand", + "chat.swipeCollapse": "Keep swiping down to collapse", "chat.shell": "Shell", "chat.outputLocal": "output stays local", "chat.outputModel": "output sent to model", @@ -217,6 +257,12 @@ export const enLocale: LocalePlugin = { "chat.compactContext": "Compact context", "chat.compacting": "Compacting…", "chat.compact": "Compact", + "chat.compactQueued": "Queued", + "chat.cancelCompactQueue": "Click to cancel queued compaction", + "chat.changeModel": "Change model", + "chat.modelSwitchStreaming": "Change model (applies next turn)", + "chat.modelSwitchPendingBadge": "next turn", + "chat.noModels": "No available models", "chat.stopAgent": "Stop agent", "chat.stop": "Stop", "chat.disableSound": "Disable completion sound", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index 7854f814..d34c9254 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -176,8 +176,42 @@ export const zhCNLocale: LocalePlugin = { "chat.extensionPanel": "扩展面板", "chat.close": "关闭", "chat.queued": "已排队 · {count}", + "chat.queueCollapse": "折叠排队消息", + "chat.queueExpand": "展开排队消息", "chat.recall": "移回输入框", "chat.recallTitle": "移除所有排队消息并将其放回输入框编辑", + "chat.queueExport": "导出队列", + "chat.queueImport": "导入队列", + "chat.queueRemove": "删除该条", + "chat.queueMoveUp": "上移", + "chat.queueMoveDown": "下移", + "chat.queueRecallOne": "移回输入框编辑", + "chat.queueImportEmpty": "文件中没有有效的消息条目", + "chat.queueImported": "已导入 {count} 条消息到队列", + "chat.queueExported": "已导出 {count} 条消息", + "chat.queueRecoveryTitle": "待恢复的排队消息 · {count}", + "chat.queueRecoveryDesc": "这些消息在服务器重启时尚在队列中,未被处理。不会自动投递——请逐条决定如何处理。", + "chat.queueRecoveryDescShort": "重启时未处理的排队消息,不会自动投递。", + "chat.queueImportTitle": "导入的排队消息 · {count}", + "chat.queueImportDesc": "这些消息来自你导入的文件。不会自动执行——请逐条决定如何处理。", + "chat.queueImportDescShort": "导入的排队消息,请决定如何处理。", + "chat.queueRecoveryKindSteer": "steer", + "chat.queueRecoveryKindFollowUp": "follow-up", + "chat.queueRecoveryImages": "含图片", + "chat.queueRecoveryNoText": "(无文本)", + "chat.queueRecoverySelectAll": "全选", + "chat.queueRecoverySelectNone": "全不选", + "chat.queueRecoveryRequeue": "重新排队", + "chat.queueRecoveryRequeueContinue": "重新排队并继续", + "chat.queueRecoveryRequeueContinueTitle": "重新排队并让 agent 立即处理", + "chat.queueRecoveryDiscard": "丢弃选中", + "chat.queueRecoveryExportMd": "导出 .md", + "chat.queueRecoveryExportJson": "导出 .json", + "chat.queueRecoveryDismiss": "稍后处理", + "chat.queueRecoveryDismissTitle": "保留待恢复状态,下次打开会话时再询问", + "chat.recalledEditing": "正在编辑已移回的排队消息——发送将放回队列原处(第 {pos} 位)", + "chat.recalledRequeue": "放回队列", + "chat.recalledCancel": "取消移回", "chat.retrying": "正在重试({attempt}/{max})…", "chat.loadingCommands": "正在加载命令...", "chat.slashCommands": "斜杠命令 · {label}", @@ -204,6 +238,12 @@ export const zhCNLocale: LocalePlugin = { "chat.noMatchingModels": "没有匹配的模型", "chat.moreControls": "更多控件", "chat.collapseControls": "收起控件", + "chat.minimizeQueue": "折叠队列面板", + "chat.minimizeInput": "折叠输入面板", + "chat.restoreBottom": "全部展开", + "chat.bottomInputCollapsed": "输入已折叠,点击右侧竖条展开", + "chat.swipeExpand": "继续上滑展开", + "chat.swipeCollapse": "继续下滑折叠", "chat.shell": "Shell", "chat.outputLocal": "输出保留在本地", "chat.outputModel": "输出发送给模型", @@ -217,6 +257,12 @@ export const zhCNLocale: LocalePlugin = { "chat.compactContext": "压缩上下文", "chat.compacting": "正在压缩…", "chat.compact": "压缩", + "chat.compactQueued": "已排队", + "chat.cancelCompactQueue": "点击取消压缩排队", + "chat.changeModel": "切换模型", + "chat.modelSwitchStreaming": "切换模型(下轮生效)", + "chat.modelSwitchPendingBadge": "下轮生效", + "chat.noModels": "无可用模型", "chat.stopAgent": "停止 Agent", "chat.stop": "停止", "chat.disableSound": "关闭完成提示音", diff --git a/lib/pi-types.ts b/lib/pi-types.ts index 1af97949..f95b3ad4 100644 --- a/lib/pi-types.ts +++ b/lib/pi-types.ts @@ -128,7 +128,10 @@ export interface AgentSessionLike { }; readonly sessionManager: SessionManager; readonly settingsManager: SettingsManager; - readonly agent: { state?: { systemPrompt?: string; thinkingLevel?: string } }; + readonly agent: { + state?: { systemPrompt?: string; thinkingLevel?: string }; + continue?: () => Promise; + }; readonly extensionRunner: ExtensionRunnerLike; readonly promptTemplates: readonly PromptTemplateLike[]; readonly resourceLoader: ResourceLoaderLike; diff --git a/lib/queue-export.ts b/lib/queue-export.ts new file mode 100644 index 00000000..e1a834a4 --- /dev/null +++ b/lib/queue-export.ts @@ -0,0 +1,131 @@ +import type { QueueEntry, QueueEntryInput, QueueImage, QueueKind } from "./queue-store"; + +/** + * Client-side helpers for queue export (Markdown / JSON) and import parsing. + * Pure functions — no node or DOM dependencies except the explicit download() + * helper used from UI components. + */ + +export interface QueueExportMeta { + sessionId?: string; + source: "live" | "recovery"; + exportedAt?: string; +} + +const QUEUE_EXPORT_FORMAT = "pi-web-queue"; + +function formatTime(ts: number): string { + return new Date(ts).toISOString(); +} + +function imageCountText(count: number): string { + if (count === 0) return ""; + return count === 1 ? "(1 image)" : `(${count} images)`; +} + +/** Markdown export — human-readable, images noted but not embedded. */ +export function queueToMarkdown(entries: QueueEntry[], meta: QueueExportMeta): string { + const lines: string[] = []; + lines.push("# Queued messages export"); + if (meta.sessionId) lines.push(`- Session: ${meta.sessionId}`); + lines.push(`- Source: ${meta.source === "live" ? "live queue" : "pending recovery"}`); + lines.push(`- Exported: ${meta.exportedAt ?? new Date().toISOString()}`); + lines.push(""); + if (entries.length === 0) { + lines.push("_No entries._"); + return lines.join("\n"); + } + entries.forEach((entry, index) => { + lines.push(`## ${index + 1}. ${entry.kind === "steer" ? "steer" : "follow-up"} · ${formatTime(entry.queuedAt)}`); + if (entry.text) lines.push(entry.text); + if (entry.images?.length) lines.push(`> _${imageCountText(entry.images.length)}_`); + lines.push(""); + }); + return lines.join("\n"); +} + +/** + * JSON export — full fidelity (images included), round-trips through + * parseQueueImport(). + */ +export function queueToJson(entries: QueueEntry[], meta: QueueExportMeta): string { + return JSON.stringify( + { + format: QUEUE_EXPORT_FORMAT, + version: 1, + exportedAt: meta.exportedAt ?? new Date().toISOString(), + source: meta.source, + sessionId: meta.sessionId, + entries: entries.map(({ kind, text, images }) => ({ + kind, + text, + ...(images?.length ? { images } : {}), + })), + }, + null, + 2, + ); +} + +/** Trigger a browser download of the generated content. */ +export function downloadQueueExport( + entries: QueueEntry[], + meta: QueueExportMeta, + format: "md" | "json", +): void { + const content = format === "md" ? queueToMarkdown(entries, meta) : queueToJson(entries, meta); + const mime = format === "md" ? "text/markdown;charset=utf-8" : "application/json;charset=utf-8"; + const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const name = `${meta.sessionId ?? "session"}-queue-${meta.source}-${stamp}.${format}`; + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = name; + anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +function isQueueImage(value: unknown): value is QueueImage { + return ( + Boolean(value) && + typeof (value as QueueImage).type === "string" && + typeof (value as QueueImage).data === "string" && + typeof (value as QueueImage).mimeType === "string" + ); +} + +function normalizeEntry(value: unknown): QueueEntryInput | null { + if (!value || typeof value !== "object") return null; + const raw = value as Record; + const kind = raw.kind as QueueKind; + if (kind !== "steer" && kind !== "followUp") return null; + if (typeof raw.text !== "string") return null; + let images: QueueImage[] | undefined; + if (Array.isArray(raw.images)) { + images = raw.images.filter(isQueueImage); + if (images.length === 0) images = undefined; + } + return { kind, text: raw.text, ...(images ? { images } : {}) }; +} + +/** + * Parse an imported JSON file. Accepts our export format + * ({ format: "pi-web-queue", entries: [...] }) or a bare array. Invalid + * entries are skipped; malformed files yield an empty array. + */ +export function parseQueueImport(content: string): QueueEntryInput[] { + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + return []; + } + const rawEntries = Array.isArray(parsed) + ? parsed + : parsed && typeof parsed === "object" && Array.isArray((parsed as { entries?: unknown }).entries) + ? (parsed as { entries: unknown[] }).entries + : null; + if (!rawEntries) return []; + return rawEntries.map(normalizeEntry).filter((e): e is QueueEntryInput => e !== null); +} diff --git a/lib/queue-store.ts b/lib/queue-store.ts new file mode 100644 index 00000000..a581c651 --- /dev/null +++ b/lib/queue-store.ts @@ -0,0 +1,116 @@ +import { randomUUID } from "crypto"; +import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs"; + +/** + * Durable store for queued (steer / follow-up) messages. + * + * pi keeps its steering/follow-up queues purely in memory. If the server dies + * before a queued message is processed, that message would be lost. This module + * mirrors the queue to a per-session sidecar file (`.jsonl.queue.json`) + * so pi-web can offer the user a manual recovery list after a restart. + * + * Design rules (see AGENTS.md / recovery UX): + * - The sidecar only SAVES entries — it never auto-delivers them. + * - After a restart every entry in the sidecar becomes a "pending recovery" + * item; the user decides to re-queue, discard, or export each one. + */ + +export type QueueKind = "steer" | "followUp"; + +export interface QueueImage { + type: "image"; + data: string; + mimeType: string; +} + +export interface QueueEntry { + id: string; + kind: QueueKind; + text: string; + images?: QueueImage[]; + queuedAt: number; +} + +/** Client-supplied shape for import_queue (ids are assigned server-side). */ +export interface QueueEntryInput { + kind: QueueKind; + text: string; + images?: QueueImage[]; +} + +/** Public (non-image) view of an entry awaiting the user's recovery decision. */ +export interface PendingRecoveryItem { + id: string; + kind: QueueKind; + text: string; + hasImages: boolean; + queuedAt: number; +} + +interface QueueFile { + version: 1; + entries: QueueEntry[]; +} + +export function queueSidecarPath(sessionFile: string): string { + return `${sessionFile}.queue.json`; +} + +export function createQueueEntry( + kind: QueueKind, + text: string, + images?: QueueImage[], +): QueueEntry { + return { + id: randomUUID(), + kind, + text, + ...(images && images.length > 0 ? { images } : {}), + queuedAt: Date.now(), + }; +} + +export function loadQueue(sessionFile: string): QueueEntry[] { + const path = queueSidecarPath(sessionFile); + if (!existsSync(path)) return []; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; + if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.entries)) return []; + return parsed.entries.filter( + (e): e is QueueEntry => + Boolean(e) && + (e.kind === "steer" || e.kind === "followUp") && + typeof e.text === "string", + ); + } catch { + // Corrupt sidecar — treat as empty rather than blocking the session. + return []; + } +} + +/** Atomically replace the sidecar (tmp file + rename). */ +export function saveQueue(sessionFile: string, entries: QueueEntry[]): void { + if (!sessionFile) return; + const path = queueSidecarPath(sessionFile); + const payload: QueueFile = { version: 1, entries }; + const tmp = `${path}.tmp-${process.pid}-${randomUUID()}`; + try { + writeFileSync(tmp, JSON.stringify(payload), "utf8"); + renameSync(tmp, path); + } catch (error) { + // Any failure must not crash the session; best-effort cleanup of tmp. + try { + rmSync(tmp, { force: true }); + } catch { /* ignore */ } + console.error( + `[pi-web] failed to persist queue sidecar ${path}:`, + error instanceof Error ? error.message : String(error), + ); + } +} + +export function removeQueue(sessionFile: string): void { + try { + rmSync(queueSidecarPath(sessionFile), { force: true }); + } catch { /* ignore */ } +} diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index cfb61100..10221295 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -5,6 +5,17 @@ import { randomUUID } from "crypto"; import { existsSync, realpathSync, writeFileSync } from "fs"; import { resolve } from "path"; import { validateAgentImages } from "./image-attachments"; +import { + createQueueEntry, + loadQueue, + removeQueue, + saveQueue, + type PendingRecoveryItem, + type QueueEntry, + type QueueEntryInput, + type QueueImage, + type QueueKind, +} from "./queue-store"; import { invalidateModelsCache } from "./models-cache"; import { resolveVisibleModels, selectInitialModelScope } from "./model-scope"; import { cacheSessionPath, invalidateSessionListCache } from "./session-reader"; @@ -148,6 +159,13 @@ export class AgentSessionWrapper { private forceEmptySystemPrompt = false; private unsubscribe: (() => void) | null = null; private idleTimer: ReturnType | null = null; + // Queue persistence & crash recovery — see lib/queue-store.ts. + // queueMirror mirrors pi's live steer/follow-up queue (with images). + // queueRecovery holds sidecar entries orphaned by a previous wrapper + // lifetime; they are surfaced to the UI and never auto-delivered. + private queueMirror: QueueEntry[] = []; + private queueRecovery: QueueEntry[] = []; + private pendingQueueHints: Record = { steer: [], followUp: [] }; private onDestroyCallback: (() => void) | null = null; private shutdownPromise: Promise | null = null; private _alive = true; @@ -179,6 +197,12 @@ export class AgentSessionWrapper { if (event.type === "agent_end") { invalidateSessionListCache(); } + if (event.type === "queue_update") { + this.reconcileQueue( + (event as { steering?: string[] }).steering, + (event as { followUp?: string[] }).followUp, + ); + } if (IDLE_RESET_EVENT_TYPES.has(event.type)) this.resetIdleTimer(); this.emit(event); if (RUNNING_STATE_EVENT_TYPES.has(event.type)) notifyRunningChange(); @@ -187,6 +211,186 @@ export class AgentSessionWrapper { notifyRunningChange(); } + // ========================================================================== + // Queue persistence & crash recovery + // + // pi keeps its steering/follow-up queues in memory only. We mirror every + // queue mutation to a per-session sidecar file so that after a server + // restart the messages can be offered for manual recovery instead of being + // lost. Recovery is NEVER automatic: sidecar entries are surfaced to the UI + // (pendingRecovery) and the user decides to re-queue, discard, or export. + // ========================================================================== + + /** + * Load entries the sidecar inherited from a previous process (or a previous + * wrapper lifetime after the 10-minute idle timeout). + */ + loadQueueRecovery(): void { + const file = this.sessionFile; + if (!file) return; + // Defensive consistency check: pi's steering/follow-up queues live in the + // same process as this wrapper, and a brand-new AgentSession always starts + // with empty queues. If pi already has queued messages here, the queue + // survived (hot reload reused the runtime, future architecture change, …) + // and the sidecar is just an older mirror. In that case skip recovery and + // resync the mirror from pi instead of offering duplicate entries. + const liveSteering = this.inner.getSteeringMessages(); + const liveFollowUp = this.inner.getFollowUpMessages(); + if (liveSteering.length > 0 || liveFollowUp.length > 0) { + this.queueRecovery = []; + this.reconcileQueue([...liveSteering], [...liveFollowUp]); + return; + } + const persisted = loadQueue(file); + if (persisted.length > 0) { + this.queueRecovery = persisted; + console.log( + `[pi-web] ${persisted.length} queued message(s) pending recovery for session ${this.sessionId}`, + ); + } + } + + private getSidecarEntries(): QueueEntry[] { + return [...this.queueRecovery, ...this.queueMirror]; + } + + private persistQueue(): void { + const file = this.sessionFile; + if (!file) return; + const entries = this.getSidecarEntries(); + if (entries.length === 0) { + removeQueue(file); + return; + } + saveQueue(file, entries); + } + + private getPendingRecoveryView(): PendingRecoveryItem[] { + return this.queueRecovery.map((entry) => ({ + id: entry.id, + kind: entry.kind, + text: entry.text, + hasImages: Boolean(entry.images?.length), + queuedAt: entry.queuedAt, + })); + } + + /** + * Record an incoming queueable message so its image attachments survive + * mirroring. pi reports queued messages by text only and template expansion + * may rewrite the text, so hints are consumed FIFO per kind. + */ + private hintQueueImages(kind: QueueKind, images: QueueImage[] | undefined): void { + this.pendingQueueHints[kind].push(images && images.length > 0 ? images : []); + } + + /** + * Sync the mirror with pi's authoritative queue (text-only). Bare entries pi + * reports that we have never mirrored consume image hints FIFO. + */ + private reconcileQueue(steering: string[] | undefined, followUp: string[] | undefined): void { + const rebuild = (kind: QueueKind, texts: string[] | undefined): QueueEntry[] => { + if (!texts) return this.queueMirror.filter((entry) => entry.kind === kind); + const used = new Set(); + return texts.map((text) => { + const match = this.queueMirror.find( + (entry) => entry.kind === kind && entry.text === text && !used.has(entry), + ); + if (match) { + used.add(match); + return match; + } + const entry = createQueueEntry(kind, text, this.pendingQueueHints[kind].shift()); + used.add(entry); + return entry; + }); + }; + this.queueMirror = [ + ...rebuild("steer", steering), + ...rebuild("followUp", followUp), + ]; + this.persistQueue(); + } + + /** Re-queue a recovered or imported entry into the live queue. */ + private async requeueEntry(entry: QueueEntry): Promise { + this.hintQueueImages(entry.kind, entry.images); + if (entry.kind === "steer") { + await this.inner.steer(entry.text, entry.images); + } else { + await this.inner.followUp(entry.text, entry.images); + } + } + + /** + * Ensure the entry is present in the mirror (queue_update may race). Called + * after requeue/import so the sidecar is never missing a re-queued message. + */ + private ensureMirrored(entry: QueueEntry): void { + const existing = this.queueMirror.find( + (e) => e.kind === entry.kind && e.text === entry.text, + ); + if (existing) return; + this.queueMirror.push({ ...entry }); + // No queue_update consumed the image hint (yet) — drop the one we pushed in + // requeueEntry so it cannot attach to an unrelated future entry. + this.pendingQueueHints[entry.kind].pop(); + } + + /** + * Continue the transcript so queued messages get processed. AgentSession has + * no public continue(); invoke the raw agent loop like AgentSession does + * internally — events/persistence still flow through its subscriptions. + */ + private runAgentContinue(): void { + // pi has no public API to resume an idle agent's queue (agent.continue() + // only works inside an active _runAgentPrompt loop). So bootstrap a run + // by prompting each queued message in series. Capture the entries BEFORE + // clearQueue (which fires queue_update and would empty the mirror). + if (this.inner.isStreaming || this.inner.isBashRunning || this.promptRunning) return; + const all = [...this.queueMirror]; + if (all.length === 0) return; + this.queueMirror = []; + this.persistQueue(); + this.promptRunning = true; + notifyRunningChange(); + void this.bootstrapQueuedRun(all); + } + + private async bootstrapQueuedRun(all: QueueEntry[]): Promise { + // Sequentially prompt each queued message as its own turn. pi's followUp/ + // steer queues are NOT drained by a prompt-bootstrapped loop (the internal + // agent.continue() continuation stalls), so prompting each entry in series + // is the robust way to run them all. AgentSession.prompt() awaits its + // _runAgentPrompt, so each prompt settles before the next starts. + this.inner.clearQueue(); + this.pendingQueueHints = { steer: [], followUp: [] }; + for (let i = 0; i < all.length; i++) { + const entry = all[i]; + try { + await this.inner.prompt( + entry.text, + entry.images?.length ? { images: entry.images } : undefined, + ); + } catch (error: unknown) { + this.promptRunning = false; + this.resetIdleTimer(); + invalidateSessionListCache(); + this.emit({ + type: "prompt_error", + errorMessage: error instanceof Error ? error.message : String(error), + }); + this.emit({ type: "prompt_done" }); + notifyRunningChange(); + return; + } + } + this.promptRunning = false; + this.resetIdleTimer(); + this.emit({ type: "prompt_done" }); + notifyRunningChange(); + } + setForceEmptySystemPrompt(force: boolean): void { this.forceEmptySystemPrompt = force; this.applyForcedEmptySystemPrompt(); @@ -354,6 +558,11 @@ export class AgentSessionWrapper { // Fire and forget — events come via subscribe const promptImages = command.images as Array<{ type: "image"; data: string; mimeType: string }> | undefined; const streamingBehavior = command.streamingBehavior as "steer" | "followUp" | undefined; + const queuesMessage = Boolean(streamingBehavior) && this.inner.isStreaming; + if (queuesMessage) { + // Record image attachments so they survive queue mirroring. + this.hintQueueImages(streamingBehavior === "followUp" ? "followUp" : "steer", promptImages); + } this.promptRunning = true; notifyRunningChange(); this.inner.prompt(command.message as string, { @@ -402,6 +611,7 @@ export class AgentSessionWrapper { steering: [...this.inner.getSteeringMessages()], followUp: [...this.inner.getFollowUpMessages()], }, + pendingRecovery: this.getPendingRecoveryView(), contextUsage: contextUsage ? { percent: contextUsage.percent, contextWindow: contextUsage.contextWindow, tokens: contextUsage.tokens } : null, @@ -521,17 +731,274 @@ export class AgentSessionWrapper { case "clear_queue": { // Full clear only: pi has no single-item dequeue, and clear+requeue // races against the agent loop pulling messages mid-flight. + this.queueMirror = []; + this.pendingQueueHints = { steer: [], followUp: [] }; + this.persistQueue(); return this.inner.clearQueue(); } + case "move_queue": { + // pi has no reorder/single-dequeue API, so reordering is implemented + // as clear-all + re-enqueue in the target order. Like pi's own + // clearQueue(), this is allowed while the agent runs: the loop only + // pulls entries between turns, and clear+requeue within one request is + // atomic enough that the queue is repopulated before the next pull. + const kind = command.kind as QueueKind | undefined; + if (kind !== "steer" && kind !== "followUp") { + throw new Error("move_queue requires kind \"steer\" or \"followUp\""); + } + const fromIndex = Number(command.fromIndex); + const toIndex = Number(command.toIndex); + if (!Number.isInteger(fromIndex) || !Number.isInteger(toIndex)) { + throw new Error("move_queue requires integer indexes"); + } + const steeringEntries = this.queueMirror.filter((e) => e.kind === "steer"); + const followUpEntries = this.queueMirror.filter((e) => e.kind === "followUp"); + const moving = kind === "steer" ? steeringEntries : followUpEntries; + if (fromIndex < 0 || fromIndex >= moving.length || toIndex < 0 || toIndex >= moving.length) { + throw new Error("move_queue index out of range"); + } + const [entry] = moving.splice(fromIndex, 1); + moving.splice(toIndex, 0, entry); + const newSteering = kind === "steer" ? moving : steeringEntries; + const newFollowUp = kind === "followUp" ? moving : followUpEntries; + // Clear the live pi queue entirely, then re-enqueue both queues in the + // new order. Image hints are pushed per entry FIFO so reconcileQueue + // can reattach them after each queue_update event. + this.inner.clearQueue(); + this.pendingQueueHints = { steer: [], followUp: [] }; + for (const e of newSteering) { + this.hintQueueImages("steer", e.images); + await this.inner.steer(e.text, e.images?.length ? e.images : undefined); + } + for (const e of newFollowUp) { + this.hintQueueImages("followUp", e.images); + await this.inner.followUp(e.text, e.images?.length ? e.images : undefined); + } + this.queueMirror = [...newSteering, ...newFollowUp]; + this.persistQueue(); + return { steering: [...this.inner.getSteeringMessages()], followUp: [...this.inner.getFollowUpMessages()] }; + } + + case "recall_queue_item": { + // pi has no single-item dequeue — same clear-all + re-enqueue strategy + // as move_queue, minus the recalled entry. Allowed while running, like + // pi's own clearQueue(). + const kind = command.kind as QueueKind | undefined; + if (kind !== "steer" && kind !== "followUp") { + throw new Error("recall_queue_item requires kind \"steer\" or \"followUp\""); + } + const index = Number(command.index); + if (!Number.isInteger(index)) { + throw new Error("recall_queue_item requires an integer index"); + } + const steeringEntries = this.queueMirror.filter((e) => e.kind === "steer"); + const followUpEntries = this.queueMirror.filter((e) => e.kind === "followUp"); + const entries = kind === "steer" ? steeringEntries : followUpEntries; + if (index < 0 || index >= entries.length) { + throw new Error("recall_queue_item index out of range"); + } + const [removed] = entries.splice(index, 1); + const newSteering = kind === "steer" ? entries : steeringEntries; + const newFollowUp = kind === "followUp" ? entries : followUpEntries; + this.inner.clearQueue(); + this.pendingQueueHints = { steer: [], followUp: [] }; + for (const e of newSteering) { + this.hintQueueImages("steer", e.images); + await this.inner.steer(e.text, e.images?.length ? e.images : undefined); + } + for (const e of newFollowUp) { + this.hintQueueImages("followUp", e.images); + await this.inner.followUp(e.text, e.images?.length ? e.images : undefined); + } + this.queueMirror = [...newSteering, ...newFollowUp]; + this.persistQueue(); + return { + entry: { text: removed.text, images: removed.images ?? [] }, + steering: [...this.inner.getSteeringMessages()], + followUp: [...this.inner.getFollowUpMessages()], + }; + } + + case "requeue_at": { + // Put an edited message back into the queue at its original position. + // Same clear-all + re-enqueue strategy; the edited entry is inserted at + // the given index. Allowed while running, like pi's own clearQueue(). + const kind = command.kind as QueueKind | undefined; + if (kind !== "steer" && kind !== "followUp") { + throw new Error("requeue_at requires kind \"steer\" or \"followUp\""); + } + const index = Number(command.index); + if (!Number.isInteger(index)) { + throw new Error("requeue_at requires an integer index"); + } + const text = command.text as string | undefined; + if (typeof text !== "string" || !text.trim()) { + throw new Error("requeue_at requires text"); + } + const rawImages = command.images as Array<{ type: "image"; data: string; mimeType: string }> | undefined; + const steeringEntries = this.queueMirror.filter((e) => e.kind === "steer"); + const followUpEntries = this.queueMirror.filter((e) => e.kind === "followUp"); + const entries = kind === "steer" ? steeringEntries : followUpEntries; + if (index < 0 || index > entries.length) { + throw new Error("requeue_at index out of range"); + } + const edited = createQueueEntry(kind, text, rawImages); + entries.splice(index, 0, edited); + const newSteering = kind === "steer" ? entries : steeringEntries; + const newFollowUp = kind === "followUp" ? entries : followUpEntries; + this.inner.clearQueue(); + this.pendingQueueHints = { steer: [], followUp: [] }; + for (const e of newSteering) { + this.hintQueueImages("steer", e.images); + await this.inner.steer(e.text, e.images?.length ? e.images : undefined); + } + for (const e of newFollowUp) { + this.hintQueueImages("followUp", e.images); + await this.inner.followUp(e.text, e.images?.length ? e.images : undefined); + } + this.queueMirror = [...newSteering, ...newFollowUp]; + this.persistQueue(); + return { + steering: [...this.inner.getSteeringMessages()], + followUp: [...this.inner.getFollowUpMessages()], + }; + } + + case "remove_queue_item": { + // pi has no single-dequeue API either; drop the entry from the mirror + // and rebuild the live queue via clear-all + re-enqueue (same strategy + // as move_queue). + const kind = command.kind as QueueKind | undefined; + if (kind !== "steer" && kind !== "followUp") { + throw new Error("remove_queue_item requires kind \"steer\" or \"followUp\""); + } + const index = Number(command.index); + if (!Number.isInteger(index)) { + throw new Error("remove_queue_item requires integer index"); + } + const steeringEntries = this.queueMirror.filter((e) => e.kind === "steer"); + const followUpEntries = this.queueMirror.filter((e) => e.kind === "followUp"); + const entries = kind === "steer" ? steeringEntries : followUpEntries; + if (index < 0 || index >= entries.length) { + throw new Error("remove_queue_item index out of range"); + } + entries.splice(index, 1); + const newSteering = kind === "steer" ? entries : steeringEntries; + const newFollowUp = kind === "followUp" ? entries : followUpEntries; + this.inner.clearQueue(); + this.pendingQueueHints = { steer: [], followUp: [] }; + for (const e of newSteering) { + this.hintQueueImages("steer", e.images); + await this.inner.steer(e.text, e.images?.length ? e.images : undefined); + } + for (const e of newFollowUp) { + this.hintQueueImages("followUp", e.images); + await this.inner.followUp(e.text, e.images?.length ? e.images : undefined); + } + this.queueMirror = [...newSteering, ...newFollowUp]; + this.persistQueue(); + return { + steering: [...this.inner.getSteeringMessages()], + followUp: [...this.inner.getFollowUpMessages()], + }; + } + + case "resolve_recovery": { + const keptIds = new Set((command.keep as string[] | undefined) ?? []); + const discardIds = new Set((command.discard as string[] | undefined) ?? []); + const continueRun = command.continueRun === true; + let keptCount = 0; + for (const entry of this.queueRecovery) { + if (!keptIds.has(entry.id)) continue; + await this.requeueEntry(entry); + this.ensureMirrored(entry); + keptCount += 1; + } + const remaining = this.queueRecovery.filter( + (entry) => !keptIds.has(entry.id) && !discardIds.has(entry.id), + ); + const resolved = this.queueRecovery.length - remaining.length; + this.queueRecovery = remaining; + this.persistQueue(); + if (continueRun && keptCount > 0) { + this.runAgentContinue(); + } + return { + resolved, + kept: keptCount, + remaining: this.getPendingRecoveryView(), + }; + } + + case "export_queue": { + return { + live: this.queueMirror.map((entry) => ({ ...entry })), + recovery: this.queueRecovery.map((entry) => ({ ...entry })), + }; + } + + case "import_queue": { + const rawEntries = command.entries as QueueEntryInput[] | undefined; + if (!Array.isArray(rawEntries)) throw new Error("import_queue requires entries[]"); + let imported = 0; + for (const input of rawEntries) { + if ( + (input.kind !== "steer" && input.kind !== "followUp") + || typeof input.text !== "string" + ) { + continue; + } + const entry = createQueueEntry(input.kind, input.text, input.images); + await this.requeueEntry(entry); + this.ensureMirrored(entry); + imported += 1; + } + this.persistQueue(); + return { + imported, + steering: [...this.inner.getSteeringMessages()], + followUp: [...this.inner.getFollowUpMessages()], + }; + } + + case "stage_recovery": { + // Stage imported entries as pending recovery (NOT into the live pi + // queue) so the QueueRecoveryDialog pops up and the user can choose: + // re-queue / re-queue & continue / discard — same flow as crash + // recovery. Lets the user decide whether to run an imported queue + // instead of auto-executing it. + const rawEntries = command.entries as QueueEntryInput[] | undefined; + if (!Array.isArray(rawEntries)) throw new Error("stage_recovery requires entries[]"); + let staged = 0; + for (const input of rawEntries) { + if ( + (input.kind !== "steer" && input.kind !== "followUp") + || typeof input.text !== "string" + ) { + continue; + } + const entry = createQueueEntry(input.kind, input.text, input.images); + this.queueRecovery.push(entry); + staged += 1; + } + this.persistQueue(); + return { + staged, + pendingRecovery: this.getPendingRecoveryView(), + }; + } + case "steer": { const steerImages = command.images as Array<{ type: "image"; data: string; mimeType: string }> | undefined; + this.hintQueueImages("steer", steerImages); await this.inner.steer(command.message as string, steerImages?.length ? steerImages : undefined); return null; } case "follow_up": { const followImages = command.images as Array<{ type: "image"; data: string; mimeType: string }> | undefined; + this.hintQueueImages("followUp", followImages); await this.inner.followUp(command.message as string, followImages?.length ? followImages : undefined); return null; } @@ -1271,6 +1738,7 @@ export async function startRpcSession( if (toolNames?.length === 0) { wrapper.setForceEmptySystemPrompt(true); } + wrapper.loadQueueRecovery(); wrapper.start(); const realSessionId = inner.sessionId as string; diff --git a/next.config.ts b/next.config.ts index fb7c5cf0..2eaabf6f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -10,6 +10,9 @@ try { } catch { /* package not found, use default */ } const nextConfig: NextConfig = { + // Dev server runs against its own build directory (.next-dev) so it can + // coexist with the production `next start` (.next) on a different port. + distDir: process.env.PI_WEB_DEV_DIST ? ".next-dev" : ".next", serverExternalPackages: [ "undici", "@earendil-works/pi-coding-agent", diff --git a/package-lock.json b/package-lock.json index 99ca9d02..2a8c0a3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9602,6 +9602,7 @@ "version": "8.0.3", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -11722,6 +11723,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -18096,7 +18098,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/tsconfig.json b/tsconfig.json index 9e9bbf7b..76f3d1e3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,7 +33,9 @@ "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", - ".next/dev/types/**/*.ts" + ".next/dev/types/**/*.ts", + ".next-dev/types/**/*.ts", + ".next-dev/dev/types/**/*.ts" ], "exclude": [ "node_modules"