From 60a4d233748d231a0a9a802ccb4e765cf24f1067 Mon Sep 17 00:00:00 2001 From: zszz3 <91608029+zszz3@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:24:54 +0800 Subject: [PATCH 01/13] feat(agent): steer active turns with Alt+Enter Keep Enter as a queued follow-up and route Alt+Enter to the active turn through native steering. Preserve accepted input and streaming reply order across Stop and recovery, and show platform-specific shortcut labels. --- apps/desktop/electron/main/index.ts | 4 + apps/desktop/electron/main/ipc/agent-ipc.ts | 49 ++++++- apps/desktop/electron/main/ipc/register.ts | 2 + .../electron/main/persistence-outbox.ts | 4 +- .../main/runtime/event-persistence.ts | 22 ++- apps/desktop/electron/main/runtime/sidecar.ts | 3 + apps/desktop/src/components/Composer.tsx | 5 +- .../features/chat/composer/ComposerInput.tsx | 10 +- .../chat/composer/ComposerToolbar.tsx | 18 ++- .../chat/composer/hooks/useComposerSubmit.ts | 14 +- apps/desktop/src/lib/api.ts | 3 + apps/desktop/src/lib/composer-smart-stop.ts | 7 +- apps/desktop/src/stores/app-state.ts | 1 + apps/desktop/src/stores/slices/queue-slice.ts | 36 +++++ .../src/stores/slices/transcript-slice.ts | 7 +- .../desktop/test/composer-send-state.test.mjs | 10 +- crates/host-core/src/plugin_sessions.rs | 1 + crates/host-core/src/sessions.rs | 138 +++++++++++++++--- docs/adr/README.md | 1 + docs/adr/active-turn-steering.md | 41 ++++++ docs/spec/03-runtime/01-ipc-protocol.md | 39 +++++ docs/spec/03-runtime/02-agent-runtime.md | 24 +++ docs/spec/03-runtime/04-data-storage.md | 21 +++ docs/spec/04-ux/09-interaction-patterns.md | 33 ++++- docs/spec/06-delivery/04-e2e-test-plan.md | 43 ++++++ docs/spec/08-meta/decisions-log.md | 13 ++ docs/zh-CN/adr/index.md | 1 + docs/zh-CN/spec/03-runtime/01-ipc-protocol.md | 32 ++++ .../zh-CN/spec/03-runtime/02-agent-runtime.md | 19 +++ docs/zh-CN/spec/03-runtime/04-data-storage.md | 14 ++ .../spec/04-ux/09-interaction-patterns.md | 22 ++- .../spec/06-delivery/04-e2e-test-plan.md | 26 ++++ docs/zh-CN/spec/08-meta/decisions-log.md | 12 ++ packages/agent-runtime/src/runtime.ts | 118 +++++++++++++-- packages/agent-runtime/src/sidecar.ts | 14 ++ packages/i18n/src/locales/de/index.ts | 2 + packages/i18n/src/locales/en/index.ts | 2 + packages/i18n/src/locales/es/index.ts | 2 + packages/i18n/src/locales/fr/index.ts | 2 + packages/i18n/src/locales/ko/index.ts | 2 + packages/i18n/src/locales/tr/index.ts | 2 + packages/i18n/src/locales/zh-CN/index.ts | 2 + packages/i18n/src/locales/zh-TW/index.ts | 2 + packages/shared/src/protocol.ts | 1 + packages/shared/src/types/agent.ts | 9 +- packages/shared/src/types/messages.ts | 2 + 46 files changed, 780 insertions(+), 55 deletions(-) create mode 100644 docs/adr/active-turn-steering.md diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index fd919626d0..c9bb6ed5c5 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -556,6 +556,7 @@ const logger = new Logger( const persistenceOutbox = new PersistenceOutbox(dataDir, (level, message, data) => { logger.app("persistence", level, message, { data }); }); +const steeringReplies = new Set(); const scheduledRuntime = createScheduledRuntime({ dataDir, getHost: () => host, @@ -1128,6 +1129,7 @@ const { const eventPersistence = createEventPersistence({ runtimeState, + steeringReplies, activeTurns, activeToolCalls, activeToolCallKey, @@ -1148,6 +1150,7 @@ const { persistAgentEvent } = eventPersistence; const sidecarRuntime = createSidecarRuntime({ runtimeState, + steeringReplies, logger, sendToRenderer, persistAgentEvent, @@ -1234,6 +1237,7 @@ function registerIpc() { updater, dataDir, activeTurns, + turnFinalizations, sessionProjects, persistenceOutbox, logger, diff --git a/apps/desktop/electron/main/ipc/agent-ipc.ts b/apps/desktop/electron/main/ipc/agent-ipc.ts index 7110c816db..ec55502be6 100644 --- a/apps/desktop/electron/main/ipc/agent-ipc.ts +++ b/apps/desktop/electron/main/ipc/agent-ipc.ts @@ -1,4 +1,4 @@ -import { IPC, ErrorCodes, isGlobalPermissionMode, type AgentEventEnvelope, type AgentPromptRequest, type AgentQueuePushRequest, type AgentStopRequest, type AskToolResolution, type GlobalPermissionMode, type MessageUsage, type PlanExecutionFinishStatus, type PlanResolutionResult, type PlanResolveRequest, type PromptEnhancementRequest, type SessionSummarizeTitleRequest } from "@pi-desktop/shared"; +import { IPC, ErrorCodes, isGlobalPermissionMode, type AgentEventEnvelope, type AgentPromptRequest, type AgentSteerRequest, type UiMessage, type AgentQueuePushRequest, type AgentStopRequest, type AskToolResolution, type GlobalPermissionMode, type MessageUsage, type PlanExecutionFinishStatus, type PlanResolutionResult, type PlanResolveRequest, type PromptEnhancementRequest, type SessionSummarizeTitleRequest } from "@pi-desktop/shared"; import { expandSlashInvocation, enhancePromptDraft, summarizeSessionTitle, visionFromModelConfig, type ComposerTemplate, type RuntimeProviderConfig } from "@pi-desktop/agent-runtime"; import { OAUTH_AUTH_KIND, type VendorOAuth } from "../oauth"; import { appendPromptFallbackPaths, durableUserMessageId, preparePromptAttachments, type PreparedPromptAttachment } from "../prompt-attachments"; @@ -23,6 +23,7 @@ export type AgentIpcDependencies = { persistenceOutbox: PersistenceOutbox; dataDir: string; activeTurns: Map; + turnFinalizations: Map>; activeTurnUsages: Map; approvedExecutionIdsBySession: Map; claimedExecutionSessions: Map; @@ -51,6 +52,7 @@ export function registerAgentIpc({ persistenceOutbox, dataDir, activeTurns, + turnFinalizations, activeTurnUsages, approvedExecutionIdsBySession, claimedExecutionSessions, @@ -182,6 +184,51 @@ export function registerAgentIpc({ return { title }; }); + handle(IPC.invoke.agentSteer, async (req: AgentSteerRequest) => { + if (!host || !sidecar) throw new Error("backend unavailable"); + if ( + !req?.sessionId || typeof req.content !== "string" || !req.expectedTurnId || + (!req.content.trim() && !req.attachments?.length) + ) { + throw Object.assign(new Error("Steering input and expectedTurnId required"), { + errorCode: ErrorCodes.INVALID_ARGUMENT, + }); + } + if (activeTurns.get(req.sessionId) !== req.expectedTurnId || turnFinalizations.has(req.sessionId)) { + throw Object.assign(new Error("The target turn has ended"), { + errorCode: ErrorCodes.TURN_NOT_FOUND, + }); + } + const context = await sidecar.call<{ projectPath?: string; supportsVision: boolean }>( + "agent.steeringContext", { sessionId: req.sessionId, expectedTurnId: req.expectedTurnId }, + ); + const prepared = await preparePromptAttachments( + dataDir, req.sessionId, context.projectPath, req.attachments ?? [], context.supportsVision, + ); + const session = await host.call<{ session?: { messages?: UiMessage[] } }>("session.get", { + id: req.sessionId, messageLimit: 1, + }); + const message: UiMessage = { + id: durableUserMessageId(req.messageId, session.session?.messages ?? []), + role: "user", + content: req.content, + status: "complete", + createdAt: new Date().toISOString(), + steering: true, + ...(prepared.length ? { attachments: prepared.map((attachment) => attachment.message) } : {}), + }; + // Revalidate inside the runtime after all file/host IO. A stale target must + // never turn into a normal prompt or alter the next turn's configuration. + return sidecar.call<{ accepted: boolean; turnId: string }>("agent.steer", { + sessionId: req.sessionId, expectedTurnId: req.expectedTurnId, message, + content: appendPromptFallbackPaths(req.content, prepared), + attachments: prepared.filter((attachment) => attachment.inlineData).map((attachment) => ({ + path: attachment.message.ref, name: attachment.message.name, kind: attachment.message.kind, + mimeType: attachment.message.mimeType, size: attachment.message.size, data: attachment.inlineData, + })), + }); + }); + handle(IPC.invoke.agentPrompt, async (req: AgentPromptRequest) => { if (!host || !sidecar) throw new Error("backend unavailable"); const releaseSessionOperation = await acquireSessionOperation(req.sessionId); diff --git a/apps/desktop/electron/main/ipc/register.ts b/apps/desktop/electron/main/ipc/register.ts index 714d7080f2..3f5bae8abc 100644 --- a/apps/desktop/electron/main/ipc/register.ts +++ b/apps/desktop/electron/main/ipc/register.ts @@ -52,6 +52,7 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) { updater, dataDir, activeTurns, + turnFinalizations, sessionProjects, persistenceOutbox, logger, @@ -290,6 +291,7 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) { persistenceOutbox, dataDir, activeTurns, + turnFinalizations, activeTurnUsages, approvedExecutionIdsBySession, claimedExecutionSessions, diff --git a/apps/desktop/electron/main/persistence-outbox.ts b/apps/desktop/electron/main/persistence-outbox.ts index 441ef5355d..790b41dc1f 100644 --- a/apps/desktop/electron/main/persistence-outbox.ts +++ b/apps/desktop/electron/main/persistence-outbox.ts @@ -95,7 +95,9 @@ export class PersistenceOutbox { }); return; } - this.entries.shift(); + // An in-flight assistant checkpoint may have been replaced by its final + // snapshot while the host append was pending. Keep that newer write. + if (this.entries[0] === current) this.entries.shift(); await this.persist(); } } diff --git a/apps/desktop/electron/main/runtime/event-persistence.ts b/apps/desktop/electron/main/runtime/event-persistence.ts index 314d230ab5..e39c56202d 100644 --- a/apps/desktop/electron/main/runtime/event-persistence.ts +++ b/apps/desktop/electron/main/runtime/event-persistence.ts @@ -6,6 +6,7 @@ import type { RuntimeState } from "./context"; export type EventPersistenceDependencies = { runtimeState: RuntimeState; + steeringReplies: Set; activeTurns: Map; activeToolCalls: Map; activeToolCallKey: (sessionId: string, toolCallId: string) => string; @@ -25,6 +26,7 @@ export type EventPersistenceDependencies = { export function createEventPersistence({ runtimeState, + steeringReplies, activeTurns, activeToolCalls, activeToolCallKey, @@ -191,6 +193,23 @@ function persistAgentEvent(envelope: AgentEventEnvelope): UiMessage | undefined if (event.type === "turn_end" && !envelope.parentToolCallId) { addActiveTurnUsage(envelope.sessionId, event.subagentUsage); } + if (event.type === "message_end" && event.message.role === "user" && !envelope.parentToolCallId) { + // Reserve the current reply before persisting input accepted during its stream. + const preceding = event.precedingAssistant?.role === "assistant" + ? event.precedingAssistant : undefined; + if (preceding) steeringReplies.add(preceding.id); + for (const message of [preceding, event.message]) { + if (!message) continue; + void persistenceOutbox.enqueue({ + key: `message:${envelope.sessionId}:${message.id}`, + sessionId: envelope.sessionId, message, turnId: envelope.turnId ?? turnId, + }, () => runtimeState.host).catch((error) => { + logger.app("persistence", "warn", "steering transcript enqueue failed", { + sessionId: envelope.sessionId, data: String(error), + }); + }); + } + } if (event.type === "message_end" && event.message.role === "assistant") { if (!envelope.parentToolCallId && event.message.usage) { addActiveTurnUsage(envelope.sessionId, event.message.usage); @@ -218,7 +237,8 @@ function persistAgentEvent(envelope: AgentEventEnvelope): UiMessage | undefined const empty = !(event.message.content || "").trim() && !(event.message.thinking || "").trim(); - if (failed && empty && !event.message.error) return; + const reservedForSteering = steeringReplies.delete(event.message.id); + if (failed && empty && !event.message.error && !reservedForSteering) return; void persistenceOutbox .enqueue( { diff --git a/apps/desktop/electron/main/runtime/sidecar.ts b/apps/desktop/electron/main/runtime/sidecar.ts index 721c9457e6..faf4c5cedf 100644 --- a/apps/desktop/electron/main/runtime/sidecar.ts +++ b/apps/desktop/electron/main/runtime/sidecar.ts @@ -21,6 +21,7 @@ import type { RuntimeState } from "./context"; export type SidecarRuntimeDependencies = { runtimeState: RuntimeState; + steeringReplies: Set; logger: Logger; sendToRenderer: (channel: string, payload: unknown) => void; persistAgentEvent: (envelope: AgentEventEnvelope) => UiMessage | undefined; @@ -49,6 +50,7 @@ export type SidecarRuntimeDependencies = { export function createSidecarRuntime({ runtimeState, + steeringReplies, logger, sendToRenderer, persistAgentEvent, @@ -121,6 +123,7 @@ export function createSidecarRuntime({ if (runtimeState.sidecar !== s) return; logger.flushChild("agent"); runtimeState.sidecar = null; + steeringReplies.clear(); if (intentional || isQuitting()) return; // A sidecar crash closes live approval waiters before the replacement // sidecar starts. This prevents an old renderer response from waking a diff --git a/apps/desktop/src/components/Composer.tsx b/apps/desktop/src/components/Composer.tsx index 7d2eaccc71..28b29b816c 100644 --- a/apps/desktop/src/components/Composer.tsx +++ b/apps/desktop/src/components/Composer.tsx @@ -79,6 +79,7 @@ export function Composer({ }) { const { t } = useTranslation(); const sendPrompt = useAppStore((s) => s.sendPrompt); + const steerPrompt = useAppStore((s) => s.steerPrompt); const removeQueuedPrompt = useAppStore((s) => s.removeQueuedPrompt); const sendQueuedNow = useAppStore((s) => s.sendQueuedNow); const abort = useAppStore((s) => s.abort); @@ -392,6 +393,7 @@ export function Composer({ activeFileReferences, t, sendPrompt, + steerPrompt, showToast, draft: { ref, @@ -529,10 +531,11 @@ export function Composer({ inputBlocked={inputBlocked} pasting={pasting} enterToSend={enterToSend} + runActive={runActive} composerAc={composerAc} onPaste={pasteClipboardFiles} onAcceptCompletion={acceptCompletion} - onSubmit={() => void submit()} + onSubmit={(steering) => void submit(steering)} onInsertNewline={insertNewlineInEditor} onInput={handleInput} onCompositionStart={() => setComposing(true)} diff --git a/apps/desktop/src/features/chat/composer/ComposerInput.tsx b/apps/desktop/src/features/chat/composer/ComposerInput.tsx index bf0c521486..0273dfd474 100644 --- a/apps/desktop/src/features/chat/composer/ComposerInput.tsx +++ b/apps/desktop/src/features/chat/composer/ComposerInput.tsx @@ -19,10 +19,11 @@ export type ComposerInputProps = { inputBlocked: boolean; pasting: boolean; enterToSend: boolean; + runActive: boolean; composerAc: AutocompleteController; onPaste: (event: ClipboardEvent) => void; onAcceptCompletion: (index: number) => void; - onSubmit: () => void; + onSubmit: (steering?: boolean) => void; onInsertNewline: () => void; onInput: (source: string, caret: number) => void; onCompositionStart: () => void; @@ -40,6 +41,7 @@ export function ComposerInput({ inputBlocked, pasting, enterToSend, + runActive, composerAc, onPaste, onAcceptCompletion, @@ -95,6 +97,12 @@ export function ComposerInput({ // An Enter that confirms an IME candidate must commit text, never // send it or drive autocomplete (D125). if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; + if (event.key === "Enter" && event.altKey && !event.shiftKey && !event.metaKey && !event.ctrlKey) { + event.preventDefault(); + composerAc.close(); + onSubmit(runActive); + return; + } if (composerAc.open && event.key === "Escape") { event.preventDefault(); event.stopPropagation(); diff --git a/apps/desktop/src/features/chat/composer/ComposerToolbar.tsx b/apps/desktop/src/features/chat/composer/ComposerToolbar.tsx index 53935b036c..a31139b7a8 100644 --- a/apps/desktop/src/features/chat/composer/ComposerToolbar.tsx +++ b/apps/desktop/src/features/chat/composer/ComposerToolbar.tsx @@ -1,6 +1,12 @@ import type { Dispatch, SetStateAction } from "react"; import type { TFunction } from "i18next"; -import type { Mode, PermissionMode, ThinkingLevel } from "@pi-desktop/shared"; +import { + keybindingDisplayParts, + type Mode, + type PermissionMode, + type ShortcutPlatform, + type ThinkingLevel, +} from "@pi-desktop/shared"; import type { AppState } from "../../../stores/app-store"; import { AnchoredMenu } from "../../../components/settings/AnchoredMenu"; import { ContextUsageInspector } from "../../../components/ContextUsageInspector"; @@ -94,6 +100,8 @@ export function ComposerToolbar({ abort, submit, }: ComposerToolbarProps) { + const platform = (window.piDesktop?.platform ?? "darwin") as ShortcutPlatform; + const steeringShortcut = keybindingDisplayParts("Alt+Enter", platform).join("+"); return (
@@ -286,7 +294,13 @@ export function ComposerToolbar({ type="button" className="send-btn" ariaLabel={modelReady ? t("chat.send") : t("settings.addProvider")} - tooltip={modelReady ? t("chat.send") : t("settings.addProvider")} + tooltip={ + runActive + ? t("chat.sendWhileRunning", { shortcut: steeringShortcut }) + : modelReady + ? t("chat.send") + : t("settings.addProvider") + } disabled={ !hasDraftContent || sendBlocked || diff --git a/apps/desktop/src/features/chat/composer/hooks/useComposerSubmit.ts b/apps/desktop/src/features/chat/composer/hooks/useComposerSubmit.ts index 414885bef5..b976e341fb 100644 --- a/apps/desktop/src/features/chat/composer/hooks/useComposerSubmit.ts +++ b/apps/desktop/src/features/chat/composer/hooks/useComposerSubmit.ts @@ -29,6 +29,7 @@ type UseComposerSubmitOptions = { activeFileReferences: ComposerFileReference[]; t: TFunction; sendPrompt: AppState["sendPrompt"]; + steerPrompt: AppState["steerPrompt"]; showToast: AppState["showToast"]; draft: Pick< ComposerDraftController, @@ -49,7 +50,7 @@ export type ComposerSubmitController = { invalidatePromptEnhancement: () => void; enhancePrompt: () => Promise; undoPromptEnhancement: () => void; - submit: () => Promise; + submit: (steering?: boolean) => Promise; }; /** @@ -70,6 +71,7 @@ export function useComposerSubmit({ activeFileReferences, t, sendPrompt, + steerPrompt, showToast, draft, }: UseComposerSubmitOptions): ComposerSubmitController { @@ -183,7 +185,7 @@ export function useComposerSubmit({ }); }; - const submit = async () => { + const submit = async (steering = false) => { const text = draft.ref.current ? readEditorValue(draft.ref.current) : value; const inlineContent = serializeInlineComposerFileReferences( text, @@ -199,7 +201,7 @@ export function useComposerSubmit({ const submittedDraftKey = draftKey; // Slash dispatch stays local for builtin and extension commands, while // templates, skills, and unknown aliases continue as normal prompt text. - if (serializedContent.startsWith("/")) { + if (!steering && serializedContent.startsWith("/")) { const commandEnd = serializedContent.search(/\s/); const name = serializedContent.slice( 1, @@ -262,13 +264,15 @@ export function useComposerSubmit({ } } } - if (!modelReady) { + if (!steering && !modelReady) { showToast(t("errors.MODEL_NOT_CONFIGURED"), { variant: "error" }); return; } const submittedDraft = draft.draftSnapshot(text); draft.clearDraftForKey(submittedDraftKey); - const accepted = await sendPrompt(inlineContent, submittedDraft); + const accepted = steering + ? await steerPrompt(inlineContent, submittedDraft) + : await sendPrompt(inlineContent, submittedDraft); if (!accepted) draft.restoreDraftForKey(submittedDraftKey, submittedDraft); }; diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index c317ffa077..943eb56076 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -5,6 +5,7 @@ import type { AgentCompactRequest, AgentCompactResponse, AgentPromptRequest, + AgentSteerRequest, UiMessage, MessageRevisionSummary, AgentPromptResponse, @@ -543,6 +544,8 @@ export const api = { prefix: UiMessage[]; }) => invoke<{ messages: UiMessage[] }>(IPC.invoke.sessionActivateRevision, input), + steer: (req: AgentSteerRequest) => + invoke(IPC.invoke.agentSteer, req), prompt: (req: AgentPromptRequest) => invoke(IPC.invoke.agentPrompt, req), enhancePrompt: (req: PromptEnhancementRequest) => diff --git a/apps/desktop/src/lib/composer-smart-stop.ts b/apps/desktop/src/lib/composer-smart-stop.ts index f3c7f5695c..94b66c0e2c 100644 --- a/apps/desktop/src/lib/composer-smart-stop.ts +++ b/apps/desktop/src/lib/composer-smart-stop.ts @@ -20,6 +20,7 @@ type AbortMessage = { role: string; content: string; thinking?: string; + steering?: boolean; }; type SubmittedDraft = { @@ -49,7 +50,11 @@ export function resolveComposerSmartStop( (message.role === "assistant" && Boolean(message.content.trim() || message.thinking?.trim())), ); - if ((lastUserIndex < 0 && !submitted) || replyStarted) { + if ( + (lastUserIndex < 0 && !submitted) || + replyStarted || + messages[lastUserIndex]?.steering + ) { return { kind: "settle" }; } diff --git a/apps/desktop/src/stores/app-state.ts b/apps/desktop/src/stores/app-state.ts index 6fa3f69223..1ef61472bf 100644 --- a/apps/desktop/src/stores/app-state.ts +++ b/apps/desktop/src/stores/app-state.ts @@ -191,6 +191,7 @@ export type AppState = { draft?: ComposerDraftSnapshot, targetSessionId?: string, ) => Promise; + steerPrompt: (content: string, draft?: ComposerDraftSnapshot) => Promise; enqueuePrompt: ( content: string, draft?: ComposerDraftSnapshot, diff --git a/apps/desktop/src/stores/slices/queue-slice.ts b/apps/desktop/src/stores/slices/queue-slice.ts index 0d3e9c3014..965aae6dd9 100644 --- a/apps/desktop/src/stores/slices/queue-slice.ts +++ b/apps/desktop/src/stores/slices/queue-slice.ts @@ -66,6 +66,7 @@ export function createQueueSlice({ | "refreshQueuedPrompts" | "applyQueueChanged" | "sendPrompt" + | "steerPrompt" > { const queuedDrafts = new Map(); @@ -244,6 +245,41 @@ export function createQueueSlice({ applyQueueEntries(event.sessionId, event.entries); }, + steerPrompt: async (content, draft) => { + const state = get(); + const sessionId = state.activeSessionId; + const expectedTurnId = sessionId ? state.agentStatuses[sessionId]?.currentTurnId : undefined; + if ( + !sessionId || !expectedTurnId || !state.runningSessions[sessionId] || + state.pendingPlans[sessionId]?.status === "pending" + ) { + get().showToast(i18n.t("chat.steeringUnavailable"), { variant: "info" }); + return false; + } + const message = optimisticUserMessage( + crypto.randomUUID(), content, draft?.fileReferences ?? [], + ); + message.steering = true; + runtime.insertOptimisticUserMessage(sessionId, message); + try { + await api.steer({ + sessionId, expectedTurnId, content, messageId: message.id, + attachments: draft ? promptAttachmentsFromDraft(draft.fileReferences) : [], + }); + return true; + } catch (error) { + runtime.retractOptimisticUserMessage(sessionId, message); + const failure = messageErrorFromUnknown(error); + get().showToast( + failure.code === "TURN_NOT_FOUND" + ? i18n.t("chat.steeringUnavailable") + : failure.message, + { variant: "error" }, + ); + return false; + } + }, + sendPrompt: async (content, draft, requestedSessionId) => { let sessionId = requestedSessionId ?? get().activeSessionId; if (sessionId && get().pendingPlans[sessionId]?.status === "pending") { diff --git a/apps/desktop/src/stores/slices/transcript-slice.ts b/apps/desktop/src/stores/slices/transcript-slice.ts index 414ab3545b..d1fd0b68b3 100644 --- a/apps/desktop/src/stores/slices/transcript-slice.ts +++ b/apps/desktop/src/stores/slices/transcript-slice.ts @@ -429,6 +429,9 @@ export function createTranscriptSlice({ const sessionId = stateBeforeAbort.activeSessionId; if (!sessionId) return; const submittedDraft = runtime.submittedComposerDrafts.get(sessionId); + const preserveSteering = stateBeforeAbort.messages + .slice(submittedDraft?.messageCountBeforeSend ?? 0) + .findLast((message) => message.role === "user")?.steering; const stoppedAtMs = Date.now(); if (submittedDraft && !submittedDraft.abortResolution) { submittedDraft.abortResolution = new Promise((resolve) => { @@ -463,7 +466,9 @@ export function createTranscriptSlice({ })); return; } - const smartStop = resolveComposerSmartStop(state.messages, submittedDraft); + const smartStop = preserveSteering + ? { kind: "settle" as const } + : resolveComposerSmartStop(state.messages, submittedDraft); if (smartStop.kind === "restore") { const fullMessages = await runtime.loadFullSessionMessages(sessionId); if (!fullMessages || get().activeSessionId !== sessionId) { diff --git a/apps/desktop/test/composer-send-state.test.mjs b/apps/desktop/test/composer-send-state.test.mjs index 9bfbb92ad3..c67513a0f9 100644 --- a/apps/desktop/test/composer-send-state.test.mjs +++ b/apps/desktop/test/composer-send-state.test.mjs @@ -163,7 +163,7 @@ test("cross-session agent_end cannot clear the active session's running flag", ( test("send clears the composer before the round trip and restores a rejected draft (D287)", () => { const submit = submitHook.match( - /const submit = async \(\) => \{[\s\S]*?\n \};/, + /const submit = async \(steering = false\) => \{[\s\S]*?\n \};/, )?.[0] ?? ""; assert.ok(submit.length > 0, "composer submit implementation not found"); // The DOM value is the source of truth for what gets sent: a state update @@ -177,10 +177,10 @@ test("send clears the composer before the round trip and restores a rejected dra assert.match(submit, /if \(pasting\) showToast\(t\("chat\.pasteInProgress"\)/); assert.match( submit, - /if \(!modelReady\) \{\s*showToast\(t\("errors\.MODEL_NOT_CONFIGURED"\), \{ variant: "error" \}\);\s*return;\s*\}/, + /if \(!steering && !modelReady\) \{\s*showToast\(t\("errors\.MODEL_NOT_CONFIGURED"\), \{ variant: "error" \}\);\s*return;\s*\}/, ); // Optimistic clear, restore on rejection. The clear must precede the await. - const clearAt = submit.indexOf("draft.clearDraftForKey(submittedDraftKey);\n const accepted = await sendPrompt(inlineContent, submittedDraft);"); + const clearAt = submit.indexOf("draft.clearDraftForKey(submittedDraftKey);\n const accepted = steering"); assert.ok(clearAt > 0, "draft must be cleared before awaiting sendPrompt"); assert.match(submit, /if \(!accepted\) draft\.restoreDraftForKey\(submittedDraftKey, submittedDraft\);/); assert.doesNotMatch(submit, /if \(accepted\) draft\.clearDraftForKey\(submittedDraftKey\);\s*\};/); @@ -198,7 +198,7 @@ test("send clears the composer before the round trip and restores a rejected dra test("mode slash prefixes send the trailing prompt and retain failed drafts", () => { const submit = submitHook.match( - /const submit = async \(\) => \{[\s\S]*?\n \};/, + /const submit = async \(steering = false\) => \{[\s\S]*?\n \};/, )?.[0] ?? ""; assert.ok(submit.length > 0, "composer submit implementation not found"); assert.match(submit, /const commandBody =/); @@ -213,7 +213,7 @@ test("mode slash prefixes send the trailing prompt and retain failed drafts", () ); assert.match( submit, - /const submittedDraft = draft\.draftSnapshot\(text\);\s*draft\.clearDraftForKey\(submittedDraftKey\);\s*const accepted = await sendPrompt\(inlineContent, submittedDraft\);\s*if \(!accepted\) draft\.restoreDraftForKey\(submittedDraftKey, submittedDraft\);/, + /const submittedDraft = draft\.draftSnapshot\(text\);\s*draft\.clearDraftForKey\(submittedDraftKey\);\s*const accepted = steering[\s\S]*?await steerPrompt\(inlineContent, submittedDraft\)[\s\S]*?await sendPrompt\(inlineContent, submittedDraft\);\s*if \(!accepted\) draft\.restoreDraftForKey\(submittedDraftKey, submittedDraft\);/, ); assert.match(store, /draft\?: ComposerDraftSnapshot/); const sendPrompt = queueSlice.slice( diff --git a/crates/host-core/src/plugin_sessions.rs b/crates/host-core/src/plugin_sessions.rs index 3cd6928c27..bdeee62a18 100644 --- a/crates/host-core/src/plugin_sessions.rs +++ b/crates/host-core/src/plugin_sessions.rs @@ -232,6 +232,7 @@ fn parse_message( role: role.to_string(), content: input.content.clone(), attachments: None, + steering: None, created_at, thinking: None, status: None, diff --git a/crates/host-core/src/sessions.rs b/crates/host-core/src/sessions.rs index 28e2dcf255..0e8d083ec8 100644 --- a/crates/host-core/src/sessions.rs +++ b/crates/host-core/src/sessions.rs @@ -145,6 +145,9 @@ pub struct UiMessage { pub content: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub attachments: Option>, + /// Accepted input to an existing turn, preserved by Stop after renderer reload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub steering: Option, pub created_at: String, #[serde(skip_serializing_if = "Option::is_none")] pub thinking: Option, @@ -259,6 +262,9 @@ fn is_default_title(title: &str) -> bool { /// the search index row (None for tool rows, matching the FTS triggers). pub(crate) fn ui_to_record(message: &UiMessage) -> (MessageRecord, Option) { let mut meta_obj = serde_json::Map::new(); + if let Some(steering) = message.steering { + meta_obj.insert("steering".into(), json!(steering)); + } if let Some(status) = &message.status { meta_obj.insert("status".into(), json!(status)); } @@ -388,6 +394,7 @@ pub(crate) fn record_to_ui(record: MessageRecord) -> UiMessage { _ => Vec::new(), }; let meta = record.meta.unwrap_or(Value::Null); + let steering = meta.get("steering").and_then(Value::as_bool); let status = meta .get("status") .and_then(|v| v.as_str()) @@ -479,6 +486,7 @@ pub(crate) fn record_to_ui(record: MessageRecord) -> UiMessage { role: record.role, content: text, attachments: None, + steering, created_at: record.created_at, thinking, status, @@ -525,6 +533,7 @@ pub(crate) fn record_to_ui(record: MessageRecord) -> UiMessage { role: record.role, content, attachments, + steering, created_at: record.created_at, thinking, status, @@ -1583,19 +1592,58 @@ pub fn append_message( ) -> Result<()> { let session_created = ensure_session_for_append(db, session_id)?; let (record, text) = ui_to_record(message); - // Electron may replay an outbox entry after a host restart. Message ids - // are globally unique, so an existing row is already the durable result. + // A steering input reserves its preceding streaming assistant's position. + // Only a terminal assistant snapshot may replace that provisional row; + // completed rows remain immutable under outbox replay. if message_indexed(db, session_id, &record.id)? { + if message.role == "assistant" + && message.status.as_deref() != Some("streaming") + && streaming_assistant_indexed(db, session_id, &record.id)? + { + invalidate_transcript_layout(session_id); + if !transcripts::update_message(db.data_dir(), session_id, &record)? { + return Err(anyhow!( + "streaming assistant is missing from its transcript" + )); + } + db.conn().execute( + "UPDATE messages SET text = ?3, is_error = ?4 WHERE session_id = ?1 AND id = ?2", + params![session_id, record.id, text, record.is_error], + )?; + } else { + return Ok(()); + } + } else { + append_record( + db, + session_id, + &session_created, + &record, + text.as_deref(), + turn_id, + )?; + } + if message.role == "assistant" && message.status.as_deref() == Some("streaming") { + // Even an empty reservation needs a checkpoint so a crash can settle + // it as aborted. Later stream checkpoints replace this snapshot. + let existing = transcripts::read_inflight(db.data_dir(), session_id)?; + if existing.as_ref().is_none_or(|checkpoint| { + ts_to_ms(&checkpoint.message.created_at) < ts_to_ms(&record.created_at) + }) { + transcripts::write_inflight( + db.data_dir(), + session_id, + &transcripts::InflightRecord { + schema: transcripts::INFLIGHT_SCHEMA, + session_id: session_id.to_string(), + turn_id: turn_id.map(str::to_string), + saved_at: ms_to_ts(now_ms()), + message: record, + }, + )?; + } return Ok(()); } - append_record( - db, - session_id, - &session_created, - &record, - text.as_deref(), - turn_id, - )?; // The final assistant row supersedes any checkpoint of the same message // (D299). A checkpoint for a different id belongs to a newer fragment and // stays until its own final row or the turn end settles it. @@ -1622,6 +1670,37 @@ fn message_indexed(db: &Database, session_id: &str, message_id: &str) -> Result< Ok(existing.is_some()) } +fn streaming_assistant_indexed(db: &Database, session_id: &str, message_id: &str) -> Result { + let layout = session_layout(db, session_id)?; + let mut end = layout.message_count(); + while end > 0 { + let start = end.saturating_sub(64); + let window = transcripts::read_transcript_window_with_layout( + db.data_dir(), + session_id, + &layout, + start, + Some(end - start), + )?; + if let Some(record) = window + .messages + .iter() + .rev() + .find(|record| record.id == message_id) + { + return Ok(record.role == "assistant" + && record + .meta + .as_ref() + .and_then(|meta| meta.get("status")) + .and_then(Value::as_str) + == Some("streaming")); + } + end = start; + } + Ok(false) +} + /// Append one canonical record: transcript line first, then the index row. fn append_record( db: &Database, @@ -1677,7 +1756,9 @@ pub fn save_inflight_message( if !has_text { return Ok(false); } - if message_indexed(db, session_id, &message.id)? { + if message_indexed(db, session_id, &message.id)? + && !streaming_assistant_indexed(db, session_id, &message.id)? + { transcripts::remove_inflight(db.data_dir(), session_id)?; return Ok(false); } @@ -1718,7 +1799,8 @@ pub fn recover_inflight_message( return Ok(None); } }; - if message_indexed(db, session_id, &inflight.message.id)? { + let indexed = message_indexed(db, session_id, &inflight.message.id)?; + if indexed && !streaming_assistant_indexed(db, session_id, &inflight.message.id)? { transcripts::remove_inflight(db.data_dir(), session_id)?; return Ok(None); } @@ -1749,14 +1831,23 @@ pub fn recover_inflight_message( meta.insert("status".into(), json!(promoted_status)); record.meta = Some(Value::Object(meta)); let text = record_index_text(&record); - append_record( - db, - session_id, - &session_created, - &record, - text.as_deref(), - inflight.turn_id.as_deref(), - )?; + if indexed { + append_message( + db, + session_id, + &record_to_ui(record.clone()), + inflight.turn_id.as_deref(), + )?; + } else { + append_record( + db, + session_id, + &session_created, + &record, + text.as_deref(), + inflight.turn_id.as_deref(), + )?; + } Ok(Some(record_to_ui(record))) } @@ -2757,7 +2848,9 @@ pub fn end_turn_settling( Some(session_id) if recover_inflight => recover_inflight_message(db, session_id, true)?, Some(session_id) if status != "aborted" => { if let Some(inflight) = transcripts::read_inflight(db.data_dir(), session_id)? { - if message_indexed(db, session_id, &inflight.message.id)? { + if message_indexed(db, session_id, &inflight.message.id)? + && !streaming_assistant_indexed(db, session_id, &inflight.message.id)? + { transcripts::remove_inflight(db.data_dir(), session_id)?; } } @@ -3114,6 +3207,7 @@ mod tests { role: "user".into(), content: content.into(), attachments: None, + steering: None, created_at: ts.into(), thinking: None, status: None, @@ -3581,6 +3675,7 @@ mod tests { role: "tool".into(), content: "ok".into(), attachments: None, + steering: None, created_at: "2025-05-01T00:00:02Z".into(), thinking: None, status: Some("complete".into()), @@ -3914,6 +4009,7 @@ mod tests { role: "assistant".into(), content: "final answer".into(), attachments: None, + steering: None, created_at: "2025-05-01T00:00:01Z".into(), thinking: Some("first plan\nsecond plan".into()), status: Some("complete".into()), diff --git a/docs/adr/README.md b/docs/adr/README.md index 0ce184979a..c9560245e8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -253,3 +253,4 @@ Each ADR includes: | 0233 | Renderer-owned multi-folder project creation | Accepted (amends ADR 0011 / ADR 0016) | | 0234 | Keep project memory host-owned and path-scoped | Accepted | | 0235 | Preserve domain facades and enforce architecture budgets | Accepted | +| active-turn-steering | Bind Composer steering to the active durable turn | Accepted (active-turn-steering; issue #164) | diff --git a/docs/adr/active-turn-steering.md b/docs/adr/active-turn-steering.md new file mode 100644 index 0000000000..6284ef2167 --- /dev/null +++ b/docs/adr/active-turn-steering.md @@ -0,0 +1,41 @@ +# ADR active-turn-steering: Bind Composer steering to the active durable turn + +- Status: Accepted +- Date: 2026-09-12 +- Issue: https://github.com/vastsa/PI-Desktop/issues/164 + +## Context + +The Composer queues follow-ups, but cannot redirect a running turn. A second +prompt is correctly rejected with `AGENT_BUSY`. pi-agent-core already supplies +`Agent.steer`; Codex's [turn/steer contract](https://learn.chatgpt.com/docs/app-server#steer-an-active-turn) +provides the expected-turn admission model. + +## Decision + +Keep Send/Enter as follow-up. Alt+Enter (Option+Enter on macOS) uses an additive +`agent/steer` channel with `expectedTurnId`. The existing queue slice submits +optimistic input and restores rejected drafts. The agent IPC handler reuses +attachment validation against the running model and workspace; the sidecar +rechecks the target after asynchronous preparation and calls `Agent.steer`. + +Steering keeps the active configuration and durable turn. Started tools finish +before the next model request consumes input. The runtime handles admission +at its closing boundary and while awaiting delegates. Stop closes admission +and retains accepted input as history without independently replaying it. + +The existing event-persistence module journals input through the outbox. +`precedingAssistant` reserves an unfinished reply's position before the user +row. Host append permits a terminal assistant to replace its own streaming +reservation, retaining sequence and turn ownership; completed rows remain +immutable on replay. Recovery updates the reservation in place. The persisted +`UiMessage.steering` marker protects input from Smart Stop, including after +renderer reload; no separate renderer submission registry is needed. + +## Consequences and validation + +No new provider transport, host protocol version, or storage migration is +required. Streaming reservation updates are the sole exception to append-only +message persistence. Existing regression suites cover surrounding behavior; +E2E-AGENT-alt-enter-steers-active-turn specifies the full journey and remains +Draft until rendered E2E validation is performed. diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md index f0d2c16886..36c8a1defc 100644 --- a/docs/spec/03-runtime/01-ipc-protocol.md +++ b/docs/spec/03-runtime/01-ipc-protocol.md @@ -45,6 +45,7 @@ event: pi-desktop//event/ Examples: - `pi-desktop/agent/prompt` +- `pi-desktop/agent/steer` - `pi-desktop/agent/stop` - `pi-desktop/agent/abort` - `pi-desktop/agent/event/message` @@ -178,6 +179,44 @@ session-bound attachment/scratch roots when history is rebuilt; oversized or unavailable images remain path fallbacks. This keeps renderer, main, sidecar, the models.dev catalog, and host persistence on one capability-aware contract. +### 5.1a Steer an active turn + +`pi-desktop/agent/steer` accepts `AgentSteerRequest`: + +```ts +type AgentSteerRequest = { + sessionId: string; + expectedTurnId: string; + content: string; + messageId?: string; + attachments?: AgentPromptAttachment[]; +}; +``` + +It returns `{ accepted: true, turnId }` for the existing turn. Main checks its +active durable turn, asks the existing sidecar runtime for the active project's +attachment roots and model image capability, then applies the ordinary bounded +attachment preparation. The sidecar revalidates `expectedTurnId` after that IO. +A missing, ended, stopping, or mismatched turn, or a pending plan/goal approval, +fails with `TURN_NOT_FOUND`; it never falls back to starting or queueing a turn. +An empty payload fails with `INVALID_ARGUMENT`. + +The internal `agent.steeringContext` and `agent.steer` methods use only an +existing runtime. They do not run launch configuration, `runtimeFor`, or +`session.beginTurn`. Steering cannot change the active model, permission mode, +workspace, or approved execution. Slash text is literal input on this channel. + +Accepted input is echoed as ordinary user message events with the current +`turnId`, main-prepared attachment refs, and `UiMessage.steering: true`. This +persisted marker protects accepted input from Smart Stop after renderer reload. +A user `message_end` can additionally +carry `precedingAssistant`, a streaming snapshot that reserves the reply's +position before the input is persisted. Main writes both through its replayable +outbox; the host replaces only that provisional assistant row with its terminal +snapshot, preserving its id, sequence and owning turn. No image bytes enter the +durable message. This is an additive desktop channel and event field; it does +not change RACP, the host RPC version, or the storage schema. See ADR active-turn-steering. + ### 5.2 stop at the next turn boundary ```ts diff --git a/docs/spec/03-runtime/02-agent-runtime.md b/docs/spec/03-runtime/02-agent-runtime.md index be59e29bd0..67e7806bd9 100644 --- a/docs/spec/03-runtime/02-agent-runtime.md +++ b/docs/spec/03-runtime/02-agent-runtime.md @@ -46,6 +46,7 @@ crates/host-core (tool execution + permissions) ```ts interface AgentRuntime { prompt(input: PromptInput): Promise<{ turnId: string }> + steer(input: RuntimePrompt, expectedTurnId: string, message: UiMessage): { accepted: boolean; turnId: string } requestGracefulStop(): { requested: boolean } abort(turnId?: string): Promise getStatus(): RuntimeStatus @@ -61,6 +62,29 @@ request. It does not cancel an active provider stream or running tool. An idle runtime returns `{ requested: false }`; immediate `abort()` remains the separate cancellation path. +### 4.0 Active-turn steering + +`steer` validates the current turn identity before changing any state, then +queues user input through pi-agent-core's native steering queue in `all` mode. +The current provider request and any started tool batch finish first; all +accepted input is included at the next model-request boundary within the same +durable turn. A live provider request is not rewritten or aborted. Main owns +attachment validation and transcript persistence as for ordinary prompts. + +Queued input retains its renderer message id when pi consumes it, including +when another input arrives before the initial user message has been consumed. +An admission after pi's last queue poll suppresses the terminal event and +continues once pi has released the run, with the same turn identity and without +a second public `agent_start`. Existing context/provider recovery takes +precedence over that continuation. Steering also wakes a parent that is idle +waiting for background delegates; it does not cancel those delegates. + +Abort, graceful stop, fatal errors and terminal settlement close admission. +Accepted but unconsumed input remains transcript/context history and is removed +from pi's steering queue so it cannot execute independently on a later turn. +An ordinary follow-up stays in the separate Host-owned FIFO until durable turn +finalization. A steering failure must not terminate the active run. + ### 4.1 Session title summarization The renderer applies a short first-prompt fallback immediately so sending a diff --git a/docs/spec/03-runtime/04-data-storage.md b/docs/spec/03-runtime/04-data-storage.md index fd1242e8ba..d80559c644 100644 --- a/docs/spec/03-runtime/04-data-storage.md +++ b/docs/spec/03-runtime/04-data-storage.md @@ -1250,3 +1250,24 @@ columns for anything the host filters, joins, sums, or indexes. session, `(pluginId, source, externalId)` idempotency, no project or model binding unless an explicit host-created `projectId` is supplied, ownership-scoped reads/mutations, and recoverable trash before purge. + + +## Active-turn steering transcript reservations + +An accepted steering input is journaled through Electron's existing message +outbox with `meta.steering: true`, round-tripped as `UiMessage.steering`. Smart +Stop preserves that input even after renderer reload loses submission state. +If an assistant is still streaming, its provisional snapshot is queued +first to reserve its transcript position before the new user row. The host +stores the provisional row and an in-flight checkpoint, including an empty +reservation so crash recovery can settle it. Further stream checkpoints remain +valid while the indexed assistant has `status: streaming`. + +`session.appendMessage` retains idempotent replay for completed messages. Its +narrow exception lets a terminal assistant replace an indexed streaming +assistant with the same session/message id. It updates exactly that transcript +line and search text, retaining sequence, owning turn and every other row. +Late partial snapshots and duplicate terminal snapshots cannot overwrite the +settled result. Recovery promotes the latest checkpoint in that same position. +The outbox likewise keeps a newer snapshot that replaces an append while its +host call is still pending. No schema migration is required. diff --git a/docs/spec/04-ux/09-interaction-patterns.md b/docs/spec/04-ux/09-interaction-patterns.md index 41effd3a64..ccf48de451 100644 --- a/docs/spec/04-ux/09-interaction-patterns.md +++ b/docs/spec/04-ux/09-interaction-patterns.md @@ -31,6 +31,7 @@ | `Enter` | Send message when Enter-to-send is on; newline when it is off | Composer focused | | `Cmd/Ctrl + Enter` | Send message when Enter-to-send is off | Composer focused | | `Shift + Enter` | Newline | Composer focused | +| `Alt + Enter` (`Option + Enter` on macOS) | Steer the current turn; send normally when idle | Composer focused, outside IME composition | | `Escape` | Clear input / blur composer | Composer focused | | `Cmd/Ctrl + ↑` | Scroll to top of transcript | Transcript focused | | `Cmd/Ctrl + ↓` | Scroll to bottom of transcript | Transcript focused | @@ -644,7 +645,8 @@ may be retained while exactly one workspace supplies the visible shell context. ### 3.4 Queued send - While a session is running, the composer shows Send when the draft has - content and Stop when it is empty. Accepted prompts clear the composer and + content and Stop when it is empty. Normal Send and Enter-to-send are follow-up + actions. Accepted follow-ups clear the composer and append to that session's Host-owned, persisted FIFO queue; session switching never moves or clears another session's queue. - The queue renders above the composer. Each row has an independently @@ -662,6 +664,32 @@ may be retained while exactly one workspace supplies the visible shell context. application restart and remain held until a controller attaches (ADR 0213). Finalization during application shutdown must not start another queued turn. +### 3.5 Steer the current turn + +- `Alt+Enter` submits the visible draft to the current turn immediately. On + macOS this is `Option+Enter`. It works with Enter-to-send on or off and takes + precedence over an open autocomplete menu. An idle composer sends normally. +- `Shift+Enter` and `Alt+Shift+Enter` insert a newline. An Enter confirming an + IME candidate (`isComposing` or key code 229) never sends or steers. +- Steering appears as a user message in the current transcript, clears the + draft immediately, and reaches the next model request after the current + response/tool batch. It creates no FIFO row and does not interrupt tools. +- Submission captures the session and current turn identity. If that target + ends, rejects input, or is awaiting approval, the draft is restored in its + own session and a concise error is shown. New text typed after submission + takes precedence over restoration. The running turn is not marked failed. +- File/image chips use the existing attachment checks and the active model's + capability. Queued configuration changes apply to the next ordinary turn; + steering keeps the current configuration and sends slash-prefixed text + literally, without dispatching local mode or extension commands. +- Stop retains all accepted steering input as history. Smart Stop does not + remove the latest steering row or restore the original prompt over it, + including after renderer reload. The persisted message marker is the source + of truth; the renderer does not keep a separate steering registry. +- The Send tooltip identifies follow-up and uses the platform's key labels + for the steering shortcut (`⌥+Enter` on macOS). The + existing single Send/Stop slot and Host-owned follow-up list are retained. + ## 3A. Context checkpoint lifecycle - `turn_end` marks one completed model/tool turn and may be followed by another @@ -1112,7 +1140,8 @@ Project drag/drop follows these patterns: ### 8a.3 Keyboard while open - ↑/↓ move the highlight with wraparound; Home/End are left to the textarea. -- Enter / Tab accept the highlighted item; Enter and Cmd/Ctrl+Enter never send +- Enter / Tab accept the highlighted item; Alt+Enter uses active-turn steering + instead of accepting a suggestion. Enter and Cmd/Ctrl+Enter never send while the menu has a highlighted item (this precedes the Enter-to-send setting). otherwise keeps its behavior). - Escape closes only the menu — it takes precedence over the composer's diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 306e70d845..cb11054741 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -10328,3 +10328,46 @@ sample extensions under `apps/desktop/test/fixtures/pi-extensions/`. - **Status**: Unit-covered (`home-project-switcher.test.mjs`, `git-clone.test.mjs`, `sidebar-preferences.test.mjs`); full UI scenario Draft (run only in a capable environment when this surface changes) + +#### E2E-AGENT-alt-enter-steers-active-turn: Enter follows up and Alt+Enter steers the active turn + +- **Preconditions**: A session with a configured model and a controllable + streaming response/tool; an image-capable model for the attachment case. +- **Steps**: + 1. Start a prompt, then type a follow-up and press Enter. Confirm a FIFO row. + 2. During the same turn, type a correction and press Alt+Enter. Repeat with + an image chip and with two corrections before the current request ends. + 3. Finish the current response/tool batch and inspect the next model input, + transcript and durable turn id. Let the turn finish and observe follow-up. + 4. Repeat with Enter-to-send off, an open autocomplete menu, Shift+Enter, + Alt+Shift+Enter and a Chinese IME candidate confirmation. Inspect the Send + tooltip on macOS (`⌥+Enter`) and Windows/Linux (`Alt+Enter`). + 5. Race steering against turn completion, Stop, and a pending plan approval; + switch sessions while a rejected request is pending. + 6. Change the next-turn model while running, then steer. Verify the active + model and permission configuration remain unchanged. + 7. Steer while the parent waits for background delegates; leave them running + and verify the parent receives the correction before their reports finish. + 8. Reload after completion and simulate a crash after a streaming reply was + reserved by steering. Inspect row order, recovered text and owning turn. + 9. Reload the renderer after steering is accepted but before its reply starts, + then press Stop and inspect the persisted transcript. +- **Expected**: Enter queues an ordinary follow-up. Alt+Enter creates a user + row in the current turn with no queue row or new public `agent_start`. + Started tools finish, then the next request contains the corrections/images. + The ordinary FIFO starts only after durable turn finalization. IME and + newline actions never submit; idle Alt+Enter sends normally. A stale/closed + target keeps the draft in its own session and never fails the active turn. + Accepted input is not replayed independently after Stop. Completed replies + and accepted steering input remain in history after renderer reload and Stop. + Terminal assistant snapshots replace provisional snapshots in place; crash + recovery preserves the latest + checkpoint and adjacent steering rows without duplicates. +- **Specs linked**: `03-runtime/01-ipc-protocol.md` (§5.1a), + `03-runtime/02-agent-runtime.md` (§4.0), `03-runtime/04-data-storage.md`, + `04-ux/09-interaction-patterns.md` (§3.5), ADR active-turn-steering +- **Acceptance**: C (conversation & stream), E (tools & permissions), Quality +- **Milestone**: M5 +- **Status**: Draft. Existing regression suites cover surrounding behavior; + the rendered steering journey has not been run + (do not run E2E locally unless explicitly requested). diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index 21025ac92d..bb7166fd82 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -4696,3 +4696,16 @@ D193, and D194. The note provides the narrow Terminal fallback for trusted unsigned builds; signed and notarized builds do not need it. - Decision D406 amends D371 / ADR 0204. See ADR 0232 and E2E-196b. + + +## 2026-09-12 — Steer the active turn with Alt+Enter + +- Normal Send/Enter remains a Host-owned follow-up; Alt+Enter submits input to + the current durable turn using a required expected turn id. +- Reuse pi-agent-core steering at the next model-request boundary, preserve + started tools, and retain the active model/workspace/permission configuration. +- Stop and ended targets reject without redirecting input to another turn. + Accepted input remains history without independent replay after cancellation. +- Journal accepted user input with a provisional preceding reply when needed; + finalize only an indexed streaming assistant in place and retain completed + message idempotency. See ADR active-turn-steering and E2E-AGENT-alt-enter-steers-active-turn. diff --git a/docs/zh-CN/adr/index.md b/docs/zh-CN/adr/index.md index 61ec827590..bbc6ba6942 100644 --- a/docs/zh-CN/adr/index.md +++ b/docs/zh-CN/adr/index.md @@ -270,6 +270,7 @@ ADR 记录那些不应被静默改变的架构选择。中文入口与英文索 | 0204 | [未签名 macOS 首次启动助手](/adr/0204-unsigned-macos-first-launch-helper) | 已接受(由 D406 / ADR 0232 修订) | | 0205 | [远程 Agent 控制使用专用的 Host 边界](/adr/0205-remote-agent-control-boundary) | 已接受待实现(MVP 之后;由 D376 修订) | | 0232 | [macOS DMG 只保留打开说明](/adr/0232-macos-dmg-text-only-opening-guidance) | 已接受(修订 D371 / ADR 0204) | +| active-turn-steering | [用 Alt+Enter 向当前回合补充指令](/adr/active-turn-steering) | 已接受 | ## 什么时候看 ADR diff --git a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md index a3c2e93032..4008cf5fe2 100644 --- a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md +++ b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md @@ -48,6 +48,7 @@ event: pi-desktop//event/ 示例: - `pi-desktop/agent/prompt` +- `pi-desktop/agent/steer` - `pi-desktop/agent/abort` - `pi-desktop/agent/event/message` - `pi-desktop/agent/askTool/resolve` @@ -147,6 +148,37 @@ Root 用户轮次可能包括 `revisionRootId`、`revisionCount` 和 附件可供性保持隐藏,直到 main、sidecar、pi 模型 功能和持久性都会消耗有效负载。 +### 5.1a 向当前回合补充指令 + +`pi-desktop/agent/steer` 接受 `AgentSteerRequest`: + +```ts +type AgentSteerRequest = { + sessionId: string; + expectedTurnId: string; + content: string; + messageId?: string; + attachments?: AgentPromptAttachment[]; +}; +``` + +成功时返回现有回合的 `{ accepted: true, turnId }`。主进程检查正在运行的持久回合, +从现有 sidecar 运行时读取当前项目的附件根目录和模型图像能力,再执行普通提示所用的 +有界附件准备。sidecar 在这些 IO 完成后重新验证 `expectedTurnId`。 +目标回合不存在、已结束、正在停止、标识不匹配,或正在等待 Plan/Goal 审批时,返回 +`TURN_NOT_FOUND`,不会退回到新建回合或排队。空载荷返回 `INVALID_ARGUMENT`。 + +内部 `agent.steeringContext` 和 `agent.steer` 只使用已存在的运行时,不执行启动配置、 +`runtimeFor` 或 `session.beginTurn`。补充指令不能改变当前模型、权限模式、工作区或 +已批准的执行;此通道中的斜杠文本按普通输入处理。 + +已接收的输入以普通用户消息事件回显,携带当前 `turnId`、主进程准备的附件引用和 +`UiMessage.steering: true`。这个持久标记确保渲染器重载后,Smart Stop 仍保留该输入。 +用户 `message_end` 还可携带 `precedingAssistant` 流式快照,在持久化输入前为回复预留 +位置。主进程通过可重放 outbox 写入两者;主机仅以终态快照替换该临时助手行,保留其 +id、顺序和所属回合。图像字节不进入持久消息。这是新增的桌面通道和事件字段, +不改变 RACP、主机 RPC 版本或存储架构。见 ADR active-turn-steering。 + ### 5.2 在下一个回合边界停止 ```ts diff --git a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md index 461ee89881..cdacb9513f 100644 --- a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md +++ b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md @@ -49,6 +49,7 @@ crates/host-core (tool execution + permissions) ```ts interface AgentRuntime { prompt(input: PromptInput): Promise<{ turnId: string }> + steer(input: RuntimePrompt, expectedTurnId: string, message: UiMessage): { accepted: boolean; turnId: string } requestGracefulStop(): { requested: boolean } abort(turnId?: string): Promise getStatus(): RuntimeStatus @@ -63,6 +64,24 @@ interface AgentRuntime { 工具。空闲的运行时返回 `{ requested: false }`;立即生效的 `abort()` 仍然是另 一条独立的取消路径。 +### 4.0 当前回合补充指令 + +`steer` 在改变任何状态之前验证当前回合标识,再通过 pi-agent-core 原生 steering 队列的 +`all` 模式加入用户输入。当前提供商请求和已启动的一批工具先完成;所有已接收输入在 +同一个持久回合的下一次模型请求边界进入上下文。进行中的请求不会被改写或中止。 +与普通提示相同,主进程负责附件验证和转录持久化。 + +pi 消费排队输入时保留渲染器提供的消息 id;即使补充输入早于最初用户消息被消费, +也遵循这一规则。如果输入在 pi 最后一次检查队列后才获准进入,运行时抑制终态事件, +等 pi 释放执行后沿用同一回合继续,不再次公开发出 `agent_start`。现有上下文和提供商 +恢复流程优先于这次继续执行。补充指令也会唤醒正在空闲等待后台委托的父代理, +不会取消这些委托。 + +中止、优雅停止、致命错误和终态落定都会关闭接收入口。已接收但尚未消费的输入保留在 +转录和上下文历史中,并从 pi steering 队列移除,避免在后续回合独立执行。 +普通 follow-up 仍留在独立的 Host FIFO 中,直到当前持久回合最终落定。 +补充指令失败不得终止当前运行。 + ## 5. 提示流程 1. 加载持久会话,会话缺失则拒绝 diff --git a/docs/zh-CN/spec/03-runtime/04-data-storage.md b/docs/zh-CN/spec/03-runtime/04-data-storage.md index 9d97b05dee..e53b0d6db3 100644 --- a/docs/zh-CN/spec/03-runtime/04-data-storage.md +++ b/docs/zh-CN/spec/03-runtime/04-data-storage.md @@ -1175,3 +1175,17 @@ UI投影损失 `(pluginId, source, externalId)` 幂等;除非显式提供宿主创建的 `projectId`,否则不绑定项目或模型;读取和变更按所有权限制,并支持先 trash、后 purge。 + +## 当前回合补充指令的转录位置预留 + +已接收的补充输入通过 Electron 现有消息 outbox 写入,带有 `meta.steering: true`, +并以 `UiMessage.steering` 往返传递。即使渲染器重载丢失提交状态,Smart Stop 仍保留 +该输入。如果助手仍在流式回复,先加入其临时快照,为回复预留位于新用户行之前的位置。 +主机保存临时行和进行中检查点,也保存空预留行,以便崩溃恢复将其落定。 +只要索引中的助手仍为 `status: streaming`,后续流式检查点就仍然有效。 + +`session.appendMessage` 对已完成消息保持幂等重放,仅允许同一会话和消息 id 的 +终态助手替换索引中的流式助手。更新仅涉及该转录行和搜索文本,保留顺序、所属回合及 +其他所有行。迟到的部分快照和重复终态快照不能覆盖已落定结果。恢复时在原位置应用 +最新检查点。如果主机调用尚未完成时出现更新的追加快照,outbox 同样保留该快照。 +无需存储架构迁移。 diff --git a/docs/zh-CN/spec/04-ux/09-interaction-patterns.md b/docs/zh-CN/spec/04-ux/09-interaction-patterns.md index 86d291362c..42c2b6e999 100644 --- a/docs/zh-CN/spec/04-ux/09-interaction-patterns.md +++ b/docs/zh-CN/spec/04-ux/09-interaction-patterns.md @@ -34,6 +34,7 @@ | `Enter` | 开启回车发送时发送;关闭后换行 | 以输入框为中心 | | `Cmd/Ctrl + Enter` | 关闭回车发送时发送 | 以输入框为中心 | | `Shift + Enter` | 换行符 | 以输入框为中心 | +| `Alt + Enter`(macOS 为 `Option + Enter`) | 向当前回合补充指令;空闲时正常发送 | 输入框聚焦,且不在输入法组词过程中 | | `Escape` | 清晰输入/模糊编辑器 | 以输入框为中心 | | `Cmd/Ctrl + ↑` | 滚动到文字记录顶部 | 注重成绩单 | | `Cmd/Ctrl + ↓` | 滚动到文字记录底部 | 注重成绩单 | @@ -555,6 +556,25 @@ - 部分中止的消息会获得静音的“(中止)”后缀。只有 未应答的智能停止分支会删除其刚刚发送的用户行。 +### 3.5 向当前回合补充指令 + +- 运行中普通发送和回车发送仍将 follow-up 加入 Host 持久 FIFO。`Alt+Enter` 将可见 + 草稿立即提交到当前回合,macOS 对应 `Option+Enter`。开启或关闭回车发送均可使用, + 且优先于已打开的自动完成菜单;空闲时正常发送。 +- `Shift+Enter` 和 `Alt+Shift+Enter` 换行。用于确认输入法候选词的 Enter + (`isComposing` 或键码 229)从不发送或提交补充指令。 +- 补充指令作为用户消息出现在当前转录中,立即清空草稿,在当前回复和工具批次完成后 + 进入下一次模型请求。它不创建 FIFO 行,也不中断工具。 +- 提交时捕获会话和当前回合标识。如果目标结束、拒绝输入或正在等待审批,草稿恢复到 + 原会话并显示简短错误。提交后新输入的文本优先于草稿恢复;当前运行不会被标记失败。 +- 文件和图像芯片复用现有附件检查及当前模型能力。排队的配置变更只作用于下一次普通 + 回合;补充指令保留当前配置,斜杠文本按字面发送,不触发本地模式或扩展命令。 +- Stop 保留所有已接收的补充输入。Smart Stop 不移除最新 steering 行,也不会用最初 + 的提示覆盖它;渲染器重载后仍然如此。持久消息标记是判断依据,渲染器不另存一份 + steering 登记状态。 +- 发送按钮提示区分 follow-up,并按平台显示补充指令快捷键(macOS 为 `⌥+Enter`)。 + 保留现有单个 Send/Stop 按钮位置及 Host 拥有的 follow-up 列表。 + ## 3A。上下文检查点生命周期 - `turn_end` 标记一个已完成的 model/tool 回合,之后可能会进行另一回合 @@ -916,7 +936,7 @@ Mode/provider/model/permission/shell 配置和新提示仍然存在 ### 8a.3 打开时的键盘 - ↑/↓ 以环绕方式移动突出显示; Home/End 留在文本区域。 -- Enter / Tab 接受突出显示的项目;菜单有高亮项时 Enter 和 Cmd/Ctrl+Enter 都不发送 +- Enter / Tab 接受突出显示的项目;Alt+Enter 提交当前回合补充指令。菜单有高亮项时 Enter 和 Cmd/Ctrl+Enter 都不发送 (该规则先于回车发送设置)。 否则保持其行为)。 - Escape 仅关闭菜单 - 它优先于输入框的菜单 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 6ae29838b0..d7820a4e38 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -6578,3 +6578,29 @@ IPC 请求无法关闭。 - **验收**:C(对话与流)、本地化、品质 - **里程碑**:M2 - **状态**:单元覆盖(`packages/shared/src/composer-trigger.test.ts`、`apps/desktop/test/composer-ime.test.mjs`);渲染桌面旅程为草稿(除非明确要求,不本地运行 E2E) + +#### E2E-AGENT-alt-enter-steers-active-turn:Enter 排队跟进,Alt+Enter 向当前回合补充指令 + +- **前提条件**:会话已配置模型,能够控制流式回复或工具完成时机;附件场景使用支持图像的模型。 +- **步骤**: + 1. 发起提示,再输入 follow-up 并按 Enter,确认出现 FIFO 行。 + 2. 在同一回合输入修正并按 Alt+Enter;分别以图像芯片、当前请求结束前连续两次修正重复操作。 + 3. 完成当前回复和工具批次,检查下一次模型输入、转录及持久回合 id;回合结束后观察 follow-up。 + 4. 分别关闭回车发送、打开自动完成菜单,并测试 Shift+Enter、Alt+Shift+Enter 和中文输入法候选词确认。 + 检查 macOS 的 `⌥+Enter` 和 Windows/Linux 的 `Alt+Enter` 发送按钮提示。 + 5. 让补充指令分别与回合结束、Stop、待处理 Plan 审批同时发生;拒绝请求返回前切换会话。 + 6. 运行中更改下一回合模型,再提交补充指令;确认当前模型和权限配置不变。 + 7. 父代理等待后台委托时提交补充指令,保持委托运行,确认父代理在委托报告完成前收到修正。 + 8. 完成后重新加载,并模拟补充指令预留流式回复后崩溃;检查行顺序、恢复文本和所属回合。 + 9. 补充指令接收成功但尚未开始回复时重载渲染器,再按 Stop 并检查持久转录。 +- **预期**:Enter 排队普通 follow-up;Alt+Enter 在当前回合创建用户行,不创建队列行或新公开 + `agent_start`。已启动工具先完成,下一次请求包含修正和图像;普通 FIFO 仅在持久回合最终落定后 + 启动。输入法确认和换行不提交,空闲时 Alt+Enter 正常发送。过期或关闭的目标将草稿保留在原会话, + 不使当前回合失败。Stop 后已接收输入不会独立重放;重载渲染器并停止后,已完成回复和补充输入仍 + 留在历史中。终态助手快照原位替换临时快照;崩溃恢复保留最新检查点和相邻补充输入,无重复行。 +- **链接规格**:`03-runtime/01-ipc-protocol.md`(§5.1a)、`03-runtime/02-agent-runtime.md`(§4.0)、 + `03-runtime/04-data-storage.md`、`04-ux/09-interaction-patterns.md`(§3.5)、ADR active-turn-steering +- **验收**:C(对话与流)、E(工具与权限)、品质 +- **里程碑**:M5 +- **状态**:草稿。现有回归套件覆盖周边行为,尚未运行渲染界面的 steering 完整流程 + (除非明确要求,不本地运行 E2E)。 diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index de2dc588de..66eb19e32f 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -3884,3 +3884,15 @@ D193 和 D194。 - macOS ZIP 安装包保留打开说明和可执行助手。说明为可信未签名构建提供范围明确的 终端备用命令;已签名和公证版本无需执行。 - 决策 D406 修订 D371 / ADR 0204。见 ADR 0232 与 E2E-196b。 + +## 2026-09-12 —— 用 Alt+Enter 向当前回合补充指令 + +- 普通 Send/Enter 仍是 Host 拥有的 follow-up;Alt+Enter 携带必填的预期回合 id, + 将输入提交到当前持久回合。 +- 复用 pi-agent-core 在下一次模型请求边界消费的 steering,保留已启动工具及当前 + 模型、工作区和权限配置。 +- 正在停止或已结束的目标拒绝输入,不将其重定向到其他回合。已接收输入保留在历史中, + 取消后不会独立重放。 +- 必要时将前一条回复的临时快照与已接收用户输入一并记入日志;只原位落定索引中的 + 流式助手,并保持已完成消息的幂等性。见 ADR active-turn-steering 和 + E2E-AGENT-alt-enter-steers-active-turn。 diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts index 3e483ca918..f9fe859216 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -1459,6 +1459,10 @@ export class DesktopAgentRuntime { private compactionEnabled: boolean; private readonly compactionStrategy: CompactionStrategy; private pendingUserMessageId?: string; + private acceptingSteering = false; + private steeringContinuation = false; + private steeringWaitAbort?: AbortController; + private pendingSteering = new Map(); private pendingOverflow = false; private overflowRecoveryAttempted = false; private suppressOverflowRunEnd = false; @@ -1669,6 +1673,7 @@ Delegation rules: // `Task` calls — subagent fan-out (ADR 0062) — and every existing tool // ordering guarantee is untouched. toolExecution: "parallel", + steeringMode: "all", // A queued renderer prompt asks the current run to finish normally at // the next turn boundary. pi-agent-core evaluates this after the // assistant response and completed tool batch, before another provider @@ -3633,6 +3638,8 @@ Delegation rules: */ private terminateParentTurn(): void { this.turnHadError = true; + this.acceptingSteering = false; + this.retainPendingSteering(); this.abortRunningDelegations(); this.delegationWaitTargets = undefined; this.clearAgentActivity(); @@ -3754,8 +3761,21 @@ Delegation rules: ) { const targets = this.pendingCurrentTurnDelegations(); this.beginDelegationWait(targets); - await this.waitForDelegations(targets, targets.length, null); - this.endDelegationWait(); + const waitAbort = new AbortController(); + this.steeringWaitAbort = waitAbort; + try { + if (!this.pendingSteering.size) { + await this.waitForDelegations(targets, targets.length, null, waitAbort.signal); + } + } finally { + if (this.steeringWaitAbort === waitAbort) this.steeringWaitAbort = undefined; + this.endDelegationWait(); + } + if (this.pendingSteering.size && !this.runCancelled && !this.turnHadError) { + await this.waitForIdleAndSteering(); + if (!(await this.runPendingRecoveries())) return; + continue; + } if ( this.disposed || this.runCancelled || @@ -3796,7 +3816,7 @@ Delegation rules: .join("\n\n"); this.requestStartedAt = Date.now(); await this.agent.prompt(text); - await this.agent.waitForIdle(); + await this.waitForIdleAndSteering(); if (!(await this.runPendingRecoveries())) return; if (this.turnHadError || epoch !== this.turnEpoch) { if (this.turnHadError) this.terminateParentTurn(); @@ -4610,7 +4630,7 @@ Delegation rules: // are suppressed; the retry must close the visible run normally. this.suppressProviderRetryRunEnd = false; await this.agent.continue(); - await this.agent.waitForIdle(); + await this.waitForIdleAndSteering(); } finally { this.providerRetryAbort = undefined; this.activeProviderRetryAttempt = 0; @@ -4647,7 +4667,7 @@ Delegation rules: try { if (this.disposed) throw new Error("runtime disposed"); await this.agent.continue(); - await this.agent.waitForIdle(); + await this.waitForIdleAndSteering(); } finally { if (this.agent.state.systemPrompt === promptWithNudge) { this.agent.state.systemPrompt = promptBefore; @@ -4716,7 +4736,7 @@ Delegation rules: this.turnHadError = false; this.requestStartedAt = Date.now(); await this.agent.continue(); - await this.agent.waitForIdle(); + await this.waitForIdleAndSteering(); continue; } if (this.pendingSilentTurnRerun) { @@ -4755,7 +4775,7 @@ Delegation rules: if (this.disposed) throw new Error("runtime disposed"); this.suppressProgressTurnRunEnd = false; await this.agent.continue(); - await this.agent.waitForIdle(); + await this.waitForIdleAndSteering(); } finally { if (this.agent.state.systemPrompt === promptWithNudge) { this.agent.state.systemPrompt = promptBefore; @@ -5659,6 +5679,7 @@ Delegation rules: this.forwardAgentEventToExtensions(event); switch (event.type) { case "agent_start": + if (this.steeringContinuation) break; if ( this.providerRetryInProgress || this.silentTurnRerunInProgress || @@ -5765,8 +5786,10 @@ Delegation rules: } case "message_end": { if (event.message.role === "user") { - const id = this.pendingUserMessageId ?? randomUUID(); - this.pendingUserMessageId = undefined; + const steeringId = this.pendingSteering.get(event.message); + const id = steeringId ?? this.pendingUserMessageId ?? randomUUID(); + if (steeringId) this.pendingSteering.delete(event.message); + else this.pendingUserMessageId = undefined; this.appendLiveEntry(id, event.message); break; } @@ -6088,6 +6111,9 @@ Delegation rules: }); break; case "agent_end": + // Input admitted after pi's last queue poll still belongs to this turn. + // Continue after the current run settles; never wake the follow-up FIFO. + if (this.pendingSteering.size && this.acceptingSteering && !this.runCancelled && !this.turnHadError) break; if ( this.suppressOverflowRunEnd || this.suppressProviderRetryRunEnd || @@ -6096,6 +6122,8 @@ Delegation rules: this.keepTurnOpenForDelegates() ) break; + this.acceptingSteering = false; + this.retainPendingSteering(); this.autonomousExecution = false; this.clearAgentActivity(); this.reportMutationTermination(); @@ -6228,6 +6256,7 @@ Delegation rules: ): Promise<{ turnId: string }> { if (this.disposed) throw new Error("runtime disposed"); this.assertNotRunning(); + this.retainPendingSteering(); if (execution.sessionId !== this.sessionId) { throw Object.assign(new Error("approved plan belongs to another session"), { errorCode: "PLAN_EXECUTION_NOT_FOUND", @@ -6247,6 +6276,7 @@ Delegation rules: this.pathInstructionClaims.clear(); this.hostTurnId = durableTurnId; this.turnId = durableTurnId; + this.acceptingSteering = true; this.pendingUserMessageId = undefined; this.gracefulStopRequested = false; this.runCancelled = false; @@ -6297,7 +6327,7 @@ Delegation rules: ).messages; this.setAgentActivity({ phase: "starting", since: Date.now() }); await this.agent.continue(); - await this.agent.waitForIdle(); + await this.waitForIdleAndSteering(); // Same recovery contract as a user prompt: a plan execution that overflows, // hits a retriable stream failure, or comes back silent must not end as a // run with no end events at all. @@ -6317,9 +6347,11 @@ Delegation rules: ): Promise<{ turnId: string }> { if (this.disposed) throw new Error("runtime disposed"); this.assertNotRunning(); + this.retainPendingSteering(); const nextTurnId = durableTurnId?.trim() || randomUUID(); this.hostTurnId = nextTurnId; this.turnId = nextTurnId; + this.acceptingSteering = true; this.gracefulStopRequested = false; this.runCancelled = false; this.turnSubagentUsage = undefined; @@ -6373,7 +6405,7 @@ Delegation rules: } else { await this.agent.prompt(input.text, promptImages(input)); } - await this.agent.waitForIdle(); + await this.waitForIdleAndSteering(); void this.extensionRunner?.emit("agent_settled", { type: "agent_settled" }); if (!(await this.runPendingRecoveries())) return { turnId: this.turnId }; @@ -6486,7 +6518,69 @@ Delegation rules: }); } + /** Resolve attachments against the configuration of the running turn. */ + steeringContext(expectedTurnId: string): { projectPath?: string; supportsVision: boolean } { + if ( + this.disposed || !this.acceptingSteering || this.runCancelled || + this.turnHadError || !this.getStatus().isRunning || + !expectedTurnId || expectedTurnId !== this.turnId || + this.planningState === "awaiting_approval" + ) { + throw Object.assign(new Error("The target turn is no longer accepting input"), { + errorCode: "TURN_NOT_FOUND", + }); + } + return { projectPath: this.projectPath, supportsVision: this.model.input.includes("image") }; + } + + steer(input: RuntimePrompt, expectedTurnId: string, message: UiMessage): { accepted: boolean; turnId: string } { + this.steeringContext(expectedTurnId); + const queued: AgentMessage = { role: "user", content: promptContent(input), timestamp: Date.now() }; + this.pendingSteering.set(queued, message.id); + this.agent.steer(queued); + this.steeringWaitAbort?.abort(); + // Main persists this echo through the same outbox as assistant messages. + this.emit({ type: "message_start", message }); + this.emit({ + type: "message_end", message, + ...(this.currentAssistant?.status === "streaming" + ? { precedingAssistant: { ...this.currentAssistant } } : {}), + }); + return { accepted: true, turnId: this.turnId! }; + } + + private retainPendingSteering(): void { + this.agent.clearSteeringQueue(); + for (const [message, id] of this.pendingSteering) { + if (!this.agent.state.messages.includes(message)) this.agent.state.messages = [...this.agent.state.messages, message]; + this.appendLiveEntry(id, message); + } + this.pendingSteering.clear(); + } + + private async waitForIdleAndSteering(): Promise { + await this.agent.waitForIdle(); + if (!this.acceptingSteering || this.runCancelled || this.turnHadError) { + this.retainPendingSteering(); + return; + } + // Recovery owns the next request when the previous response failed. Its + // continuation will consume steering after repairing the context/backoff. + if (this.suppressOverflowRunEnd || this.suppressProviderRetryRunEnd || + this.suppressSilentTurnRunEnd || this.suppressProgressTurnRunEnd) return; + while (this.pendingSteering.size && this.acceptingSteering && !this.runCancelled && !this.turnHadError) { + this.steeringContinuation = true; + try { + await this.agent.continue(); + await this.agent.waitForIdle(); + } finally { + this.steeringContinuation = false; + } + } + } + async abort(): Promise { + this.acceptingSteering = false; this.gracefulStopRequested = false; this.runCancelled = true; this.resolvePendingAskTools(); @@ -6503,6 +6597,7 @@ Delegation rules: if (this.disposed || !this.agent.state.isStreaming) { return { requested: false }; } + this.acceptingSteering = false; this.gracefulStopRequested = true; return { requested: true }; } @@ -6529,6 +6624,7 @@ Delegation rules: this.extensionRunner = undefined; if (runner) await runner.dispose().catch(() => undefined); this.disposed = true; + this.acceptingSteering = false; this.runCancelled = true; this.resolvePendingAskTools(); this.abortRunningDelegations(); diff --git a/packages/agent-runtime/src/sidecar.ts b/packages/agent-runtime/src/sidecar.ts index 02f73ee9aa..75f828da2d 100644 --- a/packages/agent-runtime/src/sidecar.ts +++ b/packages/agent-runtime/src/sidecar.ts @@ -480,6 +480,20 @@ async function handle(method: string, params: any): Promise { }); return { accepted: true, turnId }; } + case "agent.steeringContext": + case "agent.steer": { + const runtime = runtimes.get(String(params.sessionId ?? "")); + if (!runtime) { + throw Object.assign(new Error("No active turn to steer"), { errorCode: "TURN_NOT_FOUND" }); + } + const expectedTurnId = String(params.expectedTurnId ?? ""); + if (method === "agent.steeringContext") return runtime.steeringContext(expectedTurnId); + return runtime.steer( + { text: String(params.content ?? ""), attachments: params.attachments }, + expectedTurnId, + params.message, + ); + } case "agent.executeApprovedPlan": { const sessionId = String(params.sessionId ?? ""); const turnId = String(params.turnId ?? "").trim(); diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 774eef08e1..c72726eca1 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -240,6 +240,8 @@ export const de = { "enhancementFailed": "Eingabeaufforderung zur Verbesserung fehlgeschlagen", "dismissEnhancementError": "Verbesserungsfehler verwerfen", "abort": "Stoppen", + sendWhileRunning: "Folgenachricht senden · {{shortcut}} zum Lenken", + steeringUnavailable: "Diese Runde nimmt keine weiteren Eingaben an. Dein Entwurf wurde behalten.", "stopGenerating": "Generieren stoppen", "running": "Funktioniert…", "loadingSession": "Konversation wird geladen…", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index 9031164eb1..c42cde52a3 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -247,6 +247,8 @@ export const en = { enhancementFailed: "Prompt enhancement failed", dismissEnhancementError: "Dismiss enhancement error", abort: "Stop", + sendWhileRunning: "Send follow-up · {{shortcut}} to steer", + steeringUnavailable: "This turn can no longer accept steering. Your draft was kept.", stopGenerating: "Stop generating", running: "Working…", loadingSession: "Loading conversation…", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 3238a37f27..f328d008b6 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -240,6 +240,8 @@ export const es = { "enhancementFailed": "Error en la mejora de solicitud", "dismissEnhancementError": "Descartar error de mejora", "abort": "Detener", + sendWhileRunning: "Enviar seguimiento · {{shortcut}} para orientar", + steeringUnavailable: "Este turno ya no acepta indicaciones. Se conservó el borrador.", "stopGenerating": "Dejar de generar", "running": "Trabajando...", "loadingSession": "Cargando conversación...", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index d55d3a2a6a..990751de2a 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -240,6 +240,8 @@ export const fr = { "enhancementFailed": "L'amélioration de l'invite a échoué", "dismissEnhancementError": "Ignorer l'erreur d'amélioration", "abort": "Arrêter", + sendWhileRunning: "Envoyer à la suite · {{shortcut}} pour réorienter", + steeringUnavailable: "Ce tour ne peut plus recevoir de consignes. Votre brouillon a été conservé.", "stopGenerating": "Arrêter de générer", "running": "Travailler…", "loadingSession": "Chargement de la conversation…", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 558d4f6ad4..0501f4e95c 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -249,6 +249,8 @@ export const ko = { enhancementFailed: "프롬프트 개선 실패", dismissEnhancementError: "프롬프트 개선 오류 닫기", abort: "중지", + sendWhileRunning: "후속 메시지 보내기 · {{shortcut}}로 방향 수정", + steeringUnavailable: "현재 턴에 지시를 추가할 수 없습니다. 초안은 유지됩니다.", stopGenerating: "생성 중지", running: "작업 중…", loadingSession: "대화 불러오는 중…", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index dd9877b0a7..5d6be21cb9 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -249,6 +249,8 @@ export const tr = { enhancementFailed: "İstem iyileştirilemedi", dismissEnhancementError: "İyileştirme hatasını kapat", abort: "Durdur", + sendWhileRunning: "Takip mesajı gönder · {{shortcut}} ile yönlendir", + steeringUnavailable: "Bu tur artık yönlendirme kabul edemiyor. Taslağınız korundu.", stopGenerating: "Oluşturmayı durdur", running: "Çalışıyor…", loadingSession: "Sohbet yükleniyor…", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index f8329886e7..dbc70eebe8 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -242,6 +242,8 @@ export const zhCN = { enhancementFailed: "提示词增强失败", dismissEnhancementError: "关闭增强错误", abort: "停止", + sendWhileRunning: "发送后续消息 · {{shortcut}} 立即转向", + steeringUnavailable: "当前轮已无法接收转向输入,草稿已保留。", stopGenerating: "停止生成", running: "正在处理…", loadingSession: "正在加载会话…", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index a796f29159..3cf6bb99d8 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -242,6 +242,8 @@ export const zhTW = { enhancementFailed: "提示詞增強失敗", dismissEnhancementError: "關閉增強錯誤", abort: "停止", + sendWhileRunning: "傳送後續訊息 · {{shortcut}} 立即轉向", + steeringUnavailable: "目前回合已無法接收轉向輸入,草稿已保留。", stopGenerating: "停止生成", running: "正在處理…", loadingSession: "正在載入會話…", diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index face7eb7ee..d87b342893 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -68,6 +68,7 @@ export const IPC = { notificationShowNative: "pi-desktop/notification/showNative", notificationSetViewingSession: "pi-desktop/notification/setViewingSession", agentPrompt: "pi-desktop/agent/prompt", + agentSteer: "pi-desktop/agent/steer", promptEnhance: "pi-desktop/prompt/enhance", agentCompact: "pi-desktop/agent/compact", agentAbort: "pi-desktop/agent/abort", diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 4a27bf8b90..61294ce7dd 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -52,6 +52,13 @@ export type AgentPromptAttachment = { size?: number; }; +export type AgentSteerRequest = Pick< + AgentPromptRequest, + "sessionId" | "content" | "attachments" | "messageId" +> & { + expectedTurnId: string; +}; + export type AgentPromptResponse = { accepted: boolean; turnId: string; @@ -205,7 +212,7 @@ export type AgentEvent = deltaText?: string; deltaThinking?: string; } - | { type: "message_end"; message: UiMessage } + | { type: "message_end"; message: UiMessage; precedingAssistant?: UiMessage } | { type: "tool_start"; toolCallId: string; toolName: string; args: unknown } | { type: "tool_update"; toolCallId: string; partialResult?: unknown } | { diff --git a/packages/shared/src/types/messages.ts b/packages/shared/src/types/messages.ts index 73a01ef80d..f664704f7e 100644 --- a/packages/shared/src/types/messages.ts +++ b/packages/shared/src/types/messages.ts @@ -70,6 +70,8 @@ export type UiMessage = { content: string; /** Files or images associated with a user turn, kept separate from text. */ attachments?: MessageAttachment[]; + /** Accepted input to an existing turn; Stop must preserve it after reload. */ + steering?: boolean; /** Model reasoning kept separate from the answer text. */ thinking?: string; createdAt: string; From 04d40ecd8e41dc9f86c2b23650133194545ccaa8 Mon Sep 17 00:00:00 2001 From: zszz3 <91608029+zszz3@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:08:15 +0800 Subject: [PATCH 02/13] feat(search): find sessions by message content --- apps/desktop/electron/main/ipc/session-ipc.ts | 8 + apps/desktop/src/components/ChatSurface.tsx | 33 +- apps/desktop/src/components/SearchDialog.tsx | 271 +++++----- .../src/components/SearchHighlight.tsx | 22 + .../src/components/SearchSessionResults.tsx | 109 ++++ .../src/components/SessionSearchContext.tsx | 163 ++++++ apps/desktop/src/hooks/use-session-search.ts | 41 ++ apps/desktop/src/lib/api.ts | 7 + apps/desktop/src/lib/session-search.ts | 111 ++++ apps/desktop/src/styles/globals.css | 1 + apps/desktop/src/styles/session-search.css | 97 ++++ .../test/session-content-search.test.mjs | 131 +++++ .../test/session-search-rendering.test.mjs | 85 ++++ .../test/session-switch-performance.test.mjs | 2 +- crates/host-core/Cargo.toml | 2 +- crates/host-core/src/main.rs | 1 + crates/host-core/src/rpc/mod.rs | 27 + crates/host-core/src/session_search.rs | 472 ++++++++++++++++++ crates/host-core/src/sessions.rs | 8 +- crates/host-core/src/transcripts.rs | 27 + docs/adr/session-content-search.md | 71 +++ docs/spec/03-runtime/01-ipc-protocol.md | 6 + docs/spec/03-runtime/04-data-storage.md | 20 +- docs/spec/03-runtime/06-host-rpc-protocol.md | 23 +- docs/spec/04-ux/08-component-spec.md | 30 +- docs/spec/06-delivery/04-e2e-test-plan.md | 35 ++ packages/i18n/src/locales/de/index.ts | 17 +- packages/i18n/src/locales/en/index.ts | 17 +- packages/i18n/src/locales/es/index.ts | 17 +- packages/i18n/src/locales/fr/index.ts | 17 +- packages/i18n/src/locales/ko/index.ts | 17 +- packages/i18n/src/locales/tr/index.ts | 17 +- packages/i18n/src/locales/zh-CN/index.ts | 17 +- packages/i18n/src/locales/zh-TW/index.ts | 17 +- packages/shared/src/protocol.ts | 2 + packages/shared/src/types/sessions.ts | 37 ++ 36 files changed, 1825 insertions(+), 153 deletions(-) create mode 100644 apps/desktop/src/components/SearchHighlight.tsx create mode 100644 apps/desktop/src/components/SearchSessionResults.tsx create mode 100644 apps/desktop/src/components/SessionSearchContext.tsx create mode 100644 apps/desktop/src/hooks/use-session-search.ts create mode 100644 apps/desktop/src/lib/session-search.ts create mode 100644 apps/desktop/src/styles/session-search.css create mode 100644 apps/desktop/test/session-content-search.test.mjs create mode 100644 apps/desktop/test/session-search-rendering.test.mjs create mode 100644 crates/host-core/src/session_search.rs create mode 100644 docs/adr/session-content-search.md diff --git a/apps/desktop/electron/main/ipc/session-ipc.ts b/apps/desktop/electron/main/ipc/session-ipc.ts index 58c6defc6f..bd0111270e 100644 --- a/apps/desktop/electron/main/ipc/session-ipc.ts +++ b/apps/desktop/electron/main/ipc/session-ipc.ts @@ -122,6 +122,14 @@ export function registerSessionIpc({ }); }; + handle(IPC.invoke.sessionSearch, async (input) => { + if (!host) throw new Error("host unavailable"); + return host.call("search.sessions", input); + }); + handle(IPC.invoke.sessionSearchContext, async (input) => { + if (!host) throw new Error("host unavailable"); + return host.call("search.context", input); + }); handle(IPC.invoke.sessionList, async () => { if (!host) throw new Error("host unavailable"); const [result, { providers, defaults }] = await Promise.all([ diff --git a/apps/desktop/src/components/ChatSurface.tsx b/apps/desktop/src/components/ChatSurface.tsx index c479d65ba8..2e6aa134d6 100644 --- a/apps/desktop/src/components/ChatSurface.tsx +++ b/apps/desktop/src/components/ChatSurface.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo } from "react"; +import { memo, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Composer } from "./Composer"; import { HomeMascotLogo } from "./HomeMascotLogo"; @@ -10,6 +10,8 @@ import { SessionPane } from "./SessionPane"; import { useAppStore } from "../stores/app-store"; import { headPermission } from "../lib/pending-permissions"; import { headAsk } from "../lib/pending-asks"; +import { useSessionSearchState } from "../hooks/use-session-search"; +import { SessionSearchContext } from "./SessionSearchContext"; const StableComposer = memo(Composer); @@ -42,6 +44,16 @@ function projectName(path?: string | null, name?: string | null) { export const ChatSurface = memo(function ChatSurface() { const { t } = useTranslation(); const activeSessionId = useAppStore((state) => state.activeSessionId); + const searchFocus = useSessionSearchState((state) => state.focus); + const setSearchFocus = useSessionSearchState((state) => state.setFocus); + const showSearchContext = Boolean( + searchFocus && searchFocus.sessionId === activeSessionId, + ); + useEffect(() => { + if (searchFocus && searchFocus.sessionId !== activeSessionId) { + setSearchFocus(undefined); + } + }, [activeSessionId, searchFocus, setSearchFocus]); const selectingSessionId = useAppStore((state) => state.selectingSessionId); const retainedSessionIds = useAppStore((state) => state.retainedSessionIds); const messages = useAppStore((state) => state.messages); @@ -118,11 +130,21 @@ export const ChatSurface = memo(function ChatSurface() { // still resolving, the visible pane keeps its own transcript, so the hero must // not take over just because the destination projection is still empty. const showEmptyState = + !showSearchContext && !hasTranscript && (!visibleSessionId || visibleSessionId === activeSessionId); return (
{ + if ( + showSearchContext && + event.target instanceof HTMLElement && + event.target.matches(".composer-input") + ) { + setSearchFocus(undefined); + } + }} > {sessionSwitching ? (
@@ -175,9 +197,16 @@ export const ChatSurface = memo(function ChatSurface() { ))} + {showSearchContext && searchFocus ? ( + setSearchFocus(undefined)} + /> + ) : null}
diff --git a/apps/desktop/src/components/SearchDialog.tsx b/apps/desktop/src/components/SearchDialog.tsx index 772765ead2..1470d8b4e1 100644 --- a/apps/desktop/src/components/SearchDialog.tsx +++ b/apps/desktop/src/components/SearchDialog.tsx @@ -1,23 +1,17 @@ -import { - useEffect, - useMemo, - useState, - type ReactNode, -} from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { api } from "../lib/api"; import { runPaletteCommand } from "../lib/commands"; import { isDefaultSessionTitle, useAppStore } from "../stores/app-store"; import { normalizeProjectPath } from "../lib/sidebar-session-groups"; -import { - searchSettings, - type SettingsSearchHit, -} from "../lib/settings-search"; +import { searchSettings, type SettingsSearchHit } from "../lib/settings-search"; +import { useSessionSearch, useSessionSearchState } from "../hooks/use-session-search"; +import { SearchHighlight } from "./SearchHighlight"; +import { SearchSessionResults, type SearchRow } from "./SearchSessionResults"; import type { SessionSummary, CommandItem } from "@pi-desktop/shared"; import type { SessionMeta } from "../lib/sidebar-preferences"; import { IconAt, - IconChat, IconClock, IconNewSession, IconPullRequest, @@ -45,14 +39,6 @@ const GROUP_KEYS = [ type GroupKey = (typeof GROUP_KEYS)[number]; -type SearchRow = { - session: SessionSummary; - archived: boolean; - projectLabel: string; - /** Flat option index across the whole listbox (0 = "new task" row). */ - optionIndex: number; -}; - const DAY_MS = 86_400_000; function sessionArchived( @@ -60,8 +46,7 @@ function sessionArchived( meta: SessionMeta | undefined, ): boolean { return Boolean( - meta?.archived || - (session as SessionSummary & { archived?: boolean }).archived, + meta?.archived || (session as SessionSummary & { archived?: boolean }).archived, ); } @@ -81,27 +66,11 @@ function groupKeyFor(updatedAt: string | undefined, startOfToday: number): Group return "earlier"; } -function highlightMatch(title: string, query: string): ReactNode { - const q = query.trim().toLowerCase(); - if (!q) return title; - const index = title.toLowerCase().indexOf(q); - if (index < 0) return title; - return ( - <> - {title.slice(0, index)} - {title.slice(index, index + q.length)} - {title.slice(index + q.length)} - - ); +function highlightMatch(text: string, query: string) { + return ; } -export function SearchDialog({ - open, - onClose, -}: { - open: boolean; - onClose: () => void; -}) { +export function SearchDialog({ open, onClose }: { open: boolean; onClose: () => void }) { const { t } = useTranslation(); const sessions = useAppStore((s) => s.sessions); const sessionMeta = useAppStore((s) => s.sessionMeta); @@ -116,15 +85,25 @@ export function SearchDialog({ const setPage = useAppStore((s) => s.setPage); const showToast = useAppStore((s) => s.showToast); - const [query, setQuery] = useState(""); + const query = useSessionSearchState((state) => state.query); + const setQuery = useSessionSearchState((state) => state.setQuery); + const setSearchFocus = useSessionSearchState((state) => state.setFocus); + const search = useSessionSearch(open, query); + const selectionRequest = useRef(0); + useEffect(() => { + selectionRequest.current += 1; + }, [open, query]); const [active, setActive] = useState(0); - const [commandHits, setCommandHits] = useState([]); + const [commandResult, setCommandResult] = useState<{ + query: string; + hits: CommandItem[]; + }>({ query: "", hits: [] }); + const commandHits = commandResult.query === query ? commandResult.hits : []; useEffect(() => { if (!open) return; - setQuery(""); setActive(0); - setCommandHits([]); + setCommandResult((result) => ({ ...result, hits: [] })); // Catch sessions renamed/created since the last store refresh. void refreshSessions().catch(() => undefined); }, [open, refreshSessions]); @@ -133,12 +112,21 @@ export function SearchDialog({ // surface covers sessions, pages, settings, and commands. useEffect(() => { if (!open) return; + let cancelled = false; const handle = window.setTimeout(() => { void api .searchCommands(query) - .then((res) => setCommandHits(res.commands)); + .then((res) => { + if (!cancelled) setCommandResult({ query, hits: res.commands }); + }) + .catch(() => { + if (!cancelled) setCommandResult({ query, hits: [] }); + }); }, 80); - return () => window.clearTimeout(handle); + return () => { + cancelled = true; + window.clearTimeout(handle); + }; }, [query, open]); const projectNames = useMemo(() => { @@ -157,34 +145,36 @@ export function SearchDialog({ const rows = useMemo(() => { const q = query.trim().toLowerCase(); const candidates: Omit[] = []; - for (const session of sessions) { + const source = q ? search.hits.map((hit) => hit.session) : sessions; + const hits = new Map(search.hits.map((hit) => [hit.session.id, hit])); + for (const session of source) { // Untitled drafts carry no searchable signal; they stay sidebar-only. - if (isDefaultSessionTitle(session.title)) continue; + if (!q && isDefaultSessionTitle(session.title)) continue; const archived = sessionArchived(session, sessionMeta[session.id]); // Recents view keeps the sidebar's default: archived stays hidden // until the user actually searches for it. if (archived && !q) continue; const projectKey = normalizeProjectPath(session.projectPath); - const projectLabel = projectKey - ? projectNames.get(projectKey) ?? projectBasename(projectKey) - : t("nav.temporarySessions"); - if (q) { - const title = (session.title || "").toLowerCase(); - if (!title.includes(q) && !projectLabel.toLowerCase().includes(q)) { - continue; - } - } - candidates.push({ session, archived, projectLabel }); + const hit = q ? hits.get(session.id) : undefined; + const projectLabel = + hit?.projectName || + (projectKey + ? (projectNames.get(projectKey) ?? projectBasename(projectKey)) + : t("nav.temporarySessions")); + candidates.push({ session, archived, projectLabel, hit }); } candidates.sort((a, b) => { const aTs = Date.parse(a.session.updatedAt || "") || 0; const bTs = Date.parse(b.session.updatedAt || "") || 0; return bTs - aTs || a.session.id.localeCompare(b.session.id); }); - return candidates - .slice(0, q ? 50 : 30) - .map((row, index) => ({ ...row, optionIndex: index + 1 })); - }, [sessions, sessionMeta, projectNames, query, t]); + let index = 1; + return (q ? candidates : candidates.slice(0, 30)).map((row) => { + const optionIndex = index; + index += 1 + (row.hit?.matches.length ?? 0); + return { ...row, optionIndex }; + }); + }, [sessions, sessionMeta, projectNames, query, search.hits, t]); const groups = useMemo(() => { const now = new Date(); @@ -211,9 +201,7 @@ export function SearchDialog({ const pageHits = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return []; - return PAGE_ENTRIES.filter((entry) => - t(entry.labelKey).toLowerCase().includes(q), - ); + return PAGE_ENTRIES.filter((entry) => t(entry.labelKey).toLowerCase().includes(q)); }, [query, t]); const settingsHits = useMemo( @@ -221,7 +209,13 @@ export function SearchDialog({ [query, t], ); - const settingsBase = rows.length + pageHits.length + 1; + const sessionOptionCount = rows.reduce( + (count, row) => count + 1 + (row.hit?.matches.length ?? 0), + 0, + ); + const moreIndex = sessionOptionCount + 1; + const pageBase = moreIndex + (query.trim() && search.nextOffset !== null ? 1 : 0); + const settingsBase = pageBase + pageHits.length; const commandsBase = settingsBase + settingsHits.length; const optionCount = commandsBase + commandHits.length; @@ -229,6 +223,10 @@ export function SearchDialog({ setActive(0); }, [query]); + useEffect(() => { + setActive((value) => Math.min(value, optionCount - 1)); + }, [optionCount]); + useEffect(() => { if (!open) return; document @@ -238,16 +236,32 @@ export function SearchDialog({ if (!open) return null; - const run = async (row: SearchRow | null) => { + const run = async (row: SearchRow | null, messageId?: string) => { + const request = ++selectionRequest.current; try { - if (row) await selectSession(row.session.id); - else await newSession(); + setSearchFocus(undefined); + if (row) { + await selectSession(row.session.id); + const selected = useAppStore.getState(); + if ( + request !== selectionRequest.current || + selected.activeSessionId !== row.session.id || + selected.page !== "chat" + ) + return; + const target = messageId ?? row.hit?.matches[0]?.messageId; + if (target) + setSearchFocus({ + sessionId: row.session.id, + messageId: target, + query: query.trim(), + }); + } else await newSession(); onClose(); - requestAnimationFrame(() => { - document - .querySelector(".composer-input") - ?.focus(); - }); + if (!row?.hit?.matches.length) + requestAnimationFrame(() => { + document.querySelector(".composer-input")?.focus(); + }); } catch (error) { showToast(error instanceof Error ? error.message : String(error), { variant: "error", @@ -279,10 +293,15 @@ export function SearchDialog({ const runActive = () => { if (active === 0) return void run(null); - const sessionIndex = active - 1; - if (sessionIndex < rows.length) return void run(rows[sessionIndex]); - const pageIndex = active - 1 - rows.length; - if (pageIndex >= 0 && pageIndex < pageHits.length) return openPage(pageHits[pageIndex]); + for (const row of rows) { + if (active === row.optionIndex) return void run(row); + const match = row.hit?.matches[active - row.optionIndex - 1]; + if (match) return void run(row, match.messageId); + } + if (active === moreIndex && search.nextOffset !== null) return search.loadMore(); + const pageIndex = active - pageBase; + if (pageIndex >= 0 && pageIndex < pageHits.length) + return openPage(pageHits[pageIndex]); const settingsIndex = active - settingsBase; if (settingsIndex >= 0 && settingsIndex < settingsHits.length) { return openSettingsHit(settingsHits[settingsIndex]); @@ -320,12 +339,14 @@ export function SearchDialog({ aria-label={t("nav.search")} placeholder={t("search.placeholder")} value={query} + maxLength={500} autoFocus spellCheck={false} autoCorrect="off" autoCapitalize="off" onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => { + if (event.nativeEvent.isComposing || event.keyCode === 229) return; if (event.key === "Escape") { event.preventDefault(); onClose(); @@ -363,58 +384,48 @@ export function SearchDialog({ {t("nav.newTask")} - {rows.length > 0 - ? groups.map((group) => ( -
-
- {t(`search.${group.key}`)} -
- {group.rows.map((row) => ( - - ))} -
- )) - : null} + void run(row, messageId)} + /> + {query.trim() && search.loading ? ( +
+ {t("search.loading")} +
+ ) : null} + {query.trim() && search.error ? ( +
+ {t("search.failed")} + +
+ ) : null} + {query.trim() && search.nextOffset !== null ? ( + + ) : null} {pageHits.length > 0 ? (
{t("search.pages")}
{pageHits.map((entry, index) => { - const optionIndex = rows.length + 1 + index; + const optionIndex = pageBase + index; const Icon = entry.icon; return ( @@ -493,9 +502,7 @@ export function SearchDialog({ {highlightMatch(command.title, query)} {command.source === "plugin" && ( - - {t("plugins.title")} - + {t("plugins.title")} )} ); @@ -506,7 +513,9 @@ export function SearchDialog({ pageHits.length === 0 && settingsHits.length === 0 && commandHits.length === 0 && - query.trim() ? ( + query.trim() && + !search.loading && + !search.error ? (
{t("search.empty")}
) : null}
diff --git a/apps/desktop/src/components/SearchHighlight.tsx b/apps/desktop/src/components/SearchHighlight.tsx new file mode 100644 index 0000000000..fd77a5385f --- /dev/null +++ b/apps/desktop/src/components/SearchHighlight.tsx @@ -0,0 +1,22 @@ +import { Fragment } from "react"; +import { searchMatchRanges } from "../lib/session-search"; + +export function SearchHighlight({ text, query }: { text: string; query: string }) { + const ranges = searchMatchRanges(text, query); + let end = 0; + return ( + <> + {ranges.map(([start, next]) => { + const before = text.slice(end, start); + end = next; + return ( + + {before} + {text.slice(start, next)} + + ); + })} + {text.slice(end)} + + ); +} diff --git a/apps/desktop/src/components/SearchSessionResults.tsx b/apps/desktop/src/components/SearchSessionResults.tsx new file mode 100644 index 0000000000..f0d8f8c4b0 --- /dev/null +++ b/apps/desktop/src/components/SearchSessionResults.tsx @@ -0,0 +1,109 @@ +import { useTranslation } from "react-i18next"; +import type { SessionSearchHit, SessionSummary } from "@pi-desktop/shared"; +import { IconChat } from "./icons"; +import { SearchHighlight } from "./SearchHighlight"; + +export type SearchRow = { + session: SessionSummary; + hit?: SessionSearchHit; + archived: boolean; + projectLabel: string; + optionIndex: number; +}; + +export function SearchSessionResults({ + groups, + query, + active, + runningSessions, + onActivate, + onSelect, +}: { + groups: { key: string; rows: SearchRow[] }[]; + query: string; + active: number; + runningSessions: Record; + onActivate: (index: number) => void; + onSelect: (row: SearchRow, messageId?: string) => void; +}) { + const { t } = useTranslation(); + return ( + <> + {groups.map((group) => ( +
+
+ {t(`search.${group.key}`)} +
+ {group.rows.map((row) => ( +
+ + {row.hit?.matches.map((match, index) => { + const optionIndex = row.optionIndex + index + 1; + return ( + + ); + })} +
+ ))} +
+ ))} + + ); +} diff --git a/apps/desktop/src/components/SessionSearchContext.tsx b/apps/desktop/src/components/SessionSearchContext.tsx new file mode 100644 index 0000000000..189793c264 --- /dev/null +++ b/apps/desktop/src/components/SessionSearchContext.tsx @@ -0,0 +1,163 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { + SessionSearchContext as Context, + SessionSearchContextRequest, +} from "@pi-desktop/shared"; +import { api } from "../lib/api"; +import { SearchHighlight } from "./SearchHighlight"; + +/** A bounded historical reader; it never writes to the live transcript store. */ +export function SessionSearchContext({ + focus, + onClose, +}: { + focus: SessionSearchContextRequest; + onClose: () => void; +}) { + const { t } = useTranslation(); + const [request, setRequest] = useState(focus); + const [context, setContext] = useState(); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); + const target = useRef(null); + const scroller = useRef(null); + const close = useRef(null); + + useEffect(() => { + close.current?.focus(); + }, []); + useEffect(() => { + let cancelled = false; + setLoading(true); + setFailed(false); + void api + .getSearchContext(request) + .then((result) => { + if (!cancelled) setContext(result); + }) + .catch(() => { + if (!cancelled) setFailed(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [request]); + useEffect(() => { + if (!context) return; + if (target.current) target.current.scrollIntoView({ block: "center" }); + else scroller.current?.scrollTo({ top: 0 }); + }, [context]); + + const page = (direction: "before" | "after") => { + const anchor = + direction === "before" ? context?.messages[0] : context?.messages.at(-1); + if (anchor) setRequest({ ...focus, messageId: anchor.id, direction }); + }; + + return ( +
+
+ {t("search.messageContext")} + +
+
+ + +
+
+ {failed ? ( +
+ {t("search.contextUnavailable")} + +
+ ) : null} + {loading ? ( +
+ {t("search.loading")} +
+ ) : null} + {context?.hasMoreBefore ? ( + + ) : null} + {context?.messages.map((message) => ( +
+
+ + {message.role === "user" + ? t("search.user") + : message.role === "assistant" + ? t("search.assistant") + : message.toolName || message.role} + + +
+
+ +
+
+ ))} + {context?.hasMoreAfter ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/desktop/src/hooks/use-session-search.ts b/apps/desktop/src/hooks/use-session-search.ts new file mode 100644 index 0000000000..6eada2face --- /dev/null +++ b/apps/desktop/src/hooks/use-session-search.ts @@ -0,0 +1,41 @@ +import { useEffect, useMemo, useSyncExternalStore } from "react"; +import { create } from "zustand"; +import type { SessionSearchContextRequest } from "@pi-desktop/shared"; +import { api } from "../lib/api"; +import { SessionSearchController } from "../lib/session-search"; + +/** Transient search state survives closing the palette, never goes to disk. */ +export const useSessionSearchState = create<{ + query: string; + focus?: SessionSearchContextRequest; + setQuery: (query: string) => void; + setFocus: (focus?: SessionSearchContextRequest) => void; +}>((set) => ({ + query: "", + setQuery: (query) => set({ query }), + setFocus: (focus) => set({ focus }), +})); + +export function useSessionSearch(open: boolean, query: string) { + const controller = useMemo(() => new SessionSearchController(api.searchSessions), []); + const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot); + const normalized = query.trim(); + useEffect(() => { + if (!open) return; + controller.reset(normalized); + const timer = window.setTimeout(() => void controller.load(), 150); + return () => { + window.clearTimeout(timer); + controller.cancel(); + }; + }, [controller, normalized, open]); + return { + ...state, + hits: state.query === normalized ? state.hits : [], + nextOffset: state.query === normalized ? state.nextOffset : null, + error: state.query === normalized ? state.error : undefined, + loading: Boolean(normalized) && (state.query !== normalized || state.loading), + loadMore: controller.loadMore, + retry: controller.retry, + }; +} diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 889410f491..88e0e54818 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -63,6 +63,9 @@ import type { ProviderUpdateInput, Result, SessionDetail, + SessionSearchPage, + SessionSearchContext, + SessionSearchContextRequest, SessionSummary, ToolPermissionResolution, UserSkillInput, @@ -334,6 +337,10 @@ export const api = { title, throughMessageId, }).then((result) => ({ ...result, session: normalizeSessionDetail(result.session)! })), + searchSessions: (query: string, offset = 0) => + invoke(IPC.invoke.sessionSearch, { query, offset }), + getSearchContext: (request: SessionSearchContextRequest) => + invoke(IPC.invoke.sessionSearchContext, request), getSession: (id: string, options?: SessionHistoryReadOptions) => invoke<{ session: SessionDetail | null }>(IPC.invoke.sessionGet, { id, diff --git a/apps/desktop/src/lib/session-search.ts b/apps/desktop/src/lib/session-search.ts new file mode 100644 index 0000000000..62a59c0425 --- /dev/null +++ b/apps/desktop/src/lib/session-search.ts @@ -0,0 +1,111 @@ +import type { SessionSearchPage } from "@pi-desktop/shared"; + +/** Match the host's Unicode lowercase literal search. */ +export function foldSearchText(text: string): string { + return text.toLowerCase(); +} + +export function searchMatchRanges(text: string, query: string): [number, number][] { + const needle = foldSearchText(query.trim()); + if (!needle) return []; + const folded = foldSearchText(text); + // Lowercase can expand one character (İ -> i + combining dot). Map only + // when needed, so ordinary ASCII/CJK highlighting has no allocation per char. + const starts: number[] = []; + const ends: number[] = []; + if (folded.length !== text.length) { + let original = 0; + for (const character of text) { + const end = original + character.length; + for (let index = 0; index < character.toLowerCase().length; index += 1) { + starts.push(original); + ends.push(end); + } + original = end; + } + } + const ranges: [number, number][] = []; + let from = 0; + while (from < folded.length) { + const index = folded.indexOf(needle, from); + if (index < 0) break; + const start = starts.length ? starts[index] : index; + const end = ends.length ? ends[index + needle.length - 1] : index + needle.length; + const previous = ranges.at(-1); + if (previous && start < previous[1]) previous[1] = end; + else ranges.push([start, end]); + from = index + needle.length; + } + return ranges; +} + +type SearchState = SessionSearchPage & { + query: string; + loading: boolean; + error?: string; +}; + +/** Async ownership belongs to the query, including subsequent result pages. */ +export class SessionSearchController { + private generation = 0; + private listeners = new Set<() => void>(); + private state: SearchState = { query: "", hits: [], nextOffset: null, loading: false }; + + private fetchPage: (query: string, offset: number) => Promise; + constructor(fetchPage: (query: string, offset: number) => Promise) { + this.fetchPage = fetchPage; + } + + getSnapshot = () => this.state; + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + private publish(state: SearchState) { + this.state = state; + for (const listener of this.listeners) listener(); + } + cancel() { + this.generation += 1; + this.publish({ ...this.state, loading: false }); + } + reset(query: string) { + this.generation += 1; + this.publish({ query, hits: [], nextOffset: null, loading: Boolean(query) }); + } + async load(offset = 0) { + const { query } = this.state; + if (!query) return; + const generation = this.generation; + this.publish({ ...this.state, loading: true, error: undefined }); + try { + const page = await this.fetchPage(query, offset); + if (generation !== this.generation) return; + const previous = offset ? this.state.hits : []; + const known = new Set(previous.map((hit) => hit.session.id)); + this.publish({ + query, + loading: false, + nextOffset: page.nextOffset, + hits: [...previous, ...page.hits.filter((hit) => !known.has(hit.session.id))], + }); + } catch (error) { + if (generation !== this.generation) return; + this.publish({ + ...this.state, + loading: false, + error: error instanceof Error ? error.message : String(error), + }); + } + } + retry = () => { + if (!this.state.loading) + void this.load(this.state.hits.length ? (this.state.nextOffset ?? 0) : 0); + }; + loadMore = () => { + if (!this.state.loading && this.state.nextOffset !== null) + void this.load(this.state.nextOffset); + }; +} diff --git a/apps/desktop/src/styles/globals.css b/apps/desktop/src/styles/globals.css index 96ea30b75c..f26469bf10 100644 --- a/apps/desktop/src/styles/globals.css +++ b/apps/desktop/src/styles/globals.css @@ -20,6 +20,7 @@ @import "./prose.css"; @import "./ui-kit.css"; @import "./overlays.css"; +@import "./session-search.css"; @import "./theme-overrides.css"; @import "./composer-menus.css"; @import "./settings.css"; diff --git a/apps/desktop/src/styles/session-search.css b/apps/desktop/src/styles/session-search.css new file mode 100644 index 0000000000..57e2ebe716 --- /dev/null +++ b/apps/desktop/src/styles/session-search.css @@ -0,0 +1,97 @@ +.search-message-hit { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + padding-left: 36px; +} + +.search-message-meta, +.search-context-meta { + color: var(--ds-text-muted); + font-size: var(--text-xs); +} + +.search-message-snippet { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + white-space: pre-wrap; + overflow-wrap: anywhere; + text-align: left; +} + +.session-search-context { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + background: var(--ds-bg-primary); +} + +.search-context-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 24px; + border-bottom: 1px solid var(--ds-border-default); +} + +.search-context-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 20px max(24px, calc((100% - 800px) / 2)) 160px; +} + +.search-context-message { + margin: 16px 0; + padding: 16px; + border: 1px solid var(--ds-border-default); + border-radius: var(--radius-md); +} + +.search-context-target { + border-color: var(--ds-accent); + background: var(--ds-bg-hover); +} + +.search-context-meta { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.search-context-text { + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: var(--leading-relaxed); +} + +.search-context-match-nav { + display: flex; + gap: 8px; + padding: 8px 24px; +} + +.search-session-details { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 4px; +} + +.search-session-meta { + max-width: 100%; + flex-wrap: wrap; +} + +.search-context-text .search-hit, +.search-message-snippet .search-hit { + border-radius: var(--radius-sm); + background: color-mix(in oklab, var(--ds-accent) 12%, transparent); +} diff --git a/apps/desktop/test/session-content-search.test.mjs b/apps/desktop/test/session-content-search.test.mjs new file mode 100644 index 0000000000..f7a0ad88ca --- /dev/null +++ b/apps/desktop/test/session-content-search.test.mjs @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SessionSearchController, searchMatchRanges } from "../src/lib/session-search.ts"; + +function deferred() { + let resolve; + let reject; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +function hit(id) { + return { session: { id }, messageCount: 1, matches: [] }; +} + +test("literal highlighting agrees with host Unicode folding and preserves UTF-16 offsets", () => { + for (const [text, query, expected] of [ + [ + "😀 中文 中文", + "中文", + [ + [3, 5], + [6, 8], + ], + ], + [ + "a MiXeD mixed", + "mixed", + [ + [2, 7], + [8, 13], + ], + ], + ["100% a_b C:\\path", "%", [[3, 4]]], + [ + "Ä ä", + "ä", + [ + [0, 1], + [2, 3], + ], + ], + ["x", " ", []], + ["中", "中", [[0, 1]]], + ["İ prefix 中文", "中文", [[9, 11]]], + ["İ XX", "xx", [[2, 4]]], + ["İ", "\u0307", [[0, 1]]], + ["WÖRTER", "wörter", [[0, 6]]], + ]) + assert.deepEqual(searchMatchRanges(text, query), expected); +}); + +test("older queries and closed palettes cannot publish asynchronous results", async () => { + const old = deferred(); + const current = deferred(); + const controller = new SessionSearchController((query) => + query === "old" ? old.promise : current.promise, + ); + controller.reset("old"); + const a = controller.load(); + controller.reset("new"); + const b = controller.load(); + current.resolve({ hits: [hit("new")], nextOffset: null }); + await b; + old.resolve({ hits: [hit("old")], nextOffset: 30 }); + await a; + assert.deepEqual( + controller.getSnapshot().hits.map((entry) => entry.session.id), + ["new"], + ); + + const closing = deferred(); + const closed = new SessionSearchController(() => closing.promise); + closed.reset("closing"); + const load = closed.load(); + closed.cancel(); + closing.resolve({ hits: [hit("late")], nextOffset: null }); + await load; + assert.deepEqual(closed.getSnapshot().hits, []); + assert.equal(closed.getSnapshot().loading, false); +}); + +test("pagination appends sessions once and its stale errors do not replace the next query", async () => { + const later = deferred(); + const controller = new SessionSearchController(async (query, offset) => { + if (query === "new") return { hits: [hit("new")], nextOffset: null }; + if (!offset) return { hits: [hit("one")], nextOffset: 30 }; + return later.promise; + }); + controller.reset("first"); + await controller.load(); + const page = controller.load(30); + later.resolve({ hits: [hit("one"), hit("two")], nextOffset: 60 }); + await page; + assert.deepEqual( + controller.getSnapshot().hits.map((entry) => entry.session.id), + ["one", "two"], + ); + assert.equal(controller.getSnapshot().nextOffset, 60); + + const failure = deferred(); + const rejecting = new SessionSearchController((query) => + query === "old" + ? failure.promise + : Promise.resolve({ hits: [hit("new")], nextOffset: null }), + ); + rejecting.reset("old"); + const old = rejecting.load(); + rejecting.reset("new"); + await rejecting.load(); + failure.reject(new Error("stale")); + await old; + assert.equal(rejecting.getSnapshot().error, undefined); + assert.equal(rejecting.getSnapshot().hits[0].session.id, "new"); +}); + +test("a failed request is observable and can be retried", async () => { + let tries = 0; + const controller = new SessionSearchController(async () => { + if (!tries++) throw new Error("offline"); + return { hits: [hit("recovered")], nextOffset: null }; + }); + controller.reset("query"); + await controller.load(); + assert.equal(controller.getSnapshot().error, "offline"); + await controller.load(); + assert.equal(controller.getSnapshot().error, undefined); + assert.equal(controller.getSnapshot().hits.length, 1); +}); diff --git a/apps/desktop/test/session-search-rendering.test.mjs b/apps/desktop/test/session-search-rendering.test.mjs new file mode 100644 index 0000000000..cc07486cfb --- /dev/null +++ b/apps/desktop/test/session-search-rendering.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { createInstance } from "i18next"; +import { I18nextProvider } from "react-i18next"; +import { catalogs } from "@pi-desktop/i18n"; +import { createServer } from "vite"; +import { fileURLToPath } from "node:url"; + +test("session result rendering keeps literal snippets safe and selectable in every locale", async () => { + const server = await createServer({ + root: fileURLToPath(new URL("..", import.meta.url)), + configFile: false, + server: { middlewareMode: true }, + esbuild: { jsx: "automatic" }, + appType: "custom", + optimizeDeps: { noDiscovery: true, include: [] }, + }); + try { + const { SearchSessionResults } = await server.ssrLoadModule( + "/src/components/SearchSessionResults.tsx", + ); + for (const [locale, catalog] of Object.entries(catalogs)) { + const i18n = createInstance(); + await i18n.init({ lng: locale, resources: { [locale]: { translation: catalog } } }); + const html = renderToStaticMarkup( + createElement( + I18nextProvider, + { i18n }, + createElement(SearchSessionResults, { + query: "needle", + active: 2, + runningSessions: {}, + onActivate() {}, + onSelect() {}, + groups: [ + { + key: "today", + rows: [ + { + session: { id: "one", title: "Investigation" }, + optionIndex: 1, + projectLabel: "demo", + archived: true, + hit: { + metadataMatch: false, + messageCount: 125, + matches: [ + { + messageId: "u", + role: "user", + createdAt: "2026-09-13T00:00:00Z", + snippet: " needle", + }, + { + messageId: "a", + role: "assistant", + createdAt: "2026-09-13T00:01:00Z", + snippet: "The NEEDLE is here", + }, + ], + }, + }, + ], + }, + ], + }), + ), + ); + assert.equal((html.match(/role="option"/g) ?? []).length, 3, locale); + assert.match(html, /id="global-search-option-2"[^>]*aria-selected="true"/); + assert.match(html, /<img src=x>/); + assert.doesNotMatch(html, /needle<\/mark>/); + assert.match(html, /NEEDLE<\/mark>/); + assert.ok(html.includes("125"), locale); + assert.ok(html.includes(catalog.search.user), locale); + assert.ok(html.includes(catalog.search.assistant), locale); + assert.ok(html.includes(catalog.search.archived), locale); + } + } finally { + await server.close(); + } +}); diff --git a/apps/desktop/test/session-switch-performance.test.mjs b/apps/desktop/test/session-switch-performance.test.mjs index aa921cb151..c884b1b6e5 100644 --- a/apps/desktop/test/session-switch-performance.test.mjs +++ b/apps/desktop/test/session-switch-performance.test.mjs @@ -117,7 +117,7 @@ test("each retained session keeps its own mounted pane", () => { // already-painted pane instead of re-pointing one transcript (ADR 0137). assert.match( chatSurface, - /retainedSessionIds\.map\(\(id\) => \(\s*/, + /retainedSessionIds\.map\(\(id\) => \(\s*/, ); assert.match(chatSurface, /const visibleSessionId = retainedSessionIds\[0\]/); // The retention bound lives in a pure module, so eviction is unit-testable diff --git a/crates/host-core/Cargo.toml b/crates/host-core/Cargo.toml index e71fd24f19..d7ab09c00a 100644 --- a/crates/host-core/Cargo.toml +++ b/crates/host-core/Cargo.toml @@ -18,7 +18,7 @@ globset = "0.4" hex = "0.4" ignore = "0.4" regex = "1" -rusqlite = { version = "0.40", features = ["bundled"] } +rusqlite = { version = "0.40", features = ["bundled", "functions"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.11" diff --git a/crates/host-core/src/main.rs b/crates/host-core/src/main.rs index c458d5daaf..59f215aff7 100644 --- a/crates/host-core/src/main.rs +++ b/crates/host-core/src/main.rs @@ -17,6 +17,7 @@ mod rpc; mod scheduled; mod scratch; mod secrets; +mod session_search; mod sessions; mod state; mod tool_budget; diff --git a/crates/host-core/src/rpc/mod.rs b/crates/host-core/src/rpc/mod.rs index 5f11b6c3cf..8a9a71293d 100644 --- a/crates/host-core/src/rpc/mod.rs +++ b/crates/host-core/src/rpc/mod.rs @@ -2181,6 +2181,33 @@ async fn handle_request( Ok(json!({ "ok": true })) } + "search.sessions" => { + let query = params.get("query").and_then(|v| v.as_str()).unwrap_or(""); + if query.chars().count() > 500 { + return Err(rpc_err(1001, "query exceeds 500 characters", "INVALID_ARGUMENT")); + } + let offset = params.get("offset").and_then(|v| v.as_i64()).unwrap_or(0); + let st = state.lock().await; + let page = crate::session_search::search(&st.db, query, offset) + .map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL"))?; + Ok(json!(page)) + } + "search.context" => { + let session_id = params.get("sessionId").and_then(|v| v.as_str()).unwrap_or(""); + let message_id = params.get("messageId").and_then(|v| v.as_str()).unwrap_or(""); + let query = params.get("query").and_then(|v| v.as_str()).unwrap_or(""); + let direction = params.get("direction").and_then(|v| v.as_str()).unwrap_or("around"); + if session_id.is_empty() || message_id.is_empty() || query.chars().count() > 500 + || !matches!(direction, "around" | "before" | "after") { + return Err(rpc_err(1001, "invalid search context", "INVALID_ARGUMENT")); + } + let st = state.lock().await; + let context = crate::session_search::context(&st.db, session_id, message_id, direction, query) + .map_err(|e| rpc_err(1000, e.to_string(), "INTERNAL"))? + .ok_or_else(|| rpc_err(1007, "message not found", "NOT_FOUND"))?; + Ok(json!(context)) + } + "search.query" => { let query = params .get("query") diff --git a/crates/host-core/src/session_search.rs b/crates/host-core/src/session_search.rs new file mode 100644 index 0000000000..ce0f25f13b --- /dev/null +++ b/crates/host-core/src/session_search.rs @@ -0,0 +1,472 @@ +//! Desktop search projections. SQLite owns discovery; JSONL owns message content. + +use anyhow::Result; +use rusqlite::{functions::FunctionFlags, params, OptionalExtension}; +use serde::Serialize; + +use crate::db::{ms_to_ts, Database}; +use crate::sessions::{self, SessionSummary}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MessageMatch { + pub message_id: String, + pub role: String, + pub created_at: String, + pub snippet: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionMatch { + pub session: SessionSummary, + pub project_name: Option, + pub metadata_match: bool, + pub message_count: i64, + pub matches: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchPage { + pub hits: Vec, + pub next_offset: Option, +} + +/// Unicode lowercase is shared with the renderer. A scalar function keeps +/// short and non-ASCII queries literal without relying on SQLite's ASCII LIKE. +fn register_contains(db: &Database) -> Result<()> { + db.conn().create_scalar_function( + "pi_search_contains", + 2, + FunctionFlags::SQLITE_UTF8 + | FunctionFlags::SQLITE_DETERMINISTIC + | FunctionFlags::SQLITE_INNOCUOUS, + |ctx| { + let text = ctx.get::>(0)?.unwrap_or_default(); + let query = ctx.get::(1)?; + Ok(text.to_lowercase().contains(&query.to_lowercase())) + }, + )?; + Ok(()) +} + +/// FTS is a prefilter for ASCII and uncased text such as CJK. Non-ASCII +/// case mappings use a literal scan to avoid the FTS tokenizer's older Unicode +/// tables dropping characters handled by the host/renderer lowercase rules. +pub fn search(db: &Database, query: &str, offset: i64) -> Result { + let query = query.trim(); + if query.is_empty() { + return Ok(SearchPage { + hits: vec![], + next_offset: None, + }); + } + let offset = offset.max(0); + register_contains(db)?; + let quoted = format!("\"{}\"", query.replace('"', "\"\"")); + let fts = if query.chars().count() >= 3 + && !query.contains('\u{0307}') + && query + .chars() + .all(|ch| ch.is_ascii() || (!ch.is_lowercase() && !ch.is_uppercase())) + { + "AND m.mid IN (SELECT rowid FROM messages_fts WHERE messages_fts MATCH ?2)" + } else { + "AND ?2 IS NOT NULL" + }; + let sql = format!( + "WITH matched AS ( + SELECT m.session_id, COUNT(*) AS count FROM messages m + WHERE m.role IN ('user', 'assistant') AND pi_search_contains(m.text, ?1) {fts} + GROUP BY m.session_id + ) + SELECT s.id, s.title, s.last_seq, p.path, s.model_id, s.provider_id, s.mode, + s.thinking_level, s.permission_mode, s.updated_at, s.created_at, + p.name, COALESCE(matched.count, 0), + (pi_search_contains(s.title, ?1) OR pi_search_contains(p.name, ?1) + OR pi_search_contains(p.path, ?1)) AS metadata_match + FROM sessions s LEFT JOIN projects p ON p.id = s.project_id + LEFT JOIN matched ON matched.session_id = s.id + WHERE s.deleted_at IS NULL AND (matched.count > 0 OR metadata_match) + ORDER BY s.updated_at DESC, s.id ASC LIMIT 31 OFFSET ?3" + ); + let mut hits = db + .conn() + .prepare_cached(&sql)? + .query_map(params![query, quoted, offset], |row| { + Ok(SessionMatch { + session: sessions::summary_from_row(row)?, + project_name: row.get(11)?, + message_count: row.get(12)?, + metadata_match: row.get(13)?, + matches: vec![], + }) + })? + .collect::>>()?; + let next_offset = (hits.len() > 30).then(|| offset.saturating_add(30)); + hits.truncate(30); + let mut snippets = db.conn().prepare_cached( + "SELECT id, role, created_at, text FROM messages + WHERE session_id = ?1 AND role IN ('user', 'assistant') AND pi_search_contains(text, ?2) + ORDER BY created_at DESC, seq DESC, id ASC LIMIT 2", + )?; + for hit in &mut hits { + if hit.message_count == 0 { + continue; + } + hit.matches = snippets + .query_map(params![hit.session.id, query], |row| { + Ok(MessageMatch { + message_id: row.get(0)?, + role: row.get(1)?, + created_at: ms_to_ts(row.get(2)?), + snippet: excerpt(&row.get::<_, String>(3)?, query, 180), + }) + })? + .collect::>>()?; + } + Ok(SearchPage { hits, next_offset }) +} + +/// Center a bounded excerpt on the literal match, mapping lowercase expansion +/// offsets (for example Turkish dotted I) back to original Unicode characters. +pub fn excerpt(text: &str, query: &str, budget: usize) -> String { + let needle = query.trim().to_lowercase(); + let mut position = 0; + let mut match_end = 0; + if !needle.is_empty() { + if let Some(hit) = text.to_lowercase().find(&needle) { + let mut folded_offset = 0; + let mut found_start = false; + for (index, ch) in text.chars().enumerate() { + let next = folded_offset + ch.to_lowercase().map(char::len_utf8).sum::(); + if !found_start && next > hit { + position = index; + found_start = true; + } + if next >= hit + needle.len() { + match_end = index + 1; + break; + } + folded_offset = next; + } + } + } + let total = text.chars().count(); + let start = position.saturating_sub(budget / 3); + let end = (start + budget).max(match_end).min(total); + format!( + "{}{}{}", + if start > 0 { "…" } else { "" }, + text.chars() + .skip(start) + .take(end - start) + .collect::(), + if end < total { "…" } else { "" } + ) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ContextMessage { + pub id: String, + pub role: String, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + pub content: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchContext { + pub messages: Vec, + pub has_more_before: bool, + pub has_more_after: bool, + pub previous_match_id: Option, + pub next_match_id: Option, +} + +/// Keep historical search windows out of the live transcript cache. Resolve +/// stable message IDs against physical JSONL positions, never SQLite seq. +pub fn context( + db: &Database, + session_id: &str, + message_id: &str, + direction: &str, + query: &str, +) -> Result> { + register_contains(db)?; + let exists = db + .conn() + .query_row( + "SELECT 1 FROM sessions s JOIN messages m ON m.session_id = s.id + WHERE s.id = ?1 AND m.id = ?2 AND s.deleted_at IS NULL", + params![session_id, message_id], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !exists { + return Ok(None); + } + let layout = sessions::session_layout(db, session_id)?; + let Some(position) = + crate::transcripts::find_message_position(db.data_dir(), session_id, &layout, message_id)? + else { + return Ok(None); + }; + let total = layout.message_count(); + let (start, end) = match direction { + "before" => (position.saturating_sub(20), position), + "after" => ((position + 1).min(total), (position + 21).min(total)), + _ => (position.saturating_sub(10), (position + 11).min(total)), + }; + let read = crate::transcripts::read_transcript_window_with_layout( + db.data_dir(), + session_id, + &layout, + start, + Some(end - start), + )?; + let messages = sessions::dedupe_records(read.messages) + .into_iter() + .map(|record| { + let text = sessions::record_index_text(&record).unwrap_or_default(); + let content = excerpt( + &text, + if record.id == message_id && direction == "around" { + query + } else { + "" + }, + 64 * 1024, + ); + ContextMessage { + id: record.id, + role: record.role, + created_at: record.created_at, + tool_name: record.tool_name, + content, + } + }) + .collect(); + let adjacent_match = |comparison: &str, order: &str| -> Result> { + if query.trim().is_empty() { + return Ok(None); + } + Ok(db + .conn() + .query_row( + &format!( + "SELECT id FROM messages WHERE session_id = ?1 + AND role IN ('user', 'assistant') AND pi_search_contains(text, ?2) + AND seq {comparison} (SELECT seq FROM messages WHERE id = ?3) + ORDER BY seq {order} LIMIT 1" + ), + params![session_id, query.trim(), message_id], + |row| row.get(0), + ) + .optional()?) + }; + Ok(Some(SearchContext { + messages, + has_more_before: start > 0, + has_more_after: end < total, + previous_match_id: adjacent_match("<", "DESC")?, + next_match_id: adjacent_match(">", "ASC")?, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sessions::UiMessage; + use serde_json::json; + + fn message(id: &str, text: &str) -> UiMessage { + serde_json::from_value(json!({ "id": id, "role": "user", "content": text, + "createdAt": "2026-09-13T00:00:00Z" })) + .unwrap() + } + + #[test] + fn search_counts_all_matches_and_pages_all_sessions() { + let dir = tempfile::tempdir().unwrap(); + let db = Database::open(&dir.path().join("test.sqlite")).unwrap(); + let first = sessions::create_session(&db, None, None, None, None, None).unwrap(); + for n in 0..125 { + sessions::append_message(&db, &first.id, &message(&format!("m{n}"), "needle"), None) + .unwrap(); + } + for n in 0..64 { + let session = sessions::create_session(&db, None, None, None, None, None).unwrap(); + sessions::append_message( + &db, + &session.id, + &message(&format!("other{n}"), "needle"), + None, + ) + .unwrap(); + } + let mut ids = std::collections::HashSet::new(); + let mut offset = 0; + loop { + let page = search(&db, "needle", offset).unwrap(); + for hit in page.hits { + assert!(ids.insert(hit.session.id.clone())); + if hit.session.id == first.id { + assert_eq!(hit.message_count, 125); + assert_eq!(hit.matches.len(), 2); + } + } + match page.next_offset { + Some(next) => offset = next, + None => break, + } + } + assert_eq!(ids.len(), 65); + assert!(search(&db, "needle", i64::MAX).unwrap().hits.is_empty()); + } + + #[test] + fn search_literals_snippets_roles_and_deleted_sessions() { + let dir = tempfile::tempdir().unwrap(); + let db = Database::open(&dir.path().join("test.sqlite")).unwrap(); + let session = sessions::create_session(&db, None, None, None, None, None).unwrap(); + let text = format!( + "{} 中文 短 100% a_b C:\\path \"quote\" MiXeD WÖRTER İxx Key", + "prefix ".repeat(100) + ); + sessions::append_message(&db, &session.id, &message("one", &text), None).unwrap(); + for query in [ + "中文", + "短", + "%", + "a_b", + "C:\\path", + "\"quote\"", + "mixed", + "wörter", + "i\u{0307}xx", + "key", + ] { + let page = search(&db, query, 0).unwrap(); + assert_eq!(page.hits.len(), 1, "query: {query}"); + assert!(page.hits[0].matches[0] + .snippet + .to_lowercase() + .contains(&query.to_lowercase())); + } + let mut excluded = message("system", "exclusive"); + excluded.role = "system".into(); + sessions::append_message(&db, &session.id, &excluded, None).unwrap(); + assert!(search(&db, "exclusive", 0).unwrap().hits.is_empty()); + let mut assistant = message("assistant", "assistant-only"); + assistant.role = "assistant".into(); + assistant.thinking = Some("private-reasoning".into()); + sessions::append_message(&db, &session.id, &assistant, None).unwrap(); + assert_eq!( + search(&db, "assistant-only", 0).unwrap().hits[0].matches[0].role, + "assistant" + ); + assert!(search(&db, "private-reasoning", 0).unwrap().hits.is_empty()); + db.conn() + .execute( + "UPDATE sessions SET title = 'metadata-only' WHERE id = ?1", + params![session.id], + ) + .unwrap(); + let metadata = search(&db, "metadata-only", 0).unwrap(); + assert!(metadata.hits[0].metadata_match); + assert_eq!(metadata.hits[0].message_count, 0); + assert_eq!( + search(&db, "assistant-only", 0).unwrap().hits[0].message_count, + 1 + ); + db.conn() + .execute( + "UPDATE sessions SET deleted_at = 1 WHERE id = ?1", + params![session.id], + ) + .unwrap(); + assert!(search(&db, "中文", 0).unwrap().hits.is_empty()); + assert!(context(&db, &session.id, "one", "around", "中文") + .unwrap() + .is_none()); + } + + #[test] + fn context_uses_physical_positions_and_finds_matches_beyond_display_cap() { + let dir = tempfile::tempdir().unwrap(); + let db = Database::open(&dir.path().join("test.sqlite")).unwrap(); + let session = sessions::create_session(&db, None, None, None, None, None).unwrap(); + for n in 0..150 { + let msg = message(&format!("m{n}"), "context"); + sessions::append_message(&db, &session.id, &msg, None).unwrap(); + if n < 30 { + sessions::append_message(&db, &session.id, &msg, None).unwrap(); + } + } + let target = message("target", &format!("{}命中内容", "x".repeat(100_000))); + sessions::append_message(&db, &session.id, &target, None).unwrap(); + let page = context(&db, &session.id, "m50", "around", "context") + .unwrap() + .unwrap(); + assert!(page.messages.iter().any(|m| m.id == "m50")); + assert!(page.messages.len() <= 21); + assert!(page.has_more_before && page.has_more_after); + assert_eq!(page.previous_match_id.as_deref(), Some("m49")); + assert_eq!(page.next_match_id.as_deref(), Some("m51")); + let next = context( + &db, + &session.id, + &page.messages.last().unwrap().id, + "after", + "context", + ) + .unwrap() + .unwrap(); + assert_eq!(next.messages.first().unwrap().id, "m61"); + let last = context(&db, &session.id, "target", "around", "命中") + .unwrap() + .unwrap(); + let found = last.messages.iter().find(|m| m.id == "target").unwrap(); + assert!(found.content.contains("命中")); + assert!(found.content.chars().count() <= 65538); + assert!(!last.has_more_after); + assert!(context(&db, &session.id, "missing", "around", "") + .unwrap() + .is_none()); + db.conn() + .execute( + "UPDATE messages SET text = 'stale index' WHERE id = 'target'", + [], + ) + .unwrap(); + let canonical = context(&db, &session.id, "target", "around", "命中") + .unwrap() + .unwrap(); + assert!(canonical + .messages + .iter() + .find(|m| m.id == "target") + .unwrap() + .content + .contains("命中")); + sessions::replace_messages(&db, &session.id, &[message("replacement", "other")]).unwrap(); + assert!(context(&db, &session.id, "target", "around", "命中") + .unwrap() + .is_none()); + } + + #[test] + fn excerpts_include_the_whole_query_when_it_exceeds_the_preview_budget() { + let shifted = format!("{}命中", "İ".repeat(300)); + assert!(excerpt(&shifted, "命中", 180).contains("命中")); + let query = "中".repeat(300); + let text = format!("{}{} tail", "prefix ".repeat(50), query); + assert!(excerpt(&text, &query, 180).contains(&query)); + } +} diff --git a/crates/host-core/src/sessions.rs b/crates/host-core/src/sessions.rs index 8628106a2e..660cb1b026 100644 --- a/crates/host-core/src/sessions.rs +++ b/crates/host-core/src/sessions.rs @@ -699,7 +699,7 @@ fn record_to_ui_for_display(mut record: MessageRecord, limit: usize) -> UiMessag message } -fn dedupe_records(records: Vec) -> Vec { +pub(crate) fn dedupe_records(records: Vec) -> Vec { let mut ordered: Vec = Vec::with_capacity(records.len()); let mut by_id: std::collections::HashMap = std::collections::HashMap::with_capacity(records.len()); @@ -715,7 +715,7 @@ fn dedupe_records(records: Vec) -> Vec { ordered } -fn record_index_text(record: &MessageRecord) -> Option { +pub(crate) fn record_index_text(record: &MessageRecord) -> Option { if record.role == "tool" { return None; } @@ -1040,7 +1040,7 @@ const SUMMARY_SELECT: &str = FROM sessions s LEFT JOIN projects p ON p.id = s.project_id WHERE s.deleted_at IS NULL"; -fn summary_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { +pub(crate) fn summary_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(SessionSummary { id: row.get(0)?, title: row.get(1)?, @@ -1256,7 +1256,7 @@ fn layout_cache() -> &'static Mutex Result { +pub(crate) fn session_layout(db: &Database, session_id: &str) -> Result { let cached = layout_cache() .lock() .ok() diff --git a/crates/host-core/src/transcripts.rs b/crates/host-core/src/transcripts.rs index f8616e7e8c..4deb020ccc 100644 --- a/crates/host-core/src/transcripts.rs +++ b/crates/host-core/src/transcripts.rs @@ -132,6 +132,33 @@ impl TranscriptLayout { } } +/// Resolve a stable ID without materializing historical message bodies. The +/// reverse scan agrees with last-write-wins transcript deduplication. +pub fn find_message_position( + data_dir: &Path, + session_id: &str, + layout: &TranscriptLayout, + message_id: &str, +) -> Result> { + #[derive(Deserialize)] + struct Identity { + id: String, + } + let mut reader = BufReader::new(File::open(transcript_path(data_dir, session_id)?)?); + let mut line = String::new(); + for (position, offset) in layout.message_offsets.iter().enumerate().rev() { + reader.seek(SeekFrom::Start(*offset))?; + line.clear(); + reader.read_line(&mut line)?; + if serde_json::from_str::(&line) + .is_ok_and(|identity| identity.id == message_id) + { + return Ok(Some(position)); + } + } + Ok(None) +} + /// Classify a JSONL line by reading its top-level `type` value. /// /// Deserializing a `LineTag` makes serde walk the entire line -- including a diff --git a/docs/adr/session-content-search.md b/docs/adr/session-content-search.md new file mode 100644 index 0000000000..8bd5e06b84 --- /dev/null +++ b/docs/adr/session-content-search.md @@ -0,0 +1,71 @@ +# ADR session-content-search: Discover sessions by indexed message text + +- Status: Accepted +- Date: 2026-09-13 +- Issue: https://github.com/vastsa/PI-Desktop/issues/270 + +## Context + +Global search filters the renderer's session titles and project labels, even +though host-core already indexes message text. A remembered error or sentence +cannot recover its conversation. Opening a title result also loses the location +of an older matching message. + +## Decision + +Add `search.sessions` and `search.context` host RPCs with allowlisted desktop +`session/search` and `session/searchContext` IPC channels. Keep the legacy +`search.query`, `session.get`, protocol version, and storage schema compatible. +Rust remains the sole SQLite owner; Electron forwards search requests and the +renderer owns presentation and transient query state. + +`search.sessions` searches all non-deleted sessions, combining title/project +metadata with indexed user/assistant text. Trigram FTS supplies candidates for +queries of at least three Unicode characters. A host-owned Unicode literal +predicate verifies those candidates and handles shorter queries. Queries with +non-ASCII case mappings use that predicate directly to avoid tokenizer +Unicode-version gaps. Retrieval and highlighting both use Unicode lowercase +and map expanding case conversions back to original text. Quotes, `%`, `_`, +and backslashes never become operators. Queries are trimmed and bounded to +500 characters. Each page contains 30 sessions ordered by updated time and ID, +full matching-message counts, and at most two recent message excerpts. Offsets +continue the current query; reopening refreshes results against current data. + +Archive preferences stay renderer-owned: empty-query recents hide archived +sessions, while explicit searches retain the existing archived-session discovery +behavior. Tools, thinking, attachments, and discarded revisions do not expand +the searchable body scope. + +Selecting a message opens its owning session and a bounded historical reader +inside that conversation. `search.context` resolves its stable ID against the +physical JSONL layout and returns at most 21 nearby message text projections. +Adjacent context pages contain at most 20 messages. The reader provides previous +and next matching-message navigation, so two previews never cap discovery within +one conversation. Deleted or rewritten-away targets return `NOT_FOUND`. + +JSONL remains authoritative for displayed context. Text is capped at 64 Ki +characters per message, with the target excerpt centered around the query so a +match beyond the usual display cap remains visible. Context uses literal text +instead of executing/rendering Markdown; tool rows identify their tool without +loading their results into this reader. + +Historical search windows never replace, merge into, or persist through the +live transcript cache. Existing retained panes keep their messages and scroll +positions while hidden. The Back to conversation action, composer focus, or +selection of another conversation exits the historical reader. Query text survives closing +the palette in memory. Query changes, palette closure, and context navigation +invalidate asynchronous result ownership. + +## Consequences and validation + +There is no migration or new index to maintain. Short queries and non-ASCII +case mappings still require a literal scan. Resolving an old message may scan IDs in the existing physical +layout, but only the nearby text window crosses IPC or mounts in the renderer. +Search reads cannot rewrite conversation data or interfere with active turns. + +Rust regression tests cover pagination beyond 50 sessions and 100 messages, +literal CJK/symbol queries, complete counts, soft deletion, physical positions, +and target text beyond the display cap. Renderer unit tests cover literal +highlight offsets, stale result/error rejection, cancellation, and pagination. +The documented full interaction scenario is +`E2E-SESSION-content-search-and-message-navigation`. diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md index 355b21afa9..d064db91ae 100644 --- a/docs/spec/03-runtime/01-ipc-protocol.md +++ b/docs/spec/03-runtime/01-ipc-protocol.md @@ -808,6 +808,12 @@ Minimal interface: content limit applies only to display values and never changes the lossless transcript or model context. `messageBefore` and `messageStart` are physical message-line positions in the transcript file, not deduplicated index counts. +- `session/search({ query, offset? }) -> SessionSearchPage` forwards to + `search.sessions`; host-core owns discovery, counts, filtering, and pagination. +- `session/searchContext(SessionSearchContextRequest) -> SessionSearchContext` + forwards to `search.context`. This read-only text window is separate from + `session/get` and must never enter the renderer's live transcript cache. + Both channels are explicitly included in the preload IPC allowlist. - `session/delete` - `session/rename({ id, title }) -> { ok: boolean }` trims the title and accepts 1–80 Unicode code points. Blank or overlong titles are rejected as diff --git a/docs/spec/03-runtime/04-data-storage.md b/docs/spec/03-runtime/04-data-storage.md index d80559c644..39724e5a05 100644 --- a/docs/spec/03-runtime/04-data-storage.md +++ b/docs/spec/03-runtime/04-data-storage.md @@ -696,9 +696,23 @@ to. ### 4.8 messages_fts — full-text search -Global search across transcripts (WorkBuddy-benchmark search, command -palette). Trigram tokenizer covers CJK and substring matches; queries shorter -than 3 chars fall back to `LIKE` on `messages.text`. +The legacy `search.query` message search uses a trigram tokenizer for CJK and +substring matches; queries shorter than 3 chars fall back to `LIKE` on +`messages.text`. The desktop session search below reuses this index with a +Unicode-aware literal verification step. + +Desktop session discovery (`search.sessions`) counts every matching indexed +user/assistant message before paginating by session. It excludes sessions with +`deleted_at` set and treats title/project matches separately from body counts. +FTS queries are quoted literals and all candidates are verified with a +host-owned Unicode lowercase literal predicate. Short queries and non-ASCII +case mappings use that predicate directly, preserving title search behavior +and keeping message retrieval consistent with renderer highlighting. `%`, `_`, quotes, and +backslashes are literal text. Snippets surround the match, including short CJK +queries, rather than always taking the start of the message. Context navigation +resolves stable IDs against physical JSONL positions, and displays canonical +JSONL text without modifying SQLite or the live transcript cache. See +[ADR session-content-search](../../adr/session-content-search.md). ```sql CREATE VIRTUAL TABLE messages_fts USING fts5( diff --git a/docs/spec/03-runtime/06-host-rpc-protocol.md b/docs/spec/03-runtime/06-host-rpc-protocol.md index 863cf92e68..cea612780a 100644 --- a/docs/spec/03-runtime/06-host-rpc-protocol.md +++ b/docs/spec/03-runtime/06-host-rpc-protocol.md @@ -531,8 +531,27 @@ one after the final row would be wrong. activation-scope filtering (`CAPABILITY_INVALID` for an unknown scope). ### Search, artifacts, keyboard -- `search.query` — global search across sessions, projects, and settings - destinations (ADR 0034) +- `search.query` — legacy indexed-message hits; existing response and limit remain compatible +- `search.sessions({ query, offset? }) -> { hits, nextOffset }` — global session + discovery with title/project metadata and indexed user/assistant text. Trimmed + literal queries have a 500-character limit (`INVALID_ARGUMENT` above it). + Each 30-session page includes `session`, `projectName`, `metadataMatch`, the + full matching `messageCount`, and at most two `matches` containing + `messageId`, `role`, `createdAt`, and a match-centered `snippet`. + `nextOffset: null` marks the last page. Sort by updated time descending and + session ID ascending; exclude soft-deleted sessions. Empty queries return no + hits because the renderer owns its recent-session presentation. +- `search.context({ sessionId, messageId, query, direction? })` — resolve a + stable message ID in the owning, non-deleted session's JSONL layout. Default + `direction: "around"` returns up to 21 nearby message text projections; + `"before"` / `"after"` returns up to 20 messages excluding the anchor. Return + `messages`, `hasMoreBefore`, `hasMoreAfter`, `previousMatchId`, and + `nextMatchId`. Adjacent matching IDs follow transcript sequence order and + have no 100-message cutoff. Context comes from canonical JSONL and is capped + at 64 Ki characters per message; the target is centered on the query. Tool + bodies, thinking, and attachments are omitted. Missing/deleted targets return + `NOT_FOUND`; invalid directions or identifiers return `INVALID_ARGUMENT`. + See [ADR session-content-search](../../adr/session-content-search.md). - `artifacts.list` — Plan/Goal checkpoint artifacts for a session - `keyboard.setGlobalShortcut` — host-owned native fallback for the plugin launcher chord where Electron cannot register it diff --git a/docs/spec/04-ux/08-component-spec.md b/docs/spec/04-ux/08-component-spec.md index 3ea25ccfa6..e3af82808c 100644 --- a/docs/spec/04-ux/08-component-spec.md +++ b/docs/spec/04-ux/08-component-spec.md @@ -1239,9 +1239,37 @@ storage but compose into one assistant turn until the next user message. - The marker nearest the reading position exposes `aria-current="true"` and keyboard focus opens the same preview available on pointer hover +### 7.5a Global session content search + +- The existing global search input matches session titles, project metadata, + and indexed user/assistant message text across all host-visible sessions. +- Group matches by session, show the complete matching-message count, and + preview up to two snippets with localized sender labels, timestamps, and + literal keyword highlighting. Metadata matches have a distinct badge. +- Keep archived sessions hidden in empty-query recents and discoverable through + an explicit query. Exclude deleted sessions. Body-bearing untitled sessions + remain discoverable through their content. +- Load more continues host session pagination; a renderer's loaded sessions or + the old 50-session/100-message limits cannot truncate discovery. +- Clicking either snippet opens its session and centers that message in a + bounded text reader with highlighting. Previous/next match reaches remaining + body hits; previous/next context loads neighboring history on demand. Show an + explicit unavailable/retry state if a target disappeared. +- Preserve retained live transcripts and their scroll positions while the + reader is visible. Back to conversation or focusing the composer restores the + live pane; selecting another conversation exits the reader. +- Preserve the query in memory when closing/reopening search. Debounce queries + and reject stale results and errors after a new query, closure, or navigation. + Loading and failure states must not masquerade as an empty result. +- Keep page, settings, and command results available. Arrow keys and Enter + navigate session headings, snippets, Load more, and the existing result + types. IME composition Enter must not activate a result. + ### 7.6 MVP constraints -- No message search within transcript +- Global search can open a bounded historical message reader in the current + conversation (ADR session-content-search); an independent in-transcript find + bar remains outside this scope. - No inline message branching tree; regenerate variants remain linear per user root turn. Session-level Create branch produces an independent conversation row instead of adding tree chrome inside the transcript. diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 48f07bcdb1..a9e7c7eee8 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -10549,3 +10549,38 @@ sample extensions under `apps/desktop/test/fixtures/pi-extensions/`. `pnpm test:e2e:layout` — fixed-window width invariance, the 360px floor across a pointer drag, sidebar yield/restore, and the 370px reopen target); unit coverage in `work-panel-resize.test.mjs` + + +#### E2E-SESSION-content-search-and-message-navigation + +- **Scope**: Desktop global search, host search projections, and historical + message navigation (issue #270, ADR session-content-search). +- **Preconditions**: At least 65 visible sessions with a shared body keyword; + one session has 125 matching user/assistant messages. Include a body-only + keyword, a metadata-only match, an archived session, a soft-deleted session, + one/two-character CJK terms, literal `%`, `_`, quotes, and a path. Include a + long session with a match beyond the latest 100 messages and a message whose + matching text follows 100,000 characters. Include an actively streaming + conversation and a fixture with repeated physical message lines. +- **Steps**: Search body-only user and assistant terms, then rename the owning + session and repeat. Check aggregated counts and sender/time/snippet labels. + Load every result page. Open each of the two snippets in one session; inspect + the exact selected message and highlight. Use previous/next match to reach + hits outside the previews, and previous/next context to page surrounding + history. Back to conversation, reopen search, and check the retained query. + Repeat with CJK and symbols. Change queries rapidly while delayed first-page + and later-page requests resolve out of order. Close/reopen during loading. + Delete a target between search and selection; retry after a transient error. + Search a running conversation and return to its live stream, then switch + conversations. Use arrows, Enter, Escape, Tab, and CJK IME confirmation, and + exercise page/settings/plugin-command results. +- **Expected**: Every matching visible session is reachable; counts cover all + 125 messages, with no duplicate session rows. Archived visibility follows + the existing explicit-search rule and deleted sessions never appear. Each + snippet opens its own stable message ID, including unloaded and oversized + history, without replacing the live transcript or losing active output. + Later query ownership wins over stale results/errors. IME Enter does not + execute an action. Missing targets and transport failures are explicit. + Existing commands, pages, settings, and keyboard navigation still work. +- **Status**: Draft; Rust and renderer unit regressions cover the data/query + boundaries. Full rendered E2E requires an explicitly authorized run. diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 496bf9f6fe..52dddbdfba 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -1368,7 +1368,22 @@ export const de = { "uncategorized": "Andere" }, "search": { - "placeholder": "Chats durchsuchen…", + placeholder: "Titel und Nachrichten suchen…", + metadataMatch: "Titel / Projekt", + messageMatches: "{{count}} Nachrichtentreffer", + user: "Du", + assistant: "Assistent", + loading: "Suche läuft…", + failed: "Suche fehlgeschlagen. Erneut versuchen.", + loadMore: "Weitere Ergebnisse", + messageContext: "Nachrichtenkontext", + returnToConversation: "Zurück zum Gespräch", + contextUnavailable: "Nachricht nicht verfügbar. Möglicherweise gelöscht.", + retry: "Erneut versuchen", + previousMatch: "Vorheriger Treffer", + nextMatch: "Nächster Treffer", + previousMessages: "Vorherige Nachrichten", + nextMessages: "Nächste Nachrichten", "empty": "Keine passenden Chats", "pages": "Seiten", "commands": "Befehle", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index 799980ce91..a724fce62e 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -1385,7 +1385,22 @@ export const en = { uncategorized: "Other", }, search: { - placeholder: "Search chats…", + placeholder: "Search titles and messages…", + metadataMatch: "Title / project", + messageMatches: "Messages: {{count}}", + user: "You", + assistant: "Assistant", + loading: "Searching…", + failed: "Search failed. Try again.", + loadMore: "Load more results", + messageContext: "Message context", + returnToConversation: "Back to conversation", + contextUnavailable: "Message unavailable. It may have been deleted.", + retry: "Retry", + previousMatch: "Previous match", + nextMatch: "Next match", + previousMessages: "Previous messages", + nextMessages: "Next messages", empty: "No matching chats", pages: "Pages", commands: "Commands", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 41e37adbee..18b1044a28 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -1368,7 +1368,22 @@ export const es = { "uncategorized": "Otro" }, "search": { - "placeholder": "Buscar chats…", + placeholder: "Buscar títulos y mensajes…", + metadataMatch: "Título / proyecto", + messageMatches: "{{count}} mensajes coincidentes", + user: "Tú", + assistant: "Asistente", + loading: "Buscando…", + failed: "Error de búsqueda. Inténtalo de nuevo.", + loadMore: "Más resultados", + messageContext: "Contexto del mensaje", + returnToConversation: "Volver a la conversación", + contextUnavailable: "Mensaje no disponible. Puede haberse eliminado.", + retry: "Reintentar", + previousMatch: "Coincidencia anterior", + nextMatch: "Siguiente coincidencia", + previousMessages: "Mensajes anteriores", + nextMessages: "Mensajes siguientes", "empty": "No hay chats coincidentes", "pages": "Páginas", "commands": "Comandos", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index 7d3fe744d7..6202f7ff32 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -1368,7 +1368,22 @@ export const fr = { "uncategorized": "Autre" }, "search": { - "placeholder": "Rechercher des chats…", + placeholder: "Rechercher titres et messages…", + metadataMatch: "Titre / projet", + messageMatches: "{{count}} messages correspondants", + user: "Vous", + assistant: "Assistant", + loading: "Recherche…", + failed: "Échec de la recherche. Réessayez.", + loadMore: "Plus de résultats", + messageContext: "Contexte du message", + returnToConversation: "Retour à la conversation", + contextUnavailable: "Message indisponible. Il a peut-être été supprimé.", + retry: "Réessayer", + previousMatch: "Résultat précédent", + nextMatch: "Résultat suivant", + previousMessages: "Messages précédents", + nextMessages: "Messages suivants", "empty": "Aucun chat correspondant", "pages": "Pages", "commands": "Commandes", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 58f36bfe4b..2cafa69abb 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -1387,7 +1387,22 @@ export const ko = { uncategorized: "기타", }, search: { - placeholder: "채팅 검색…", + placeholder: "제목 및 메시지 검색…", + metadataMatch: "제목 / 프로젝트", + messageMatches: "메시지 {{count}}개 일치", + user: "사용자", + assistant: "어시스턴트", + loading: "검색 중…", + failed: "검색에 실패했습니다. 다시 시도하세요.", + loadMore: "결과 더 보기", + messageContext: "메시지 문맥", + returnToConversation: "대화로 돌아가기", + contextUnavailable: "메시지를 읽을 수 없습니다. 삭제되었을 수 있습니다.", + retry: "다시 시도", + previousMatch: "이전 일치 항목", + nextMatch: "다음 일치 항목", + previousMessages: "이전 메시지", + nextMessages: "다음 메시지", empty: "일치하는 채팅 없음", pages: "페이지", commands: "명령", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index 07e8249893..3c3901302c 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -1387,7 +1387,22 @@ export const tr = { uncategorized: "Diğer", }, search: { - placeholder: "Sohbet ara…", + placeholder: "Başlık ve mesaj ara…", + metadataMatch: "Başlık / proje", + messageMatches: "{{count}} mesaj eşleşmesi", + user: "Siz", + assistant: "Asistan", + loading: "Aranıyor…", + failed: "Arama başarısız. Tekrar deneyin.", + loadMore: "Daha fazla sonuç", + messageContext: "Mesaj bağlamı", + returnToConversation: "Sohbete dön", + contextUnavailable: "Mesaj kullanılamıyor. Silinmiş olabilir.", + retry: "Yeniden dene", + previousMatch: "Önceki eşleşme", + nextMatch: "Sonraki eşleşme", + previousMessages: "Önceki mesajlar", + nextMessages: "Sonraki mesajlar", empty: "Eşleşen sohbet yok", pages: "Sayfalar", commands: "Komutlar", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index 6fbf00a757..1105ecf278 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -1377,7 +1377,22 @@ export const zhCN = { uncategorized: "其他", }, search: { - placeholder: "搜索对话…", + placeholder: "搜索标题和消息…", + metadataMatch: "标题 / 项目", + messageMatches: "{{count}} 条消息命中", + user: "用户", + assistant: "助手", + loading: "正在搜索…", + failed: "搜索失败,请重试。", + loadMore: "加载更多结果", + messageContext: "消息上下文", + returnToConversation: "返回会话", + contextUnavailable: "消息无法读取,可能已被删除。", + retry: "重试", + previousMatch: "上一处命中", + nextMatch: "下一处命中", + previousMessages: "之前的消息", + nextMessages: "之后的消息", empty: "没有匹配的对话", pages: "页面", commands: "命令", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index bb0e99235c..ecba35cb48 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -1377,7 +1377,22 @@ export const zhTW = { uncategorized: "其他", }, search: { - placeholder: "搜尋對話…", + placeholder: "搜尋標題和訊息…", + metadataMatch: "標題 / 專案", + messageMatches: "{{count}} 則訊息符合", + user: "使用者", + assistant: "助手", + loading: "搜尋中…", + failed: "搜尋失敗,請重試。", + loadMore: "載入更多結果", + messageContext: "訊息上下文", + returnToConversation: "返回對話", + contextUnavailable: "無法讀取訊息,可能已被刪除。", + retry: "重試", + previousMatch: "上一個符合項目", + nextMatch: "下一個符合項目", + previousMessages: "之前的訊息", + nextMessages: "之後的訊息", empty: "沒有匹配的對話", pages: "頁面", commands: "命令", diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index f0da97a468..3ead124e5f 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -84,6 +84,8 @@ export const IPC = { sessionCreate: "pi-desktop/session/create", sessionFork: "pi-desktop/session/fork", sessionMoveProject: "pi-desktop/session/moveProject", + sessionSearch: "pi-desktop/session/search", + sessionSearchContext: "pi-desktop/session/searchContext", sessionGet: "pi-desktop/session/get", /** Validate and select a durable session from a reviewed host operation. */ sessionOpen: "pi-desktop/session/open", diff --git a/packages/shared/src/types/sessions.ts b/packages/shared/src/types/sessions.ts index 0b28ed39ba..027c9f7970 100644 --- a/packages/shared/src/types/sessions.ts +++ b/packages/shared/src/types/sessions.ts @@ -151,3 +151,40 @@ export type AgentActivity = /** Running targets, in wait order, with the latest coarse child action. */ agents?: AgentActivityAgent[]; }; + +/** Host-owned global search, grouped and paginated by session. */ +export type SessionMessageMatch = { + messageId: string; + role: "user" | "assistant"; + createdAt: string; + snippet: string; +}; + +export type SessionSearchHit = { + session: SessionSummary; + projectName?: string | null; + metadataMatch: boolean; + messageCount: number; + matches: SessionMessageMatch[]; +}; + +export type SessionSearchPage = { + hits: SessionSearchHit[]; + nextOffset: number | null; +}; + +export type SessionSearchContext = { + /** Read-only canonical text projection; no mutation or model-facing fields. */ + messages: Pick[]; + hasMoreBefore: boolean; + hasMoreAfter: boolean; + previousMatchId: string | null; + nextMatchId: string | null; +}; + +export type SessionSearchContextRequest = { + sessionId: string; + messageId: string; + query: string; + direction?: "around" | "before" | "after"; +}; From 430eda580e5039d8bcbbc1c98d4774f352309972 Mon Sep 17 00:00:00 2001 From: zszz3 <91608029+zszz3@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:55:18 +0800 Subject: [PATCH 03/13] fix(search): show and highlight the matching sentence --- apps/desktop/src/styles/session-search.css | 8 +- crates/host-core/src/session_search.rs | 162 ++++++++++++++++--- docs/adr/session-content-search.md | 8 + docs/spec/03-runtime/06-host-rpc-protocol.md | 4 +- docs/spec/04-ux/08-component-spec.md | 7 + docs/spec/06-delivery/04-e2e-test-plan.md | 8 + 6 files changed, 170 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/styles/session-search.css b/apps/desktop/src/styles/session-search.css index 57e2ebe716..32a6a60ec9 100644 --- a/apps/desktop/src/styles/session-search.css +++ b/apps/desktop/src/styles/session-search.css @@ -13,11 +13,7 @@ } .search-message-snippet { - display: -webkit-box; - overflow: hidden; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - white-space: pre-wrap; + white-space: normal; overflow-wrap: anywhere; text-align: left; } @@ -93,5 +89,5 @@ .search-context-text .search-hit, .search-message-snippet .search-hit { border-radius: var(--radius-sm); - background: color-mix(in oklab, var(--ds-accent) 12%, transparent); + background: color-mix(in oklab, var(--ds-accent) 28%, var(--ds-bg-primary)); } diff --git a/crates/host-core/src/session_search.rs b/crates/host-core/src/session_search.rs index ce0f25f13b..fc5379156d 100644 --- a/crates/host-core/src/session_search.rs +++ b/crates/host-core/src/session_search.rs @@ -121,7 +121,7 @@ pub fn search(db: &Database, query: &str, offset: i64) -> Result { message_id: row.get(0)?, role: row.get(1)?, created_at: ms_to_ts(row.get(2)?), - snippet: excerpt(&row.get::<_, String>(3)?, query, 180), + snippet: sentence_excerpt(&row.get::<_, String>(3)?, query, 180), }) })? .collect::>>()?; @@ -129,30 +129,81 @@ pub fn search(db: &Database, query: &str, offset: i64) -> Result { Ok(SearchPage { hits, next_offset }) } -/// Center a bounded excerpt on the literal match, mapping lowercase expansion -/// offsets (for example Turkish dotted I) back to original Unicode characters. -pub fn excerpt(text: &str, query: &str, budget: usize) -> String { +/// Map lowercase expansion offsets back to original Unicode characters. +fn match_range(text: &str, query: &str) -> Option<(usize, usize)> { let needle = query.trim().to_lowercase(); + if needle.is_empty() { + return None; + } + let hit = text.to_lowercase().find(&needle)?; + let mut folded_offset = 0; let mut position = 0; - let mut match_end = 0; - if !needle.is_empty() { - if let Some(hit) = text.to_lowercase().find(&needle) { - let mut folded_offset = 0; - let mut found_start = false; - for (index, ch) in text.chars().enumerate() { - let next = folded_offset + ch.to_lowercase().map(char::len_utf8).sum::(); - if !found_start && next > hit { - position = index; - found_start = true; - } - if next >= hit + needle.len() { - match_end = index + 1; - break; - } - folded_offset = next; + let mut found_start = false; + for (index, ch) in text.chars().enumerate() { + let next = folded_offset + ch.to_lowercase().map(char::len_utf8).sum::(); + if !found_start && next > hit { + position = index; + found_start = true; + } + if next >= hit + needle.len() { + return Some((position, index + 1)); + } + folded_offset = next; + } + None +} + +/// Return the sentence or line containing the match. Long sentences retain a +/// bounded window around the complete query, rather than adjacent sentences. +fn sentence_excerpt(text: &str, query: &str, budget: usize) -> String { + let Some((position, match_end)) = match_range(text, query) else { + return excerpt(text, query, budget); + }; + let chars: Vec = text.chars().collect(); + let is_closing = |ch: char| matches!(ch, '"' | '\'' | '”' | '’' | '」' | '』' | ')' | ')'); + let mut start = 0; + let mut end = chars.len(); + let mut index = 0; + while index < chars.len() { + let ch = chars[index]; + let newline = matches!(ch, '\n' | '\r' | '\u{2028}' | '\u{2029}'); + let boundary = newline + || matches!(ch, '。' | '!' | '?' | '!' | '?') + || (ch == '.' + && chars + .get(index + 1) + .is_none_or(|next| next.is_whitespace() || is_closing(*next))); + index += 1; + if !boundary { + continue; + } + if !newline { + while index < chars.len() + && (is_closing(chars[index]) + || matches!(chars[index], '.' | '!' | '?' | '。' | '!' | '?')) + { + index += 1; } } + if index <= position { + start = index; + } else if index >= match_end { + end = index; + break; + } } + let sentence: String = chars[start..end].iter().collect(); + let sentence = sentence.trim(); + if sentence.chars().count() <= budget { + sentence.to_owned() + } else { + excerpt(sentence, query, budget) + } +} + +/// Center a bounded excerpt on the literal match without splitting Unicode. +pub fn excerpt(text: &str, query: &str, budget: usize) -> String { + let (position, match_end) = match_range(text, query).unwrap_or((0, 0)); let total = text.chars().count(); let start = position.saturating_sub(budget / 3); let end = (start + budget).max(match_end).min(total); @@ -461,6 +512,77 @@ mod tests { .is_none()); } + #[test] + fn search_previews_show_the_matching_sentence() { + let dir = tempfile::tempdir().unwrap(); + let db = Database::open(&dir.path().join("test.sqlite")).unwrap(); + let session = sessions::create_session(&db, None, None, None, None, None).unwrap(); + for (text, query, expected) in [ + ( + "前一句。消息一直在排队,没有继续执行。后一句。", + "排队", + "消息一直在排队,没有继续执行。", + ), + ( + "Before. The NEEDLE is here! After.", + "needle", + "The NEEDLE is here!", + ), + ( + "Before. Open src/main.rs for the needle. After.", + "needle", + "Open src/main.rs for the needle.", + ), + ( + "Heading\n\nOne\nTwo\nThe needle is here\nUnrelated", + "needle", + "The needle is here", + ), + ( + "前一句。“消息还在排队!”后一句。", + "排队", + "“消息还在排队!”", + ), + ( + "Before. İSTANBUL is here. After.", + "i\u{0307}stanbul", + "İSTANBUL is here.", + ), + ] { + sessions::replace_messages(&db, &session.id, &[message("target", text)]).unwrap(); + let result = search(&db, query, 0).unwrap(); + assert_eq!(result.hits[0].matches[0].snippet, expected); + } + } + + #[test] + fn sentence_previews_preserve_long_and_multiline_matches() { + assert_eq!( + sentence_excerpt( + &format!("{} The needle is here.", "!".repeat(100_000)), + "needle", + 180 + ), + "The needle is here." + ); + let text = format!( + "Before. {}needle{}. After.", + "a".repeat(1000), + "z".repeat(1000) + ); + let snippet = sentence_excerpt(&text, "needle", 180); + assert!(snippet.contains("needle")); + assert!(snippet.chars().count() <= 182); + assert!(!snippet.contains("Before") && !snippet.contains("After")); + assert_eq!( + sentence_excerpt("Before. One.\nTwo match! After.", "One.\nTwo", 180), + "One.\nTwo match!" + ); + let query = "中".repeat(300); + let text = format!("Before. {}{} tail. After.", "İ".repeat(300), query); + assert!(sentence_excerpt(&text, &query, 180).contains(&query)); + } + #[test] fn excerpts_include_the_whole_query_when_it_exceeds_the_preview_budget() { let shifted = format!("{}命中", "İ".repeat(300)); diff --git a/docs/adr/session-content-search.md b/docs/adr/session-content-search.md index 8bd5e06b84..69ef84d635 100644 --- a/docs/adr/session-content-search.md +++ b/docs/adr/session-content-search.md @@ -31,6 +31,14 @@ and backslashes never become operators. Queries are trimmed and bounded to full matching-message counts, and at most two recent message excerpts. Offsets continue the current query; reopening refreshes results against current data. +Body previews use the sentence or line containing the first match. Chinese and +English sentence punctuation and line breaks bound the preview; periods inside +paths or identifiers do not end a sentence. Sentences longer than 180 characters +use a bounded match-centered excerpt that preserves the complete query. The +renderer wraps the full returned preview without a line clamp and uses a +visible background highlight, so preceding newlines or a narrow window cannot +hide the matching text. + Archive preferences stay renderer-owned: empty-query recents hide archived sessions, while explicit searches retain the existing archived-session discovery behavior. Tools, thinking, attachments, and discarded revisions do not expand diff --git a/docs/spec/03-runtime/06-host-rpc-protocol.md b/docs/spec/03-runtime/06-host-rpc-protocol.md index cea612780a..bee495addf 100644 --- a/docs/spec/03-runtime/06-host-rpc-protocol.md +++ b/docs/spec/03-runtime/06-host-rpc-protocol.md @@ -537,7 +537,9 @@ activation-scope filtering (`CAPABILITY_INVALID` for an unknown scope). literal queries have a 500-character limit (`INVALID_ARGUMENT` above it). Each 30-session page includes `session`, `projectName`, `metadataMatch`, the full matching `messageCount`, and at most two `matches` containing - `messageId`, `role`, `createdAt`, and a match-centered `snippet`. + `messageId`, `role`, `createdAt`, and a `snippet` containing the matching + sentence or line. Long sentences are capped to a match-centered 180-character + window, extended when needed to preserve the complete literal query. `nextOffset: null` marks the last page. Sort by updated time descending and session ID ascending; exclude soft-deleted sessions. Empty queries return no hits because the renderer owns its recent-session presentation. diff --git a/docs/spec/04-ux/08-component-spec.md b/docs/spec/04-ux/08-component-spec.md index e3af82808c..ec0358a07e 100644 --- a/docs/spec/04-ux/08-component-spec.md +++ b/docs/spec/04-ux/08-component-spec.md @@ -1246,6 +1246,13 @@ storage but compose into one assistant turn until the next user message. - Group matches by session, show the complete matching-message count, and preview up to two snippets with localized sender labels, timestamps, and literal keyword highlighting. Metadata matches have a distinct badge. +- Each body preview shows the sentence or line containing the first match, + using Chinese/English sentence punctuation and line breaks as boundaries. + Preserve the complete sentence when it fits 180 characters; longer sentences + use an ellipsized window that always includes the entire query. Render the + whole bounded preview with normal wrapping, including on narrow windows; + line clamping must never hide the matching text. Matches have a clearly + visible background highlight in both light and dark themes. - Keep archived sessions hidden in empty-query recents and discoverable through an explicit query. Exclude deleted sessions. Body-bearing untitled sessions remain discoverable through their content. diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index a9e7c7eee8..362b2fcc23 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -10562,6 +10562,9 @@ sample extensions under `apps/desktop/test/fixtures/pi-extensions/`. long session with a match beyond the latest 100 messages and a message whose matching text follows 100,000 characters. Include an actively streaming conversation and a fixture with repeated physical message lines. + Add short matching sentences surrounded by unrelated Chinese/English + sentences, multiple short lines before a match, a quoted sentence, a file + path containing periods, and a matching sentence longer than 180 characters. - **Steps**: Search body-only user and assistant terms, then rename the owning session and repeat. Check aggregated counts and sender/time/snippet labels. Load every result page. Open each of the two snippets in one session; inspect @@ -10574,6 +10577,11 @@ sample extensions under `apps/desktop/test/fixtures/pi-extensions/`. Search a running conversation and return to its live stream, then switch conversations. Use arrows, Enter, Escape, Tab, and CJK IME confirmation, and exercise page/settings/plugin-command results. + Search a word in the middle sentence and verify that the + result shows that sentence with a background highlight on the literal match. + Repeat after multiple line breaks and at narrow window widths in light and + dark themes. Long-sentence truncation must keep the whole matching query + visible; short previews must omit unrelated adjacent sentences. - **Expected**: Every matching visible session is reachable; counts cover all 125 messages, with no duplicate session rows. Archived visibility follows the existing explicit-search rule and deleted sessions never appear. Each From 234e56d55a1cd012a9436136d8ef53570e27ede8 Mon Sep 17 00:00:00 2001 From: zszz3 <91608029+zszz3@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:08:45 +0800 Subject: [PATCH 04/13] fix(search): open results in the original conversation --- apps/desktop/src/components/ChatSurface.tsx | 33 +--- apps/desktop/src/components/SearchDialog.tsx | 27 ++- .../src/components/SearchSessionResults.tsx | 4 +- .../src/components/SessionSearchContext.tsx | 163 ------------------ apps/desktop/src/hooks/use-session-search.ts | 4 - apps/desktop/src/styles/session-search.css | 59 +------ docs/adr/session-content-search.md | 46 ++--- docs/spec/04-ux/08-component-spec.md | 22 +-- docs/spec/06-delivery/04-e2e-test-plan.md | 23 +-- packages/i18n/src/locales/de/index.ts | 7 - packages/i18n/src/locales/en/index.ts | 7 - packages/i18n/src/locales/es/index.ts | 7 - packages/i18n/src/locales/fr/index.ts | 7 - packages/i18n/src/locales/ko/index.ts | 7 - packages/i18n/src/locales/tr/index.ts | 7 - packages/i18n/src/locales/zh-CN/index.ts | 7 - packages/i18n/src/locales/zh-TW/index.ts | 7 - 17 files changed, 63 insertions(+), 374 deletions(-) delete mode 100644 apps/desktop/src/components/SessionSearchContext.tsx diff --git a/apps/desktop/src/components/ChatSurface.tsx b/apps/desktop/src/components/ChatSurface.tsx index 2e6aa134d6..c479d65ba8 100644 --- a/apps/desktop/src/components/ChatSurface.tsx +++ b/apps/desktop/src/components/ChatSurface.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useMemo } from "react"; +import { memo, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Composer } from "./Composer"; import { HomeMascotLogo } from "./HomeMascotLogo"; @@ -10,8 +10,6 @@ import { SessionPane } from "./SessionPane"; import { useAppStore } from "../stores/app-store"; import { headPermission } from "../lib/pending-permissions"; import { headAsk } from "../lib/pending-asks"; -import { useSessionSearchState } from "../hooks/use-session-search"; -import { SessionSearchContext } from "./SessionSearchContext"; const StableComposer = memo(Composer); @@ -44,16 +42,6 @@ function projectName(path?: string | null, name?: string | null) { export const ChatSurface = memo(function ChatSurface() { const { t } = useTranslation(); const activeSessionId = useAppStore((state) => state.activeSessionId); - const searchFocus = useSessionSearchState((state) => state.focus); - const setSearchFocus = useSessionSearchState((state) => state.setFocus); - const showSearchContext = Boolean( - searchFocus && searchFocus.sessionId === activeSessionId, - ); - useEffect(() => { - if (searchFocus && searchFocus.sessionId !== activeSessionId) { - setSearchFocus(undefined); - } - }, [activeSessionId, searchFocus, setSearchFocus]); const selectingSessionId = useAppStore((state) => state.selectingSessionId); const retainedSessionIds = useAppStore((state) => state.retainedSessionIds); const messages = useAppStore((state) => state.messages); @@ -130,21 +118,11 @@ export const ChatSurface = memo(function ChatSurface() { // still resolving, the visible pane keeps its own transcript, so the hero must // not take over just because the destination projection is still empty. const showEmptyState = - !showSearchContext && !hasTranscript && (!visibleSessionId || visibleSessionId === activeSessionId); return (
{ - if ( - showSearchContext && - event.target instanceof HTMLElement && - event.target.matches(".composer-input") - ) { - setSearchFocus(undefined); - } - }} > {sessionSwitching ? (
@@ -197,16 +175,9 @@ export const ChatSurface = memo(function ChatSurface() { ))} - {showSearchContext && searchFocus ? ( - setSearchFocus(undefined)} - /> - ) : null}
diff --git a/apps/desktop/src/components/SearchDialog.tsx b/apps/desktop/src/components/SearchDialog.tsx index 1470d8b4e1..06bc26b569 100644 --- a/apps/desktop/src/components/SearchDialog.tsx +++ b/apps/desktop/src/components/SearchDialog.tsx @@ -87,7 +87,6 @@ export function SearchDialog({ open, onClose }: { open: boolean; onClose: () => const query = useSessionSearchState((state) => state.query); const setQuery = useSessionSearchState((state) => state.setQuery); - const setSearchFocus = useSessionSearchState((state) => state.setFocus); const search = useSessionSearch(open, query); const selectionRequest = useRef(0); useEffect(() => { @@ -236,12 +235,14 @@ export function SearchDialog({ open, onClose }: { open: boolean; onClose: () => if (!open) return null; - const run = async (row: SearchRow | null, messageId?: string) => { + const run = async (row: SearchRow | null) => { const request = ++selectionRequest.current; try { - setSearchFocus(undefined); if (row) { - await selectSession(row.session.id); + const current = useAppStore.getState(); + if (current.activeSessionId === row.session.id && !current.selectingSessionId) + current.setPage("chat"); + else await selectSession(row.session.id); const selected = useAppStore.getState(); if ( request !== selectionRequest.current || @@ -249,19 +250,11 @@ export function SearchDialog({ open, onClose }: { open: boolean; onClose: () => selected.page !== "chat" ) return; - const target = messageId ?? row.hit?.matches[0]?.messageId; - if (target) - setSearchFocus({ - sessionId: row.session.id, - messageId: target, - query: query.trim(), - }); } else await newSession(); onClose(); - if (!row?.hit?.matches.length) - requestAnimationFrame(() => { - document.querySelector(".composer-input")?.focus(); - }); + requestAnimationFrame(() => { + document.querySelector(".composer-input")?.focus({ preventScroll: true }); + }); } catch (error) { showToast(error instanceof Error ? error.message : String(error), { variant: "error", @@ -296,7 +289,7 @@ export function SearchDialog({ open, onClose }: { open: boolean; onClose: () => for (const row of rows) { if (active === row.optionIndex) return void run(row); const match = row.hit?.matches[active - row.optionIndex - 1]; - if (match) return void run(row, match.messageId); + if (match) return void run(row); } if (active === moreIndex && search.nextOffset !== null) return search.loadMore(); const pageIndex = active - pageBase; @@ -390,7 +383,7 @@ export function SearchDialog({ open, onClose }: { open: boolean; onClose: () => active={active} runningSessions={runningSessions} onActivate={setActive} - onSelect={(row, messageId) => void run(row, messageId)} + onSelect={(row) => void run(row)} /> {query.trim() && search.loading ? (
diff --git a/apps/desktop/src/components/SearchSessionResults.tsx b/apps/desktop/src/components/SearchSessionResults.tsx index f0d8f8c4b0..e8126ccb83 100644 --- a/apps/desktop/src/components/SearchSessionResults.tsx +++ b/apps/desktop/src/components/SearchSessionResults.tsx @@ -24,7 +24,7 @@ export function SearchSessionResults({ active: number; runningSessions: Record; onActivate: (index: number) => void; - onSelect: (row: SearchRow, messageId?: string) => void; + onSelect: (row: SearchRow) => void; }) { const { t } = useTranslation(); return ( @@ -88,7 +88,7 @@ export function SearchSessionResults({ aria-selected={active === optionIndex} className={`search-item search-message-hit ${active === optionIndex ? "active" : ""}`} onMouseEnter={() => onActivate(optionIndex)} - onClick={() => onSelect(row, match.messageId)} + onClick={() => onSelect(row)} > {match.role === "user" ? t("search.user") : t("search.assistant")} ·{" "} diff --git a/apps/desktop/src/components/SessionSearchContext.tsx b/apps/desktop/src/components/SessionSearchContext.tsx deleted file mode 100644 index 189793c264..0000000000 --- a/apps/desktop/src/components/SessionSearchContext.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import type { - SessionSearchContext as Context, - SessionSearchContextRequest, -} from "@pi-desktop/shared"; -import { api } from "../lib/api"; -import { SearchHighlight } from "./SearchHighlight"; - -/** A bounded historical reader; it never writes to the live transcript store. */ -export function SessionSearchContext({ - focus, - onClose, -}: { - focus: SessionSearchContextRequest; - onClose: () => void; -}) { - const { t } = useTranslation(); - const [request, setRequest] = useState(focus); - const [context, setContext] = useState(); - const [loading, setLoading] = useState(true); - const [failed, setFailed] = useState(false); - const target = useRef(null); - const scroller = useRef(null); - const close = useRef(null); - - useEffect(() => { - close.current?.focus(); - }, []); - useEffect(() => { - let cancelled = false; - setLoading(true); - setFailed(false); - void api - .getSearchContext(request) - .then((result) => { - if (!cancelled) setContext(result); - }) - .catch(() => { - if (!cancelled) setFailed(true); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [request]); - useEffect(() => { - if (!context) return; - if (target.current) target.current.scrollIntoView({ block: "center" }); - else scroller.current?.scrollTo({ top: 0 }); - }, [context]); - - const page = (direction: "before" | "after") => { - const anchor = - direction === "before" ? context?.messages[0] : context?.messages.at(-1); - if (anchor) setRequest({ ...focus, messageId: anchor.id, direction }); - }; - - return ( -
-
- {t("search.messageContext")} - -
-
- - -
-
- {failed ? ( -
- {t("search.contextUnavailable")} - -
- ) : null} - {loading ? ( -
- {t("search.loading")} -
- ) : null} - {context?.hasMoreBefore ? ( - - ) : null} - {context?.messages.map((message) => ( -
-
- - {message.role === "user" - ? t("search.user") - : message.role === "assistant" - ? t("search.assistant") - : message.toolName || message.role} - - -
-
- -
-
- ))} - {context?.hasMoreAfter ? ( - - ) : null} -
-
- ); -} diff --git a/apps/desktop/src/hooks/use-session-search.ts b/apps/desktop/src/hooks/use-session-search.ts index 6eada2face..32010da4f1 100644 --- a/apps/desktop/src/hooks/use-session-search.ts +++ b/apps/desktop/src/hooks/use-session-search.ts @@ -1,19 +1,15 @@ import { useEffect, useMemo, useSyncExternalStore } from "react"; import { create } from "zustand"; -import type { SessionSearchContextRequest } from "@pi-desktop/shared"; import { api } from "../lib/api"; import { SessionSearchController } from "../lib/session-search"; /** Transient search state survives closing the palette, never goes to disk. */ export const useSessionSearchState = create<{ query: string; - focus?: SessionSearchContextRequest; setQuery: (query: string) => void; - setFocus: (focus?: SessionSearchContextRequest) => void; }>((set) => ({ query: "", setQuery: (query) => set({ query }), - setFocus: (focus) => set({ focus }), })); export function useSessionSearch(open: boolean, query: string) { diff --git a/apps/desktop/src/styles/session-search.css b/apps/desktop/src/styles/session-search.css index 32a6a60ec9..8e6e08cbab 100644 --- a/apps/desktop/src/styles/session-search.css +++ b/apps/desktop/src/styles/session-search.css @@ -6,8 +6,7 @@ padding-left: 36px; } -.search-message-meta, -.search-context-meta { +.search-message-meta { color: var(--ds-text-muted); font-size: var(--text-xs); } @@ -18,61 +17,6 @@ text-align: left; } -.session-search-context { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - background: var(--ds-bg-primary); -} - -.search-context-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 12px 24px; - border-bottom: 1px solid var(--ds-border-default); -} - -.search-context-scroll { - flex: 1; - min-height: 0; - overflow-y: auto; - padding: 20px max(24px, calc((100% - 800px) / 2)) 160px; -} - -.search-context-message { - margin: 16px 0; - padding: 16px; - border: 1px solid var(--ds-border-default); - border-radius: var(--radius-md); -} - -.search-context-target { - border-color: var(--ds-accent); - background: var(--ds-bg-hover); -} - -.search-context-meta { - display: flex; - justify-content: space-between; - gap: 12px; - margin-bottom: 8px; -} - -.search-context-text { - white-space: pre-wrap; - overflow-wrap: anywhere; - line-height: var(--leading-relaxed); -} - -.search-context-match-nav { - display: flex; - gap: 8px; - padding: 8px 24px; -} - .search-session-details { display: flex; flex: 1; @@ -86,7 +30,6 @@ flex-wrap: wrap; } -.search-context-text .search-hit, .search-message-snippet .search-hit { border-radius: var(--radius-sm); background: color-mix(in oklab, var(--ds-accent) 28%, var(--ds-bg-primary)); diff --git a/docs/adr/session-content-search.md b/docs/adr/session-content-search.md index 69ef84d635..11d84c3477 100644 --- a/docs/adr/session-content-search.md +++ b/docs/adr/session-content-search.md @@ -6,10 +6,11 @@ ## Context -Global search filters the renderer's session titles and project labels, even -though host-core already indexes message text. A remembered error or sentence -cannot recover its conversation. Opening a title result also loses the location -of an older matching message. +Global search originally filtered the renderer's session titles and project +labels, even though host-core already indexed message text. A remembered error +or sentence could not recover its conversation. A separate historical reader +then duplicated the transcript and required an extra Back to conversation +action. Search results should identify and open the original conversation. ## Decision @@ -44,36 +45,35 @@ sessions, while explicit searches retain the existing archived-session discovery behavior. Tools, thinking, attachments, and discarded revisions do not expand the searchable body scope. -Selecting a message opens its owning session and a bounded historical reader -inside that conversation. `search.context` resolves its stable ID against the -physical JSONL layout and returns at most 21 nearby message text projections. -Adjacent context pages contain at most 20 messages. The reader provides previous -and next matching-message navigation, so two previews never cap discovery within -one conversation. Deleted or rewritten-away targets return `NOT_FOUND`. +Selecting a heading or message preview closes search and opens the owning +conversation through the existing session selection path. The original +transcript, composer, Markdown rendering, live output, and message actions stay +available. Opening a retained conversation follows its existing scroll +restoration; opening the active session does not reload its history. Composer +focus uses `preventScroll`. There is no separate historical reader, extra Back +to conversation action, or forced message-level navigation. -JSONL remains authoritative for displayed context. Text is capped at 64 Ki -characters per message, with the target excerpt centered around the query so a -match beyond the usual display cap remains visible. Context uses literal text -instead of executing/rendering Markdown; tool rows identify their tool without -loading their results into this reader. +The additive `search.context` RPC and `session/searchContext` IPC remain +compatible for existing callers, but global search no longer uses them. They +still resolve stable IDs against the physical JSONL layout and return bounded +text context; removing the renderer reader does not change the protocol or +storage schema. -Historical search windows never replace, merge into, or persist through the -live transcript cache. Existing retained panes keep their messages and scroll -positions while hidden. The Back to conversation action, composer focus, or -selection of another conversation exits the historical reader. Query text survives closing -the palette in memory. Query changes, palette closure, and context navigation -invalidate asynchronous result ownership. +Query text survives closing the palette in memory. Query changes and palette +closure invalidate asynchronous result ownership. Existing session navigation +owns conversation loading, live transcript preservation, and workspace changes. ## Consequences and validation There is no migration or new index to maintain. Short queries and non-ASCII -case mappings still require a literal scan. Resolving an old message may scan IDs in the existing physical -layout, but only the nearby text window crosses IPC or mounts in the renderer. +case mappings still require a literal scan. Search previews stay bounded, and +opening a result uses ordinary session history pagination and retained panes. Search reads cannot rewrite conversation data or interfere with active turns. Rust regression tests cover pagination beyond 50 sessions and 100 messages, literal CJK/symbol queries, complete counts, soft deletion, physical positions, and target text beyond the display cap. Renderer unit tests cover literal highlight offsets, stale result/error rejection, cancellation, and pagination. +Historical context regression tests remain as protocol compatibility coverage. The documented full interaction scenario is `E2E-SESSION-content-search-and-message-navigation`. diff --git a/docs/spec/04-ux/08-component-spec.md b/docs/spec/04-ux/08-component-spec.md index ec0358a07e..465002fdbf 100644 --- a/docs/spec/04-ux/08-component-spec.md +++ b/docs/spec/04-ux/08-component-spec.md @@ -1258,13 +1258,15 @@ storage but compose into one assistant turn until the next user message. remain discoverable through their content. - Load more continues host session pagination; a renderer's loaded sessions or the old 50-session/100-message limits cannot truncate discovery. -- Clicking either snippet opens its session and centers that message in a - bounded text reader with highlighting. Previous/next match reaches remaining - body hits; previous/next context loads neighboring history on demand. Show an - explicit unavailable/retry state if a target disappeared. -- Preserve retained live transcripts and their scroll positions while the - reader is visible. Back to conversation or focusing the composer restores the - live pane; selecting another conversation exits the reader. +- Clicking a session heading or either snippet closes global search and opens + the owning conversation through the normal session selection path. Show the + original rendered transcript and composer directly, with the same reading + position behavior as opening it from the sidebar. A result for the active + session reveals its existing pane without reloading its history. Focusing + the composer must not scroll the page. +- Snippets identify which conversation to open; they do not open a separate + historical reader or force message-level navigation. Keep the original + transcript, live updates, and editing actions available immediately. - Preserve the query in memory when closing/reopening search. Debounce queries and reject stale results and errors after a new query, closure, or navigation. Loading and failure states must not masquerade as an empty result. @@ -1274,9 +1276,9 @@ storage but compose into one assistant turn until the next user message. ### 7.6 MVP constraints -- Global search can open a bounded historical message reader in the current - conversation (ADR session-content-search); an independent in-transcript find - bar remains outside this scope. +- Global search opens the original conversation (ADR session-content-search). + Message-level navigation and an independent in-transcript find bar remain + outside this scope. - No inline message branching tree; regenerate variants remain linear per user root turn. Session-level Create branch produces an independent conversation row instead of adding tree chrome inside the transcript. diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 362b2fcc23..cc00e22c9c 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -10553,8 +10553,8 @@ sample extensions under `apps/desktop/test/fixtures/pi-extensions/`. #### E2E-SESSION-content-search-and-message-navigation -- **Scope**: Desktop global search, host search projections, and historical - message navigation (issue #270, ADR session-content-search). +- **Scope**: Desktop global search, host search projections, and original + conversation navigation (issue #270, ADR session-content-search). - **Preconditions**: At least 65 visible sessions with a shared body keyword; one session has 125 matching user/assistant messages. Include a body-only keyword, a metadata-only match, an archived session, a soft-deleted session, @@ -10567,13 +10567,14 @@ sample extensions under `apps/desktop/test/fixtures/pi-extensions/`. path containing periods, and a matching sentence longer than 180 characters. - **Steps**: Search body-only user and assistant terms, then rename the owning session and repeat. Check aggregated counts and sender/time/snippet labels. - Load every result page. Open each of the two snippets in one session; inspect - the exact selected message and highlight. Use previous/next match to reach - hits outside the previews, and previous/next context to page surrounding - history. Back to conversation, reopen search, and check the retained query. + Load every result page. Open a session heading and each of its two snippets; + verify that they all close search and open the same original conversation. + Check the normal Markdown, message actions, and composer. Scroll within the + active conversation, reopen search, and select its own result; the original + pane and reading position must remain. Reopen search and check the retained query. Repeat with CJK and symbols. Change queries rapidly while delayed first-page and later-page requests resolve out of order. Close/reopen during loading. - Delete a target between search and selection; retry after a transient error. + Retry after a transient search error. Search a running conversation and return to its live stream, then switch conversations. Use arrows, Enter, Escape, Tab, and CJK IME confirmation, and exercise page/settings/plugin-command results. @@ -10585,10 +10586,12 @@ sample extensions under `apps/desktop/test/fixtures/pi-extensions/`. - **Expected**: Every matching visible session is reachable; counts cover all 125 messages, with no duplicate session rows. Archived visibility follows the existing explicit-search rule and deleted sessions never appear. Each - snippet opens its own stable message ID, including unloaded and oversized - history, without replacing the live transcript or losing active output. + snippet opens its owning conversation directly without an intermediate + context reader, plain-text replacement, or Back to conversation action. + Normal session selection preserves live output and retained reading positions; + selecting the active conversation does not reload or reset its history. Later query ownership wins over stale results/errors. IME Enter does not - execute an action. Missing targets and transport failures are explicit. + execute an action. Search transport failures are explicit. Existing commands, pages, settings, and keyboard navigation still work. - **Status**: Draft; Rust and renderer unit regressions cover the data/query boundaries. Full rendered E2E requires an explicitly authorized run. diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 52dddbdfba..54fff34818 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -1376,14 +1376,7 @@ export const de = { loading: "Suche läuft…", failed: "Suche fehlgeschlagen. Erneut versuchen.", loadMore: "Weitere Ergebnisse", - messageContext: "Nachrichtenkontext", - returnToConversation: "Zurück zum Gespräch", - contextUnavailable: "Nachricht nicht verfügbar. Möglicherweise gelöscht.", retry: "Erneut versuchen", - previousMatch: "Vorheriger Treffer", - nextMatch: "Nächster Treffer", - previousMessages: "Vorherige Nachrichten", - nextMessages: "Nächste Nachrichten", "empty": "Keine passenden Chats", "pages": "Seiten", "commands": "Befehle", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index a724fce62e..da2bc7685f 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -1393,14 +1393,7 @@ export const en = { loading: "Searching…", failed: "Search failed. Try again.", loadMore: "Load more results", - messageContext: "Message context", - returnToConversation: "Back to conversation", - contextUnavailable: "Message unavailable. It may have been deleted.", retry: "Retry", - previousMatch: "Previous match", - nextMatch: "Next match", - previousMessages: "Previous messages", - nextMessages: "Next messages", empty: "No matching chats", pages: "Pages", commands: "Commands", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 18b1044a28..d28df59c68 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -1376,14 +1376,7 @@ export const es = { loading: "Buscando…", failed: "Error de búsqueda. Inténtalo de nuevo.", loadMore: "Más resultados", - messageContext: "Contexto del mensaje", - returnToConversation: "Volver a la conversación", - contextUnavailable: "Mensaje no disponible. Puede haberse eliminado.", retry: "Reintentar", - previousMatch: "Coincidencia anterior", - nextMatch: "Siguiente coincidencia", - previousMessages: "Mensajes anteriores", - nextMessages: "Mensajes siguientes", "empty": "No hay chats coincidentes", "pages": "Páginas", "commands": "Comandos", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index 6202f7ff32..5d226c0713 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -1376,14 +1376,7 @@ export const fr = { loading: "Recherche…", failed: "Échec de la recherche. Réessayez.", loadMore: "Plus de résultats", - messageContext: "Contexte du message", - returnToConversation: "Retour à la conversation", - contextUnavailable: "Message indisponible. Il a peut-être été supprimé.", retry: "Réessayer", - previousMatch: "Résultat précédent", - nextMatch: "Résultat suivant", - previousMessages: "Messages précédents", - nextMessages: "Messages suivants", "empty": "Aucun chat correspondant", "pages": "Pages", "commands": "Commandes", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 2cafa69abb..850c19d67c 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -1395,14 +1395,7 @@ export const ko = { loading: "검색 중…", failed: "검색에 실패했습니다. 다시 시도하세요.", loadMore: "결과 더 보기", - messageContext: "메시지 문맥", - returnToConversation: "대화로 돌아가기", - contextUnavailable: "메시지를 읽을 수 없습니다. 삭제되었을 수 있습니다.", retry: "다시 시도", - previousMatch: "이전 일치 항목", - nextMatch: "다음 일치 항목", - previousMessages: "이전 메시지", - nextMessages: "다음 메시지", empty: "일치하는 채팅 없음", pages: "페이지", commands: "명령", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index 3c3901302c..d87e470999 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -1395,14 +1395,7 @@ export const tr = { loading: "Aranıyor…", failed: "Arama başarısız. Tekrar deneyin.", loadMore: "Daha fazla sonuç", - messageContext: "Mesaj bağlamı", - returnToConversation: "Sohbete dön", - contextUnavailable: "Mesaj kullanılamıyor. Silinmiş olabilir.", retry: "Yeniden dene", - previousMatch: "Önceki eşleşme", - nextMatch: "Sonraki eşleşme", - previousMessages: "Önceki mesajlar", - nextMessages: "Sonraki mesajlar", empty: "Eşleşen sohbet yok", pages: "Sayfalar", commands: "Komutlar", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index 1105ecf278..8be824b232 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -1385,14 +1385,7 @@ export const zhCN = { loading: "正在搜索…", failed: "搜索失败,请重试。", loadMore: "加载更多结果", - messageContext: "消息上下文", - returnToConversation: "返回会话", - contextUnavailable: "消息无法读取,可能已被删除。", retry: "重试", - previousMatch: "上一处命中", - nextMatch: "下一处命中", - previousMessages: "之前的消息", - nextMessages: "之后的消息", empty: "没有匹配的对话", pages: "页面", commands: "命令", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index ecba35cb48..d2733bff1b 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -1385,14 +1385,7 @@ export const zhTW = { loading: "搜尋中…", failed: "搜尋失敗,請重試。", loadMore: "載入更多結果", - messageContext: "訊息上下文", - returnToConversation: "返回對話", - contextUnavailable: "無法讀取訊息,可能已被刪除。", retry: "重試", - previousMatch: "上一個符合項目", - nextMatch: "下一個符合項目", - previousMessages: "之前的訊息", - nextMessages: "之後的訊息", empty: "沒有匹配的對話", pages: "頁面", commands: "命令", From 677599b9ff1449d8de149786019da03871b887dd Mon Sep 17 00:00:00 2001 From: zszz3 <91608029+zszz3@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:37:43 +0800 Subject: [PATCH 05/13] fix(search): jump to matching text in the original transcript --- apps/desktop/electron/main/ipc/session-ipc.ts | 4 + apps/desktop/src/components/SearchDialog.tsx | 10 +- .../src/components/SearchSessionResults.tsx | 4 +- apps/desktop/src/components/SessionPane.tsx | 21 ++- .../chat/transcript/AssistantTurn.tsx | 1 + .../chat/transcript/ChatTranscript.tsx | 52 +++++-- .../features/chat/transcript/MessageRow.tsx | 1 + .../transcript/hooks/useTranscriptScroll.ts | 71 +++++++++- apps/desktop/src/hooks/use-session-search.ts | 8 ++ .../src/hooks/use-transcript-navigation.ts | 37 +++++ apps/desktop/src/lib/api.ts | 2 + apps/desktop/src/lib/transcript-navigation.ts | 88 ++++++++++++ .../src/lib/transcript-search-highlight.ts | 25 ++++ .../src/stores/runtime/session-runtime.ts | 12 +- .../src/stores/runtime/transcript-action.ts | 32 +++++ .../src/stores/slices/session-slice.ts | 4 +- .../src/stores/slices/transcript-slice.ts | 7 +- apps/desktop/src/styles/messages.css | 31 +++++ .../test/session-search-rendering.test.mjs | 48 ++++++- .../test/transcript-navigation.test.mjs | 128 ++++++++++++++++++ crates/host-core/src/rpc/mod.rs | 7 + crates/host-core/src/session_search.rs | 102 ++++++++++++++ crates/host-core/src/sessions.rs | 60 +++++++- docs/adr/session-content-search.md | 40 ++++-- docs/spec/03-runtime/01-ipc-protocol.md | 7 +- docs/spec/03-runtime/04-data-storage.md | 7 +- docs/spec/03-runtime/06-host-rpc-protocol.md | 6 + docs/spec/04-ux/08-component-spec.md | 29 ++-- docs/spec/06-delivery/04-e2e-test-plan.md | 21 ++- packages/i18n/src/locales/de/index.ts | 1 + packages/i18n/src/locales/en/index.ts | 1 + packages/i18n/src/locales/es/index.ts | 1 + packages/i18n/src/locales/fr/index.ts | 1 + packages/i18n/src/locales/ko/index.ts | 1 + packages/i18n/src/locales/tr/index.ts | 1 + packages/i18n/src/locales/zh-CN/index.ts | 1 + packages/i18n/src/locales/zh-TW/index.ts | 1 + packages/shared/src/types/sessions.ts | 3 + 38 files changed, 807 insertions(+), 69 deletions(-) create mode 100644 apps/desktop/src/hooks/use-transcript-navigation.ts create mode 100644 apps/desktop/src/lib/transcript-navigation.ts create mode 100644 apps/desktop/src/lib/transcript-search-highlight.ts create mode 100644 apps/desktop/src/stores/runtime/transcript-action.ts create mode 100644 apps/desktop/test/transcript-navigation.test.mjs diff --git a/apps/desktop/electron/main/ipc/session-ipc.ts b/apps/desktop/electron/main/ipc/session-ipc.ts index bd0111270e..0bf332b623 100644 --- a/apps/desktop/electron/main/ipc/session-ipc.ts +++ b/apps/desktop/electron/main/ipc/session-ipc.ts @@ -210,6 +210,7 @@ export function registerSessionIpc({ | { id?: string; messageBefore?: number; + messageAround?: string; messageLimit?: number; contentLimit?: number; }, @@ -221,6 +222,9 @@ export function registerSessionIpc({ const [result, { providers, defaults }] = await Promise.all([ host.call<{ session?: RuntimeSession | null }>("session.get", { id, + ...(typeof request.messageAround === "string" && request.messageAround.trim() + ? { messageAround: request.messageAround } + : {}), ...(Number.isInteger(request.messageBefore) && request.messageBefore! >= 0 ? { messageBefore: request.messageBefore } : {}), diff --git a/apps/desktop/src/components/SearchDialog.tsx b/apps/desktop/src/components/SearchDialog.tsx index 06bc26b569..87e60ae53a 100644 --- a/apps/desktop/src/components/SearchDialog.tsx +++ b/apps/desktop/src/components/SearchDialog.tsx @@ -235,7 +235,7 @@ export function SearchDialog({ open, onClose }: { open: boolean; onClose: () => if (!open) return null; - const run = async (row: SearchRow | null) => { + const run = async (row: SearchRow | null, messageId?: string) => { const request = ++selectionRequest.current; try { if (row) { @@ -250,6 +250,10 @@ export function SearchDialog({ open, onClose }: { open: boolean; onClose: () => selected.page !== "chat" ) return; + const targetId = messageId ?? row.hit?.matches[0]?.messageId; + if (targetId) useSessionSearchState.getState().navigate({ + sessionId: row.session.id, messageId: targetId, query: query.trim(), + }); } else await newSession(); onClose(); requestAnimationFrame(() => { @@ -289,7 +293,7 @@ export function SearchDialog({ open, onClose }: { open: boolean; onClose: () => for (const row of rows) { if (active === row.optionIndex) return void run(row); const match = row.hit?.matches[active - row.optionIndex - 1]; - if (match) return void run(row); + if (match) return void run(row, match.messageId); } if (active === moreIndex && search.nextOffset !== null) return search.loadMore(); const pageIndex = active - pageBase; @@ -383,7 +387,7 @@ export function SearchDialog({ open, onClose }: { open: boolean; onClose: () => active={active} runningSessions={runningSessions} onActivate={setActive} - onSelect={(row) => void run(row)} + onSelect={(row, messageId) => void run(row, messageId)} /> {query.trim() && search.loading ? (
diff --git a/apps/desktop/src/components/SearchSessionResults.tsx b/apps/desktop/src/components/SearchSessionResults.tsx index e8126ccb83..f0d8f8c4b0 100644 --- a/apps/desktop/src/components/SearchSessionResults.tsx +++ b/apps/desktop/src/components/SearchSessionResults.tsx @@ -24,7 +24,7 @@ export function SearchSessionResults({ active: number; runningSessions: Record; onActivate: (index: number) => void; - onSelect: (row: SearchRow) => void; + onSelect: (row: SearchRow, messageId?: string) => void; }) { const { t } = useTranslation(); return ( @@ -88,7 +88,7 @@ export function SearchSessionResults({ aria-selected={active === optionIndex} className={`search-item search-message-hit ${active === optionIndex ? "active" : ""}`} onMouseEnter={() => onActivate(optionIndex)} - onClick={() => onSelect(row)} + onClick={() => onSelect(row, match.messageId)} > {match.role === "user" ? t("search.user") : t("search.assistant")} ·{" "} diff --git a/apps/desktop/src/components/SessionPane.tsx b/apps/desktop/src/components/SessionPane.tsx index 3c99fbc6e2..4bdd798e46 100644 --- a/apps/desktop/src/components/SessionPane.tsx +++ b/apps/desktop/src/components/SessionPane.tsx @@ -3,6 +3,7 @@ import { ChatTranscript } from "./ChatTranscript"; import { useAppStore } from "../stores/app-store"; import { headPermission, sessionPermissions } from "../lib/pending-permissions"; import { headAsk } from "../lib/pending-asks"; +import { useTranscriptNavigation } from "../hooks/use-transcript-navigation"; /** * One retained conversation pane (ADR 0137). @@ -47,6 +48,8 @@ export const SessionPane = memo(function SessionPane({ Boolean(headAsk(state.pendingAsks, sessionId)), ); const planningState = useAppStore((state) => state.planningStates[sessionId]); + const navigation = useTranscriptNavigation(sessionId, visible, isRunning); + const reading = navigation.window; return (
loadOlderMessages(sessionId)} + messages={reading?.messages ?? messages} + hasMoreBefore={reading ? reading.hasMoreBefore : hasMoreBefore} + onLoadOlder={reading ? navigation.loadBefore : () => loadOlderMessages(sessionId)} + searchTarget={reading ? navigation.target : null} + readingWindow={Boolean(reading)} + hasMoreAfter={reading?.hasMoreAfter} + onLoadNewer={navigation.loadAfter} + onReturnToLatest={navigation.clear} + navigationLoading={navigation.loading} isRunning={isRunning} - pendingPermission={pendingPermission} + pendingPermission={reading ? undefined : pendingPermission} queuedPermissions={queuedPermissions} - askPending={askPending} - planningState={planningState} + askPending={reading ? false : askPending} + planningState={reading ? undefined : planningState} paneVisible={visible} />
diff --git a/apps/desktop/src/features/chat/transcript/AssistantTurn.tsx b/apps/desktop/src/features/chat/transcript/AssistantTurn.tsx index 31e8d9e204..e25a96e57d 100644 --- a/apps/desktop/src/features/chat/transcript/AssistantTurn.tsx +++ b/apps/desktop/src/features/chat/transcript/AssistantTurn.tsx @@ -281,6 +281,7 @@ export const AssistantTurn = memo(function AssistantTurn({ ? " streaming" : "" }`} + data-message-id={part.message.id} key={part.message.id} > {part.message.content ? ( diff --git a/apps/desktop/src/features/chat/transcript/ChatTranscript.tsx b/apps/desktop/src/features/chat/transcript/ChatTranscript.tsx index d2d4b753d6..a5835a0a44 100644 --- a/apps/desktop/src/features/chat/transcript/ChatTranscript.tsx +++ b/apps/desktop/src/features/chat/transcript/ChatTranscript.tsx @@ -17,6 +17,7 @@ import { } from "./ActivityGroup"; import { TranscriptHistory, TranscriptTail } from "./AssistantTurn"; import { useTranscriptScroll } from "./hooks/useTranscriptScroll"; +import type { TranscriptSearchTarget } from "../../../lib/transcript-navigation"; export const ChatTranscript = memo(function ChatTranscript({ sessionId, @@ -29,6 +30,12 @@ export const ChatTranscript = memo(function ChatTranscript({ askPending = false, planningState, paneVisible = true, + searchTarget = null, + readingWindow = false, + hasMoreAfter = false, + onLoadNewer, + onReturnToLatest, + navigationLoading = false, }: { sessionId: string | undefined; messages: UiMessage[]; @@ -46,8 +53,15 @@ export const ChatTranscript = memo(function ChatTranscript({ * or re-anchor, because its scroller has no visible viewport to correct. */ paneVisible?: boolean; + searchTarget?: TranscriptSearchTarget | null; + readingWindow?: boolean; + hasMoreAfter?: boolean; + onLoadNewer?: () => Promise; + onReturnToLatest?: () => void; + navigationLoading?: boolean; }) { const { t } = useTranslation(); + const transcriptRunning = isRunning && !readingWindow; const latestTurnResult = useAppStore((state) => sessionId ? state.latestTurnResults[sessionId] : undefined, ); @@ -95,18 +109,20 @@ export const ChatTranscript = memo(function ChatTranscript({ compactions, hasMoreBefore, onLoadOlder, - isRunning, + isRunning: transcriptRunning, pendingPermission, askPending, approvalPending, planningState, paneVisible, + searchTarget, + readingWindow, }); const lastEntry = tailEntry; const lastTurnPart = lastEntry?.kind === "assistant-turn" ? lastEntry.parts.at(-1) : undefined; - const activeToolGroup = isRunning && lastTurnPart?.kind === "activity"; + const activeToolGroup = transcriptRunning && lastTurnPart?.kind === "activity"; const assistantIsAnswering = lastTurnPart?.kind === "message" && lastTurnPart.message.status === "streaming" && @@ -114,7 +130,7 @@ export const ChatTranscript = memo(function ChatTranscript({ const specializedActivity = agentActivity; const hasSpecializedActivity = specializedActivity !== undefined; const showRunActivity = - isRunning && + transcriptRunning && !pendingPermission && !askPending && !approvalPending && @@ -123,7 +139,7 @@ export const ChatTranscript = memo(function ChatTranscript({ // Show immediate feedback after send, then let the concrete activity row // (thinking/tool/answer) take over so the transcript never duplicates state. const showWorking = - isRunning && + transcriptRunning && !pendingPermission && !askPending && !approvalPending && @@ -135,7 +151,7 @@ export const ChatTranscript = memo(function ChatTranscript({ // rows carry the live state so a Planning label does not sit orphaned above // the composer. The Composer mode chip keeps pulsing for the turn. const showPlanning = - isRunning && + transcriptRunning && planningState === "planning" && !approvalPending && !pendingPermission && @@ -194,14 +210,24 @@ export const ChatTranscript = memo(function ChatTranscript({ ) : null} - void onLoadNewer?.()} + > + {t("chat.loadLaterMessages")} + + ) : null} + {!readingWindow ? + /> : null} {pendingPermission ? (
) : null} - {showJump && !veilCovering ? ( + {navigationLoading ? ( +
{t("chat.loadingSession")}
+ ) : null} + {(showJump || readingWindow) && !veilCovering ? ( { + onReturnToLatest?.(); + jumpToLatest(); + }} > diff --git a/apps/desktop/src/features/chat/transcript/MessageRow.tsx b/apps/desktop/src/features/chat/transcript/MessageRow.tsx index fb907df32b..1d2ef96aac 100644 --- a/apps/desktop/src/features/chat/transcript/MessageRow.tsx +++ b/apps/desktop/src/features/chat/transcript/MessageRow.tsx @@ -79,6 +79,7 @@ export const MessageRow = memo(function MessageRow({
diff --git a/apps/desktop/src/features/chat/transcript/hooks/useTranscriptScroll.ts b/apps/desktop/src/features/chat/transcript/hooks/useTranscriptScroll.ts index c146ff0513..09545b424b 100644 --- a/apps/desktop/src/features/chat/transcript/hooks/useTranscriptScroll.ts +++ b/apps/desktop/src/features/chat/transcript/hooks/useTranscriptScroll.ts @@ -33,6 +33,8 @@ import { isRecentScrollGesture, reduceTranscriptScroll, } from "../../../../lib/transcript-scroll"; +import type { TranscriptSearchTarget } from "../../../../lib/transcript-navigation"; +import { transcriptSearchRanges } from "../../../../lib/transcript-search-highlight"; const HISTORY_REVEAL_THRESHOLD_PX = 120; @@ -48,6 +50,8 @@ type UseTranscriptScrollOptions = { approvalPending: boolean; planningState?: PlanningState; paneVisible: boolean; + searchTarget: TranscriptSearchTarget | null; + readingWindow: boolean; }; export function useTranscriptScroll({ @@ -62,6 +66,8 @@ export function useTranscriptScroll({ approvalPending, planningState, paneVisible, + searchTarget, + readingWindow, }: UseTranscriptScrollOptions) { const scrollRef = useRef(null); const wrapRef = useRef(null); @@ -81,6 +87,8 @@ export function useTranscriptScroll({ // Read by `reachTop`, which must stay referentially stable for the scroll // listener; the projection it describes is only known later in this render. const historyLengthRef = useRef(0); + const positionedSearchRef = useRef(0); + const searchAlignUntilRef = useRef(0); const scrollToBottom = useCallback((behavior: ScrollBehavior = "auto") => { const el = scrollRef.current; @@ -105,6 +113,7 @@ export function useTranscriptScroll({ // `handleScroll` can tell the two apart and never let a clamp between a // follow `scrollTo` and its native event release follow mode. const markScrollGesture = useCallback((event: Event) => { + searchAlignUntilRef.current = 0; if ( event.type === "wheel" || event.type === "touchstart" || @@ -278,6 +287,12 @@ export function useTranscriptScroll({ const el = scrollRef.current; if (!el) return; if (el.scrollTop <= HISTORY_REVEAL_THRESHOLD_PX) reachTop(); + if (readingWindow) { + pinnedRef.current = false; + lastScrollTopRef.current = el.scrollTop; + setShowJump(true); + return; + } const wasPinned = pinnedRef.current; const transition = reduceTranscriptScroll({ previousScrollTop: lastScrollTopRef.current, @@ -314,7 +329,7 @@ export function useTranscriptScroll({ pinnedRef.current = transition.pinned; setShowJump(transition.showJump); } - }, [cancelFollowScroll, reachTop, scheduleFollowScroll]); + }, [cancelFollowScroll, reachTop, readingWindow, scheduleFollowScroll]); // Send / retry / regenerate always re-pins follow mode so the new prompt and // its stream stay in view, even if the user had scrolled up through history. @@ -381,7 +396,7 @@ export function useTranscriptScroll({ const deferredMessages = useDeferredValue(messages); const deferredCompactions = useDeferredValue(compactions); const renderedMessages = - firstCommit || paneRevealed ? messages : deferredMessages; + readingWindow || firstCommit || paneRevealed ? messages : deferredMessages; const renderedCompactions = firstCommit || paneRevealed ? compactions : deferredCompactions; const { entries, visible } = useMemo( @@ -415,7 +430,7 @@ export function useTranscriptScroll({ // its Markdown and highlighting for rows nobody was looking at. const [hydrationTick, setHydrationTick] = useState(0); const hydrationBounded = - firstCommit && allHistoryEntries.length > TRANSCRIPT_INITIAL_MOUNT; + !readingWindow && firstCommit && allHistoryEntries.length > TRANSCRIPT_INITIAL_MOUNT; // The bounded commit and the expansion must show the transcript at the same // place. A spacer sized from a per-entry guess cannot match the rows it stands // in for, so the expansion moved the visible text by the estimate error - the @@ -458,7 +473,7 @@ export function useTranscriptScroll({ const transcriptWindow = reduceTranscriptWindow({ historyLength: allHistoryEntries.length, - windowSize, + windowSize: readingWindow ? allHistoryEntries.length : windowSize, initialCommit: hydrationBounded, }); // Memoized so unrelated re-renders (jump pill, loading row) hand @@ -472,6 +487,54 @@ export function useTranscriptScroll({ [allHistoryEntries, transcriptWindow.bounded, transcriptWindow.mounted], ); + useLayoutEffect(() => { + const scroller = scrollRef.current; + const content = contentRef.current; + if (!paneVisible || !searchTarget || !scroller || !content) return; + const message = content.querySelector( + `[data-message-id="${CSS.escape(searchTarget.messageId)}"]`, + ); + if (!message) return; + const row = message.closest(".message-row") ?? message; + row.classList.add("transcript-search-target"); + const fresh = positionedSearchRef.current !== searchTarget.requestId; + if (fresh) { + positionedSearchRef.current = searchTarget.requestId; + searchAlignUntilRef.current = performance.now() + 1500; + prependHeightRef.current = null; + cancelFollowScroll(); + pinnedRef.current = false; + setShowJump(true); + } + let highlight: Highlight | undefined; + const locate = () => { + const ranges = transcriptSearchRanges(message, searchTarget.query); + if (typeof Highlight !== "undefined" && CSS.highlights) { + highlight = new Highlight(...ranges); + CSS.highlights.set("transcript-search", highlight); + } + if (performance.now() >= searchAlignUntilRef.current) return; + const rect = ranges[0]?.getBoundingClientRect() ?? message.getBoundingClientRect(); + const viewport = scroller.getBoundingClientRect(); + scroller.scrollTop += rect.top - viewport.top - Math.min(160, scroller.clientHeight / 3); + lastScrollTopRef.current = scroller.scrollTop; + }; + locate(); + // Syntax highlighting and images can settle after the first layout. Keep + // the target anchored briefly, stopping immediately on a reading gesture. + const resize = new ResizeObserver(locate); + resize.observe(content); + const mutation = new MutationObserver(locate); + mutation.observe(message, { childList: true, subtree: true, characterData: true }); + return () => { + resize.disconnect(); + mutation.disconnect(); + row.classList.remove("transcript-search-target"); + if (highlight && CSS.highlights?.get("transcript-search") === highlight) + CSS.highlights.delete("transcript-search"); + }; + }, [cancelFollowScroll, historyEntries, paneVisible, searchTarget, tailEntry]); + // Runs in the same layout phase the expansion commits in, before the browser // paints it, so mounting the remaining history cannot move the rows the user // is already looking at. A user who scrolled up during the bounded frame keeps diff --git a/apps/desktop/src/hooks/use-session-search.ts b/apps/desktop/src/hooks/use-session-search.ts index 32010da4f1..97853701e5 100644 --- a/apps/desktop/src/hooks/use-session-search.ts +++ b/apps/desktop/src/hooks/use-session-search.ts @@ -1,14 +1,22 @@ import { useEffect, useMemo, useSyncExternalStore } from "react"; import { create } from "zustand"; import { api } from "../lib/api"; +import type { TranscriptSearchTarget } from "../lib/transcript-navigation"; import { SessionSearchController } from "../lib/session-search"; /** Transient search state survives closing the palette, never goes to disk. */ +let nextTargetRequest = 0; export const useSessionSearchState = create<{ query: string; + target: TranscriptSearchTarget | null; + navigate: (target: Omit) => void; + consumeTarget: (requestId: number) => void; setQuery: (query: string) => void; }>((set) => ({ query: "", + target: null, + navigate: (target) => set({ target: { ...target, requestId: ++nextTargetRequest } }), + consumeTarget: (requestId) => set((state) => state.target?.requestId === requestId ? { target: null } : state), setQuery: (query) => set({ query }), })); diff --git a/apps/desktop/src/hooks/use-transcript-navigation.ts b/apps/desktop/src/hooks/use-transcript-navigation.ts new file mode 100644 index 0000000000..cfef9acbec --- /dev/null +++ b/apps/desktop/src/hooks/use-transcript-navigation.ts @@ -0,0 +1,37 @@ +import { useEffect, useMemo, useRef, useSyncExternalStore } from "react"; +import { api } from "../lib/api"; +import { TranscriptNavigationController } from "../lib/transcript-navigation"; +import { useAppStore } from "../stores/app-store"; +import { useSessionSearchState } from "./use-session-search"; + +export function useTranscriptNavigation(sessionId: string, visible: boolean, isRunning: boolean) { + const controller = useMemo(() => new TranscriptNavigationController(api.getSession), []); + const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot); + const target = useSessionSearchState((search) => search.target); + const consumed = useRef(0); + const wasRunning = useRef(isRunning); + useEffect(() => { + if (!visible || target?.sessionId !== sessionId || target.requestId === consumed.current) return; + consumed.current = target.requestId; + void controller.navigate(target); + useSessionSearchState.getState().consumeTarget(target.requestId); + }, [controller, sessionId, target, visible]); + useEffect(() => { + if (isRunning && !wasRunning.current) controller.clear(); + wasRunning.current = isRunning; + }, [controller, isRunning]); + useEffect(() => () => controller.clear(), [controller]); + useEffect(() => useAppStore.subscribe((next, previous) => { + // Explicit edits, revisions, and deletions publish an authoritative + // transcript. Do not leave a historical snapshot covering their result. + if (next.activeSessionId !== sessionId || previous.activeSessionId !== sessionId || + next.messages === previous.messages || + next.isRunning || previous.isRunning) return; + const remaining = new Set(next.messages.map((message) => message.id)); + if (previous.messages.some((message) => !remaining.has(message.id))) controller.clear(); + }), [controller, sessionId]); + useEffect(() => { + if (state.error && visible) useAppStore.getState().showToast(state.error, { variant: "error" }); + }, [state.error, visible]); + return { ...state, loadBefore: () => controller.page("before"), loadAfter: () => controller.page("after"), clear: controller.clear }; +} diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 88e0e54818..8f94c05496 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -178,6 +178,8 @@ function normalizeSessionDetail(detail: SessionDetail | null): SessionDetail | n } export type SessionHistoryReadOptions = { + /** Center a bounded read on this stable ID and retain its original text. */ + messageAround?: string; /** Return the newest page ending before this zero-based message offset. */ messageBefore?: number; /** Maximum number of messages in the returned page. */ diff --git a/apps/desktop/src/lib/transcript-navigation.ts b/apps/desktop/src/lib/transcript-navigation.ts new file mode 100644 index 0000000000..1768b8c164 --- /dev/null +++ b/apps/desktop/src/lib/transcript-navigation.ts @@ -0,0 +1,88 @@ +import type { SessionDetail } from "@pi-desktop/shared"; +import type { SessionHistoryReadOptions } from "./api"; + +export type TranscriptSearchTarget = { + sessionId: string; + messageId: string; + query: string; + requestId: number; +}; + +type NavigationState = { + target: TranscriptSearchTarget | null; + window: SessionDetail | null; + loading: boolean; + error?: string; +}; + +const PAGE_SIZE = 60; + +/** A reading window belongs to the pane, never to the live/model cache. */ +export class TranscriptNavigationController { + private generation = 0; + private listeners = new Set<() => void>(); + private state: NavigationState = { target: null, window: null, loading: false }; + private read: (id: string, options: SessionHistoryReadOptions) => Promise<{ session: SessionDetail | null }>; + constructor(read: (id: string, options: SessionHistoryReadOptions) => Promise<{ session: SessionDetail | null }>) { this.read = read; } + + getSnapshot = () => this.state; + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + }; + private publish(state: NavigationState) { + this.state = state; + for (const listener of this.listeners) listener(); + } + clear = () => { + this.generation += 1; + this.publish({ target: null, window: null, loading: false }); + }; + async navigate(target: TranscriptSearchTarget) { + const generation = ++this.generation; + this.publish({ target, window: null, loading: true }); + try { + const { session } = await this.read(target.sessionId, { + messageAround: target.messageId, messageLimit: PAGE_SIZE, contentLimit: 64 * 1024, + }); + if (generation !== this.generation) return; + if (!session?.messages.some((message) => message.id === target.messageId)) + throw new Error("Message no longer exists in this conversation."); + this.publish({ target, window: session, loading: false }); + } catch (error) { + if (generation === this.generation) + this.publish({ target: null, window: null, loading: false, error: String(error) }); + } + } + async page(direction: "before" | "after") { + const { window, target, loading } = this.state; + if (!window || !target || loading) return; + if (!(direction === "before" ? window.hasMoreBefore : window.hasMoreAfter)) return; + const generation = this.generation; + this.publish({ ...this.state, loading: true, error: undefined }); + try { + const { session } = await this.read(target.sessionId, { + messageBefore: direction === "before" ? window.messageStart : (window.messageEnd ?? 0) + PAGE_SIZE, + messageLimit: PAGE_SIZE, contentLimit: 64 * 1024, + }); + if (generation !== this.generation) return; + if (!session) throw new Error("Conversation no longer exists."); + const ordered = direction === "before" + ? [...session.messages, ...window.messages] : [...window.messages, ...session.messages]; + const messages = [...new Map(ordered.map((message) => [message.id, message])).values()]; + // An overlapping page must not truncate the explicitly focused text. + const focused = window.messages.find((message) => message.id === target.messageId); + if (focused) messages[messages.findIndex((message) => message.id === focused.id)] = focused; + this.publish({ target, loading: false, window: { + ...session, messages, + messageStart: Math.min(window.messageStart ?? 0, session.messageStart ?? 0), + messageEnd: Math.max(window.messageEnd ?? 0, session.messageEnd ?? 0), + hasMoreBefore: direction === "before" ? session.hasMoreBefore : window.hasMoreBefore, + hasMoreAfter: direction === "after" ? session.hasMoreAfter : window.hasMoreAfter, + } }); + } catch (error) { + if (generation === this.generation) + this.publish({ ...this.state, loading: false, error: String(error) }); + } + } +} diff --git a/apps/desktop/src/lib/transcript-search-highlight.ts b/apps/desktop/src/lib/transcript-search-highlight.ts new file mode 100644 index 0000000000..67aaee17af --- /dev/null +++ b/apps/desktop/src/lib/transcript-search-highlight.ts @@ -0,0 +1,25 @@ +import { searchMatchRanges } from "./session-search"; + +/** Match across Markdown's inline elements without rewriting React-owned DOM. */ +export function transcriptSearchRanges(root: HTMLElement, query: string): Range[] { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode: (node) => node.parentElement?.closest("button:not(.chat-text-link):not(.chat-file-chip), textarea, [aria-hidden='true']") + ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT, + }); + const nodes: { node: Text; start: number; end: number }[] = []; + let text = ""; + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const start = text.length; + text += node.textContent ?? ""; + nodes.push({ node: node as Text, start, end: text.length }); + } + return searchMatchRanges(text, query).flatMap(([start, end]) => { + const first = nodes.find((part) => part.end > start); + const last = nodes.find((part) => part.end >= end && part.start < end); + if (!first || !last) return []; + const range = document.createRange(); + range.setStart(first.node, start - first.start); + range.setEnd(last.node, end - last.start); + return [range]; + }); +} diff --git a/apps/desktop/src/stores/runtime/session-runtime.ts b/apps/desktop/src/stores/runtime/session-runtime.ts index 4df7ee2e4b..b868a83c06 100644 --- a/apps/desktop/src/stores/runtime/session-runtime.ts +++ b/apps/desktop/src/stores/runtime/session-runtime.ts @@ -75,7 +75,7 @@ export type SessionRuntime = { contentLimit?: number; }, ) => ReturnType; - loadFullSessionMessages: (id: string) => Promise; + loadFullSessionMessages: (id: string, cache?: boolean) => Promise; insertOptimisticUserMessage: (sessionId: string, message: UiMessage) => void; retractOptimisticUserMessage: (sessionId: string, message: UiMessage) => void; cacheBackgroundTranscriptEvent: (envelope: AgentEventEnvelope) => void; @@ -188,14 +188,14 @@ export function createSessionRuntime({ get, set }: StoreAccess): SessionRuntime return request; } - async function loadFullSessionMessages(id: string): Promise { + async function loadFullSessionMessages(id: string, cache = true): Promise { const detail = await api.getSession(id); if (!detail.session) return null; const messages = detail.session.messages ?? []; - cacheSessionTranscript(id, messages, { - messageStart: 0, - hasMoreBefore: false, - }); + if (cache) cacheSessionTranscript(id, messages, { + messageStart: 0, + hasMoreBefore: false, + }); return messages; } diff --git a/apps/desktop/src/stores/runtime/transcript-action.ts b/apps/desktop/src/stores/runtime/transcript-action.ts new file mode 100644 index 0000000000..f4e0f8efa0 --- /dev/null +++ b/apps/desktop/src/stores/runtime/transcript-action.ts @@ -0,0 +1,32 @@ +import type { SessionRuntime } from "./session-runtime"; +import type { StoreAccess } from "../slices/types"; + +/** Load canonical action input only when an explicit action targets old history. */ +export async function prepareTranscriptAction( + { get, set }: StoreAccess, + runtime: Pick, + messageId: string, +) { + const state = get(); + const id = state.activeSessionId; + if (!id || state.isRunning) return null; + if (state.messages.some((message) => message.id === messageId)) return state; + let messages; + try { + // Cache ownership is checked after this asynchronous read, too: a new + // turn may have already appended a live tail while the read was pending. + messages = await runtime.loadFullSessionMessages(id, false); + } catch (error) { + if (get().activeSessionId === id) + get().showToast(error instanceof Error ? error.message : String(error), { variant: "error" }); + return null; + } + const current = get(); + if (!messages || current.activeSessionId !== id || current.isRunning || current.selectingSessionId) return null; + if (!messages.some((message) => message.id === messageId)) return null; + runtime.cacheSessionTranscript(id, messages, { messageStart: 0, hasMoreBefore: false }); + set({ messages, sessionHistory: { + ...current.sessionHistory, [id]: { messageStart: 0, hasMoreBefore: false }, + } }); + return get(); +} diff --git a/apps/desktop/src/stores/slices/session-slice.ts b/apps/desktop/src/stores/slices/session-slice.ts index bfa60297a7..1a6fbd2f33 100644 --- a/apps/desktop/src/stores/slices/session-slice.ts +++ b/apps/desktop/src/stores/slices/session-slice.ts @@ -1,4 +1,5 @@ import i18n from "i18next"; +import { prepareTranscriptAction } from "../runtime/transcript-action"; import type { Mode, PlanProposal, @@ -543,7 +544,8 @@ export function createSessionSlice({ forkAssistantMessage: async (messageId) => { const intent = runtime.beginNavigationIntent(); - const state = get(); + const state = await prepareTranscriptAction({ get, set }, runtime, messageId); + if (!state || !runtime.navigationIntentIsCurrent(intent)) return; const sessionId = state.activeSessionId; if (!sessionId || state.runningSessions[sessionId]) return; const message = state.messages.find((candidate) => candidate.id === messageId); diff --git a/apps/desktop/src/stores/slices/transcript-slice.ts b/apps/desktop/src/stores/slices/transcript-slice.ts index d1fd0b68b3..10cfc2174d 100644 --- a/apps/desktop/src/stores/slices/transcript-slice.ts +++ b/apps/desktop/src/stores/slices/transcript-slice.ts @@ -17,6 +17,7 @@ import { withReviewChangeState } from "../../lib/workspace-review"; import { settleStoppedAssistantMetrics } from "../../lib/context-usage"; import type { AppState } from "../app-state"; import type { SessionRuntime } from "../runtime/session-runtime"; +import { prepareTranscriptAction } from "../runtime/transcript-action"; import type { StoreAccess } from "./types"; export type TranscriptSliceDependencies = StoreAccess & { @@ -85,8 +86,9 @@ export function createTranscriptSlice({ }, retryAssistantMessage: async (messageId) => { + const prepared = await prepareTranscriptAction({ get, set }, runtime, messageId); const state = get(); - if (state.isRunning) return; + if (!prepared || state.activeSessionId !== prepared.activeSessionId || state.isRunning) return; const index = state.messages.findIndex((message) => message.id === messageId); if (index < 0) return; const target = state.messages[index]; @@ -108,8 +110,9 @@ export function createTranscriptSlice({ }, editUserMessage: async (messageId, content, attachments) => { + const prepared = await prepareTranscriptAction({ get, set }, runtime, messageId); const state = get(); - if (state.isRunning) return false; + if (!prepared || state.activeSessionId !== prepared.activeSessionId || state.isRunning) return false; const sessionId = state.activeSessionId; if (!sessionId) return false; if (state.pendingPlans[sessionId]?.status === "pending") return false; diff --git a/apps/desktop/src/styles/messages.css b/apps/desktop/src/styles/messages.css index 896df74453..064992e9cf 100644 --- a/apps/desktop/src/styles/messages.css +++ b/apps/desktop/src/styles/messages.css @@ -2748,3 +2748,34 @@ transform: rotate(360deg); } } + +/* Search navigation uses the original message tree and its rendered text. */ +.message-row.transcript-search-target { + content-visibility: visible; +} + +::highlight(transcript-search) { + color: var(--ds-text-primary); + background-color: color-mix(in srgb, var(--ds-accent) 35%, transparent); +} + +.transcript-load-later { + display: block; + margin: 16px auto; + padding: 8px 12px; + color: var(--ds-text-muted); + border-radius: var(--radius-md); + background: var(--ds-bg-elevated-opaque); +} + +.transcript-navigation-loading { + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + padding: 6px 12px; + border-radius: var(--radius-md); + background: var(--ds-bg-elevated-opaque); + color: var(--ds-text-muted); + font-size: var(--text-xs); +} diff --git a/apps/desktop/test/session-search-rendering.test.mjs b/apps/desktop/test/session-search-rendering.test.mjs index cc07486cfb..a88fd1edab 100644 --- a/apps/desktop/test/session-search-rendering.test.mjs +++ b/apps/desktop/test/session-search-rendering.test.mjs @@ -21,14 +21,30 @@ test("session result rendering keeps literal snippets safe and selectable in eve const { SearchSessionResults } = await server.ssrLoadModule( "/src/components/SearchSessionResults.tsx", ); + const selections = []; + function ClickProbe(props) { + const element = SearchSessionResults({ + ...props, + onSelect: (row, messageId) => selections.push([row.session.id, messageId]), + }); + const visit = (node) => { + if (Array.isArray(node)) return node.forEach(visit); + if (!node?.props) return; + if (node.type === "button" && node.props.role === "option") node.props.onClick(); + visit(node.props.children); + }; + visit(element); + return element; + } for (const [locale, catalog] of Object.entries(catalogs)) { + selections.length = 0; const i18n = createInstance(); await i18n.init({ lng: locale, resources: { [locale]: { translation: catalog } } }); const html = renderToStaticMarkup( createElement( I18nextProvider, { i18n }, - createElement(SearchSessionResults, { + createElement(ClickProbe, { query: "needle", active: 2, runningSessions: {}, @@ -69,6 +85,7 @@ test("session result rendering keeps literal snippets safe and selectable in eve ), ); assert.equal((html.match(/role="option"/g) ?? []).length, 3, locale); + assert.deepEqual(selections, [["one", undefined], ["one", "u"], ["one", "a"]], locale); assert.match(html, /id="global-search-option-2"[^>]*aria-selected="true"/); assert.match(html, /<img src=x>/); assert.doesNotMatch(html, / ({ + textContent, parentElement: { closest: () => null }, + })); + globalThis.NodeFilter = { SHOW_TEXT: 4, FILTER_ACCEPT: 1, FILTER_REJECT: 2 }; + globalThis.document = { + createTreeWalker: () => { + let index = 0; + return { nextNode: () => textNodes[index++] ?? null }; + }, + createRange: () => ({ + setStart(node, offset) { this.startContainer = node; this.startOffset = offset; }, + setEnd(node, offset) { this.endContainer = node; this.endOffset = offset; }, + }), + }; + const ranges = transcriptSearchRanges({}, "needle"); + assert.equal(ranges.length, 1); + assert.equal(ranges[0].startContainer, textNodes[1]); + assert.equal(ranges[0].startOffset, 0); + assert.equal(ranges[0].endContainer, textNodes[2]); + assert.equal(ranges[0].endOffset, 3); + assert.equal(textNodes[1].textContent, "NEE", "highlighting leaves the original DOM text intact"); + } finally { + globalThis.document = originalDocument; + globalThis.NodeFilter = originalFilter; + } } finally { await server.close(); } diff --git a/apps/desktop/test/transcript-navigation.test.mjs b/apps/desktop/test/transcript-navigation.test.mjs new file mode 100644 index 0000000000..34c81cb917 --- /dev/null +++ b/apps/desktop/test/transcript-navigation.test.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { TranscriptNavigationController } from "../src/lib/transcript-navigation.ts"; +import { prepareTranscriptAction } from "../src/stores/runtime/transcript-action.ts"; + +const target = (messageId, requestId = 1) => ({ sessionId: "s", messageId, requestId, query: "needle" }); +const message = (id, content = id) => ({ id, role: "user", content }); +const page = (messages, options = {}) => ({ session: { + id: "s", messages, messageStart: 20, messageEnd: 80, + hasMoreBefore: true, hasMoreAfter: true, ...options, +} }); +const deferred = () => { + let resolve; + let reject; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +}; + +test("a search jump loads the stable target with a bounded original transcript read", async () => { + const calls = []; + const content = `${"prefix ".repeat(15_000)}needle`; + const controller = new TranscriptNavigationController(async (...args) => { + calls.push(args); + return page([message("old", content)]); + }); + await controller.navigate(target("old")); + assert.deepEqual(calls, [["s", { messageAround: "old", messageLimit: 60, contentLimit: 65536 }]]); + assert.equal(controller.getSnapshot().window.messages[0].content, content); + assert.equal(controller.getSnapshot().target.messageId, "old"); + assert.equal(controller.getSnapshot().loading, false); +}); + +test("the most recently clicked message owns completion and errors", async () => { + const first = deferred(); + const second = deferred(); + let count = 0; + const controller = new TranscriptNavigationController(() => (++count === 1 ? first : second).promise); + const a = controller.navigate(target("a")); + const b = controller.navigate(target("b", 2)); + second.resolve(page([message("b")])); + await b; + first.reject(new Error("obsolete request")); + await a; + assert.equal(controller.getSnapshot().target.messageId, "b"); + assert.equal(controller.getSnapshot().error, undefined); +}); + +test("both directions use physical cursors, deduplicate overlap, and keep focused text", async () => { + const calls = []; + const responses = [ + page([message("a"), message("b", "full needle"), message("c")]), + page([message("b", "clipped"), message("c"), message("d")], { messageStart: 70, messageEnd: 130, hasMoreAfter: false }), + page([message("z"), message("a")], { messageStart: 0, messageEnd: 20, hasMoreBefore: false }), + ]; + const controller = new TranscriptNavigationController(async (_id, options) => { + calls.push(options); + return responses.shift(); + }); + await controller.navigate(target("b")); + await controller.page("after"); + assert.equal(calls[1].messageBefore, 140, "use the physical end, not the number of returned messages"); + await controller.page("before"); + assert.equal(calls[2].messageBefore, 20); + const window = controller.getSnapshot().window; + assert.deepEqual(window.messages.map((item) => item.id), ["z", "a", "b", "c", "d"]); + assert.equal(window.messages[2].content, "full needle"); + assert.equal(window.hasMoreBefore, false); + assert.equal(window.hasMoreAfter, false); + await controller.page("after"); + assert.equal(calls.length, 3); +}); + +test("returning to the live transcript cancels an outstanding historical page", async () => { + const pending = deferred(); + let count = 0; + const controller = new TranscriptNavigationController(() => ++count === 1 + ? Promise.resolve(page([message("a")])) : pending.promise); + await controller.navigate(target("a")); + const load = controller.page("after"); + controller.clear(); + pending.resolve(page([message("b")])); + await load; + assert.deepEqual(controller.getSnapshot(), { target: null, window: null, loading: false }); +}); + +test("a deleted result cannot silently land at the newest message", async () => { + const controller = new TranscriptNavigationController(async () => page([message("different")])); + await controller.navigate(target("deleted")); + assert.equal(controller.getSnapshot().window, null); + assert.match(controller.getSnapshot().error, /no longer exists/); +}); + +test("an explicit action on an old message loads canonical input without changing sessions", async () => { + let state = { activeSessionId: "s", messages: [message("tail")], sessionHistory: { s: { hasMoreBefore: true } } }; + const access = { get: () => state, set: (patch) => { state = { ...state, ...patch }; } }; + const full = [message("old"), message("tail")]; + let reads = 0; + let cached; + const runtime = { + loadFullSessionMessages: async (_id, cache) => { + assert.equal(cache, false, "defer cache writes until action ownership is rechecked"); + reads += 1; return full; + }, + cacheSessionTranscript: (_id, messages) => { cached = messages; }, + }; + assert.equal(await prepareTranscriptAction(access, runtime, "tail"), state); + assert.equal(reads, 0); + await prepareTranscriptAction(access, runtime, "old"); + assert.equal(reads, 1); + assert.equal(state.messages, full); + assert.equal(cached, full); + assert.deepEqual(state.sessionHistory.s, { messageStart: 0, hasMoreBefore: false }); +}); + +test("action preparation cannot overwrite a later navigation or newly started turn", async () => { + for (const change of [{ activeSessionId: "other" }, { isRunning: true }]) { + let state = { activeSessionId: "s", messages: [message("tail")], sessionHistory: {} }; + const pending = deferred(); + const access = { get: () => state, set: () => assert.fail("stale action must not write") }; + const read = prepareTranscriptAction(access, { + loadFullSessionMessages: () => pending.promise, + cacheSessionTranscript: () => assert.fail("stale action must not replace the live cache"), + }, "old"); + state = { ...state, ...change }; + pending.resolve([message("old")]); + assert.equal(await read, null); + } +}); diff --git a/crates/host-core/src/rpc/mod.rs b/crates/host-core/src/rpc/mod.rs index 8a9a71293d..d6a9b1db58 100644 --- a/crates/host-core/src/rpc/mod.rs +++ b/crates/host-core/src/rpc/mod.rs @@ -1620,9 +1620,15 @@ async fn handle_request( .and_then(|v| v.as_str()) .ok_or_else(|| rpc_err(1002, "id required", "INVALID_PARAMS"))?; let message_before = params.get("messageBefore").and_then(|v| v.as_i64()); + let message_around = params + .get("messageAround") + .and_then(|v| v.as_str()) + .map(str::to_owned); let message_limit = params.get("messageLimit").and_then(|v| v.as_i64()); if message_before.is_some_and(|value| value < 0) || message_limit.is_some_and(|value| value <= 0) + || (message_around.is_some() + && (message_before.is_some() || message_limit.is_none())) { return Err(rpc_err( 1002, @@ -1650,6 +1656,7 @@ async fn handle_request( &st.db, id, sessions::SessionReadOptions { + message_around, message_before, message_limit, content_limit, diff --git a/crates/host-core/src/session_search.rs b/crates/host-core/src/session_search.rs index fc5379156d..515c7cfaa2 100644 --- a/crates/host-core/src/session_search.rs +++ b/crates/host-core/src/session_search.rs @@ -342,6 +342,108 @@ mod tests { .unwrap() } + #[test] + fn transcript_navigation_reads_original_messages_by_stable_id() { + let dir = tempfile::tempdir().unwrap(); + let db = Database::open(&dir.path().join("test.sqlite")).unwrap(); + let session = sessions::create_session(&db, None, None, None, None, None).unwrap(); + let text = format!( + "{}\n**needle** is in the original message.", + "prefix ".repeat(15_000) + ); + for n in 0..150 { + sessions::append_message( + &db, + &session.id, + &message( + &format!("m{n}"), + if n == 20 || n == 21 { + &text + } else { + "neighbor" + }, + ), + None, + ) + .unwrap(); + if n == 5 { + // A duplicate physical line shifts every later position away + // from SQLite's deduplicated message sequence. + let path = crate::transcripts::transcript_path(db.data_dir(), &session.id).unwrap(); + let contents = std::fs::read_to_string(&path).unwrap(); + let line = contents + .lines() + .find(|line| line.contains("\"id\":\"m5\"")) + .unwrap(); + use std::io::Write; + writeln!( + std::fs::OpenOptions::new().append(true).open(path).unwrap(), + "{line}" + ) + .unwrap(); + } + } + let page = sessions::get_session_with_options( + &db, + &session.id, + sessions::SessionReadOptions { + message_around: Some("m20".into()), + message_limit: Some(10), + content_limit: Some(64 * 1024), + ..Default::default() + }, + ) + .unwrap() + .unwrap(); + assert_eq!(page.messages.len(), 10); + assert_eq!(page.message_start, Some(17)); + assert_eq!(page.message_end, Some(27)); + assert_eq!(page.has_more_before, Some(true)); + assert_eq!(page.has_more_after, Some(true)); + assert_eq!( + page.messages + .iter() + .find(|m| m.id == "m20") + .unwrap() + .content, + text + ); + assert!( + page.messages + .iter() + .find(|m| m.id == "m21") + .unwrap() + .content + .len() + < text.len() + ); + let next = sessions::get_session_with_options( + &db, + &session.id, + sessions::SessionReadOptions { + message_before: Some(page.message_end.unwrap() + 10), + message_limit: Some(10), + content_limit: Some(64 * 1024), + ..Default::default() + }, + ) + .unwrap() + .unwrap(); + assert_eq!(next.message_start, page.message_end); + assert_eq!(next.messages.first().unwrap().id, "m26"); + let missing = sessions::get_session_with_options( + &db, + &session.id, + sessions::SessionReadOptions { + message_around: Some("deleted".into()), + message_limit: Some(60), + ..Default::default() + }, + ) + .unwrap(); + assert!(missing.is_none()); + } + #[test] fn search_counts_all_matches_and_pages_all_sessions() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/host-core/src/sessions.rs b/crates/host-core/src/sessions.rs index 660cb1b026..67206a1b35 100644 --- a/crates/host-core/src/sessions.rs +++ b/crates/host-core/src/sessions.rs @@ -213,6 +213,11 @@ pub struct SessionDetail { /// for a window. Omitted for the full-history form. #[serde(skip_serializing_if = "Option::is_none")] pub message_start: Option, + /// Exclusive physical end of a bounded read, including deduplicated lines. + #[serde(skip_serializing_if = "Option::is_none")] + pub message_end: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub has_more_after: Option, /// True when older messages exist outside the returned window. #[serde(skip_serializing_if = "Option::is_none")] pub has_more_before: Option, @@ -227,8 +232,10 @@ pub struct SessionDetail { pub compactions: Vec, } -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Default)] pub struct SessionReadOptions { + /// Center a bounded UI read on this stable message ID. + pub message_around: Option, /// Exclusive message sequence before which the window ends. When omitted, /// the window is taken from the end of the canonical transcript. pub message_before: Option, @@ -1300,7 +1307,7 @@ pub fn get_session_with_options( return Ok(None); }; - let (records, compactions, message_start, has_more_before) = + let (records, compactions, message_start, has_more_before, message_end, has_more_after) = if let Some(raw_limit) = options.message_limit.filter(|limit| *limit > 0) { let limit = raw_limit.min(1_000) as usize; // Window coordinates are physical transcript lines, so they must be @@ -1310,9 +1317,18 @@ pub fn get_session_with_options( // silently dropped the newest messages of a long session. let layout = session_layout(db, id)?; let total = layout.message_count(); - let before = match options.message_before { - Some(value) => (value.max(0) as usize).min(total), - None => total, + let before = if let Some(message_id) = options.message_around.as_deref() { + let Some(position) = + transcripts::find_message_position(db.data_dir(), id, &layout, message_id)? + else { + return Ok(None); + }; + (position + limit / 2 + 1).min(total) + } else { + match options.message_before { + Some(value) => (value.max(0) as usize).min(total), + None => total, + } }; let start = before.saturating_sub(limit); let read = transcripts::read_transcript_window_with_layout( @@ -1327,10 +1343,19 @@ pub fn get_session_with_options( read.compactions, Some(start as i64), Some(start > 0), + Some(before as i64), + Some(before < total), ) } else { let read = transcripts::read_transcript_with_compactions(db.data_dir(), id)?; - (dedupe_records(read.messages), read.compactions, None, None) + ( + dedupe_records(read.messages), + read.compactions, + None, + None, + None, + None, + ) }; // Content comes from the transcript file; SQLite only indexes it. A // renderer window may additionally request a display cap so a single @@ -1339,13 +1364,27 @@ pub fn get_session_with_options( let messages = match options.content_limit { Some(limit) => records .into_iter() - .map(|record| record_to_ui_for_display(record, limit)) + .map(|record| { + // An explicit search jump must retain the focused message's + // original text, including hits beyond the normal display cap. + // Neighbors and tool payloads keep their presentation limits. + let focused = options.message_around.as_deref() == Some(record.id.as_str()) + && matches!(record.role.as_str(), "user" | "assistant"); + let content = focused.then(|| record_index_text(&record)).flatten(); + let mut message = record_to_ui_for_display(record, limit); + if let Some(content) = content { + message.content = content; + } + message + }) .collect(), None => records.into_iter().map(record_to_ui).collect(), }; Ok(Some(SessionDetail { summary, message_start, + message_end, + has_more_after, has_more_before, messages, compaction: compactions.last().cloned(), @@ -1469,6 +1508,8 @@ pub fn fork_session_through( Ok(ForkSessionResult::Created(Box::new(SessionDetail { summary, message_start: None, + message_end: None, + has_more_after: None, has_more_before: None, messages, compaction: compactions.last().cloned(), @@ -3915,6 +3956,7 @@ mod tests { &db, &session.id, SessionReadOptions { + message_around: None, message_before: before, message_limit: Some(2), content_limit: None, @@ -3947,6 +3989,7 @@ mod tests { &db, &session.id, SessionReadOptions { + message_around: None, message_before: None, message_limit: Some(2), content_limit: None, @@ -4002,6 +4045,7 @@ mod tests { &db, &session.id, SessionReadOptions { + message_around: None, message_before: None, message_limit: Some(2), content_limit: None, @@ -4024,6 +4068,7 @@ mod tests { &db, &session.id, SessionReadOptions { + message_around: None, message_before: Some(start), message_limit: Some(2), content_limit: None, @@ -4062,6 +4107,7 @@ mod tests { &db, &session.id, SessionReadOptions { + message_around: None, message_before: None, message_limit: Some(1), content_limit: Some(128), diff --git a/docs/adr/session-content-search.md b/docs/adr/session-content-search.md index 11d84c3477..b8d921d826 100644 --- a/docs/adr/session-content-search.md +++ b/docs/adr/session-content-search.md @@ -45,13 +45,35 @@ sessions, while explicit searches retain the existing archived-session discovery behavior. Tools, thinking, attachments, and discarded revisions do not expand the searchable body scope. -Selecting a heading or message preview closes search and opens the owning -conversation through the existing session selection path. The original -transcript, composer, Markdown rendering, live output, and message actions stay -available. Opening a retained conversation follows its existing scroll -restoration; opening the active session does not reload its history. Composer -focus uses `preventScroll`. There is no separate historical reader, extra Back -to conversation action, or forced message-level navigation. +Selecting a message preview closes search, selects its original conversation, +and scrolls the existing transcript to the clicked message's matching rendered +text. A heading with body matches selects its first preview; metadata-only +headings retain normal sidebar navigation. Mouse and keyboard carry the same +stable message ID. Each assistant fragment exposes its own anchor even when +several fragments share a single assistant-turn row. Literal text ranges are +highlighted without rewriting the React-owned Markdown tree. Navigation releases +bottom following and briefly anchors while asynchronous layout settles; a real +reading gesture ends that correction. Composer focus uses `preventScroll`. + +The additive `session.get({ messageAround, messageLimit, contentLimit })` form +resolves a stable ID against physical JSONL positions and returns the original +`UiMessage` projection centered on that ID. It requires a positive limit and is +mutually exclusive with `messageBefore`. A missing target returns no session, +never an unrelated tail. The renderer requests 60 lines. Neighbors and tool +payloads retain their display caps; only the explicitly selected user/assistant +message's text is complete, including a match beyond the usual 64 KiB cap. +Bounded responses add exclusive `messageEnd` and `hasMoreAfter`, so forward +paging uses physical positions rather than deduplicated array lengths. + +This reading window belongs to the retained session pane and renders through +its original `ChatTranscript`, Markdown, message actions, and composer. It does +not replace the live/model transcript cache. Upward paging and Load later +messages extend it contiguously. The existing latest-message control returns to +the live projection; starting a new turn does the same. Edits and deletions must +not leave an obsolete reading window in front of their results. Explicit actions +on an old message prepare canonical input through the existing full-read path +only when needed; search navigation itself never loads the whole transcript. +No separate historical reader or Back to conversation action is introduced. The additive `search.context` RPC and `session/searchContext` IPC remain compatible for existing callers, but global search no longer uses them. They @@ -67,13 +89,15 @@ owns conversation loading, live transcript preservation, and workspace changes. There is no migration or new index to maintain. Short queries and non-ASCII case mappings still require a literal scan. Search previews stay bounded, and -opening a result uses ordinary session history pagination and retained panes. +opening a result uses a bounded original transcript window in its retained pane. Search reads cannot rewrite conversation data or interfere with active turns. Rust regression tests cover pagination beyond 50 sessions and 100 messages, literal CJK/symbol queries, complete counts, soft deletion, physical positions, and target text beyond the display cap. Renderer unit tests cover literal highlight offsets, stale result/error rejection, cancellation, and pagination. +Regressions also cover stable-ID navigation past the tail/display cap, physical +cursors, stale jump/page rejection, and explicit old-message action preparation. Historical context regression tests remain as protocol compatibility coverage. The documented full interaction scenario is `E2E-SESSION-content-search-and-message-navigation`. diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md index d064db91ae..2c64d4461b 100644 --- a/docs/spec/03-runtime/01-ipc-protocol.md +++ b/docs/spec/03-runtime/01-ipc-protocol.md @@ -802,12 +802,17 @@ Minimal interface: through the reviewed desktop-control path; it does not create or mutate the session - `session/fork({ sessionId, title?, throughMessageId? }) -> { session: SessionDetail }` -- `session/get({ id, messageBefore?, messageLimit?, contentLimit? })` — without +- `session/get({ id, messageBefore?, messageAround?, messageLimit?, contentLimit? })` — without read-window options returns the complete UI projection; with them returns a bounded newest/older page plus `messageStart` and `hasMoreBefore`. The content limit applies only to display values and never changes the lossless transcript or model context. `messageBefore` and `messageStart` are physical message-line positions in the transcript file, not deduplicated index counts. + `messageAround` centers a bounded read on a stable message ID; it requires + `messageLimit` and cannot accompany `messageBefore`. A missing target returns + no session. Only the selected user/assistant text bypasses the display cap. + Bounded responses also include exclusive `messageEnd` and `hasMoreAfter` for + forward paging; reading windows never replace the live transcript cache. - `session/search({ query, offset? }) -> SessionSearchPage` forwards to `search.sessions`; host-core owns discovery, counts, filtering, and pagination. - `session/searchContext(SessionSearchContextRequest) -> SessionSearchContext` diff --git a/docs/spec/03-runtime/04-data-storage.md b/docs/spec/03-runtime/04-data-storage.md index 39724e5a05..96eae03979 100644 --- a/docs/spec/03-runtime/04-data-storage.md +++ b/docs/spec/03-runtime/04-data-storage.md @@ -1025,7 +1025,12 @@ projection. The full transcript remains lossless on disk and the sidecar's uncapped `session.get` path is unchanged for model context, edits, revisions, and other host-owned mutations. The renderer opens with the newest window and requests older windows on demand; the response's `messageStart` and -`hasMoreBefore` fields are the only pagination state it needs. +`hasMoreBefore` fields support backward paging. Search navigation additionally +uses `messageAround` to center a bounded original-message window on a stable ID, +plus exclusive physical `messageEnd` and `hasMoreAfter` for forward paging. Only +the explicitly selected user/assistant text bypasses the display cap. The +retained pane owns that reading window separately from live/model caches; +missing targets never fall back to a different message (ADR session-content-search). A bounded window is served through a per-session **transcript layout**: the byte offset of every message and compaction line, plus the file length those offsets diff --git a/docs/spec/03-runtime/06-host-rpc-protocol.md b/docs/spec/03-runtime/06-host-rpc-protocol.md index bee495addf..0c100f6843 100644 --- a/docs/spec/03-runtime/06-host-rpc-protocol.md +++ b/docs/spec/03-runtime/06-host-rpc-protocol.md @@ -266,6 +266,12 @@ to later refresh and inference; the vendor picker does not collect them. positions, clamped against the cached transcript layout rather than the deduplicated session index counter, and a window is served by seeking to its first selected line instead of scanning the history before it. + Optional `messageAround` centers that window on a stable message ID resolved + against the canonical file, requires `messageLimit`, and excludes + `messageBefore`. Missing IDs return no session. The focused user/assistant + message retains its complete text; neighboring text and tool fields stay + capped. Bounded reads also return exclusive physical `messageEnd` and + `hasMoreAfter` to support contiguous forward pages (ADR session-content-search). - `session.delete` - `session.getScratchPath` — the session's scratch directory (D114), created on demand diff --git a/docs/spec/04-ux/08-component-spec.md b/docs/spec/04-ux/08-component-spec.md index 465002fdbf..15fda5bdd1 100644 --- a/docs/spec/04-ux/08-component-spec.md +++ b/docs/spec/04-ux/08-component-spec.md @@ -1258,15 +1258,22 @@ storage but compose into one assistant turn until the next user message. remain discoverable through their content. - Load more continues host session pagination; a renderer's loaded sessions or the old 50-session/100-message limits cannot truncate discovery. -- Clicking a session heading or either snippet closes global search and opens - the owning conversation through the normal session selection path. Show the - original rendered transcript and composer directly, with the same reading - position behavior as opening it from the sidebar. A result for the active - session reveals its existing pane without reloading its history. Focusing - the composer must not scroll the page. -- Snippets identify which conversation to open; they do not open a separate - historical reader or force message-level navigation. Keep the original - transcript, live updates, and editing actions available immediately. +- Clicking a message snippet closes search, opens its original conversation, + and scrolls to the clicked message's matching rendered text with a visible + literal highlight. A heading with body matches selects its first snippet; + metadata-only headings use ordinary session navigation. Mouse and keyboard + must select the same target ID, including individual assistant fragments. +- Old targets load a bounded 60-line original `UiMessage` window centered on + their stable ID. The selected message keeps complete text beyond the usual + display cap. Render the same transcript, Markdown, message actions, and + composer; do not open a separate reader. Preserve the live cache separately. + Upward paging and Load later messages extend the reading window. The existing + latest-message control and a new turn return to live output. Actions on an + old message prepare canonical input before editing, retrying, or branching. +- Release bottom following on a search jump. Brief layout corrections may keep + the selected text visible, but stop on a real reading gesture. Composer focus + must not move the transcript. A missing target reports failure without jumping + to an unrelated message; stale requests must not override a newer target. - Preserve the query in memory when closing/reopening search. Debounce queries and reject stale results and errors after a new query, closure, or navigation. Loading and failure states must not masquerade as an empty result. @@ -1276,8 +1283,8 @@ storage but compose into one assistant turn until the next user message. ### 7.6 MVP constraints -- Global search opens the original conversation (ADR session-content-search). - Message-level navigation and an independent in-transcript find bar remain +- Global search locates a matching message inside its original conversation + (ADR session-content-search). An independent in-transcript find bar remains outside this scope. - No inline message branching tree; regenerate variants remain linear per user root turn. Session-level Create branch produces an independent conversation diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index cc00e22c9c..b936d7ca57 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -10568,10 +10568,15 @@ sample extensions under `apps/desktop/test/fixtures/pi-extensions/`. - **Steps**: Search body-only user and assistant terms, then rename the owning session and repeat. Check aggregated counts and sender/time/snippet labels. Load every result page. Open a session heading and each of its two snippets; - verify that they all close search and open the same original conversation. - Check the normal Markdown, message actions, and composer. Scroll within the - active conversation, reopen search, and select its own result; the original - pane and reading position must remain. Reopen search and check the retained query. + verify that each snippet closes search, opens the same original conversation, + and scrolls to its own matching text. The heading selects its first snippet. + Check normal Markdown, message actions, and composer. Repeat with two assistant + fragments in one turn, an old target outside the latest page, and a match after + 100,000 characters. Verify a visible highlight and that layout settling does + not pull the target away. Read upward and load later messages without gaps. + Use the latest-message control to resume the live transcript. Scroll within + the active conversation, reopen search, and select its own result; locate the + target inside the same pane. Edit, retry, branch, and delete an old message. Reopen search and check the retained query. Repeat with CJK and symbols. Change queries rapidly while delayed first-page and later-page requests resolve out of order. Close/reopen during loading. Retry after a transient search error. @@ -10588,8 +10593,12 @@ sample extensions under `apps/desktop/test/fixtures/pi-extensions/`. the existing explicit-search rule and deleted sessions never appear. Each snippet opens its owning conversation directly without an intermediate context reader, plain-text replacement, or Back to conversation action. - Normal session selection preserves live output and retained reading positions; - selecting the active conversation does not reload or reset its history. + Each selected snippet lands at its exact message and matching rendered text, + including old history and individual assistant fragments. Reading-window + loads do not overwrite live output. New turns return to the live transcript. + Actions on historical messages work normally and do not leave obsolete rows. + Rapidly selecting another result rejects stale target/page completions. + Deleting a result before selection reports failure instead of landing at the tail. Later query ownership wins over stale results/errors. IME Enter does not execute an action. Search transport failures are explicit. Existing commands, pages, settings, and keyboard navigation still work. diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 54fff34818..a309f8b505 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -247,6 +247,7 @@ export const de = { "loadingSession": "Konversation wird geladen…", "pasteInProgress": "Der eingefügte Inhalt wird weiterhin gespeichert. Versuchen Sie es gleich noch einmal.", "loadingEarlierMessages": "Frühere Nachrichten laden…", + loadLaterMessages: "Spätere Nachrichten laden", "showEarlierMessages": "Frühere Nachrichten anzeigen", "copy": "Kopieren", "copied": "Kopiert", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index da2bc7685f..05198b0c47 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -254,6 +254,7 @@ export const en = { loadingSession: "Loading conversation…", pasteInProgress: "Still saving the pasted content. Try again in a moment.", loadingEarlierMessages: "Loading earlier messages…", + loadLaterMessages: "Load later messages", showEarlierMessages: "Show earlier messages", copy: "Copy", copied: "Copied", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index d28df59c68..0c6e8b9d4e 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -247,6 +247,7 @@ export const es = { "loadingSession": "Cargando conversación...", "pasteInProgress": "Todavía guardando el contenido pegado. Inténtalo de nuevo en un momento.", "loadingEarlierMessages": "Cargando mensajes anteriores…", + loadLaterMessages: "Cargar mensajes posteriores", "showEarlierMessages": "Mostrar mensajes anteriores", "copy": "Copiar", "copied": "Copiado", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index 5d226c0713..a756b4ff4c 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -247,6 +247,7 @@ export const fr = { "loadingSession": "Chargement de la conversation…", "pasteInProgress": "Toujours enregistrer le contenu collé. Réessayez dans un instant.", "loadingEarlierMessages": "Chargement des messages précédents…", + loadLaterMessages: "Charger les messages suivants", "showEarlierMessages": "Afficher les messages précédents", "copy": "Copier", "copied": "Copié", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 850c19d67c..8cb17924ed 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -256,6 +256,7 @@ export const ko = { loadingSession: "대화 불러오는 중…", pasteInProgress: "붙여넣은 내용을 아직 저장 중입니다. 잠시 후 다시 시도하세요.", loadingEarlierMessages: "이전 메시지 불러오는 중…", + loadLaterMessages: "이후 메시지 불러오기", showEarlierMessages: "이전 메시지 표시", copy: "복사", copied: "복사됨", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index d87e470999..bd1ec76e28 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -256,6 +256,7 @@ export const tr = { loadingSession: "Sohbet yükleniyor…", pasteInProgress: "Yapıştırılan içerik hâlâ kaydediliyor. Biraz sonra yeniden deneyin.", loadingEarlierMessages: "Önceki iletiler yükleniyor…", + loadLaterMessages: "Sonraki mesajları yükle", showEarlierMessages: "Önceki iletileri göster", copy: "Kopyala", copied: "Kopyalandı", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index 8be824b232..cd8e04a93f 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -249,6 +249,7 @@ export const zhCN = { loadingSession: "正在加载会话…", pasteInProgress: "粘贴内容仍在保存中,请稍后再发送。", loadingEarlierMessages: "正在加载更早的消息…", + loadLaterMessages: "加载后续消息", showEarlierMessages: "显示更早的消息", copy: "复制", copied: "已复制", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index d2733bff1b..e9d22c4c93 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -249,6 +249,7 @@ export const zhTW = { loadingSession: "正在載入會話…", pasteInProgress: "貼上內容仍在儲存中,請稍後再發送。", loadingEarlierMessages: "正在載入更早的訊息…", + loadLaterMessages: "載入後續訊息", showEarlierMessages: "顯示更早的訊息", copy: "複製", copied: "已複製", diff --git a/packages/shared/src/types/sessions.ts b/packages/shared/src/types/sessions.ts index 027c9f7970..7c2755cafb 100644 --- a/packages/shared/src/types/sessions.ts +++ b/packages/shared/src/types/sessions.ts @@ -30,6 +30,9 @@ export type SessionDetail = SessionSummary & { messages: UiMessage[]; /** Zero-based offset of the first message returned by a bounded history read. */ messageStart?: number; + /** Exclusive physical end of a bounded read; not the deduplicated length. */ + messageEnd?: number; + hasMoreAfter?: boolean; /** True when older messages must be requested with another bounded read. */ hasMoreBefore?: boolean; /** The checkpoint that governs the next model request, i.e. the last of From 381f3d9c5e1d4b91fa5af9c5e18a1ce0ca818be1 Mon Sep 17 00:00:00 2001 From: zszz3 <91608029+zszz3@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:50:28 +0800 Subject: [PATCH 06/13] feat(tray): add bounded session shortcuts --- .../electron/main/bootstrap/app-lifecycle.ts | 78 +++++++- apps/desktop/electron/main/index.ts | 4 + apps/desktop/electron/main/ipc/register.ts | 13 +- apps/desktop/electron/main/ipc/window-ipc.ts | 16 ++ apps/desktop/electron/main/mcp-control.ts | 1 + apps/desktop/electron/main/tray-sessions.ts | 180 ++++++++++++++++++ .../src/features/app/useAppShellRuntime.tsx | 8 +- .../src/features/app/useTraySessions.ts | 65 +++++++ apps/desktop/src/lib/api.ts | 10 + apps/desktop/src/lib/sidebar-preferences.ts | 66 +------ .../desktop/src/lib/sidebar-session-status.ts | 23 +-- .../test/interaction-performance.test.mjs | 2 +- ...8-cross-platform-tray-resident-minimize.md | 6 +- docs/adr/README.md | 3 +- docs/adr/tray-session-shortcuts.md | 54 ++++++ docs/spec/03-runtime/01-ipc-protocol.md | 20 ++ docs/spec/03-runtime/07-process-model.md | 14 ++ docs/spec/04-ux/08-component-spec.md | 11 ++ docs/spec/04-ux/09-interaction-patterns.md | 26 ++- docs/spec/06-delivery/04-e2e-test-plan.md | 40 ++++ docs/spec/08-meta/decisions-log.md | 11 ++ docs/zh-CN/adr/index.md | 4 + docs/zh-CN/spec/03-runtime/01-ipc-protocol.md | 20 ++ .../zh-CN/spec/03-runtime/07-process-model.md | 14 ++ docs/zh-CN/spec/04-ux/08-component-spec.md | 11 ++ .../spec/04-ux/09-interaction-patterns.md | 35 +++- .../spec/06-delivery/04-e2e-test-plan.md | 40 ++++ docs/zh-CN/spec/08-meta/decisions-log.md | 11 ++ packages/i18n/src/locales/de/index.ts | 4 + packages/i18n/src/locales/en/index.ts | 4 + packages/i18n/src/locales/es/index.ts | 4 + packages/i18n/src/locales/fr/index.ts | 4 + packages/i18n/src/locales/ko/index.ts | 4 + packages/i18n/src/locales/tr/index.ts | 4 + packages/i18n/src/locales/zh-CN/index.ts | 4 + packages/i18n/src/locales/zh-TW/index.ts | 4 + packages/shared/src/index.ts | 3 + packages/shared/src/protocol.ts | 2 + packages/shared/src/session-outcomes.ts | 22 +++ packages/shared/src/session-presentation.ts | 89 +++++++++ packages/shared/src/tray-sessions.ts | 95 +++++++++ 41 files changed, 920 insertions(+), 109 deletions(-) create mode 100644 apps/desktop/electron/main/tray-sessions.ts create mode 100644 apps/desktop/src/features/app/useTraySessions.ts create mode 100644 docs/adr/tray-session-shortcuts.md create mode 100644 packages/shared/src/session-outcomes.ts create mode 100644 packages/shared/src/session-presentation.ts create mode 100644 packages/shared/src/tray-sessions.ts diff --git a/apps/desktop/electron/main/bootstrap/app-lifecycle.ts b/apps/desktop/electron/main/bootstrap/app-lifecycle.ts index 9962013b9f..13c9ad8726 100644 --- a/apps/desktop/electron/main/bootstrap/app-lifecycle.ts +++ b/apps/desktop/electron/main/bootstrap/app-lifecycle.ts @@ -1,10 +1,14 @@ -import { app, BrowserWindow, Menu, nativeImage, nativeTheme, Tray } from "electron"; +import { + app, BrowserWindow, Menu, nativeImage, nativeTheme, Tray, + type MenuItemConstructorOptions, +} from "electron"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { APP_MENU_COMMANDS, APP_NAME, IPC, + traySessionTitle, type AppMenuCommand, type CloseBehavior, type KeybindingOverrides, @@ -12,6 +16,8 @@ import { } from "@pi-desktop/shared"; import { catalogs, resolveLocale } from "@pi-desktop/i18n"; import { installApplicationMenu } from "../application-menu"; +import { createTraySessions } from "../tray-sessions"; +import type { HostProcess } from "../host-process"; import { createWindow, type WindowLifecycleState } from "./window"; import type { BrowserPane } from "../browser-view"; import type { Logger } from "../logger"; @@ -62,6 +68,8 @@ export type ApplicationLifecycleDependencies = { applyPluginLauncherShortcut: (keybindings?: KeybindingOverrides) => void; applySummonWindowShortcut: (keybindings?: KeybindingOverrides) => void; broadcastPluginPanelEvent: (event: string, payload: unknown) => void; + getHost: () => HostProcess | null; + getRunningSessionIds: () => Iterable; }; export function createApplicationLifecycle({ @@ -93,7 +101,17 @@ export function createApplicationLifecycle({ applyPluginLauncherShortcut, applySummonWindowShortcut, broadcastPluginPanelEvent, + getHost, + getRunningSessionIds, }: ApplicationLifecycleDependencies) { + const traySessions = createTraySessions({ + getHost, + getRunningSessionIds, + isQuitting: () => state.quitting, + onChanged: () => updateTrayMenu(), + logger, + }); + let trayActivationGeneration = 0; function applyDevelopmentBranding() { if (process.platform !== "darwin" || !isDevelopmentBuild || !app.dock) return; @@ -146,12 +164,56 @@ export function createApplicationLifecycle({ }); } - function updateTrayMenu(locale = app.getLocale()) { + async function activateTraySession(sessionId: string | null) { + const generation = ++trayActivationGeneration; + await ensureWindow(); + const window = state.mainWindow; + if (!window || window.isDestroyed() || !(await waitForMenuRenderer(window))) return; + if (generation !== trayActivationGeneration) return; + if (window.isMinimized()) window.restore(); + window.show(); + window.focus(); + if (sessionId) await traySessions.refresh(); + if ( + generation !== trayActivationGeneration || + window !== state.mainWindow || + window.isDestroyed() + ) return; + if (sessionId && !traySessions.canActivate(sessionId)) return; + sendToRenderer(IPC.event.traySessionActivated, { sessionId }); + } + + function dispatchTrayActivation(sessionId: string | null) { + void activateTraySession(sessionId).catch((error) => { + logger.app("diagnostics", "error", "tray session activation failed", { + data: String(error), + }); + }); + } + + function updateTrayMenu(locale = appearanceState.updaterLocale || app.getLocale()) { if (!state.tray) return; - const labels = catalogs[resolveLocale(locale)].tray; + const catalog = catalogs[resolveLocale(locale)]; + const labels = catalog.tray; + const template: MenuItemConstructorOptions[] = [ + { label: labels.open, click: restoreMainWindow }, + ]; + for (const group of traySessions.getGroups()) { + template.push({ type: "separator" }, { label: labels[group.kind], enabled: false }); + for (const session of group.sessions) { + const title = traySessionTitle(session.title, catalog.chat.untitledTask); + template.push({ + label: process.platform === "darwin" ? title : title.replace(/&/g, "&&"), + click: () => dispatchTrayActivation(session.id), + }); + } + if (group.hasMore) { + template.push({ label: labels.viewMore, click: () => dispatchTrayActivation(null) }); + } + } state.tray.setContextMenu( Menu.buildFromTemplate([ - { label: labels.open, click: restoreMainWindow }, + ...template, { type: "separator" }, { label: labels.quit, click: () => app.quit() }, ]), @@ -183,9 +245,14 @@ export function createApplicationLifecycle({ state.tray = new Tray(icon); state.tray.setToolTip(APP_NAME); - state.tray.on("click", restoreMainWindow); + // macOS single-click opens its attached menu without focusing/reading a conversation. + if (process.platform !== "darwin") state.tray.on("click", restoreMainWindow); state.tray.on("double-click", restoreMainWindow); + // User access retries a transient Host read failure without a polling timer. + state.tray.on("mouse-enter", () => { void traySessions.refresh(); }); + state.tray.on("right-click", () => { void traySessions.refresh(); }); updateTrayMenu(); + void traySessions.refresh(); } @@ -510,6 +577,7 @@ export function createApplicationLifecycle({ } return { + traySessions, applyDevelopmentBranding, hasVisibleWindow, restoreMainWindow, diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index 38a5b1865d..7431f840d3 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -808,6 +808,7 @@ function isHostUnavailable(error: unknown): boolean { /** Pull the user's MCP server records from host-core into the local runtime. */ function sendToRenderer(channel: string, payload: unknown) { + applicationLifecycle?.traySessions.observeEvent(channel, payload); if (channel === IPC.event.pluginChanged) { applicationLifecycle?.applyNativeThemeSource({ theme: applicationAppearanceState.appThemePreference, @@ -892,6 +893,8 @@ const applicationAppearanceState: ApplicationAppearanceState = { }; applicationLifecycle = createApplicationLifecycle({ + getHost: () => host, + getRunningSessionIds: () => activeTurns.keys(), state: windowLifecycleState, appState: applicationLifecycleState, appearanceState: applicationAppearanceState, @@ -1222,6 +1225,7 @@ const { bootHostStatus, runtimeArch, bootBackends } = runtimeLifecycle; function registerIpc() { return registerIpcHandlers({ + traySessions: applicationLifecycle!.traySessions, ipcMain, wrap, getMainWindow: () => mainWindow, diff --git a/apps/desktop/electron/main/ipc/register.ts b/apps/desktop/electron/main/ipc/register.ts index 3f5bae8abc..1ee3d4231f 100644 --- a/apps/desktop/electron/main/ipc/register.ts +++ b/apps/desktop/electron/main/ipc/register.ts @@ -23,11 +23,13 @@ import { registerWindowIpc } from "./window-ipc"; import { createComposerTemplateLoader, registerWorkspaceIpc } from "./workspace-ipc"; import { registerComposerIpc } from "./composer-ipc"; import type { IpcRegistrar } from "./types"; +import type { createTraySessions } from "../tray-sessions"; export type RegisterIpcDependencies = { ipcMain: IpcMain; getMainWindow: () => BrowserWindow | null; getHost: () => HostProcess | null; + traySessions: ReturnType; getSidecar: () => AgentSidecar | null; getAgentHostBridge: () => AgentHostBridge | null; getNotificationViewingSessionId: () => string | null; @@ -86,6 +88,7 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) { applyCloseBehavior, getCloseBehavior, markMenuRendererReady, + traySessions, executeNativeMenuAction, scheduledRunsBySession, isDevelopmentBuild, @@ -120,8 +123,13 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) { const ipcHandlers = new Map Promise>(); const handle = (channel: string, fn: (...args: any[]) => Promise) => { - ipcHandlers.set(channel, fn); - ipcMain.handle(channel, async (_event, ...args) => wrap(() => fn(...args))); + const handler = async (...args: any[]) => { + const result = await fn(...args); + traySessions.observeInvoke(channel); + return result; + }; + ipcHandlers.set(channel, handler); + ipcMain.handle(channel, async (_event, ...args) => wrap(() => handler(...args))); }; const handleWithEvent = ( channel: string, @@ -226,6 +234,7 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) { loadComposerTemplatesCached, }); registerWindowIpc({ + setTraySessionPreferences: traySessions.setPreferences, registrar, getMainWindow, getWorkPanelReservationWidth, diff --git a/apps/desktop/electron/main/ipc/window-ipc.ts b/apps/desktop/electron/main/ipc/window-ipc.ts index 73fc2e88b8..c78b2a7441 100644 --- a/apps/desktop/electron/main/ipc/window-ipc.ts +++ b/apps/desktop/electron/main/ipc/window-ipc.ts @@ -4,8 +4,10 @@ import { IPC, NATIVE_MENU_ACTIONS, WINDOW_CONTROL_ACTIONS, + parseTraySessionPreferences, type NativeMenuAction, type WindowControlAction, + type TraySessionPreferences, } from "@pi-desktop/shared"; import { emptyWorkPanelReservationState, @@ -26,6 +28,7 @@ export type WindowIpcDependencies = { getCloseBehavior: () => "ask" | "tray" | "quit"; markMenuRendererReady: (window: BrowserWindow) => boolean; executeNativeMenuAction: (action: NativeMenuAction) => unknown; + setTraySessionPreferences: (preferences: TraySessionPreferences) => Promise; }; /** Register renderer-drawn window chrome and work-panel geometry channels. */ @@ -40,9 +43,22 @@ export function registerWindowIpc({ getCloseBehavior, markMenuRendererReady, executeNativeMenuAction, + setTraySessionPreferences, }: WindowIpcDependencies): void { const { handle, handleWithEvent } = registrar; + handleWithEvent(IPC.invoke.traySetSessionPreferences, async (event, input: unknown) => { + registrar.assertMainWindowSender(event); + const preferences = parseTraySessionPreferences(input); + if (!preferences) { + throw Object.assign(new Error("invalid tray session preferences"), { + errorCode: ErrorCodes.INVALID_ARGUMENT, + }); + } + await setTraySessionPreferences(preferences); + return { ok: true }; + }); + handle(IPC.invoke.windowSetWorkPanelReservation, async (input: unknown = {}) => { const requested = parseWorkPanelReservationWidth(input); if (requested === null) { diff --git a/apps/desktop/electron/main/mcp-control.ts b/apps/desktop/electron/main/mcp-control.ts index 53b5310224..dae7aa9259 100644 --- a/apps/desktop/electron/main/mcp-control.ts +++ b/apps/desktop/electron/main/mcp-control.ts @@ -457,6 +457,7 @@ export const MCP_CONTROL_BLOCKED_CHANNEL_KEYS = [ "composerPasteFiles", "clipboardRecordPaste", "menuRendererReady", + "traySetSessionPreferences", "providersCreate", "providersUpdate", "providersDelete", diff --git a/apps/desktop/electron/main/tray-sessions.ts b/apps/desktop/electron/main/tray-sessions.ts new file mode 100644 index 0000000000..b3b2ad3a6a --- /dev/null +++ b/apps/desktop/electron/main/tray-sessions.ts @@ -0,0 +1,180 @@ +import { + buildTraySessionGroups, + IPC, + normalizeProjectPath, + type AgentEventEnvelope, + type HostStatusEvent, + type NotificationListResult, + type SessionSummary, + type TraySessionGroup, + type TraySessionPreferences, +} from "@pi-desktop/shared"; +import type { HostProcess } from "./host-process"; +import type { Logger } from "./logger"; + +const REFRESH_AFTER_INVOKE = new Set([ + IPC.invoke.sessionCreate, + IPC.invoke.sessionFork, + IPC.invoke.sessionRename, + IPC.invoke.sessionDelete, + IPC.invoke.sessionMoveProject, + IPC.invoke.sessionSummarizeTitle, + IPC.invoke.sessionImportRun, + IPC.invoke.notificationMarkRead, + IPC.invoke.notificationMarkAllRead, + IPC.invoke.notificationClear, +]); + +/** Main owns native presentation; durable records still come exclusively from Host. */ +export function createTraySessions({ + getHost, + getRunningSessionIds, + isQuitting, + onChanged, + logger, +}: { + getHost: () => HostProcess | null; + getRunningSessionIds: () => Iterable; + isQuitting: () => boolean; + onChanged: () => void; + logger: Pick; +}) { + let preferences: TraySessionPreferences = { + sessionMeta: {}, + archivedProjectPaths: [], + sort: "recent", + }; + let sessions: SessionSummary[] = []; + let groups: TraySessionGroup[] = []; + let signature = "[]"; + let revision = 0; + let pending: Promise | null = null; + let agentAvailable = true; + let hasPreferences = false; + // Root terminal events settle the UI before Main releases durable turn ownership. + const runningOverrides = new Map(); + + function publish(next: TraySessionGroup[]) { + const nextSignature = JSON.stringify(next); + if (signature === nextSignature) return; + signature = nextSignature; + groups = next; + onChanged(); + } + + function refresh(): Promise { + revision += 1; + if (pending) return pending; + pending = (async () => { + let observed: number; + do { + observed = revision; + const host = getHost(); + if (!host || isQuitting()) { + sessions = []; + publish([]); + return; + } + try { + const [listed, inbox] = await Promise.all([ + host.call<{ sessions: SessionSummary[] }>("session.list"), + host.call("notification.list", { limit: 200 }), + ]); + if (isQuitting()) return; + if (host !== getHost()) { + revision += 1; + continue; + } + if (observed !== revision) continue; + sessions = listed.sessions; + const running = new Set(agentAvailable ? getRunningSessionIds() : []); + if (agentAvailable) { + for (const [id, active] of runningOverrides) { + if (active) running.add(id); + else running.delete(id); + } + } + const existing = new Set(sessions.map((session) => session.id)); + for (const id of runningOverrides.keys()) { + if (!existing.has(id)) runningOverrides.delete(id); + } + publish(hasPreferences + ? buildTraySessionGroups(sessions, running, inbox.notifications, preferences) + : []); + } catch (error) { + if (host !== getHost()) { + revision += 1; + continue; + } + if (observed !== revision || isQuitting()) continue; + sessions = []; + publish([]); + logger.app("diagnostics", "warn", "tray session refresh failed", { + data: String(error), + }); + } + } while (observed !== revision); + })().finally(() => { + pending = null; + }); + return pending; + } + + function observeEvent(channel: string, payload: unknown) { + if (isQuitting()) return; + if (channel === IPC.event.agentMessage) { + const envelope = payload as AgentEventEnvelope; + if (envelope.parentToolCallId) return; + const event = envelope.event; + let running: boolean; + if ( + event.type === "agent_start" || + event.type === "turn_start" || + event.type === "compaction_start" + ) { + running = true; + } else if ( + event.type === "agent_end" || + event.type === "error" || + (event.type === "compaction_end" && event.reason === "manual") + ) { + running = false; + } else if (event.type === "status") { + running = event.status.isRunning; + } else { + return; + } + if (runningOverrides.get(envelope.sessionId) === running) return; + runningOverrides.set(envelope.sessionId, running); + } else if (channel === IPC.event.hostStatus) { + const status = payload as HostStatusEvent; + if (!status.ok) runningOverrides.clear(); + if (!status.component || status.component === "sidecar") agentAvailable = status.ok; + } else if (channel !== IPC.event.sessionsChanged && channel !== IPC.event.notificationChanged) { + return; + } + void refresh(); + } + + return { + refresh, + observeEvent, + observeInvoke(channel: string) { + if (REFRESH_AFTER_INVOKE.has(channel)) void refresh(); + }, + getGroups: () => groups, + setPreferences(next: TraySessionPreferences) { + hasPreferences = true; + preferences = next; + return refresh(); + }, + canActivate(sessionId: string) { + const session = sessions.find((item) => item.id === sessionId); + return Boolean( + session && + !preferences.sessionMeta[sessionId]?.archived && + !preferences.archivedProjectPaths.includes(normalizeProjectPath(session.projectPath)), + ); + }, + }; +} diff --git a/apps/desktop/src/features/app/useAppShellRuntime.tsx b/apps/desktop/src/features/app/useAppShellRuntime.tsx index 59e8614a0f..0cbe8c1ff5 100644 --- a/apps/desktop/src/features/app/useAppShellRuntime.tsx +++ b/apps/desktop/src/features/app/useAppShellRuntime.tsx @@ -26,6 +26,7 @@ import { saveSidebarWidth, } from "../../lib/sidebar-preferences"; import { StartupSplash } from "../../components/StartupSplash"; +import { useTraySessions } from "./useTraySessions"; const MODIFIER_ONLY_KEYS = new Set([ "Alt", @@ -127,6 +128,7 @@ export function useAppShellRuntime() { autoCollapsedSidebarRef.current = false; setSidebarCollapsed(false); }, []); + useTraySessions({ setSearchOpen, reopenSidebar }); // Stable identity: the keydown and native-menu handlers register once and // must never capture a stale `sidebarCollapsed`. Every invocation is a user @@ -392,7 +394,6 @@ export function useAppShellRuntime() { useEffect(() => { const unsubscribe = api.onMenuCommand((command) => void runMenuCommand(command)); - void api.menuRendererReady().catch(() => undefined); return unsubscribe; }, [runMenuCommand]); @@ -499,7 +500,10 @@ export function useAppShellRuntime() { useEffect(() => { if (bootstrapStartedRef.current) return; bootstrapStartedRef.current = true; - void bootstrap(); + // A tray activation must win over bootstrap's initial draft/plan navigation. + void bootstrap().finally(() => { + void api.menuRendererReady().catch(() => undefined); + }); }, [bootstrap]); // The Host owns the prompt queue (D375); mirror it whenever the visible diff --git a/apps/desktop/src/features/app/useTraySessions.ts b/apps/desktop/src/features/app/useTraySessions.ts new file mode 100644 index 0000000000..7f70c96ec9 --- /dev/null +++ b/apps/desktop/src/features/app/useTraySessions.ts @@ -0,0 +1,65 @@ +import { useEffect } from "react"; +import { api } from "../../lib/api"; +import { useAppStore } from "../../stores/app-store"; + +/** Keep local organization mirrored while Main reads durable session/inbox state. */ +export function useTraySessions({ setSearchOpen, reopenSidebar }: { + setSearchOpen: (open: boolean) => void; + reopenSidebar: () => void; +}) { + useEffect(() => { + let disposed = false; + let scheduled = false; + + const activate = (sessionId: string | null) => { + const store = useAppStore.getState(); + setSearchOpen(false); + if (sessionId === null) { + store.setPage("chat"); + reopenSidebar(); + return; + } + void store.selectSession(sessionId).catch((error) => { + if (!disposed) { + store.showToast(error instanceof Error ? error.message : String(error), { variant: "error" }); + } + }); + }; + + const sync = () => { + if (scheduled) return; + scheduled = true; + queueMicrotask(() => { + scheduled = false; + if (disposed) return; + const store = useAppStore.getState(); + if (!store.ready) return; + void api.setTraySessionPreferences({ + sessionMeta: store.sessionMeta, + archivedProjectPaths: Object.entries(store.projectMeta) + .filter(([, meta]) => meta.archived) + .map(([path]) => path), + // The sidebar presents its legacy manual setting using recent order. + sort: store.sessionView.sort === "manual" ? "recent" : store.sessionView.sort, + }).catch((error) => console.error("Tray session synchronization failed", error)); + }); + }; + + // Main waits for the shell's post-bootstrap menuRendererReady acknowledgement. + const offActivation = api.onTraySessionActivated(activate); + const offStore = useAppStore.subscribe((state, previous) => { + if ( + state.ready !== previous.ready || + state.sessionMeta !== previous.sessionMeta || + state.projectMeta !== previous.projectMeta || + state.sessionView !== previous.sessionView + ) sync(); + }); + sync(); + return () => { + disposed = true; + offActivation(); + offStore(); + }; + }, [reopenSidebar, setSearchOpen]); +} diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 8f94c05496..0b165884e3 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -67,6 +67,7 @@ import type { SessionSearchContext, SessionSearchContextRequest, SessionSummary, + TraySessionPreferences, ToolPermissionResolution, UserSkillInput, UserSkillRecord, @@ -889,6 +890,15 @@ export const api = { ), menuRendererReady: () => invoke<{ ready: boolean }>(IPC.invoke.menuRendererReady), + setTraySessionPreferences: (preferences: TraySessionPreferences) => + invoke<{ ok: boolean }>(IPC.invoke.traySetSessionPreferences, preferences), + onTraySessionActivated: (listener: (sessionId: string | null) => void) => { + if (!window.piDesktop?.on) return () => undefined; + return window.piDesktop.on(IPC.event.traySessionActivated, (payload) => { + const sessionId = (payload as { sessionId?: unknown })?.sessionId; + if (sessionId === null || (typeof sessionId === "string" && sessionId)) listener(sessionId); + }); + }, nativeMenuAction: (action: NativeMenuAction) => invoke<{ maximized: boolean; fullScreen: boolean }>( IPC.invoke.nativeMenuAction, diff --git a/apps/desktop/src/lib/sidebar-preferences.ts b/apps/desktop/src/lib/sidebar-preferences.ts index e35864b4d0..66e7e5d379 100644 --- a/apps/desktop/src/lib/sidebar-preferences.ts +++ b/apps/desktop/src/lib/sidebar-preferences.ts @@ -1,4 +1,6 @@ -import type { ProjectWorkspace, SessionSummary } from "@pi-desktop/shared"; +import type { ProjectWorkspace, SessionSummary, SessionSort } from "@pi-desktop/shared"; +export type { SessionSort } from "@pi-desktop/shared"; +export { sortSessions, sessionIsPinned, sessionIsArchived } from "@pi-desktop/shared"; /** Local copy keeps this pure module runnable in Node's TS test loader. */ export function normalizeProjectPath(projectPath?: string | null): string | null { @@ -14,7 +16,6 @@ export function normalizeProjectPath(projectPath?: string | null): string | null return normalized || "/"; } -export type SessionSort = "recent" | "created" | "oldest" | "name" | "manual"; export type ProjectSort = "recent" | "created" | "oldest" | "name" | "manual"; export type SessionMeta = { pinned?: boolean; @@ -226,12 +227,6 @@ export function saveSidebarWidth(value: number): void { write(SIDEBAR_WIDTH_KEY, clampSidebarWidth(value)); } -export function sessionIsPinned(id: string, meta: Record): boolean { - return meta[id]?.pinned === true; -} -export function sessionIsArchived(id: string, meta: Record): boolean { - return meta[id]?.archived === true; -} export function projectIsPinned(path: string, meta: Record): boolean { const key = normalizeProjectPath(path); return !!key && meta[key]?.pinned === true; @@ -244,61 +239,6 @@ export function projectIsCollapsed(path: string, meta: Record, - sort: SessionSort = "recent", - includeArchived = false, -): SessionSummary[] { - const rows = includeArchived - ? sessions - : sessions.filter((session) => !sessionIsArchived(session.id, meta)); - return [...rows].sort((a, b) => { - const archived = Number(sessionIsArchived(a.id, meta)) - Number(sessionIsArchived(b.id, meta)); - if (archived) return archived; - const pinned = Number(sessionIsPinned(b.id, meta)) - Number(sessionIsPinned(a.id, meta)); - if (pinned) return pinned; - if (sort === "name") { - const byName = a.title.localeCompare(b.title, undefined, { sensitivity: "base" }); - if (byName) return byName; - } else if (sort === "created") { - const byCreated = compareOptionalNumber( - timestamp(a.createdAt) || undefined, - timestamp(b.createdAt) || undefined, - true, - ); - if (byCreated) return byCreated; - } else if (sort === "oldest") { - const byCreated = compareOptionalNumber( - timestamp(a.createdAt) || undefined, - timestamp(b.createdAt) || undefined, - false, - ); - if (byCreated) return byCreated; - } else if (sort === "manual") { - const byOrder = (manualOrder(meta[a.id]?.order) ?? Number.MAX_SAFE_INTEGER) - - (manualOrder(meta[b.id]?.order) ?? Number.MAX_SAFE_INTEGER); - if (byOrder) return byOrder; - } else { - const byUpdated = compareOptionalNumber( - timestamp(a.updatedAt) || undefined, - timestamp(b.updatedAt) || undefined, - true, - ); - if (byUpdated) return byUpdated; - } - return compareOptionalNumber( - timestamp(a.updatedAt) || undefined, - timestamp(b.updatedAt) || undefined, - true, - ) || a.id.localeCompare(b.id); - }); -} export type SidebarProject = Pick & { openedAt?: number; diff --git a/apps/desktop/src/lib/sidebar-session-status.ts b/apps/desktop/src/lib/sidebar-session-status.ts index 84158cb244..0e0399cf05 100644 --- a/apps/desktop/src/lib/sidebar-session-status.ts +++ b/apps/desktop/src/lib/sidebar-session-status.ts @@ -1,5 +1,3 @@ -import type { AppNotification } from "@pi-desktop/shared"; - export type SidebarSessionStatus = | "running" | "selected" @@ -11,26 +9,7 @@ export type SidebarSessionOutcome = Extract< "completed" | "failed" >; -export function latestSessionOutcomes( - notifications: AppNotification[], -): Record { - const outcomes: Record = {}; - const seen = new Set(); - - // The host and renderer both keep notifications newest-first, so the first - // entry per session is its latest terminal result. The badge means "a result - // you have not looked at yet": once the notification is read — opening the - // conversation reads it — the session gets no indicator at all. - for (const notification of notifications) { - if (seen.has(notification.sessionId)) continue; - seen.add(notification.sessionId); - if (notification.readAt) continue; - outcomes[notification.sessionId] = - notification.kind === "task.failed" ? "failed" : "completed"; - } - - return outcomes; -} +export { latestSessionOutcomes } from "@pi-desktop/shared"; export function sidebarSessionStatus({ running, diff --git a/apps/desktop/test/interaction-performance.test.mjs b/apps/desktop/test/interaction-performance.test.mjs index 95ec72a749..a8af61e02a 100644 --- a/apps/desktop/test/interaction-performance.test.mjs +++ b/apps/desktop/test/interaction-performance.test.mjs @@ -52,7 +52,7 @@ test("bootstrap cannot replay navigation after destination state changes", () => assert.match(app, /const bootstrapStartedRef = useRef\(false\);/); assert.match( app, - /useEffect\(\(\) => \{\s*if \(bootstrapStartedRef\.current\) return;\s*bootstrapStartedRef\.current = true;\s*void bootstrap\(\);\s*\}, \[bootstrap\]\);/, + /useEffect\(\(\) => \{\s*if \(bootstrapStartedRef\.current\) return;\s*bootstrapStartedRef\.current = true;[\s\S]*?void bootstrap\(\)\.finally\(\(\) => \{\s*void api\.menuRendererReady\(\)\.catch\(\(\) => undefined\);\s*\}\);\s*\}, \[bootstrap\]\);/, ); const subscriptions = app.match(/useEffect\(\(\) => \{\s*const offEvent = api\.onAgentEvent[\s\S]*?\n \}, \[/)?.[0] ?? ""; diff --git a/docs/adr/0078-cross-platform-tray-resident-minimize.md b/docs/adr/0078-cross-platform-tray-resident-minimize.md index 9dadc9f200..79459fd9ab 100644 --- a/docs/adr/0078-cross-platform-tray-resident-minimize.md +++ b/docs/adr/0078-cross-platform-tray-resident-minimize.md @@ -1,6 +1,6 @@ # ADR 0078: Cross-platform tray-resident minimize -- Status: Accepted for implementation (amended by ADR 0117 and ADR 0123) +- Status: Accepted for implementation (amended by ADR 0117, ADR 0123, and ADR tray-session-shortcuts) - Date: 2026-08-12 - Deciders: PI-Desktop core - Related: D216, D252, D256, E2E-124, ADR 0117, ADR 0123 @@ -43,3 +43,7 @@ so a tray integration belongs there rather than in the renderer bridge. the app remains usable rather than crashing during boot. - Window bounds persistence remains unchanged because a hidden window retains its normal bounds and is not treated as a new window state. + +## Session-menu amendment + +[ADR tray-session-shortcuts](tray-session-shortcuts.md) extends clauses 3–4 with bounded session groups and makes macOS single-click open the menu without restoring the main window. Open, double-click, and Quit retain their lifecycle behavior. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7c7a8b7f59..9ef34cac44 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -97,7 +97,7 @@ Each ADR includes: | 0075 | Manual reload for development-plugin permission ceilings | Accepted | | 0076 | Capture the Windows-reserved plugin launcher chord in host-core | Accepted | | 0077 | Add an interactive multi-question asktool | Accepted for implementation | -| 0078 | Cross-platform tray-resident minimize | Accepted for implementation (amended by 0117 and 0123) | +| 0078 | Cross-platform tray-resident minimize | Accepted for implementation (amended by 0117, 0123, and tray-session-shortcuts) | | 0079 | Use VitePress for the bilingual documentation site | Accepted | | 0080 | Prewarm the global plugin launcher after boot | Accepted | | 0081 | Host-owned cross-platform plugin panel chrome | Accepted | @@ -257,3 +257,4 @@ Each ADR includes: | 0236 | Restore archived projects when session import adds a bound session | Accepted | | 0237 | Keep Session Orchestration in an Official Plugin | Accepted | | 0238 | Prioritize MainChat in the three-column shell | Accepted (amends ADR 0226) | +| tray-session-shortcuts | [Bounded session navigation in the native tray](tray-session-shortcuts.md) | Accepted (amends ADR 0078; issue #293) | diff --git a/docs/adr/tray-session-shortcuts.md b/docs/adr/tray-session-shortcuts.md new file mode 100644 index 0000000000..bb971e7d06 --- /dev/null +++ b/docs/adr/tray-session-shortcuts.md @@ -0,0 +1,54 @@ +# ADR tray-session-shortcuts: Bounded session navigation in the native tray + +- Status: Accepted +- Date: 2026-09-13 +- Related: Issue #293, ADR 0078, ADR 0016, E2E-TRAY-bounded-session-navigation + +## Context + +The resident tray exposes only Open and Quit. Session pins and archives are +renderer-owned presentation metadata; Host owns session records and the durable +notification inbox. Background turns continue after the main window is hidden +or closed, so a renderer-only menu snapshot would become stale. + +## Decision + +1. Extend the existing native menu with Running, Unread, and Pinned groups in + that order. Assign each eligible session to its highest-priority group + before taking at most three rows per group (nine total). Hide empty groups. + Follow the existing session sort preference; unread results use newest-first + inbox order and the same latest-result/read rule as sidebar outcome badges. +2. Renderer mirrors its session pin/archive/order metadata, archived project + paths, and effective session sort through the main-window-only + `tray/setSessionPreferences` IPC. Main validates it and retains only an + ephemeral copy; localStorage remains the owner of organization preferences. + No groups are shown until the first preference synchronization. +3. A Main-owned tray service reads `session.list` and `notification.list` + (the existing 200-record inbox), consumes root agent lifecycle/status + events, and refreshes after session/inbox mutations. It coalesces pending + reads, rejects obsolete host generations, and clears shortcuts on a failed + refresh. Tray hover/right-click retries a failed read. It continues to + refresh with no renderer attached; deleting a + session or archiving it or its project removes the shortcut. +4. A session click restores/focuses the main window and sends + `tray/event/sessionActivated { sessionId }` (`null` means View more) only after the renderer finishes + bootstrap and acknowledges menu readiness. Recheck existence and archive + state after asynchronous waits. Renderer uses normal `selectSession`, + including project alignment, navigation ownership, and read acknowledgement. +5. Opening the macOS tray menu does not restore the window or mark results + read. Single-click opens the attached menu; double-click and Open retain + window restore behavior. Each overflowing group has View more, which + restores the main window and expands the existing session sidebar. Titles are one + line and capped at 48 Unicode code points including an ellipsis; labels + use the active shipped locale. Quit retains confirmation and shutdown. + +## Consequences + +- Two additive allowlisted desktop IPC channels; the preferences setter is + excluded from local MCP control and rejected for non-main-window senders. +- No database migration, host protocol version change, persisted preference + format change, Plugin SDK change, new timers, or renderer persistence owner. +- Tray reuses existing organization sorting and latest-result helpers without + changing their renderer behavior. Main retains its 1,500-line budget. +- Native menu rendering and cross-platform activation require the documented + E2E scenario; unit/build results alone do not prove OS menu behavior. diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md index 2c64d4461b..8ce3b7c346 100644 --- a/docs/spec/03-runtime/01-ipc-protocol.md +++ b/docs/spec/03-runtime/01-ipc-protocol.md @@ -1537,6 +1537,26 @@ Window bounds persistence and display reconciliation therefore operate on the ordinary application bounds; there is no panel-specific width or x-offset reservation, and background artifacts cannot change visible window geometry. +### Tray session shortcuts (ADR tray-session-shortcuts) + +- `pi-desktop/tray/setSessionPreferences({ sessionMeta, archivedProjectPaths, sort })` + returns `{ ok: true }`. `sessionMeta` maps IDs to optional boolean `pinned` + and `archived` flags plus a non-negative safe integer `order`. `sort` is + `recent`, `created`, `oldest`, `name`, or `manual`; the renderer mirrors the + sidebar's effective sort. Main validates the payload, strips unrelated + metadata, and rejects senders other than the current main window. The setter + is excluded from the local MCP catalog and persists nothing. +- Main emits `pi-desktop/tray/event/sessionActivated { sessionId: string | null }` + after restoring/focusing the window, waiting for post-bootstrap + `menu/rendererReady`, and checking that the session still exists and is not + archived. Renderer enters normal session selection, including cross-project + navigation and unread acknowledgement. A null ID closes search, returns to + the conversation page, and expands the sidebar for View more. Merely opening + the menu is read-only. +- Main reads existing Host session/inbox APIs, observes root runtime events and + successful session/inbox mutations, and combines them with the ephemeral + organization copy. No host protocol or storage schema changes. + ## 13c. Composer input APIs (D123/D124/D197, ADR 0024/0059) Electron-only channels backing composer autocomplete and file references. diff --git a/docs/spec/03-runtime/07-process-model.md b/docs/spec/03-runtime/07-process-model.md index 67ce5a0425..3dd5d73eb0 100644 --- a/docs/spec/03-runtime/07-process-model.md +++ b/docs/spec/03-runtime/07-process-model.md @@ -267,3 +267,17 @@ until a post-MVP implementation milestone explicitly amends this section. 6. A queued/running execution that was already approved is interrupted without replay and its durable session remains Agent 7. Bash timeout/abort shuts down the complete child process tree + + +### Native tray session projection + +The tray service keeps Running, Unread, and Pinned groups current independently +of renderer visibility or lifetime. Host remains authoritative for sessions and +notifications; root agent events describe running state. Renderer mirrors only +organization preferences through a main-window-only IPC. Read requests are +coalesced; obsolete Host results cannot repopulate the menu, failures clear +shortcuts, and quitting prevents further publication. A closed window retains +only the last organization copy, which is replaced after renderer bootstrap. +Menu command readiness is acknowledged after bootstrap's initial navigation, +so a tray click cannot be overwritten by the startup draft or pending-plan +selection. See [ADR tray-session-shortcuts](/adr/tray-session-shortcuts). diff --git a/docs/spec/04-ux/08-component-spec.md b/docs/spec/04-ux/08-component-spec.md index 15fda5bdd1..46c321ae5f 100644 --- a/docs/spec/04-ux/08-component-spec.md +++ b/docs/spec/04-ux/08-component-spec.md @@ -197,6 +197,17 @@ Outer frame that positions Topbar, Sidebar, MainChat, and WorkPanel. Owns resize --- +### Native tray session menu + +The Main-owned native menu contains Open, non-empty Running/Unread/Pinned +sections, and Quit. Each section has a disabled localized heading, at most +three single-line session rows, and View more only when it overflows. Session +rows are globally deduplicated before truncation. View more expands session navigation; +session rows enter their original conversation. The menu follows active locale +changes and never marks a result read merely by opening. macOS single-click +opens the attached menu; Open and double-click restore/focus the window. +See [ADR tray-session-shortcuts](/adr/tray-session-shortcuts). + ## 2. Topbar ### 2.1 Purpose diff --git a/docs/spec/04-ux/09-interaction-patterns.md b/docs/spec/04-ux/09-interaction-patterns.md index 3ae34feb44..80a0bcb197 100644 --- a/docs/spec/04-ux/09-interaction-patterns.md +++ b/docs/spec/04-ux/09-interaction-patterns.md @@ -156,16 +156,36 @@ recency only breaks ties between equally relevant matches. window from the taskbar/dock window list while the Electron process and background work remain alive. It does not persist a minimized geometry or dispose the host/sidecar. -- Clicking or double-clicking the PI-Desktop tray icon, choosing Show from its - menu, or activating the app from the macOS dock restores and focuses the +- Double-clicking the PI-Desktop tray icon (or single-clicking on Windows/Linux), + choosing Open, or activating the app from the macOS dock restores and focuses the existing window. If the window was closed, the same action creates a fresh window. - The tray menu is localized with the active shipped shell locale and - exposes Show PI-Desktop plus an explicit Quit PI-Desktop action. Quit uses + exposes Open, bounded session groups, and an explicit Quit action. Quit uses the existing ordered shutdown path. What closing the window does is the user's own choice on Windows/Linux (ADR 0090) and a Dock-lifecycle close on macOS; the tray icon itself is created once at startup either way. +### 1.5.2 Tray session navigation (issue #293) + +- The native menu shows Running, Unread, and Pinned in that order, at most + three sessions in each and nine in total. Membership is assigned before + applying limits; higher-priority overflow never spills into a lower group. +- Empty groups are hidden. Archived sessions/projects and deleted sessions + are excluded. Running/Pinned follow sidebar sorting; Unread follows the + latest unread result per session, newest first, including failed results. +- Long titles use one line and an ellipsis after at most 48 Unicode code + points. An overflowing group offers View more to restore the window and + expand session navigation. A session row restores/focuses its exact conversation, + activating its project through the existing selection flow. +- macOS single-click opens the menu without restoring/focusing a conversation + or marking it read. Entering a conversation uses normal acknowledgement. + Open and double-click restore the window; Quit keeps its confirmation and + ordered shutdown. Group/action labels follow the active shipped locale. +- Start/finish, read, pin, rename, archive, delete, and backend restart update + the menu. The menu remains available when the main window is hidden or + closed, without creating another window until an explicit activation. + ### 1.6 Sidebar project and conversation organization The sidebar is a path-keyed presentation of host-owned projects and sessions. diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index b936d7ca57..0fe724d3b6 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -6474,6 +6474,7 @@ needed. | Acceptance | Scenarios | |---|---| | A — App startup | E2E-001, E2E-002, E2E-003, E2E-004, E2E-067, E2E-076, E2E-079, E2E-092, E2E-097, E2E-143, E2E-150, E2E-168, E2E-204 | +| A / C / F / Quality — Tray session navigation | E2E-TRAY-bounded-session-navigation | | B — Model config | E2E-005, E2E-006, E2E-007, E2E-038, E2E-050, E2E-052, E2E-055, E2E-066, E2E-080, E2E-082, E2E-102c, E2E-102d, E2E-102e, E2E-151, E2E-154, E2E-163, E2E-166, E2E-172, E2E-174, E2E-197, E2E-005G, E2E-005J, E2E-199, E2E-201, E2E-202, E2E-203, E2E-205, E2E-206, E2E-209 | | C — Conversation & stream | E2E-008, E2E-008d, E2E-008a, E2E-009, E2E-010, E2E-011, E2E-011a, E2E-011b, E2E-011d, E2E-011e, E2E-011g, E2E-031, E2E-040, E2E-047, E2E-048, E2E-048A, E2E-049, E2E-052, E2E-053, E2E-054, E2E-055, E2E-059, E2E-059a, E2E-060c, E2E-060d, E2E-061, E2E-061a, E2E-062, E2E-064, E2E-065, E2E-068, E2E-071, E2E-073, E2E-074, E2E-075, E2E-081, E2E-083, E2E-084, E2E-086, E2E-087, E2E-088, E2E-088b, E2E-089, E2E-090, E2E-094, E2E-095, E2E-096, E2E-097, E2E-098, E2E-099, E2E-102, E2E-102a, E2E-102b, E2E-102c, E2E-102d, E2E-102g, E2E-106, E2E-109, E2E-111, E2E-114, E2E-116, E2E-117, E2E-118, E2E-119, E2E-120, E2E-121, E2E-218, E2E-219, E2E-AGENTS-001, E2E-142, E2E-144, E2E-145, E2E-146, E2E-146a, E2E-147, E2E-151, E2E-154, E2E-155, E2E-158, E2E-159, E2E-161, E2E-162, E2E-166, E2E-172, E2E-173, E2E-174, E2E-177, E2E-178, E2E-179, E2E-180, E2E-182, E2E-183, E2E-187, E2E-198, E2E-199, E2E-202, E2E-203, E2E-207, E2E-208, E2E-250, E2E-102i, E2E-PLUGIN-session-orchestrator-real-workers, E2E-SUBAGENT-settlement-updates-before-parent-poll | | D — Workspace | E2E-012, E2E-013, E2E-022B, E2E-024I, E2E-047, E2E-049, E2E-057, E2E-058, E2E-060, E2E-068, E2E-075, E2E-078, E2E-153, E2E-158, E2E-182, E2E-187, E2E-252 | @@ -10604,3 +10605,42 @@ sample extensions under `apps/desktop/test/fixtures/pi-extensions/`. Existing commands, pages, settings, and keyboard navigation still work. - **Status**: Draft; Rust and renderer unit regressions cover the data/query boundaries. Full rendered E2E requires an explicitly authorized run. + + +#### E2E-TRAY-bounded-session-navigation + +- **Scope**: Native tray groups, hidden/recreated window activation, and unread + semantics (issue #293, ADR tray-session-shortcuts). +- **Preconditions**: At least four running, four unread, and four pinned + sessions across two projects; include overlaps, read-latest/older-unread + notifications, archived/deleted sessions, an archived project, empty titles, + multiline titles, long CJK/emoji titles, and literal ampersands. Use an + isolated profile. Repeat native activation on macOS and Windows/Linux. +- **Steps**: Hide the main window and open the tray menu. Inspect group order, + counts, duplicates, titles, and unchanged unread records. Choose the third + row from another project, then View more from Settings with a collapsed + sidebar and a retained search query. Finish/abort tasks while hidden; + read a result, pin/unpin, rename, archive/restore, and delete a session. + Close the macOS window while a task runs and let it finish, then activate + its tray row while the new renderer bootstraps a pending plan. Delay a Host + read while a newer preference update, delete, or Host restart arrives. + Retry a transient read failure by hovering/right-clicking the tray. + Repeat after clearing all group memberships and changing shipped locales. + Choose Quit then Cancel, then Quit and confirm. +- **Expected**: Running → Unread → Pinned; at most three rows per group and + nine total. Deduplicate before limits, so hidden Running overflow cannot + appear as Unread/Pinned. Empty groups and stale shortcuts disappear. Unread + uses the latest terminal result per session, newest first. Titles remain + one line within the cap, including literal ampersands. Opening the macOS + menu leaves the window hidden and records unread. A row opens exactly that + session/project, acknowledges it normally, and wins over startup navigation. + View more returns from Settings, closes search, and expands session navigation. Hidden/closed windows receive fresh groups; + stale reads, archived/deleted targets, and a failed backend cannot restore + stale shortcuts. Open, localization, quit cancellation, and shutdown work. +- **Specs linked**: `03-runtime/01-ipc-protocol.md` §13b, + `03-runtime/07-process-model.md`, `04-ux/08-component-spec.md`, + `04-ux/09-interaction-patterns.md`, ADR tray-session-shortcuts. +- **Acceptance**: A (app/window lifecycle), C (conversation navigation), + F (unread persistence), Quality (bounded localized menu). +- **Milestone**: Post-M6 desktop shell maintenance. +- **Status**: Draft; native E2E requires an explicitly authorized run. diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index 50576b538c..60f4c16fa8 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -4736,3 +4736,14 @@ D193, and D194. no panel width or x-offset geometry is applied. - Decision D408 records the issue #267 behavior. See ADR 0238 and E2E-LAYOUT-three-column-width-priority. + + +### Tray session shortcuts (issue #293) + +[ADR tray-session-shortcuts](/adr/tray-session-shortcuts) amends the D216 native +tray menu with Running, Unread, and Pinned groups, three rows each after global +priority assignment. Host session/inbox reads and runtime events remain the +source of truth; renderer organization is mirrored without a persistence +change. macOS single-click opens the menu without restoring the window; +selection is delivered after bootstrap and uses normal session navigation. +Validation contract: E2E-TRAY-bounded-session-navigation. diff --git a/docs/zh-CN/adr/index.md b/docs/zh-CN/adr/index.md index bbc6ba6942..b6d4b9fdb8 100644 --- a/docs/zh-CN/adr/index.md +++ b/docs/zh-CN/adr/index.md @@ -279,3 +279,7 @@ ADR 记录那些不应被静默改变的架构选择。中文入口与英文索 - 决策日志记录更细的冻结条款和后续修订。 前往 [英文 ADR 索引](/adr/README) 查看完整记录,或打开 [中文决策日志](/zh-CN/spec/08-meta/decisions-log) 按编号检索。 + +## Tray session shortcuts + +[ADR tray-session-shortcuts](/adr/tray-session-shortcuts) defines bounded native session groups and the renderer/Main ownership boundary. diff --git a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md index 348ef9a199..d6494a10ea 100644 --- a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md +++ b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md @@ -1324,6 +1324,26 @@ Electron 报告的右侧角)改变的是面板目标。Main 通过 仅针对当前可见的会话设置此目标:背景工件 无法更改可见的保留几何形状。 +### Tray session shortcuts (ADR tray-session-shortcuts) + +- `pi-desktop/tray/setSessionPreferences({ sessionMeta, archivedProjectPaths, sort })` + returns `{ ok: true }`. `sessionMeta` maps IDs to optional boolean `pinned` + and `archived` flags plus a non-negative safe integer `order`. `sort` is + `recent`, `created`, `oldest`, `name`, or `manual`; the renderer mirrors the + sidebar's effective sort. Main validates the payload, strips unrelated + metadata, and rejects senders other than the current main window. The setter + is excluded from the local MCP catalog and persists nothing. +- Main emits `pi-desktop/tray/event/sessionActivated { sessionId: string | null }` + after restoring/focusing the window, waiting for post-bootstrap + `menu/rendererReady`, and checking that the session still exists and is not + archived. Renderer enters normal session selection, including cross-project + navigation and unread acknowledgement. A null ID closes search, returns to + the conversation page, and expands the sidebar for View more. Merely opening + the menu is read-only. +- Main reads existing Host session/inbox APIs, observes root runtime events and + successful session/inbox mutations, and combines them with the ephemeral + organization copy. No host protocol or storage schema changes. + ## 13c. Composer 输入 API(D123/D124/D197、ADR 0024/0059) 仅电子通道支持输入框自动完成和剪贴板文件 diff --git a/docs/zh-CN/spec/03-runtime/07-process-model.md b/docs/zh-CN/spec/03-runtime/07-process-model.md index 213b486e0a..d409f3de10 100644 --- a/docs/zh-CN/spec/03-runtime/07-process-model.md +++ b/docs/zh-CN/spec/03-runtime/07-process-model.md @@ -213,3 +213,17 @@ Gateway 负责路由已认证客户,但不拥有工作区状态。 6. 已批准的 queued/running 执行被中断,无需 重播及其持久会话仍然是 Agent 7. Bash timeout/abort 关闭完整的子进程树 + + +### Native tray session projection + +The tray service keeps Running, Unread, and Pinned groups current independently +of renderer visibility or lifetime. Host remains authoritative for sessions and +notifications; root agent events describe running state. Renderer mirrors only +organization preferences through a main-window-only IPC. Read requests are +coalesced; obsolete Host results cannot repopulate the menu, failures clear +shortcuts, and quitting prevents further publication. A closed window retains +only the last organization copy, which is replaced after renderer bootstrap. +Menu command readiness is acknowledged after bootstrap's initial navigation, +so a tray click cannot be overwritten by the startup draft or pending-plan +selection. See [ADR tray-session-shortcuts](/adr/tray-session-shortcuts). diff --git a/docs/zh-CN/spec/04-ux/08-component-spec.md b/docs/zh-CN/spec/04-ux/08-component-spec.md index 05f41a0fc8..ff8c8c2a17 100644 --- a/docs/zh-CN/spec/04-ux/08-component-spec.md +++ b/docs/zh-CN/spec/04-ux/08-component-spec.md @@ -164,6 +164,17 @@ --- +### Native tray session menu + +The Main-owned native menu contains Open, non-empty Running/Unread/Pinned +sections, and Quit. Each section has a disabled localized heading, at most +three single-line session rows, and View more only when it overflows. Session +rows are globally deduplicated before truncation. View more expands session navigation; +session rows enter their original conversation. The menu follows active locale +changes and never marks a result read merely by opening. macOS single-click +opens the attached menu; Open and double-click restore/focus the window. +See [ADR tray-session-shortcuts](/adr/tray-session-shortcuts). + ## 2. Topbar ### 2.1 目的 diff --git a/docs/zh-CN/spec/04-ux/09-interaction-patterns.md b/docs/zh-CN/spec/04-ux/09-interaction-patterns.md index 5b1955d735..fdabfd63e4 100644 --- a/docs/zh-CN/spec/04-ux/09-interaction-patterns.md +++ b/docs/zh-CN/spec/04-ux/09-interaction-patterns.md @@ -127,15 +127,32 @@ - 隐藏将从 taskbar/dock 窗口列表中删除主窗口,而 Electron 进程和后台工作仍然有效。它不坚持 最小化几何形状或处置 host/sidecar。 -- 单击或双击 PI-Desktop 托盘图标,从其中选择“显示” - 菜单,或从 macOS 扩展坞激活应用程序可恢复并聚焦 - 现有的窗口。如果窗口关闭,相同的操作会创建一个新的窗口 - 窗口。 -- 托盘菜单使用当前已发布的 shell 语言进行本地化,并且 - 公开 Show PI-Desktop 以及显式退出 PI-Desktop 操作。退出走现有的有序 - 关闭路径。关闭窗口做什么,在 Windows/Linux 上由用户自己选择 - (ADR 0090),在 macOS 上是一次 Dock 生命周期的关闭;无论哪种情况, - 托盘图标本身都只在启动时创建一次。 +- Double-clicking the tray icon (or single-clicking on Windows/Linux), choosing + Open, or activating the macOS Dock restores/focuses the existing window or + creates a new one if it was closed. macOS single-click opens the menu. +- The localized tray includes Open, bounded session groups, and Quit. Quit + keeps confirmation and ordered shutdown. Close behavior remains user-owned + on Windows/Linux (ADR 0090), and macOS retains its Dock lifecycle. + +### 1.5.2 Tray session navigation (issue #293) + +- The native menu shows Running, Unread, and Pinned in that order, at most + three sessions in each and nine in total. Membership is assigned before + applying limits; higher-priority overflow never spills into a lower group. +- Empty groups are hidden. Archived sessions/projects and deleted sessions + are excluded. Running/Pinned follow sidebar sorting; Unread follows the + latest unread result per session, newest first, including failed results. +- Long titles use one line and an ellipsis after at most 48 Unicode code + points. An overflowing group offers View more to restore the window and + expand session navigation. A session row restores/focuses its exact conversation, + activating its project through the existing selection flow. +- macOS single-click opens the menu without restoring/focusing a conversation + or marking it read. Entering a conversation uses normal acknowledgement. + Open and double-click restore the window; Quit keeps its confirmation and + ordered shutdown. Group/action labels follow the active shipped locale. +- Start/finish, read, pin, rename, archive, delete, and backend restart update + the menu. The menu remains available when the main window is hidden or + closed, without creating another window until an explicit activation. ### 1. 6 侧边栏项目和对话组织 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index e90865fa78..cc55c4e439 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -4585,6 +4585,7 @@ IPC 请求无法关闭。 | 验收 | 应用场景 | |---|---| | A — 应用程序启动 | E2E-001、E2E-002、E2E-003、E2E-004、E2E-067、E2E-076、E2E-079、E2E-092、E2E-097、E2E-143、E2E-150、E2E-168、E2E-204、E2E-217 | +| A / C / F / Quality — Tray session navigation | E2E-TRAY-bounded-session-navigation | | B——模型配置 | E2E-005、E2E-005G、E2E-006、E2E-007、E2E-038、E2E-050、E2E-052、E2E-055、E2E-066、E2E-080、E2E-082、E2E-151、E2E-005J、E2E-199、E2E-201、E2E-202、E2E-203、E2E-209 | | C — 对话和直播 | E2E-008、E2E-008d、E2E-008a、E2E-009、E2E-010、E2E-011、E2E-011a、E2E-011b、E2E-031、E2E-040、E2E-047、E2E-048、E2E-048A、E2E-049、E2E-052、 E2E-053、E2E-054、E2E-055、E2E-059、E2E-059a、E2E-060c、E2E-060d、E2E-061、E2E-061a、E2E-062、E2E-064、E2E-065、E2E-068、E2E-071、 E2E-073、E2E-074、E2E-075、E2E-081、E2E-083、E2E-084、E2E-086、E2E-087、E2E-088、E2E-088b、E2E-089、E2E-090、E2E-094、E2E-095、E2E-096、 E2E-097、E2E-098、E2E-099、E2E-102、E2E-102a、E2E-102b、E2E-106、E2E-109、E2E-111、E2E-114、E2E-116、E2E-117、E2E-118、E2E-119、 E2E-120、E2E-121、E2E-代理-001、E2E-142、E2E-144、E2E-145、E2E-146、E2E-147、E2E-151、E2E-199、E2E-250 | | D——工作区 | E2E-012、E2E-013、E2E-022B、E2E-024I、E2E-047、E2E-049、E2E-057、E2E-058、E2E-060、E2E-068、E2E-075、E2E-078、E2E-153 | @@ -6685,3 +6686,42 @@ IPC 请求无法关闭。 - **验收**:F(持久化)、品质 - **里程碑**:M6 之后的桌面外壳维护 - **状态**:已自动化(`scripts/e2e-three-column-layout.mjs`,经 `pnpm test:e2e:layout` —— 固定窗口宽度不变、指针拖动全程 360px 下限、左栏让位/恢复、370px 重开目标);单元覆盖见 `work-panel-resize.test.mjs` + + +#### E2E-TRAY-bounded-session-navigation + +- **Scope**: Native tray groups, hidden/recreated window activation, and unread + semantics (issue #293, ADR tray-session-shortcuts). +- **Preconditions**: At least four running, four unread, and four pinned + sessions across two projects; include overlaps, read-latest/older-unread + notifications, archived/deleted sessions, an archived project, empty titles, + multiline titles, long CJK/emoji titles, and literal ampersands. Use an + isolated profile. Repeat native activation on macOS and Windows/Linux. +- **Steps**: Hide the main window and open the tray menu. Inspect group order, + counts, duplicates, titles, and unchanged unread records. Choose the third + row from another project, then View more from Settings with a collapsed + sidebar and a retained search query. Finish/abort tasks while hidden; + read a result, pin/unpin, rename, archive/restore, and delete a session. + Close the macOS window while a task runs and let it finish, then activate + its tray row while the new renderer bootstraps a pending plan. Delay a Host + read while a newer preference update, delete, or Host restart arrives. + Retry a transient read failure by hovering/right-clicking the tray. + Repeat after clearing all group memberships and changing shipped locales. + Choose Quit then Cancel, then Quit and confirm. +- **Expected**: Running → Unread → Pinned; at most three rows per group and + nine total. Deduplicate before limits, so hidden Running overflow cannot + appear as Unread/Pinned. Empty groups and stale shortcuts disappear. Unread + uses the latest terminal result per session, newest first. Titles remain + one line within the cap, including literal ampersands. Opening the macOS + menu leaves the window hidden and records unread. A row opens exactly that + session/project, acknowledges it normally, and wins over startup navigation. + View more returns from Settings, closes search, and expands session navigation. Hidden/closed windows receive fresh groups; + stale reads, archived/deleted targets, and a failed backend cannot restore + stale shortcuts. Open, localization, quit cancellation, and shutdown work. +- **Specs linked**: `03-runtime/01-ipc-protocol.md` §13b, + `03-runtime/07-process-model.md`, `04-ux/08-component-spec.md`, + `04-ux/09-interaction-patterns.md`, ADR tray-session-shortcuts. +- **Acceptance**: A (app/window lifecycle), C (conversation navigation), + F (unread persistence), Quality (bounded localized menu). +- **Milestone**: Post-M6 desktop shell maintenance. +- **Status**: Draft; native E2E requires an explicitly authorized run. diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index b15040a144..c972d63d52 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -3911,3 +3911,14 @@ D193 和 D194。 - 手动重开左栏优先占用右栏宽度,否则以 370px 为目标。关闭右栏只恢复由布局机制收起的左栏;手动收起保持收起。 - 原生窗口不参与:预留 seam 保持 0,不套用任何面板宽度或 x 偏移几何。 - 决策 D408 记录 issue #267 的行为。见 ADR 0238 与 E2E-LAYOUT-three-column-width-priority。 + + +### Tray session shortcuts (issue #293) + +[ADR tray-session-shortcuts](/adr/tray-session-shortcuts) amends the D216 native +tray menu with Running, Unread, and Pinned groups, three rows each after global +priority assignment. Host session/inbox reads and runtime events remain the +source of truth; renderer organization is mirrored without a persistence +change. macOS single-click opens the menu without restoring the window; +selection is delivered after bootstrap and uses normal session navigation. +Validation contract: E2E-TRAY-bounded-session-navigation. diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index a309f8b505..0112b53621 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -23,6 +23,10 @@ export const de = { "close": "Schließen" }, "tray": { + "running": "Laufend", + "unread": "Ungelesen", + "pinned": "Angeheftet", + "viewMore": "Mehr anzeigen…", "open": "Öffnen Sie PI-Desktop", "quit": "Beenden Sie PI-Desktop", "askTitle": "Lassen Sie PI-Desktop im Hintergrund laufen?", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index 05198b0c47..373f2a0a3a 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -22,6 +22,10 @@ export const en = { close: "Close", }, tray: { + running: "Running", + unread: "Unread", + pinned: "Pinned", + viewMore: "View more…", open: "Open PI-Desktop", quit: "Quit PI-Desktop", askTitle: "Keep PI-Desktop running in the background?", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 0c6e8b9d4e..af61f79f9f 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -23,6 +23,10 @@ export const es = { "close": "Cerrar" }, "tray": { + "running": "En curso", + "unread": "Sin leer", + "pinned": "Fijadas", + "viewMore": "Ver más…", "open": "Abrir PI-Desktop", "quit": "Salir de PI-Desktop", "askTitle": "¿Mantener PI-Desktop ejecutándose en segundo plano?", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index a756b4ff4c..96e1592218 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -23,6 +23,10 @@ export const fr = { "close": "Fermer" }, "tray": { + "running": "En cours", + "unread": "Non lues", + "pinned": "Épinglées", + "viewMore": "Voir plus…", "open": "Ouvrir PI-Desktop", "quit": "Quitter PI-Desktop", "askTitle": "Garder PI-Desktop en arrière-plan ?", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 8cb17924ed..06f511adfe 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -24,6 +24,10 @@ export const ko = { close: "닫기", }, tray: { + running: "실행 중", + unread: "읽지 않음", + pinned: "고정됨", + viewMore: "더 보기…", open: "PI-Desktop 열기", quit: "PI-Desktop 종료", askTitle: "PI-Desktop을 백그라운드에서 계속 실행할까요?", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index bd1ec76e28..110e619c18 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -24,6 +24,10 @@ export const tr = { close: "Kapat", }, tray: { + running: "Çalışan", + unread: "Okunmamış", + pinned: "Sabitlenmiş", + viewMore: "Daha fazla göster…", open: "PI-Desktop’u aç", quit: "PI-Desktop’tan çık", askTitle: "PI-Desktop arka planda çalışmaya devam etsin mi?", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index cd8e04a93f..2066493c07 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -23,6 +23,10 @@ export const zhCN = { close: "关闭", }, tray: { + running: "运行中", + unread: "未读", + pinned: "置顶", + viewMore: "查看更多…", open: "打开 PI-Desktop", quit: "退出 PI-Desktop", askTitle: "关闭后继续在后台运行 PI-Desktop?", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index e9d22c4c93..41fb6f9276 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -23,6 +23,10 @@ export const zhTW = { close: "關閉", }, tray: { + running: "執行中", + unread: "未讀", + pinned: "釘選", + viewMore: "查看更多…", open: "開啟 PI-Desktop", quit: "退出 PI-Desktop", askTitle: "關閉後繼續在後臺執行 PI-Desktop?", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8dc6c71f76..5630011de6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -25,3 +25,6 @@ export * from "./font-size.js"; export * from "./racp.js"; export * from "./trusted-extensions.js"; export * from "./provider-retry.js"; +export * from "./session-presentation.js"; +export * from "./session-outcomes.js"; +export * from "./tray-sessions.js"; diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 3ead124e5f..d16b809764 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -234,6 +234,7 @@ export const IPC = { closeBehaviorGet: "pi-desktop/window/closeBehavior/get", closeBehaviorSet: "pi-desktop/window/closeBehavior/set", menuRendererReady: "pi-desktop/menu/rendererReady", + traySetSessionPreferences: "pi-desktop/tray/setSessionPreferences", nativeMenuAction: "pi-desktop/menu/nativeAction", }, event: { @@ -251,6 +252,7 @@ export const IPC = { windowFullScreen: "pi-desktop/window/event/fullscreen", windowWorkPanelResize: "pi-desktop/window/event/workPanelResize", menuCommand: "pi-desktop/menu/event/command", + traySessionActivated: "pi-desktop/tray/event/sessionActivated", notificationChanged: "pi-desktop/notification/event/changed", sessionsChanged: "pi-desktop/session/event/changed", notificationActivated: "pi-desktop/notification/event/activated", diff --git a/packages/shared/src/session-outcomes.ts b/packages/shared/src/session-outcomes.ts new file mode 100644 index 0000000000..73d8f5c844 --- /dev/null +++ b/packages/shared/src/session-outcomes.ts @@ -0,0 +1,22 @@ +import type { AppNotification } from "./types/workspace.js"; + +export function latestSessionOutcomes( + notifications: AppNotification[], +): Record { + const outcomes: Record = {}; + const seen = new Set(); + + // The host and renderer both keep notifications newest-first, so the first + // entry per session is its latest terminal result. The badge means "a result + // you have not looked at yet": once the notification is read — opening the + // conversation reads it — the session gets no indicator at all. + for (const notification of notifications) { + if (seen.has(notification.sessionId)) continue; + seen.add(notification.sessionId); + if (notification.readAt) continue; + outcomes[notification.sessionId] = + notification.kind === "task.failed" ? "failed" : "completed"; + } + + return outcomes; +} diff --git a/packages/shared/src/session-presentation.ts b/packages/shared/src/session-presentation.ts new file mode 100644 index 0000000000..f00137eebb --- /dev/null +++ b/packages/shared/src/session-presentation.ts @@ -0,0 +1,89 @@ +import type { SessionSummary } from "./types/sessions.js"; + +export type SessionSort = "recent" | "created" | "oldest" | "name" | "manual"; +export type SessionPresentationMeta = { + pinned?: boolean; + archived?: boolean; + order?: number; +}; + +export function sessionIsPinned(id: string, meta: Record): boolean { + return meta[id]?.pinned === true; +} +export function sessionIsArchived(id: string, meta: Record): boolean { + return meta[id]?.archived === true; +} + +function manualOrder(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 + ? value + : undefined; +} +function timestamp(value?: string): number { + const parsed = value ? Date.parse(value) : NaN; + return Number.isFinite(parsed) ? parsed : 0; +} + +export function sortSessions( + sessions: SessionSummary[], + meta: Record, + sort: SessionSort = "recent", + includeArchived = false, +): SessionSummary[] { + const rows = includeArchived + ? sessions + : sessions.filter((session) => !sessionIsArchived(session.id, meta)); + return [...rows].sort((a, b) => { + const archived = Number(sessionIsArchived(a.id, meta)) - Number(sessionIsArchived(b.id, meta)); + if (archived) return archived; + const pinned = Number(sessionIsPinned(b.id, meta)) - Number(sessionIsPinned(a.id, meta)); + if (pinned) return pinned; + if (sort === "name") { + const byName = a.title.localeCompare(b.title, undefined, { sensitivity: "base" }); + if (byName) return byName; + } else if (sort === "created") { + const byCreated = compareOptionalNumber( + timestamp(a.createdAt) || undefined, + timestamp(b.createdAt) || undefined, + true, + ); + if (byCreated) return byCreated; + } else if (sort === "oldest") { + const byCreated = compareOptionalNumber( + timestamp(a.createdAt) || undefined, + timestamp(b.createdAt) || undefined, + false, + ); + if (byCreated) return byCreated; + } else if (sort === "manual") { + const byOrder = (manualOrder(meta[a.id]?.order) ?? Number.MAX_SAFE_INTEGER) - + (manualOrder(meta[b.id]?.order) ?? Number.MAX_SAFE_INTEGER); + if (byOrder) return byOrder; + } else { + const byUpdated = compareOptionalNumber( + timestamp(a.updatedAt) || undefined, + timestamp(b.updatedAt) || undefined, + true, + ); + if (byUpdated) return byUpdated; + } + return compareOptionalNumber( + timestamp(a.updatedAt) || undefined, + timestamp(b.updatedAt) || undefined, + true, + ) || a.id.localeCompare(b.id); + }); +} + +function compareOptionalNumber( + a: number | undefined, + b: number | undefined, + descending: boolean, +): number { + const hasA = typeof a === "number" && Number.isFinite(a); + const hasB = typeof b === "number" && Number.isFinite(b); + if (!hasA && !hasB) return 0; + if (!hasA) return 1; + if (!hasB) return -1; + return descending ? (b as number) - (a as number) : (a as number) - (b as number); +} diff --git a/packages/shared/src/tray-sessions.ts b/packages/shared/src/tray-sessions.ts new file mode 100644 index 0000000000..1b9395478d --- /dev/null +++ b/packages/shared/src/tray-sessions.ts @@ -0,0 +1,95 @@ +import { normalizeProjectPath } from "./activation.js"; +import { latestSessionOutcomes } from "./session-outcomes.js"; +import { + sortSessions, + type SessionPresentationMeta, + type SessionSort, +} from "./session-presentation.js"; +import type { SessionSummary } from "./types/sessions.js"; +import type { AppNotification } from "./types/workspace.js"; + +export const TRAY_SESSION_LIMIT = 3; +export const TRAY_SESSION_TITLE_LIMIT = 48; +export const TRAY_SESSION_GROUPS = ["running", "unread", "pinned"] as const; +export type TraySessionGroupKind = (typeof TRAY_SESSION_GROUPS)[number]; + +/** An ephemeral copy of renderer-owned organization; never persisted by Main. */ +export type TraySessionPreferences = { + sessionMeta: Record; + archivedProjectPaths: string[]; + sort: SessionSort; +}; + +export type TraySessionGroup = { + kind: TraySessionGroupKind; + sessions: Pick[]; + hasMore: boolean; +}; + +export function parseTraySessionPreferences(input: unknown): TraySessionPreferences | null { + if (!input || typeof input !== "object") return null; + const { sessionMeta, archivedProjectPaths, sort } = input as TraySessionPreferences; + if (!sessionMeta || typeof sessionMeta !== "object" || Array.isArray(sessionMeta)) return null; + if ( + !Array.isArray(archivedProjectPaths) || + !archivedProjectPaths.every((path) => typeof path === "string") + ) return null; + if (!["recent", "created", "oldest", "name", "manual"].includes(sort)) return null; + const cleanedMeta: Record = Object.create(null); + for (const [id, entry] of Object.entries(sessionMeta)) { + if (!id || !entry || typeof entry !== "object" || Array.isArray(entry)) return null; + const { pinned, archived, order } = entry; + if (pinned !== undefined && typeof pinned !== "boolean") return null; + if (archived !== undefined && typeof archived !== "boolean") return null; + if (order !== undefined && (!Number.isSafeInteger(order) || order < 0)) return null; + cleanedMeta[id] = { pinned, archived, order }; + } + return { + sessionMeta: cleanedMeta, + archivedProjectPaths: archivedProjectPaths.map(normalizeProjectPath), + sort, + }; +} + +export function traySessionTitle(title: string, fallback: string): string { + const singleLine = title + .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ") + .replace(/\s+/g, " ") + .trim(); + const characters = Array.from(singleLine || fallback); + return characters.length > TRAY_SESSION_TITLE_LIMIT + ? `${characters.slice(0, TRAY_SESSION_TITLE_LIMIT - 1).join("")}…` + : characters.join(""); +} + +export function buildTraySessionGroups( + sessions: SessionSummary[], + running: ReadonlySet, + notifications: AppNotification[], + preferences: TraySessionPreferences, +): TraySessionGroup[] { + const archivedProjects = new Set(preferences.archivedProjectPaths); + const outcomes = latestSessionOutcomes(notifications); + const unreadOrder = new Map(); + notifications.forEach((notification, index) => { + if (!unreadOrder.has(notification.sessionId)) unreadOrder.set(notification.sessionId, index); + }); + const groups: Record = { + running: [], unread: [], pinned: [], + }; + const seen = new Set(); + for (const session of sortSessions(sessions, preferences.sessionMeta, preferences.sort)) { + if (seen.has(session.id) || archivedProjects.has(normalizeProjectPath(session.projectPath))) continue; + seen.add(session.id); + // Assign before truncating, so overflow from Running cannot leak into Unread/Pinned. + if (running.has(session.id)) groups.running.push(session); + else if (outcomes[session.id]) groups.unread.push(session); + else if (preferences.sessionMeta[session.id]?.pinned) groups.pinned.push(session); + } + groups.unread.sort((a, b) => unreadOrder.get(a.id)! - unreadOrder.get(b.id)!); + return TRAY_SESSION_GROUPS.filter((kind) => groups[kind].length > 0).map((kind) => ({ + kind, + sessions: groups[kind].slice(0, TRAY_SESSION_LIMIT).map(({ id, title }) => ({ id, title })), + hasMore: groups[kind].length > TRAY_SESSION_LIMIT, + })); +} From e7cdbb75066a939b591f12670a61c7eb28065aeb Mon Sep 17 00:00:00 2001 From: zszz3 <91608029+zszz3@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:44:32 +0800 Subject: [PATCH 07/13] fix(search): unify transcript reading and resolve nested hits --- apps/desktop/src/components/Markdown.tsx | 69 ++- apps/desktop/src/components/SearchDialog.tsx | 2 +- apps/desktop/src/components/SessionPane.tsx | 46 +- .../components/workpanel/SubagentPanel.tsx | 36 +- .../chat/transcript/ActivityGroup.tsx | 7 +- .../chat/transcript/ChatTranscript.tsx | 6 +- .../features/chat/transcript/MessageRow.tsx | 2 + .../src/features/chat/transcript/ToolRow.tsx | 3 +- .../transcript/hooks/useTranscriptScroll.ts | 73 +--- .../src/features/chat/transcript/shared.tsx | 33 +- apps/desktop/src/hooks/use-follow-scroll.ts | 8 + apps/desktop/src/hooks/use-session-search.ts | 8 - .../src/hooks/use-transcript-navigation.ts | 37 -- .../src/hooks/use-transcript-search-focus.ts | 132 ++++++ apps/desktop/src/hooks/use-transcript-view.ts | 24 ++ apps/desktop/src/lib/markdown-source.ts | 38 ++ apps/desktop/src/lib/subagent-panel.ts | 14 + apps/desktop/src/lib/transcript-navigation.ts | 88 ---- apps/desktop/src/lib/transcript-reading.ts | 97 +++++ .../src/lib/transcript-search-context.ts | 4 + .../src/lib/transcript-search-highlight.ts | 45 +- apps/desktop/src/stores/app-state.ts | 9 +- apps/desktop/src/stores/app-store.ts | 40 +- .../src/stores/runtime/session-runtime.ts | 19 +- .../src/stores/runtime/transcript-action.ts | 7 +- .../runtime/transcript-reading-runtime.ts | 166 +++++++ .../src/stores/slices/initial-state.ts | 1 + .../src/stores/slices/project-slice.ts | 1 - .../src/stores/slices/session-slice.ts | 51 +-- apps/desktop/src/styles/messages.css | 6 + .../test/session-search-rendering.test.mjs | 2 +- .../test/session-switch-performance.test.mjs | 22 +- apps/desktop/test/subagent-panel.test.mjs | 4 +- .../test/transcript-navigation.test.mjs | 128 ------ apps/desktop/test/transcript-reading.test.mjs | 405 ++++++++++++++++++ .../test/transcript-search-focus.test.mjs | 173 ++++++++ .../test/transcript-search-rendering.test.mjs | 224 ++++++++++ apps/desktop/test/transcript-settle.test.mjs | 2 +- crates/host-core/src/session_search.rs | 93 ++++ crates/host-core/src/sessions.rs | 29 +- crates/host-core/src/transcripts.rs | 41 +- docs/adr/session-content-search.md | 45 +- docs/spec/03-runtime/01-ipc-protocol.md | 4 + docs/spec/03-runtime/04-data-storage.md | 5 + docs/spec/03-runtime/06-host-rpc-protocol.md | 4 + docs/spec/04-ux/08-component-spec.md | 10 + docs/spec/06-delivery/04-e2e-test-plan.md | 16 + docs/spec/08-meta/decisions-log.md | 11 + packages/shared/src/types/sessions.ts | 2 + 49 files changed, 1776 insertions(+), 516 deletions(-) delete mode 100644 apps/desktop/src/hooks/use-transcript-navigation.ts create mode 100644 apps/desktop/src/hooks/use-transcript-search-focus.ts create mode 100644 apps/desktop/src/hooks/use-transcript-view.ts create mode 100644 apps/desktop/src/lib/markdown-source.ts delete mode 100644 apps/desktop/src/lib/transcript-navigation.ts create mode 100644 apps/desktop/src/lib/transcript-reading.ts create mode 100644 apps/desktop/src/lib/transcript-search-context.ts create mode 100644 apps/desktop/src/stores/runtime/transcript-reading-runtime.ts delete mode 100644 apps/desktop/test/transcript-navigation.test.mjs create mode 100644 apps/desktop/test/transcript-reading.test.mjs create mode 100644 apps/desktop/test/transcript-search-focus.test.mjs create mode 100644 apps/desktop/test/transcript-search-rendering.test.mjs diff --git a/apps/desktop/src/components/Markdown.tsx b/apps/desktop/src/components/Markdown.tsx index aba59f3c74..240b70ffd6 100644 --- a/apps/desktop/src/components/Markdown.tsx +++ b/apps/desktop/src/components/Markdown.tsx @@ -39,6 +39,11 @@ import { import { TooltipButton } from "./ui"; import { createPortal } from "react-dom"; import { api } from "../lib/api"; +import { + rehypeSourcePositions, + sourcePositionProps, + type SourcePositionProps, +} from "../lib/markdown-source"; import { useAppStore } from "../stores/app-store"; import { useReferencedImageDataUrl } from "../lib/use-referenced-image-data-url"; import { @@ -192,11 +197,11 @@ export function HighlightedCode({ ); } -function CodeBlock({ code, lang }: { code: string; lang: string }) { +function CodeBlock({ code, lang, ...position }: { code: string; lang: string } & SourcePositionProps) { const { t } = useTranslation(); const { copied, copy } = useCopy(); return ( -
+
{lang || "text"} ): boolean { return nearViewport; } -function MermaidBlock({ code }: { code: string }) { +function MermaidBlock({ code, ...position }: { code: string } & SourcePositionProps) { const { t } = useTranslation(); const theme = useThemeMode(); const reactId = useId(); @@ -303,6 +308,7 @@ function MermaidBlock({ code }: { code: string }) { return (
@@ -410,7 +416,7 @@ function PreBlock({ node: _node, children, ...rest -}: ComponentProps<"pre"> & { node?: unknown }) { +}: ComponentProps<"pre"> & SourcePositionProps & { node?: unknown }) { const { closedFence, renderDiagrams } = useContext(MarkdownBlockContext); const info = extractCode(children); if (!info) return
{children}
; @@ -419,9 +425,9 @@ function PreBlock({ closedFence && info.lang.toLowerCase() === "mermaid" ) { - return ; + return ; } - return ; + return ; } /** Preview-in-panel tooltip for file and URL chat references. */ @@ -661,7 +667,7 @@ function MarkdownImage({ src, alt, ...rest -}: ComponentProps<"img"> & { node?: unknown }) { +}: ComponentProps<"img"> & SourcePositionProps & { node?: unknown }) { const root = useAppStore((s) => s.workspace?.path); const baseDir = useContext(MarkdownBaseDirContext); const openFile = useAppStore((s) => s.openFileInWorkPanel); @@ -709,6 +715,7 @@ function MarkdownImage({
!pinnedSessionIds.has(session.id)); + const visibleSessions = sessionsExpanded ? history : history.slice(0, MAX_VISIBLE_SESSIONS); + const hiddenCount = history.length - visibleSessions.length; const renderTimeGroupedSessions = (sessions: SessionSummary[]) => { - // Group the visible slice by time, preserving recency order. - const grouped = new Map(); - for (const session of sessions) { - const group = getTimeGroup(session.updatedAt); - if (!grouped.has(group)) grouped.set(group, []); - grouped.get(group)!.push(session); - } const result: React.ReactNode[] = []; - for (const group of TIME_GROUP_ORDER) { - const groupSessions = grouped.get(group); - if (!groupSessions || groupSessions.length === 0) continue; - + for (const { group, sessions: groupSessions } of groupSidebarSessionsByTime(sessions)) { // For today, don't show header (as per requirement) if (group !== "today") { const i18nKey = @@ -1964,6 +1958,23 @@ export function Sidebar({
+ {pinnedSessions.length > 0 ? ( +
+
+ + {t("nav.pinnedSessions")} + +
+
closeMenus(false)}> + {renderSessionRows(pinnedSessions, { global: true })} +
+
+ ) : null} +
- {temporarySessions.length > 0 ? renderSessionRows(temporarySessions, { temporary: true }) : ( + {temporarySessionHistory.length > 0 ? ( + renderSessionRows(temporarySessionHistory, { temporary: true }) + ) : temporarySessions.length === 0 ? (
{t("nav.noTemporarySessions")}
- )} + ) : null}
diff --git a/apps/desktop/src/lib/sidebar-session-groups.ts b/apps/desktop/src/lib/sidebar-session-groups.ts index 94d8281f54..f832fe7543 100644 --- a/apps/desktop/src/lib/sidebar-session-groups.ts +++ b/apps/desktop/src/lib/sidebar-session-groups.ts @@ -1,4 +1,5 @@ import type { SessionSummary } from "@pi-desktop/shared"; +import type { ProjectMeta, SessionMeta } from "./sidebar-preferences"; export function normalizeProjectPath(projectPath?: string | null): string | null { const value = projectPath?.trim(); @@ -56,3 +57,60 @@ export function groupSidebarSessions( ), }; } + +export function sessionArchived(session: SessionSummary, meta: SessionMeta | undefined): boolean { + return Boolean(meta?.archived || (session as SessionSummary & { archived?: boolean }).archived); +} + +export function sessionPinned(session: SessionSummary, meta: SessionMeta | undefined): boolean { + return Boolean(meta?.pinned || (session as SessionSummary & { pinned?: boolean }).pinned); +} + +/** Pins are global shortcuts; project retention and dates do not limit discovery. */ +export function getGlobalPinnedSessions( + sessions: SessionSummary[], + sessionMeta: Record, + projectMeta: Record, + showArchived: boolean, +): SessionSummary[] { + return sessions.filter((session) => { + if (!sessionPinned(session, sessionMeta[session.id])) return false; + const path = normalizeProjectPath(session.projectPath); + const project = path ? (projectMeta[path] ?? projectMeta[session.projectPath!]) : undefined; + return ( + showArchived || (!sessionArchived(session, sessionMeta[session.id]) && !project?.archived) + ); + }); +} + +type TimeGroup = "today" | "yesterday" | "thisWeek" | "older14d" | "archived"; + +function getTimeGroup(dateStr: string | undefined, now: Date): TimeGroup { + if (!dateStr) return "older14d"; + const ts = Date.parse(dateStr); + if (!Number.isFinite(ts)) return "older14d"; + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const startOfYesterday = startOfToday - 86400000; + const startOfWeek = startOfToday - 6 * 86400000; // last 7 days + const startOf14d = startOfToday - 13 * 86400000; + if (ts >= startOfToday) return "today"; + if (ts >= startOfYesterday) return "yesterday"; + if (ts >= startOfWeek) return "thisWeek"; + if (ts >= startOf14d) return "older14d"; + return "archived"; // older than 14 days +} + +const TIME_GROUP_ORDER: TimeGroup[] = ["today", "yesterday", "thisWeek", "older14d", "archived"]; +/** Group only normal history; callers remove global pins before any row limit. */ +export function groupSidebarSessionsByTime(sessions: SessionSummary[], now = new Date()) { + const grouped = new Map(); + for (const session of sessions) { + const group = getTimeGroup(session.updatedAt, now); + if (!grouped.has(group)) grouped.set(group, []); + grouped.get(group)!.push(session); + } + return TIME_GROUP_ORDER.flatMap((group) => { + const rows = grouped.get(group); + return rows?.length ? [{ group, sessions: rows }] : []; + }); +} diff --git a/apps/desktop/src/styles/sidebar-threads.css b/apps/desktop/src/styles/sidebar-threads.css index 63e262f423..3056c6962c 100644 --- a/apps/desktop/src/styles/sidebar-threads.css +++ b/apps/desktop/src/styles/sidebar-threads.css @@ -248,3 +248,32 @@ font-size: var(--text-md); /* empty-state copy tracks group titles */ line-height: var(--leading-compact); } + +.sidebar-pinned-sessions { + min-width: 0; + flex: 0 0 auto; +} + +.sidebar-session-group-body.pinned { + max-height: min(224px, 30vh); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior-y: contain; + padding: 0 2px; +} + +.sidebar-pinned-sessions .thread-item-title { + min-width: 0; + flex: 1; +} + +.thread-item-project { + min-width: 0; + max-width: 40%; + margin-left: auto; + overflow: hidden; + color: var(--ds-text-muted); + font-size: var(--text-sm); + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/apps/desktop/test/sidebar-navigation.test.mjs b/apps/desktop/test/sidebar-navigation.test.mjs index 87f204335b..886c15f745 100644 --- a/apps/desktop/test/sidebar-navigation.test.mjs +++ b/apps/desktop/test/sidebar-navigation.test.mjs @@ -126,7 +126,7 @@ test("sidebar shows a bounded standalone session list before retained projects", assert.match(standaloneSessions, /t\("nav\.sessions"/); assert.match(standaloneSessions, /data-action="new-standalone-session"/); assert.match(standaloneSessions, /createSession\(\{ projectPath: null \}\)/); - assert.match(standaloneSessions, /renderSessionRows\(temporarySessions/); + assert.match(standaloneSessions, /renderSessionRows\(temporarySessionHistory/); assert.ok( sidebarSource.indexOf('data-sidebar-session-section="temporary"') < sidebarSource.indexOf('data-action="new-project"'), diff --git a/apps/desktop/test/sidebar-pinned-rendering.test.mjs b/apps/desktop/test/sidebar-pinned-rendering.test.mjs new file mode 100644 index 0000000000..2402279c41 --- /dev/null +++ b/apps/desktop/test/sidebar-pinned-rendering.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import i18n from "i18next"; +import { I18nextProvider } from "react-i18next"; +import { catalogs } from "@pi-desktop/i18n"; +import { createServer } from "vite"; + +test("sidebar renders global pins once, outside project folding and history limits", async () => { + const previousDocument = globalThis.document; + const server = await createServer({ + root: fileURLToPath(new URL("..", import.meta.url)), + configFile: false, + server: { middlewareMode: true, hmr: false, ws: false }, + esbuild: { jsx: "automatic" }, + appType: "custom", + optimizeDeps: { noDiscovery: true, include: [] }, + }); + try { + const { Sidebar } = await server.ssrLoadModule("/src/components/Sidebar.tsx"); + const { useAppStore } = await server.ssrLoadModule("/src/stores/app-store.ts"); + await i18n.init({ lng: "en", resources: { en: { translation: catalogs.en } } }); + const old = "2020-01-01T00:00:00Z"; + const session = (id, projectPath, updatedAt = old) => ({ + id, + title: id, + projectPath, + createdAt: old, + updatedAt, + }); + const pins = [ + session("open-pin", "/open"), + session("collapsed-pin", "/collapsed"), + session("closed-pin", "/closed"), + session("temporary-pin", null), + session("archived-pin", "/open"), + session("archived-project-pin", "/archived"), + ]; + const normal = Array.from({ length: 11 }, (_, index) => + session(`normal-${String(index).padStart(2, "0")}`, "/open", new Date().toISOString()), + ); + const meta = Object.fromEntries(pins.map(({ id }) => [id, { pinned: true }])); + meta["archived-pin"].archived = true; + const seed = { + sessions: [...pins, ...normal, session("temporary-normal", null)], + sessionMeta: meta, + projectMeta: { + "/closed": { name: "Closed project" }, + "/collapsed": { collapsed: true }, + "/archived": { archived: true }, + }, + openProjectPaths: ["/open", "/collapsed"], + openProjects: [], + workspace: null, + activeProjectPath: null, + projectCollapsed: {}, + sessionView: { sort: "recent", archived: false }, + }; + const render = (overrides = {}) => { + Object.assign(useAppStore.getInitialState(), seed, overrides); + globalThis.document = { documentElement: { dataset: { theme: "light" } } }; + return renderToStaticMarkup( + createElement( + I18nextProvider, + { i18n }, + createElement(Sidebar, { + collapsed: false, + onToggle() {}, + sidebarWidth: 275, + onWidthChange() {}, + onWidthCommit() {}, + }), + ), + ); + }; + const rows = (html) => + [...html.matchAll(/data-sidebar-session-row="([^"]+)"/g)].map((match) => match[1]); + const pinnedSection = (html) => + html.match(/]*data-sidebar-session-section="pinned"[\s\S]*?<\/section>/)?.[0] ?? + ""; + const html = render(); + const expectedPins = ["closed-pin", "collapsed-pin", "open-pin", "temporary-pin"]; + assert.deepEqual(rows(pinnedSection(html)), expectedPins); + assert.match(pinnedSection(html), />Closed project<\/span>/); + assert.ok(pinnedSection(html).includes(catalogs.en.nav.hoverCardTemporarySpace)); + assert.doesNotMatch(pinnedSection(html), /sidebar-time-group/); + assert.ok( + html.indexOf('data-sidebar-session-section="pinned"') < + html.indexOf('data-sidebar-session-section="temporary"'), + ); + assert.equal(rows(html).length, new Set(rows(html)).size, "pins have no duplicate rows"); + assert.equal(rows(html).filter((id) => id.startsWith("normal-")).length, 10); + assert.ok(rows(html).includes("temporary-normal")); + assert.ok(!rows(html).includes("archived-pin")); + assert.ok(!rows(html).includes("archived-project-pin")); + + const unpinned = render({ sessionMeta: {} }); + assert.equal(pinnedSection(unpinned), ""); + assert.ok(rows(unpinned).includes("temporary-pin")); + assert.ok(!rows(unpinned).includes("closed-pin"), "unpin respects closed project tabs"); + const collapsedGroup = + unpinned.match(/data-sidebar-project-group="\/collapsed"[\s\S]*?<\/section>/)?.[0] ?? ""; + assert.match( + collapsedGroup, + /class="sidebar-session-group-body project collapsed"[^>]*aria-hidden="true"/, + ); + assert.deepEqual(rows(collapsedGroup), ["collapsed-pin"], "unpin returns to folded history"); + const restored = render({ sessionView: { sort: "recent", archived: true } }); + assert.equal(rows(pinnedSection(restored)).length, pins.length); + assert.equal(rows(restored).length, new Set(rows(restored)).size); + } finally { + globalThis.document = previousDocument; + await server.close(); + } +}); diff --git a/apps/desktop/test/sidebar-session-groups.test.mjs b/apps/desktop/test/sidebar-session-groups.test.mjs index d7855f81dc..0184c89042 100644 --- a/apps/desktop/test/sidebar-session-groups.test.mjs +++ b/apps/desktop/test/sidebar-session-groups.test.mjs @@ -2,6 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { groupSidebarSessions, + getGlobalPinnedSessions, + groupSidebarSessionsByTime, normalizeProjectPath, projectPathsForNewSessions, sessionMatchesProject, @@ -86,3 +88,74 @@ test("finds only newly imported project-bound sessions", () => { ["/work/archived", "C:/work/new"], ); }); + +test("global pins include closed projects and temporary sessions without date buckets", () => { + const sessions = [ + session({ id: "today", projectPath: "/open", updatedAt: "2026-09-13T01:00:00Z" }), + session({ id: "old-pin", projectPath: "/open", updatedAt: "2025-01-01T00:00:00Z" }), + session({ id: "closed-pin", projectPath: "/closed", updatedAt: "2024-01-01T00:00:00Z" }), + session({ id: "temporary-pin" }), + ]; + const meta = Object.fromEntries(sessions.slice(1).map((row) => [row.id, { pinned: true }])); + const pins = getGlobalPinnedSessions(sessions, meta, {}, false); + assert.deepEqual( + pins.map((row) => row.id), + ["old-pin", "closed-pin", "temporary-pin"], + ); + for (const now of [new Date(2026, 8, 13, 12), new Date(2026, 8, 14, 12)]) { + const ids = new Set(pins.map((row) => row.id)); + const history = groupSidebarSessionsByTime( + sessions.filter((row) => !ids.has(row.id)), + now, + ); + const rendered = [...pins, ...history.flatMap((group) => group.sessions)].map((row) => row.id); + assert.deepEqual(rendered, ["old-pin", "closed-pin", "temporary-pin", "today"]); + assert.equal(new Set(rendered).size, sessions.length); + } + assert.equal(sessions[1].projectPath, "/open", "pinning must not rebind a conversation"); +}); + +test("global pins honor session and project archives including normalized paths", () => { + const sessions = [ + session({ id: "visible", projectPath: "/open" }), + session({ id: "session-archived" }), + session({ id: "project-archived", projectPath: "C:\\work\\archived\\" }), + session({ id: "legacy", pinned: true, archived: true }), + ]; + const meta = { + visible: { pinned: true }, + "session-archived": { pinned: true, archived: true }, + "project-archived": { pinned: true }, + }; + const projects = { "C:/work/archived": { archived: true } }; + assert.deepEqual( + getGlobalPinnedSessions(sessions, meta, projects, false).map((row) => row.id), + ["visible"], + ); + assert.equal(getGlobalPinnedSessions(sessions, meta, projects, true).length, 4); + assert.equal( + getGlobalPinnedSessions([], meta, projects, false).length, + 0, + "stale preferences cannot restore deleted rows", + ); + assert.equal( + getGlobalPinnedSessions(sessions, { ...meta, visible: { pinned: false } }, projects, false) + .length, + 0, + ); +}); + +test("ordinary date groups retain their order after pins are removed", () => { + const date = (day) => new Date(2026, 8, day, 1).toISOString(); + const rows = [ + session({ id: "old", updatedAt: "2025-01-01T00:00:00Z" }), + session({ id: "week", updatedAt: date(10) }), + session({ id: "today", updatedAt: date(13) }), + session({ id: "yesterday", updatedAt: date(12) }), + session({ id: "fortnight", updatedAt: date(3) }), + ]; + assert.deepEqual( + groupSidebarSessionsByTime(rows, new Date(2026, 8, 13, 12)).map((group) => group.group), + ["today", "yesterday", "thisWeek", "older14d", "archived"], + ); +}); diff --git a/docs/adr/global-sidebar-pins.md b/docs/adr/global-sidebar-pins.md new file mode 100644 index 0000000000..669418213a --- /dev/null +++ b/docs/adr/global-sidebar-pins.md @@ -0,0 +1,44 @@ +# ADR: Show pinned conversations in a global sidebar section + +- Status: Accepted +- Date: 2026-09-13 +- Related issue: [#306](https://github.com/vastsa/PI-Desktop/issues/306) +- Amends: [ADR 0016](0016-sidebar-organization-and-multi-project-tabs.md) + +## Context + +Conversation sorting puts pinned rows first, but project rendering then places +them in chronological buckets. An older pin can therefore appear below today's +unpinned conversations. Pins also disappear when their project tab is collapsed +or closed, which defeats their role as quick access to frequently used sessions. + +## Decision + +- Render one global Pinned section above standalone Sessions and Projects. + Derive it from existing session summaries and organization metadata, including + sessions whose project tab is not retained. Do not create another pin registry. +- Apply archive visibility to both the session and its owning project. Closed + and collapsed projects do not hide otherwise visible pins. +- Show project context on each pin and use the existing session sort within the + section without date buckets. Keep project pinning and project sorting intact. +- Remove visible pins from ordinary rows before chronological grouping and the + per-project ten-row limit. Preserve full project membership for activity + ordering, archive/delete navigation, and workspace ownership. +- Reuse ordinary row selection and actions. Unpinning returns a session to its + normal history location and visibility rules. Restore focus to its relocated + overflow control, with the Sessions sort control as a fallback when hidden. +- Omit an empty section and bound its scroll area to `min(224px, 30vh)`. + +## Consequences + +Pins remain discoverable across dates and projects without duplicate rows. +Existing persisted pins take effect automatically; no storage migration, IPC +change, or host ownership change is required. Rendering project context costs +some row width, so it is ellipsized and subordinate to the session title. + +## Validation + +Pure grouping tests cover old, temporary, closed-project, archived, deleted, +unbound, and unpinned sessions. Sidebar rendering coverage checks actual section +order, uniqueness, project folding, archive visibility, and the history limit. +The interaction contract is E2E-SIDEBAR-global-pinned-conversations. diff --git a/docs/spec/04-ux/08-component-spec.md b/docs/spec/04-ux/08-component-spec.md index a3ebca77e2..37e056e4ce 100644 --- a/docs/spec/04-ux/08-component-spec.md +++ b/docs/spec/04-ux/08-component-spec.md @@ -314,11 +314,11 @@ combined model × reasoning selection (§11). ### 3.1 Purpose Scoped project and session navigation, management, and notification access. The -expanded sidebar shows path-less conversations first under a compact `Sessions` -heading and retained project tabs under a following `Projects` heading; the +expanded sidebar shows a global `Pinned` section when pins exist, followed by +path-less history under `Sessions` and retained project tabs under `Projects`; the collapsed state is an icon rail. Retained tabs are renderer presentation state, not additional host workspaces. -The sidebar body is reserved for Sessions and Projects; the footer exposes the +The sidebar body is reserved for Pinned, Sessions, and Projects; the footer exposes the Plugins destination beside Settings. Projects is managed through Settings → Project archive, while Pull requests and Scheduled are not rendered in the sidebar. @@ -335,6 +335,8 @@ Expanded (~275px, D034/D070): +---------------------------+ | [lights] [◧] | macOS | [π] PI-Desktop [◧] | Windows/Linux +| PINNED | +| • Pinned task project-A| | SESSIONS [msg+][↕]| | • Path-less session ↕| | PROJECTS [dir+]| @@ -366,7 +368,7 @@ tier; weight, indentation, and disclosure icons preserve their hierarchy: | Footer action icons | `--text-base` (14px) | Settings, Extensions, notifications; left side of footer | | Session / thread titles | `--text-md` (13px) | Compact list content | | Project / group titles, empty copy | `--text-md` (13px) | Hierarchy comes from weight and indentation | -| Section labels (`SESSIONS`, `PROJECTS`) | `--text-sm` (12px) | Uppercase secondary labels | +| Section labels (`PINNED`, `SESSIONS`, `PROJECTS`) | `--text-sm` (12px) | Uppercase secondary labels; global pin project context uses the same size | | Footer profile name + profile menu items | `--text-base` (14px) | Identity cluster matches nav body | | Footer status / version | `--text-sm` (12px) | Right-aligned build/version chip | @@ -388,7 +390,7 @@ visually distinct from list content. | Session failed | Red circled alert mark from the latest unread task notification when the row is not selected | | Hover session | bg-tertiary background | | Active project | Header carries active state; topbar follows that workspace; composer exposes no workspace identity | -| Collapsed project | Header remains visible; child conversations are hidden | +| Collapsed project | Header remains visible; unpinned child conversations are hidden; global pins remain visible | | Archived row | Hidden by default; visible in the explicit archived view | | No retained project | Compact Open project entry; standalone Sessions rows remain available | | Empty group | Muted one-line empty state; group create action remains available | @@ -488,12 +490,26 @@ visually distinct from list content. developer mode is on, the menu also offers Copy conversation ID (clipboard) and Open session path (the session scratch directory in the system file manager). +- Pinned conversations appear once in a global section above Sessions and + Projects, independent of date buckets, project collapse, retained tabs, and + each project's ten-row history limit. Each pin shows its project display + name (full path on hover), or Temporary space for a path-less conversation. + The section is omitted when empty and scrolls within `min(224px, 30vh)` when + needed. Its rows reuse normal selection, status, hover, and overflow actions. +- Pinning moves the existing row into that section; unpinning returns it to + normal project or temporary history, subject to existing folding and closed + tab visibility. Keyboard focus follows the relocated row's overflow control, + or returns to the Sessions sort control if the row becomes hidden. Archived + conversations and pins in archived projects stay hidden until Show archived + is enabled. Closing a project does not remove its global pins. - The `Sessions` toolbar places the sort button before the message-plus New Chat control. The sort menu and every other body-level sidebar menu remain content-sized and open 4px to the right of their trigger or pointer. Their left edge never flips to the trigger's left side; the surface has a viewport width cap for narrow windows. The sort choices remain Recently updated, - Created date, Oldest first, and Name; pinned rows stay ahead of unpinned rows. + Created date, Oldest first, and Name; the chosen session sort orders global + pins internally without date headers. Pinned projects still precede unpinned + projects within the project sort. Project rows have no reorder grip. Pressing the project title and moving 8px starts a pointer reorder and selects the persisted `manual` project order without changing the session sort. - When a session hover card is revealed for the active project, the renderer @@ -1054,9 +1070,9 @@ It does not render separate Details or Output tabs. ### 6.1 Purpose -List user sessions by execution context inside the sidebar. It exposes the -sessions for every retained project tab plus persistent sessions that have no -project. Pin/archive/collapse state is a presentation over durable host +List user sessions inside the sidebar: global pinned shortcuts, followed by +unpinned history for retained project tabs and path-less sessions. +Pin/archive/collapse state is a presentation over durable host sessions, not a replacement persistence model. ### 6.2 Anatomy @@ -1064,6 +1080,8 @@ sessions, not a replacement persistence model. Groups and session items: ```text +PINNED + Pinned session title project-name [folder] current-project [+] Session title [star] pinned-project [+] @@ -1085,7 +1103,7 @@ SESSIONS [msg+][↕] | Completed | success-green check mark | | Failed | error-red circled alert mark | | Pinned project | filled accent Star replaces the Folder glyph; ordered before unpinned projects within the selected sort | -| Pinned conversation | ordered before unpinned rows within the selected sort | +| Pinned conversation | shown once in the global Pinned section, with project context and the selected session sort | | Archived | omitted by default; shown only when archived view is enabled | ### 6.4 Interactions @@ -1093,9 +1111,10 @@ SESSIONS [msg+][↕] - Click: activate session - Project matching uses the normalized full project path, never only the folder basename. -- Sessions for retained paths appear beneath their corresponding project - group. Sessions for closed paths remain discoverable from Settings → Project - archive. +- Unpinned sessions for retained paths appear beneath their corresponding + project group. Global pins remain available when their project is collapsed + or closed; all sessions for closed paths remain discoverable from Settings → + Project archive. - Selecting a temporary session clears the active workspace so session and tool context do not imply project access. - Rename opens a modal title editor from the session overflow menu or a diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 9d15cb08da..59ebb53226 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -7134,12 +7134,39 @@ This test plan spec is accepted when: actions with keyboard-reachable menu semantics. - Open the sort menu from the standalone `Sessions` heading, pin one project/session, and choose each user-facing sort mode (Recently updated, - Created date, Oldest first, Name). Pinned rows remain first. + Created date, Oldest first, Name). Pinned projects remain first in Projects; + pinned conversations appear once in the global Pinned section above Sessions + and Projects, with the selected session sort and no date headers. - Archive a row, verify it is absent by default, enable Show archived, and restore it. The transcript and project binding remain unchanged. - A legacy `manual` preference loads without presenting a drag-reorder affordance. +### E2E-SIDEBAR-global-pinned-conversations + +- Seed an old pinned conversation in project A, today's unpinned conversation + and eleven other normal rows in A, a pinned conversation in collapsed project + B, one in closed project C, and a pinned Temporary conversation. +- Expect one Pinned section above Sessions and Projects, containing all four + pins with their project names or Temporary space label. Pins have no date + headers and do not appear a second time in ordinary history. A still shows + ten normal rows initially, with its remaining rows behind Load more. +- Change the date across midnight and select each session sort. Pins remain + above history; sorting changes only their internal order. Selecting B or C's + pin activates its original conversation and project; selecting the Temporary + pin clears workspace context. Running and unread states remain visible. +- Pin and unpin through the keyboard menu. The row moves immediately and focus + follows its overflow control, or returns to the Sessions sort control if the + row is now folded or belongs to a closed project. Unpin the last pin and + expect no empty Pinned section. +- Archive a pin and a pinned conversation's project. Both disappear by default; + Show archived reveals them and Restore preserves the pin. Delete a pinned + conversation and expect no stale row. Reload and expect saved pins to return. +- With enough pins to overflow, scroll within Pinned and verify that Sessions, + Projects, and the footer remain reachable in light/dark themes at minimum + supported window size. Existing hover cards, context menus, drag/drop, and + project pinning retain their normal behavior. + ### US-UI-59 Session-rooted background tools - Start a visible turn in project A, switch to project B while it runs, and inspect both sidebar status indicators. diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index ac25f7ef49..84004b08a9 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -4798,3 +4798,12 @@ source of truth; renderer organization is mirrored without a persistence change. macOS single-click opens the menu without restoring the window; selection is delivered after bootstrap and uses normal session navigation. Validation contract: E2E-TRAY-bounded-session-navigation. + +### Global sidebar conversation pins (issue #306) + +[ADR global-sidebar-pins](/adr/global-sidebar-pins) amends ADR 0016: conversation +pins occupy one global section above standalone and project history, including +closed or collapsed projects. Archive visibility and session sorting still +apply. Pins carry project context and are removed from normal history before +date grouping and row limits. Persisted metadata and host ownership stay intact. +Validation contract: E2E-SIDEBAR-global-pinned-conversations. diff --git a/docs/zh-CN/spec/04-ux/08-component-spec.md b/docs/zh-CN/spec/04-ux/08-component-spec.md index 9cb0fa1f7b..84aed1a345 100644 --- a/docs/zh-CN/spec/04-ux/08-component-spec.md +++ b/docs/zh-CN/spec/04-ux/08-component-spec.md @@ -278,6 +278,8 @@ Expanded (~275px, D034/D070): +---------------------------+ | [lights] [◧] | macOS | [π] PI-Desktop [◧] | Windows/Linux +| PINNED | +| • Pinned task project-A| | 会话 [消息+][↕] | | • 无路径会话 ↕ | | 项目 [目录+] | diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 1a66ae6efa..1c8a12aa2d 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -123,6 +123,7 @@ export const de = { "devDisabled": "Updates sind in Entwicklungs-Builds deaktiviert." }, "nav": { + "pinnedSessions": "Angeheftet", "home": "Startseite", "newTask": "Neue Aufgabe", "newProject": "Neues Projekt", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index 78ec5762b3..25cc0b7c29 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -130,6 +130,7 @@ export const en = { devDisabled: "Updates are disabled in development builds.", }, nav: { + "pinnedSessions": "Pinned", home: "Home", newTask: "New task", newProject: "New project", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index c6ae04ad4a..fd10a2b857 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -123,6 +123,7 @@ export const es = { "devDisabled": "Las actualizaciones están deshabilitadas en las compilaciones de desarrollo." }, "nav": { + "pinnedSessions": "Fijadas", "home": "Inicio", "newTask": "Nueva tarea", "newProject": "Nuevo proyecto", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index a80938f344..4528c70667 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -123,6 +123,7 @@ export const fr = { "devDisabled": "Les mises à jour sont désactivées dans les versions de développement." }, "nav": { + "pinnedSessions": "Épinglées", "home": "Accueil", "newTask": "Nouvelle tâche", "newProject": "Nouveau projet", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 2f7814e740..c1c2425367 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -132,6 +132,7 @@ export const ko = { devDisabled: "개발 빌드에서는 업데이트가 비활성화됩니다.", }, nav: { + "pinnedSessions": "고정됨", home: "홈", newTask: "새 작업", newProject: "새 프로젝트", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index bb4029dc57..44e957864e 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -132,6 +132,7 @@ export const tr = { devDisabled: "Geliştirme derlemelerinde güncellemeler kapalıdır.", }, nav: { + "pinnedSessions": "Sabitlenmiş", home: "Ana sayfa", newTask: "Yeni görev", newProject: "Yeni proje", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index 40a6b81973..daea1bdf03 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -125,6 +125,7 @@ export const zhCN = { devDisabled: "开发版本不支持自动更新。", }, nav: { + pinnedSessions: "置顶", home: "首页", newTask: "新建任务", newProject: "新建项目", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index da1cb08adf..94f192804c 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -125,6 +125,7 @@ export const zhTW = { devDisabled: "開發版本不支援自動更新。", }, nav: { + pinnedSessions: "釘選", home: "首頁", newTask: "新建任務", newProject: "新建專案", From 56c5e9111d69ec3dd66b39433f7fc71d43a9801d Mon Sep 17 00:00:00 2001 From: zszz3 <91608029+zszz3@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:58:14 +0800 Subject: [PATCH 10/13] feat(tray): share the session row budget across groups Every non-empty group still keeps its own three-row share, but the share smaller groups leave unused now goes to the groups that still overflow, in Running -> Unread -> Pinned order, up to nine rows in total. Seven running sessions with no unread or pinned show all seven instead of three. Overflow is measured against the share a group actually received, so View more no longer appears when every session is already listed. Co-Authored-By: Claude Opus 5 (1M context) --- docs/adr/tray-session-shortcuts.md | 6 +- docs/spec/04-ux/08-component-spec.md | 7 +- docs/spec/04-ux/09-interaction-patterns.md | 7 +- docs/spec/06-delivery/04-e2e-test-plan.md | 12 +- docs/spec/08-meta/decisions-log.md | 6 +- docs/zh-CN/spec/04-ux/08-component-spec.md | 7 +- .../spec/04-ux/09-interaction-patterns.md | 7 +- .../spec/06-delivery/04-e2e-test-plan.md | 12 +- docs/zh-CN/spec/08-meta/decisions-log.md | 6 +- packages/shared/src/tray-sessions.test.ts | 107 ++++++++++++++++++ packages/shared/src/tray-sessions.ts | 48 +++++++- 11 files changed, 198 insertions(+), 27 deletions(-) create mode 100644 packages/shared/src/tray-sessions.test.ts diff --git a/docs/adr/tray-session-shortcuts.md b/docs/adr/tray-session-shortcuts.md index bb971e7d06..82d1046d62 100644 --- a/docs/adr/tray-session-shortcuts.md +++ b/docs/adr/tray-session-shortcuts.md @@ -15,7 +15,11 @@ or closed, so a renderer-only menu snapshot would become stale. 1. Extend the existing native menu with Running, Unread, and Pinned groups in that order. Assign each eligible session to its highest-priority group - before taking at most three rows per group (nine total). Hide empty groups. + before allocating rows: every non-empty group keeps up to three rows, then + the share smaller groups leave unused goes to the groups that still + overflow, in the same priority order, up to nine rows in total. A single + busy group can therefore fill the whole menu when the others are empty, + while no group is ever crowded out below its own share. Hide empty groups. Follow the existing session sort preference; unread results use newest-first inbox order and the same latest-result/read rule as sidebar outcome badges. 2. Renderer mirrors its session pin/archive/order metadata, archived project diff --git a/docs/spec/04-ux/08-component-spec.md b/docs/spec/04-ux/08-component-spec.md index 6a5588974b..fc8e821ca6 100644 --- a/docs/spec/04-ux/08-component-spec.md +++ b/docs/spec/04-ux/08-component-spec.md @@ -199,9 +199,10 @@ Outer frame that positions Topbar, Sidebar, MainChat, and WorkPanel. Owns resize ### Native tray session menu The Main-owned native menu contains Open, non-empty Running/Unread/Pinned -sections, and Quit. Each section has a disabled localized heading, at most -three single-line session rows, and View more only when it overflows. Session -rows are globally deduplicated before truncation. View more expands session navigation; +sections, and Quit. Each section has a disabled localized heading, single-line +session rows up to the share allocated to that group, and View more only when +it overflows that share. Session rows are globally deduplicated before +truncation. View more expands session navigation; session rows enter their original conversation. The menu follows active locale changes and never marks a result read merely by opening. macOS single-click opens the attached menu; Open and double-click restore/focus the window. diff --git a/docs/spec/04-ux/09-interaction-patterns.md b/docs/spec/04-ux/09-interaction-patterns.md index 98788836db..551aa61f16 100644 --- a/docs/spec/04-ux/09-interaction-patterns.md +++ b/docs/spec/04-ux/09-interaction-patterns.md @@ -171,8 +171,11 @@ recency only breaks ties between equally relevant matches. ### 1.5.2 Tray session navigation (issue #293) - The native menu shows Running, Unread, and Pinned in that order, at most - three sessions in each and nine in total. Membership is assigned before - applying limits; higher-priority overflow never spills into a lower group. + nine sessions in total. Every non-empty group keeps up to three rows; the + share smaller groups leave unused goes to the groups that still overflow, + in priority order, so one busy group can fill all nine while the others are + empty. Membership is assigned before applying limits; higher-priority + overflow never spills into a lower group. - Empty groups are hidden. Archived sessions/projects and deleted sessions are excluded. Running/Pinned follow sidebar sorting; Unread follows the latest unread result per session, newest first, including failed results. diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 9eaee53361..8135b15882 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -11739,7 +11739,9 @@ plugin-form fixtures in an isolated temporary directory at runtime. - **Preconditions**: At least four running, four unread, and four pinned sessions across two projects; include overlaps, read-latest/older-unread notifications, archived/deleted sessions, an archived project, empty titles, - multiline titles, long CJK/emoji titles, and literal ampersands. Use an + multiline titles, long CJK/emoji titles, and literal ampersands. Also cover + a single populated group of seven and one of more than nine, with the other + two groups empty, to exercise reclaimed share. Use an isolated profile. Repeat native activation on macOS and Windows/Linux. - **Steps**: Hide the main window and open the tray menu. Inspect group order, counts, duplicates, titles, and unchanged unread records. Choose the third @@ -11752,8 +11754,12 @@ plugin-form fixtures in an isolated temporary directory at runtime. Retry a transient read failure by hovering/right-clicking the tray. Repeat after clearing all group memberships and changing shipped locales. Choose Quit then Cancel, then Quit and confirm. -- **Expected**: Running → Unread → Pinned; at most three rows per group and - nine total. Deduplicate before limits, so hidden Running overflow cannot +- **Expected**: Running → Unread → Pinned; at most nine rows in total. Each + non-empty group keeps up to three rows and overflowing groups reclaim the + share smaller groups leave unused, in priority order: seven running sessions + with no unread or pinned show all seven, and a single group holding more than + nine shows nine behind View more. Deduplicate before limits, so hidden + Running overflow cannot appear as Unread/Pinned. Empty groups and stale shortcuts disappear. Unread uses the latest terminal result per session, newest first. Titles remain one line within the cap, including literal ampersands. Opening the macOS diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index 741db2e506..2ca0ae6996 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -5015,8 +5015,10 @@ Validation contract: E2E-SIDEBAR-global-pinned-conversations. ### Tray session shortcuts (issue #293) [ADR tray-session-shortcuts](/adr/tray-session-shortcuts) amends the D216 native -tray menu with Running, Unread, and Pinned groups, three rows each after global -priority assignment. Host session/inbox reads and runtime events remain the +tray menu with Running, Unread, and Pinned groups after global priority +assignment: each non-empty group keeps up to three rows, then overflowing groups +reclaim the share smaller groups leave unused, in priority order, up to nine rows +in total. Host session/inbox reads and runtime events remain the source of truth; renderer organization is mirrored without a persistence change. macOS single-click opens the menu without restoring the window; selection is delivered after bootstrap and uses normal session navigation. diff --git a/docs/zh-CN/spec/04-ux/08-component-spec.md b/docs/zh-CN/spec/04-ux/08-component-spec.md index a89212b289..a8f1db03f1 100644 --- a/docs/zh-CN/spec/04-ux/08-component-spec.md +++ b/docs/zh-CN/spec/04-ux/08-component-spec.md @@ -169,9 +169,10 @@ ### Native tray session menu The Main-owned native menu contains Open, non-empty Running/Unread/Pinned -sections, and Quit. Each section has a disabled localized heading, at most -three single-line session rows, and View more only when it overflows. Session -rows are globally deduplicated before truncation. View more expands session navigation; +sections, and Quit. Each section has a disabled localized heading, single-line +session rows up to the share allocated to that group, and View more only when +it overflows that share. Session rows are globally deduplicated before +truncation. View more expands session navigation; session rows enter their original conversation. The menu follows active locale changes and never marks a result read merely by opening. macOS single-click opens the attached menu; Open and double-click restore/focus the window. diff --git a/docs/zh-CN/spec/04-ux/09-interaction-patterns.md b/docs/zh-CN/spec/04-ux/09-interaction-patterns.md index 727e65b46f..13dc54bea7 100644 --- a/docs/zh-CN/spec/04-ux/09-interaction-patterns.md +++ b/docs/zh-CN/spec/04-ux/09-interaction-patterns.md @@ -137,8 +137,11 @@ ### 1.5.2 Tray session navigation (issue #293) - The native menu shows Running, Unread, and Pinned in that order, at most - three sessions in each and nine in total. Membership is assigned before - applying limits; higher-priority overflow never spills into a lower group. + nine sessions in total. Every non-empty group keeps up to three rows; the + share smaller groups leave unused goes to the groups that still overflow, + in priority order, so one busy group can fill all nine while the others are + empty. Membership is assigned before applying limits; higher-priority + overflow never spills into a lower group. - Empty groups are hidden. Archived sessions/projects and deleted sessions are excluded. Running/Pinned follow sidebar sorting; Unread follows the latest unread result per session, newest first, including failed results. diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 3a4b7a48e0..1ed5611a52 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -7110,7 +7110,9 @@ runner 会在运行时的隔离临时目录中生成六个插件形态 fixture - **Preconditions**: At least four running, four unread, and four pinned sessions across two projects; include overlaps, read-latest/older-unread notifications, archived/deleted sessions, an archived project, empty titles, - multiline titles, long CJK/emoji titles, and literal ampersands. Use an + multiline titles, long CJK/emoji titles, and literal ampersands. Also cover + a single populated group of seven and one of more than nine, with the other + two groups empty, to exercise reclaimed share. Use an isolated profile. Repeat native activation on macOS and Windows/Linux. - **Steps**: Hide the main window and open the tray menu. Inspect group order, counts, duplicates, titles, and unchanged unread records. Choose the third @@ -7123,8 +7125,12 @@ runner 会在运行时的隔离临时目录中生成六个插件形态 fixture Retry a transient read failure by hovering/right-clicking the tray. Repeat after clearing all group memberships and changing shipped locales. Choose Quit then Cancel, then Quit and confirm. -- **Expected**: Running → Unread → Pinned; at most three rows per group and - nine total. Deduplicate before limits, so hidden Running overflow cannot +- **Expected**: Running → Unread → Pinned; at most nine rows in total. Each + non-empty group keeps up to three rows and overflowing groups reclaim the + share smaller groups leave unused, in priority order: seven running sessions + with no unread or pinned show all seven, and a single group holding more than + nine shows nine behind View more. Deduplicate before limits, so hidden + Running overflow cannot appear as Unread/Pinned. Empty groups and stale shortcuts disappear. Unread uses the latest terminal result per session, newest first. Titles remain one line within the cap, including literal ampersands. Opening the macOS diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index 6ab2ba3a92..a7996669ca 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -4042,8 +4042,10 @@ D193 和 D194。 ### Tray session shortcuts (issue #293) [ADR tray-session-shortcuts](/adr/tray-session-shortcuts) amends the D216 native -tray menu with Running, Unread, and Pinned groups, three rows each after global -priority assignment. Host session/inbox reads and runtime events remain the +tray menu with Running, Unread, and Pinned groups after global priority +assignment: each non-empty group keeps up to three rows, then overflowing groups +reclaim the share smaller groups leave unused, in priority order, up to nine rows +in total. Host session/inbox reads and runtime events remain the source of truth; renderer organization is mirrored without a persistence change. macOS single-click opens the menu without restoring the window; selection is delivered after bootstrap and uses normal session navigation. diff --git a/packages/shared/src/tray-sessions.test.ts b/packages/shared/src/tray-sessions.test.ts new file mode 100644 index 0000000000..0c0ff673cd --- /dev/null +++ b/packages/shared/src/tray-sessions.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { + allocateTraySessionRows, + buildTraySessionGroups, + type TraySessionPreferences, +} from "./tray-sessions.js"; +import type { SessionSummary } from "./types/sessions.js"; + +function session(id: string): SessionSummary { + return { + id, + title: id, + messageCount: 1, + mode: "agent", + thinkingLevel: "off", + permissionMode: "inherit", + createdAt: "2026-09-13T00:00:00.000Z", + updatedAt: "2026-09-13T00:00:00.000Z", + }; +} + +function sessions(prefix: string, count: number): SessionSummary[] { + return Array.from({ length: count }, (_, index) => session(`${prefix}-${index}`)); +} + +function preferences(pinned: string[] = []): TraySessionPreferences { + const sessionMeta = Object.fromEntries(pinned.map((id) => [id, { pinned: true }])); + return { sessionMeta, archivedProjectPaths: [], sort: "recent" }; +} + +describe("allocateTraySessionRows", () => { + it("gives a lone group the whole budget instead of only its own share", () => { + expect(allocateTraySessionRows([7, 0, 0])).toEqual([7, 0, 0]); + expect(allocateTraySessionRows([0, 7, 0])).toEqual([0, 7, 0]); + expect(allocateTraySessionRows([0, 0, 7])).toEqual([0, 0, 7]); + }); + + it("caps the menu at the total budget", () => { + expect(allocateTraySessionRows([12, 0, 0])).toEqual([9, 0, 0]); + const total = allocateTraySessionRows([12, 8, 40]).reduce((sum, rows) => sum + rows, 0); + expect(total).toBe(9); + }); + + it("holds the total even when the group list outgrows the per-group share", () => { + // Four groups would claim 12 rows if each simply took its own share first. + const limits = allocateTraySessionRows([5, 5, 5, 5]); + expect(limits.reduce((sum, rows) => sum + rows, 0)).toBe(9); + expect(limits).toEqual([3, 3, 3, 0]); + }); + + it("keeps every group's share when all of them overflow", () => { + expect(allocateTraySessionRows([5, 4, 6])).toEqual([3, 3, 3]); + expect(allocateTraySessionRows([40, 40, 40])).toEqual([3, 3, 3]); + }); + + it("hands spare share to overflowing groups in priority order", () => { + // Running keeps 3, takes 2 more to clear its overflow; Pinned takes the last 1. + expect(allocateTraySessionRows([5, 0, 6])).toEqual([5, 0, 4]); + // A small Running group cannot consume share it has no rows for. + expect(allocateTraySessionRows([2, 10, 0])).toEqual([2, 7, 0]); + }); + + it("never allocates more rows than a group actually has", () => { + expect(allocateTraySessionRows([1, 1, 0])).toEqual([1, 1, 0]); + expect(allocateTraySessionRows([0, 0, 0])).toEqual([0, 0, 0]); + }); + + it("never shows fewer rows than the per-group share alone would", () => { + for (const counts of [[7, 0, 0], [5, 4, 6], [2, 10, 0], [4, 4, 0], [1, 0, 12]]) { + const limits = allocateTraySessionRows(counts); + counts.forEach((count, index) => expect(limits[index]).toBeGreaterThanOrEqual(Math.min(count, 3))); + } + }); +}); + +describe("buildTraySessionGroups", () => { + it("shows all seven running sessions when no other group claims share", () => { + const running = sessions("run", 7); + const groups = buildTraySessionGroups(running, new Set(running.map((s) => s.id)), [], preferences()); + expect(groups).toHaveLength(1); + expect(groups[0].kind).toBe("running"); + expect(groups[0].sessions).toHaveLength(7); + expect(groups[0].hasMore).toBe(false); + }); + + it("marks overflow only once the total budget is exhausted", () => { + const running = sessions("run", 12); + const groups = buildTraySessionGroups(running, new Set(running.map((s) => s.id)), [], preferences()); + expect(groups[0].sessions).toHaveLength(9); + expect(groups[0].hasMore).toBe(true); + }); + + it("reclaims the empty group's share across running and pinned", () => { + const running = sessions("run", 4); + const pinned = sessions("pin", 4); + const groups = buildTraySessionGroups( + [...running, ...pinned], + new Set(running.map((s) => s.id)), + [], + preferences(pinned.map((s) => s.id)), + ); + expect(groups.map((group) => [group.kind, group.sessions.length, group.hasMore])).toEqual([ + ["running", 4, false], + ["pinned", 4, false], + ]); + }); +}); diff --git a/packages/shared/src/tray-sessions.ts b/packages/shared/src/tray-sessions.ts index 1b9395478d..0d68ab2225 100644 --- a/packages/shared/src/tray-sessions.ts +++ b/packages/shared/src/tray-sessions.ts @@ -8,7 +8,10 @@ import { import type { SessionSummary } from "./types/sessions.js"; import type { AppNotification } from "./types/workspace.js"; -export const TRAY_SESSION_LIMIT = 3; +/** Rows every non-empty group keeps for itself before spare capacity is shared. */ +export const TRAY_SESSION_GROUP_SHARE = 3; +/** Rows the menu may show across all groups once unused shares are reclaimed. */ +export const TRAY_SESSION_TOTAL_LIMIT = 9; export const TRAY_SESSION_TITLE_LIMIT = 48; export const TRAY_SESSION_GROUPS = ["running", "unread", "pinned"] as const; export type TraySessionGroupKind = (typeof TRAY_SESSION_GROUPS)[number]; @@ -62,6 +65,31 @@ export function traySessionTitle(title: string, fallback: string): string { : characters.join(""); } +/** + * Every non-empty group keeps its own share first, so a busy higher-priority + * group can never crowd a lower one out of the menu entirely. Whatever share + * the smaller groups leave unused is handed to the groups that still overflow, + * in priority order, until the total row budget runs out. + * + * Both passes draw from the same budget, so the result stays within + * `TRAY_SESSION_TOTAL_LIMIT` even if the group list outgrows what the + * per-group share would divide into. + */ +export function allocateTraySessionRows(counts: readonly number[]): number[] { + let budget = TRAY_SESSION_TOTAL_LIMIT; + const limits = counts.map((count) => { + const share = Math.min(count, TRAY_SESSION_GROUP_SHARE, budget); + budget -= share; + return share; + }); + for (let index = 0; index < limits.length && budget > 0; index += 1) { + const extra = Math.min(counts[index] - limits[index], budget); + limits[index] += extra; + budget -= extra; + } + return limits; +} + export function buildTraySessionGroups( sessions: SessionSummary[], running: ReadonlySet, @@ -87,9 +115,17 @@ export function buildTraySessionGroups( else if (preferences.sessionMeta[session.id]?.pinned) groups.pinned.push(session); } groups.unread.sort((a, b) => unreadOrder.get(a.id)! - unreadOrder.get(b.id)!); - return TRAY_SESSION_GROUPS.filter((kind) => groups[kind].length > 0).map((kind) => ({ - kind, - sessions: groups[kind].slice(0, TRAY_SESSION_LIMIT).map(({ id, title }) => ({ id, title })), - hasMore: groups[kind].length > TRAY_SESSION_LIMIT, - })); + const limits = allocateTraySessionRows(TRAY_SESSION_GROUPS.map((kind) => groups[kind].length)); + return TRAY_SESSION_GROUPS.flatMap((kind, index) => { + const rows = groups[kind]; + const limit = limits[index]; + // Hide a group with no rows to show, whether it is empty or the budget ran + // out before it: a heading whose only entry is View more is not a group. + if (limit === 0) return []; + return [{ + kind, + sessions: rows.slice(0, limit).map(({ id, title }) => ({ id, title })), + hasMore: rows.length > limit, + }]; + }); } From 57f9734b19eb82ab47cc5345315d2b0866c341fe Mon Sep 17 00:00:00 2001 From: zszz3 <91608029+zszz3@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:58:31 +0800 Subject: [PATCH 11/13] refactor(desktop): hold the error-code union in one module The desktop augmentation of the shared error-code union, and the isHostUnavailable classifier that reads it, were copied verbatim into both index.ts and runtime/session-launch.ts. Moving them beside describeError in error-codes.ts leaves one copy and keeps index.ts under its LOC budget. Co-Authored-By: Claude Opus 5 (1M context) --- apps/desktop/electron/main/error-codes.ts | 39 +++++++++++++++++++ apps/desktop/electron/main/index.ts | 31 +-------------- .../electron/main/runtime/session-launch.ts | 13 +------ apps/desktop/test/subagent-wiring.test.mjs | 9 ++++- 4 files changed, 49 insertions(+), 43 deletions(-) create mode 100644 apps/desktop/electron/main/error-codes.ts diff --git a/apps/desktop/electron/main/error-codes.ts b/apps/desktop/electron/main/error-codes.ts new file mode 100644 index 0000000000..a2b7ac70f0 --- /dev/null +++ b/apps/desktop/electron/main/error-codes.ts @@ -0,0 +1,39 @@ +/** + * The desktop error-code union and the helpers that read an error's shape. + * + * These live outside `index.ts` so every main-process module can import them + * directly. Both the union and `isHostUnavailable` were previously copied into + * `index.ts` and `runtime/session-launch.ts`, and `describeError` was threaded + * down to the IPC and plugin layers as an injected function. + */ + +import { ErrorCodes as SharedErrorCodes } from "@pi-desktop/shared"; + +// The shared error-code union is reconciled in the shared lane. Keep desktop +// source type-safe while that lane is temporarily staged at main. +export const ErrorCodes = { + ...SharedErrorCodes, + COMMAND_SHELL_INVALID: "COMMAND_SHELL_INVALID", + SHELL_NOT_FOUND: "SHELL_NOT_FOUND", + PLAN_EXECUTION_INTERRUPTED: "PLAN_EXECUTION_INTERRUPTED", + PLAN_PERMISSION_MODE_REQUIRED: "PLAN_PERMISSION_MODE_REQUIRED", +} as const; + +/** One-line message for an error of unknown shape, for user-facing lists. */ +export function describeError(error: unknown): string { + if (error instanceof Error) return error.message.slice(0, 300); + return String(error).slice(0, 300); +} + +/** + * True when a rejection only says the host transport is gone (D080): the call + * lost a race with shutdown, a crash, or a supervised restart. Every such + * rejection carries `HOST_UNAVAILABLE`, whether it was refused before it was + * sent or was in flight when the transport closed. + */ +export function isHostUnavailable(error: unknown): boolean { + return ( + (error as { errorCode?: string } | null | undefined)?.errorCode === + ErrorCodes.HOST_UNAVAILABLE + ); +} diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index 00435216a9..a7f22b6ecd 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -8,6 +8,7 @@ import { } from "electron"; import { join } from "node:path"; import { homedir } from "node:os"; +import { describeError, isHostUnavailable } from "./error-codes"; import { applyNetworkProxyFromAppSettings, currentNetworkProxy, @@ -17,7 +18,6 @@ import { APP_ID, APP_NAME, APP_VERSION, - ErrorCodes as SharedErrorCodes, IPC, IPC_WHITELIST, KEYBOARD_SHORTCUTS, @@ -155,16 +155,6 @@ import { registerPluginUiIpc } from "./ipc/plugin-ui-ipc"; import { registerSkillsIpc } from "./ipc/skills-ipc"; import { stripWinLongPrefix } from "./path-utils"; -// The shared error-code union is reconciled in the shared lane. Keep desktop -// source type-safe while that lane is temporarily staged at main. -const ErrorCodes = { - ...SharedErrorCodes, - COMMAND_SHELL_INVALID: "COMMAND_SHELL_INVALID", - SHELL_NOT_FOUND: "SHELL_NOT_FOUND", - PLAN_EXECUTION_INTERRUPTED: "PLAN_EXECUTION_INTERRUPTED", - PLAN_PERMISSION_MODE_REQUIRED: "PLAN_PERMISSION_MODE_REQUIRED", -} as const; - // A closed stdout/stderr (Linux AppImage, GUI launch without a TTY) must not // surface as Electron's "Uncaught Exception: write EPIPE" dialog. The same // default dialog must not appear for a stray uncaughtException (non-ASCII @@ -783,25 +773,6 @@ function setCurrentWorkspacePath(path: string | null): void { plugins.broadcastEvent("workspace:changed", [payload]); } -/** One-line message for an error of unknown shape, for user-facing lists. */ -function describeError(error: unknown): string { - if (error instanceof Error) return error.message.slice(0, 300); - return String(error).slice(0, 300); -} - -/** - * True when a rejection only says the host transport is gone (D080): the call - * lost a race with shutdown, a crash, or a supervised restart. Every such - * rejection carries `HOST_UNAVAILABLE`, whether it was refused before it was - * sent or was in flight when the transport closed. - */ -function isHostUnavailable(error: unknown): boolean { - return ( - (error as { errorCode?: string } | null | undefined)?.errorCode === - ErrorCodes.HOST_UNAVAILABLE - ); -} - /** Pull the user's MCP server records from host-core into the local runtime. */ function sendToRenderer(channel: string, payload: unknown) { applicationLifecycle?.traySessions.observeEvent(channel, payload); diff --git a/apps/desktop/electron/main/runtime/session-launch.ts b/apps/desktop/electron/main/runtime/session-launch.ts index 912bd8e58b..da3a14712d 100644 --- a/apps/desktop/electron/main/runtime/session-launch.ts +++ b/apps/desktop/electron/main/runtime/session-launch.ts @@ -1,6 +1,5 @@ import { join } from "node:path"; import { - ErrorCodes as SharedErrorCodes, isActiveInProject, isCommandShellCatalog, normalizeMode, @@ -26,6 +25,7 @@ import { type UserSubagentDocument, } from "@pi-desktop/agent-runtime"; import { builtinSkills } from "../builtin-skills"; +import { ErrorCodes, isHostUnavailable } from "../error-codes"; import { OAUTH_AUTH_KIND, type VendorOAuth } from "../oauth"; import { modelConfigFromModelsDev, @@ -38,14 +38,6 @@ import type { UserMcpRuntime } from "../user-mcp"; import type { RuntimeState } from "./context"; import type { RuntimeProvider } from "./provider-catalog"; -const ErrorCodes = { - ...SharedErrorCodes, - COMMAND_SHELL_INVALID: "COMMAND_SHELL_INVALID", - SHELL_NOT_FOUND: "SHELL_NOT_FOUND", - PLAN_EXECUTION_INTERRUPTED: "PLAN_EXECUTION_INTERRUPTED", - PLAN_PERMISSION_MODE_REQUIRED: "PLAN_PERMISSION_MODE_REQUIRED", -} as const; - export type SessionLaunchRuntimeDependencies = { runtimeState: RuntimeState; logger: Logger; @@ -95,9 +87,6 @@ export function createSessionLaunchRuntime({ effectiveSubagentModelConfig, normalizeThinkingLevel, }: SessionLaunchRuntimeDependencies) { - const isHostUnavailable = (error: unknown): boolean => - (error as { errorCode?: string } | null | undefined)?.errorCode === - ErrorCodes.HOST_UNAVAILABLE; async function refreshUserMcp( projectPath: string | null | undefined = getWorkspacePath(), ): Promise { diff --git a/apps/desktop/test/subagent-wiring.test.mjs b/apps/desktop/test/subagent-wiring.test.mjs index 721d0a5eab..057b4b9fb2 100644 --- a/apps/desktop/test/subagent-wiring.test.mjs +++ b/apps/desktop/test/subagent-wiring.test.mjs @@ -8,6 +8,7 @@ const providerCatalogSource = await readMainModule("runtime/provider-catalog.ts" const desktopSidecarSource = await readMainModule("runtime/sidecar.ts"); const eventPersistenceSource = await readMainModule("runtime/event-persistence.ts"); const hostRuntimeSource = await readMainModule("runtime/host.ts"); +const errorCodesSource = await readMainModule("error-codes.ts"); const sidecarSource = await readFile( new URL("../../../packages/agent-runtime/src/sidecar.ts", import.meta.url), "utf8", @@ -125,9 +126,15 @@ test("a dead host transport degrades quietly instead of warning", () => { } // The bare guard only covers a host that was never constructed. assert.doesNotMatch(sessionLaunchSource, /^\s+if \(!host\) return \[\];$/m); + // One classifier, shared: it lives beside the error-code union rather than + // being re-declared in each module that has to tell teardown from a fault. assert.match( sessionLaunchSource, - /const isHostUnavailable = \(error: unknown\): boolean =>[\s\S]*?ErrorCodes\.HOST_UNAVAILABLE/, + /import \{[^}]*\bisHostUnavailable\b[^}]*\} from "\.\.\/error-codes";/, + ); + assert.match( + errorCodesSource, + /export function isHostUnavailable\(error: unknown\): boolean \{[\s\S]*?ErrorCodes\.HOST_UNAVAILABLE/, ); // Classification works only because both teardown rejections are tagged. assert.match( From e858f72223405bf50e9089acc0e8a84fd201afac Mon Sep 17 00:00:00 2001 From: zszz3 <91608029+zszz3@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:02:13 +0800 Subject: [PATCH 12/13] docs(test): mirror the model-fallback index rows in Chinese The English scenario index gained two ordered-model-fallback rows without the Chinese mirror, so check:locales rejected the pair's table shape. Co-Authored-By: Claude Opus 5 (1M context) --- docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 5de5c4e95c..940a052937 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -4941,6 +4941,8 @@ IPC 请求无法关闭。 | MVP 后远程控制 | E2E-221、E2E-222、E2E-223、E2E-224、E2E-225、E2E-226、E2E-227、E2E-228、E2E-229、E2E-230、E2E-231、E2E-232 | | 受信任扩展(R7 v1) | E2E-241、E2E-242、E2E-243、E2E-244、E2E-245、E2E-PLUGIN-imported-pi-package-skills、E2E-PLUGIN-import-extension-installs-dependencies、E2E-PLUGIN-import-extension-reports-missing-dependency | | M6+(删除项目) | E2E-PROJECT-delete-removes-project-and-owned-sessions | +| C — 对话和直播(模型回退) | E2E-SUBAGENT-ordered-model-fallback-preserves-work | +| 品质(模型回退隔离) | E2E-SUBAGENT-ordered-model-fallback-preserves-work | | C — 对话和直播(旧版子代理回合上限) | E2E-SUBAGENT-legacy-turn-limit-frontmatter-is-ignored | | 品质(旧版子代理回合上限) | E2E-SUBAGENT-legacy-turn-limit-frontmatter-is-ignored | From df1b4abc9327c64478f911e972437ea036aa9e9f Mon Sep 17 00:00:00 2001 From: zszz3 <91608029+zszz3@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:50:32 +0800 Subject: [PATCH 13/13] feat(provider): support adaptive thinking protocols Add explicit legacy and adaptive thinking metadata so Claude Opus 5.5 uses Anthropic's adaptive request shape while existing bindings keep legacy behavior. Closes #1126 --- .../electron/main/models-dev-catalog.ts | 9 ++ .../settings/ModelSelectionPanes.tsx | 18 ++++ .../src/components/settings/provider-copy.ts | 3 + .../test/model-advanced-capabilities.test.mjs | 11 +++ apps/desktop/test/models-dev-catalog.test.mjs | 1 + crates/host-core/src/providers/catalog.rs | 8 ++ crates/host-core/src/providers/model.rs | 3 + crates/host-core/src/providers/tests.rs | 19 ++++ .../03-runtime/11-provider-model-system.md | 8 +- .../03-runtime/12-provider-config-schema.md | 4 + docs/spec/06-delivery/04-e2e-test-plan.md | 20 +++++ .../agent-runtime/src/model-capabilities.ts | 11 +++ .../src/provider-binding.test.ts | 87 +++++++++++++++++++ .../agent-runtime/src/provider-binding.ts | 9 +- packages/agent-runtime/src/thinking-level.ts | 2 + packages/i18n/src/locales/de/index.ts | 3 + packages/i18n/src/locales/en/index.ts | 3 + packages/i18n/src/locales/es/index.ts | 3 + packages/i18n/src/locales/fr/index.ts | 3 + packages/i18n/src/locales/ko/index.ts | 3 + packages/i18n/src/locales/tr/index.ts | 3 + packages/i18n/src/locales/zh-CN/index.ts | 3 + packages/i18n/src/locales/zh-TW/index.ts | 3 + packages/shared/src/model-catalog.ts | 1 + packages/shared/src/types/models.ts | 5 ++ 25 files changed, 241 insertions(+), 2 deletions(-) diff --git a/apps/desktop/electron/main/models-dev-catalog.ts b/apps/desktop/electron/main/models-dev-catalog.ts index e675f60768..24150ba327 100644 --- a/apps/desktop/electron/main/models-dev-catalog.ts +++ b/apps/desktop/electron/main/models-dev-catalog.ts @@ -12,6 +12,7 @@ import type { ModelProviderMetadata, ModelReasoningOption, ThinkingLevel, + ThinkingProtocol, } from "@pi-desktop/shared"; import type { ModelConfig } from "@pi-desktop/agent-runtime"; @@ -70,6 +71,7 @@ export type ModelsDevModel = { reasoning: boolean; reasoningPublished: boolean; reasoningOptions?: ModelReasoningOption[]; + thinkingProtocol?: ThinkingProtocol; thinkingLevels: ThinkingLevel[]; modalities: ModelModalities; modalitiesPublished: boolean; @@ -400,6 +402,10 @@ function modelFromRaw( const reasoningPublished = typeof raw.reasoning === "boolean"; const reasoning = raw.reasoning === true; const reasoningOptions = parseReasoningOptions(raw.reasoning_options); + const thinkingProtocol: ThinkingProtocol | undefined = + providerKey === "anthropic" && reasoningOptions?.some((option) => option.type === "effort") + ? "adaptive" + : undefined; const limit = parseLimit(raw.limit); const experimental = publishedExperimental(raw.experimental); const providerMetadata = publishedMetadata(raw.provider); @@ -426,6 +432,7 @@ function modelFromRaw( reasoning, reasoningPublished, ...(reasoningOptions ? { reasoningOptions } : {}), + ...(thinkingProtocol ? { thinkingProtocol } : {}), thinkingLevels: thinkingLevelsFromModelsDev(reasoning, raw.reasoning_options), modalities: modalityResult.modalities, modalitiesPublished: modalityResult.published, @@ -687,6 +694,7 @@ export function modelInfoFromModelsDev( ...(model.attachment !== undefined ? { attachment: model.attachment } : {}), reasoning: model.reasoning, ...(model.reasoningOptions !== undefined ? { reasoningOptions: model.reasoningOptions } : {}), + ...(model.thinkingProtocol !== undefined ? { thinkingProtocol: model.thinkingProtocol } : {}), ...(thinkingLevelMap !== undefined ? { thinkingLevelMap } : {}), ...(model.toolCall !== undefined ? { toolCall: model.toolCall } : {}), ...(model.structuredOutput !== undefined ? { structuredOutput: model.structuredOutput } : {}), @@ -753,6 +761,7 @@ export function modelConfigFromModelsDev( if (model.family !== undefined) config.family = model.family; if (model.attachment !== undefined) config.attachment = model.attachment; if (model.reasoningOptions !== undefined) config.reasoningOptions = model.reasoningOptions; + if (model.thinkingProtocol !== undefined) config.thinkingProtocol = model.thinkingProtocol; const thinkingLevelMap = thinkingLevelMapFromModelsDev(model.reasoningOptions, thinkingLevels); if (thinkingLevelMap) config.thinkingLevelMap = thinkingLevelMap; if (model.toolCall !== undefined) config.toolCall = model.toolCall; diff --git a/apps/desktop/src/components/settings/ModelSelectionPanes.tsx b/apps/desktop/src/components/settings/ModelSelectionPanes.tsx index b99f653ebd..04a97e40c7 100644 --- a/apps/desktop/src/components/settings/ModelSelectionPanes.tsx +++ b/apps/desktop/src/components/settings/ModelSelectionPanes.tsx @@ -21,6 +21,7 @@ import { type ModelBinding, type ModelInfo, type ThinkingLevel, + type ThinkingProtocol, } from "@pi-desktop/shared"; import { CONTEXT_WINDOW_PRESETS, @@ -746,6 +747,23 @@ export function ModelSelectionPanes({ })}
+
{t("settings.modelCapabilities")} diff --git a/apps/desktop/src/components/settings/provider-copy.ts b/apps/desktop/src/components/settings/provider-copy.ts index 9d56fa1a23..d6c139bb61 100644 --- a/apps/desktop/src/components/settings/provider-copy.ts +++ b/apps/desktop/src/components/settings/provider-copy.ts @@ -32,6 +32,9 @@ export function copyProviderConfiguration(provider: ProviderPublic, name: string maxTokens: model.maxTokens, thinkingLevels: [...model.thinkingLevels], defaultThinkingLevel: model.defaultThinkingLevel, + ...(model.thinkingProtocol !== undefined + ? { thinkingProtocol: model.thinkingProtocol } + : {}), ...(model.supportsImages !== undefined ? { supportsImages: model.supportsImages } : {}), ...(model.supportsDocuments !== undefined ? { supportsDocuments: model.supportsDocuments } : {}), ...(model.availableForSubagents !== undefined ? { availableForSubagents: model.availableForSubagents } : {}), diff --git a/apps/desktop/test/model-advanced-capabilities.test.mjs b/apps/desktop/test/model-advanced-capabilities.test.mjs index 64ef08bc3c..612e23572b 100644 --- a/apps/desktop/test/model-advanced-capabilities.test.mjs +++ b/apps/desktop/test/model-advanced-capabilities.test.mjs @@ -43,6 +43,17 @@ test("advanced settings choose the default thinking level among the enabled ones assert.match(pickerSource, /enabledLevels\.length > 1 \?/); }); +test("advanced settings expose the model thinking protocol", () => { + assert.match(pickerSource, /settings\.thinkingProtocol/); + assert.match(pickerSource, /settings\.thinkingProtocolLegacy/); + assert.match(pickerSource, /settings\.thinkingProtocolAdaptive/); + assert.match( + pickerSource, + /binding\.thinkingProtocol \?\? info\?\.thinkingProtocol \?\? "legacy"/, + ); + assert.match(pickerSource, /thinkingProtocol: event\.target\.value as ThinkingProtocol/); +}); + test("the capability checkboxes show and follow the published value", () => { assert.match(pickerSource, /settings\.imageInput/); assert.match(pickerSource, /settings\.documentInput/); diff --git a/apps/desktop/test/models-dev-catalog.test.mjs b/apps/desktop/test/models-dev-catalog.test.mjs index 0f7ab00d18..6ad5c38b38 100644 --- a/apps/desktop/test/models-dev-catalog.test.mjs +++ b/apps/desktop/test/models-dev-catalog.test.mjs @@ -309,6 +309,7 @@ test("models.dev records retain all published model parameters and modalities", assert.deepEqual(config.input, ["text", "image"]); assert.deepEqual(config.modalities, info.modalities); assert.deepEqual(config.supportedThinkingLevels, ["low", "medium", "high", "xhigh", "max"]); + assert.equal(config.thinkingProtocol, "adaptive"); assert.deepEqual(config.thinkingLevelMap, { low: "low", medium: "medium", diff --git a/crates/host-core/src/providers/catalog.rs b/crates/host-core/src/providers/catalog.rs index aee686c483..eb5a48e7ce 100644 --- a/crates/host-core/src/providers/catalog.rs +++ b/crates/host-core/src/providers/catalog.rs @@ -7,6 +7,7 @@ pub(crate) const PROVIDER_SELECT: &str = pub(crate) const CANONICAL_THINKING_LEVELS: &[&str] = &["off", "minimal", "low", "medium", "high", "xhigh", "max"]; +const THINKING_PROTOCOLS: &[&str] = &["legacy", "adaptive"]; const DEFAULT_CONTEXT_WINDOW: u32 = 128_000; const DEFAULT_MAX_TOKENS: u32 = 8_192; @@ -19,6 +20,11 @@ pub(crate) fn normalize_model_bindings(bindings: &[ModelBinding]) -> Vec Vec) -> Vec { max_tokens: DEFAULT_MAX_TOKENS, thinking_levels: Vec::new(), default_thinking_level: None, + thinking_protocol: None, supports_images: None, supports_documents: None, available_for_subagents: None, diff --git a/crates/host-core/src/providers/model.rs b/crates/host-core/src/providers/model.rs index 26d3e29a4d..a28e888fd7 100644 --- a/crates/host-core/src/providers/model.rs +++ b/crates/host-core/src/providers/model.rs @@ -126,6 +126,9 @@ pub struct ModelBinding { #[serde(default)] pub thinking_levels: Vec, pub default_thinking_level: Option, + /// Provider request protocol used when thinking is enabled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thinking_protocol: Option, /// Attachment capability overrides. `None` follows the published catalog /// capability, so a models.dev correction still reaches a saved binding. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/host-core/src/providers/tests.rs b/crates/host-core/src/providers/tests.rs index f82dfa4bab..9c00946cdc 100644 --- a/crates/host-core/src/providers/tests.rs +++ b/crates/host-core/src/providers/tests.rs @@ -166,6 +166,7 @@ fn model_bindings_roundtrip_and_legacy_model_migrates_on_read() { max_tokens: 16_000, thinking_levels: vec!["high".into(), "medium".into()], default_thinking_level: Some("medium".into()), + thinking_protocol: Some("adaptive".into()), supports_images: Some(true), supports_documents: None, available_for_subagents: Some(true), @@ -177,6 +178,7 @@ fn model_bindings_roundtrip_and_legacy_model_migrates_on_read() { max_tokens: 8_192, thinking_levels: vec![], default_thinking_level: None, + thinking_protocol: None, supports_images: None, supports_documents: Some(false), available_for_subagents: None, @@ -277,6 +279,7 @@ fn binding_with_alias(id: &str, alias: Option<&str>) -> ModelBinding { max_tokens: DEFAULT_MAX_TOKENS, thinking_levels: Vec::new(), default_thinking_level: None, + thinking_protocol: None, supports_images: None, supports_documents: None, available_for_subagents: None, @@ -297,6 +300,22 @@ fn normalize_model_bindings_trims_aliases_and_drops_blank_ones() { assert_eq!(normalized[3].alias, None); } +#[test] +fn normalize_model_bindings_preserves_known_thinking_protocols_only() { + let normalized = normalize_model_bindings(&[ + ModelBinding { + thinking_protocol: Some("adaptive".into()), + ..binding_with_alias("adaptive-model", None) + }, + ModelBinding { + thinking_protocol: Some("unsupported".into()), + ..binding_with_alias("legacy-model", None) + }, + ]); + assert_eq!(normalized[0].thinking_protocol.as_deref(), Some("adaptive")); + assert_eq!(normalized[1].thinking_protocol, None); +} + #[test] fn alias_survives_the_provider_config_round_trip() { let bindings = vec![ diff --git a/docs/spec/03-runtime/11-provider-model-system.md b/docs/spec/03-runtime/11-provider-model-system.md index d9158e6d8a..9cae041fa6 100644 --- a/docs/spec/03-runtime/11-provider-model-system.md +++ b/docs/spec/03-runtime/11-provider-model-system.md @@ -205,7 +205,12 @@ PI-Desktop must not permanently restrict users to a short fixed model list. follows a newly published `limit.context`; a non-default Advanced value remains explicit. This keeps the sidecar and context inspector on the same effective window after a catalog refresh. -8. Settings renders the seven canonical thinking levels for every binding. +8. Settings renders the seven canonical thinking levels for every binding and + exposes the model's thinking protocol as `legacy` or `adaptive`. Missing + protocol metadata preserves the legacy request path. Anthropic models whose + catalog reasoning options publish an `effort` control are seeded as + `adaptive`; the runtime passes that choice to pi-ai so it emits + `thinking.type=adaptive` and `output_config.effort`. Published levels begin selected for a known reasoning model. A non-reasoning or unknown model shows the same choices unselected, with a short manual override note. `defaultThinkingLevel` is chosen from the levels the binding @@ -308,6 +313,7 @@ type ModelBinding = { maxTokens: number thinkingLevels: ThinkingLevel[] defaultThinkingLevel: ThinkingLevel | null + thinkingProtocol?: "legacy" | "adaptive" availableForSubagents?: boolean // opt-in for AI-driven delegation } diff --git a/docs/spec/03-runtime/12-provider-config-schema.md b/docs/spec/03-runtime/12-provider-config-schema.md index d6c0d15206..495ad43b33 100644 --- a/docs/spec/03-runtime/12-provider-config-schema.md +++ b/docs/spec/03-runtime/12-provider-config-schema.md @@ -99,6 +99,10 @@ Tables (canonical DDL in [04-data-storage](04-data-storage.md) §4.3–4.4, §4. "type": ["string", "null"], "enum": ["off", "minimal", "low", "medium", "high", "xhigh", "max", null] }, + "thinkingProtocol": { + "enum": ["legacy", "adaptive"], + "description": "Provider request protocol used when thinking is enabled; absent preserves legacy behavior." + }, "supportsImages": { "type": ["boolean", "null"] }, "supportsDocuments": { "type": ["boolean", "null"] }, "availableForSubagents": { "type": "boolean", "default": false } diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index adcb332f39..ef8d0d7de0 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -32,6 +32,26 @@ - Keep validation evidence tied to the executable commit currently integrated into `main`. +### E2E-PROVIDER-adaptive-thinking-protocol + +- **Preconditions:** Isolated provider settings fixture with an Anthropic + Messages provider and models `claude-opus-5-5` and a legacy Claude model; + no live credentials. +- **Steps:** Open a configured model's Advanced settings, inspect the + thinking-protocol row, save an adaptive model with `medium`, then submit a + request through the local Anthropic transport fixture. Repeat with the + legacy model. +- **Expected:** The adaptive model sends `thinking.type=adaptive` and + `output_config.effort=medium`; the legacy model sends the existing + budget-based thinking shape. Omitting the new field on an old binding keeps + legacy behavior after reload. +- **Specs:** 03-runtime/11-provider-model-system; 03-runtime/12-provider-config-schema. +- **Acceptance:** Provider/model protocol selection and wire compatibility. +- **Milestone:** Maintenance. +- **Status:** Protocol coverage is automated in the agent-runtime provider + binding tests; desktop settings coverage remains part of the provider model + configuration E2E fixture. + ## 2. Non-goals - Full UI-driven automated coverage; protocol and source-contract automation is diff --git a/packages/agent-runtime/src/model-capabilities.ts b/packages/agent-runtime/src/model-capabilities.ts index 780610fc57..87decfb8e7 100644 --- a/packages/agent-runtime/src/model-capabilities.ts +++ b/packages/agent-runtime/src/model-capabilities.ts @@ -74,6 +74,7 @@ export function modelConfigWithBinding( | "contextWindow" | "maxTokens" | "thinkingLevels" + | "thinkingProtocol" | "supportsImages" | "supportsDocuments" > @@ -84,6 +85,12 @@ export function modelConfigWithBinding( binding.thinkingLevels.includes(level), ); const thinkingLevelMap = { ...(model.thinkingLevelMap ?? {}) }; + const compat = binding.thinkingProtocol + ? { + ...(model.compat ?? {}), + forceAdaptiveThinking: binding.thinkingProtocol === "adaptive", + } + : model.compat; // pi-ai treats xhigh/max as unsupported when their adapter-facing mapping // is absent or null. The explicit binding is authoritative, so an enabled // extended level without a catalog translation must pass through as-is. @@ -108,6 +115,10 @@ export function modelConfigWithBinding( maxTokens: binding.maxTokens, reasoning: enabledThinkingLevels.some((level) => level !== "off"), supportedThinkingLevels: enabledThinkingLevels, + ...(binding.thinkingProtocol + ? { thinkingProtocol: binding.thinkingProtocol } + : {}), + ...(compat ? { compat } : {}), ...(Object.keys(thinkingLevelMap).length > 0 ? { thinkingLevelMap } : {}), ...modalityOverride(model, binding), }; diff --git a/packages/agent-runtime/src/provider-binding.test.ts b/packages/agent-runtime/src/provider-binding.test.ts index 12476722c0..d6eda3f727 100644 --- a/packages/agent-runtime/src/provider-binding.test.ts +++ b/packages/agent-runtime/src/provider-binding.test.ts @@ -93,6 +93,93 @@ describe("Anthropic runtime endpoint", () => { expect(result.stopReason).toBe("error"); expect(urls).toEqual(["https://gw.example/anthropic/v1/messages?beta=true"]); }); + + it("uses adaptive thinking for models explicitly marked adaptive", async () => { + const provider: RuntimeProviderConfig = { + ...keyedProvider, + id: "anthropic-adaptive", + name: "Anthropic adaptive", + baseUrl: "https://api.anthropic.com", + modelId: "claude-opus-5-5", + apiStyle: "anthropic_messages", + supportsReasoning: true, + supportedThinkingLevels: ["off", "medium"], + modelConfig: { + source: "models.dev", + name: "Claude Opus 5.5", + baseUrl: "https://api.anthropic.com", + reasoning: true, + thinkingProtocol: "adaptive", + thinkingLevelMap: { medium: "medium" }, + input: ["text"], + contextWindow: 200_000, + maxTokens: 16_000, + }, + }; + const model = buildProviderModel(provider); + const requests: Record[] = []; + const fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + requests.push(JSON.parse(String(init?.body)) as Record); + return new Response("bad request", { status: 400 }); + }); + + await createProviderModels(provider, model) + .streamSimple( + model, + { systemPrompt: "system", messages: [{ role: "user", content: "hello", timestamp: Date.now() }], tools: [] }, + { reasoning: "medium", fetch }, + ) + .result(); + + expect(model.compat).toMatchObject({ forceAdaptiveThinking: true }); + expect(requests[0]).toMatchObject({ + thinking: { type: "adaptive" }, + output_config: { effort: "medium" }, + }); + }); + + it("keeps the legacy budget request for legacy models", async () => { + const provider: RuntimeProviderConfig = { + ...keyedProvider, + id: "anthropic-legacy", + name: "Anthropic legacy", + baseUrl: "https://api.anthropic.com", + modelId: "claude-opus-4-5", + apiStyle: "anthropic_messages", + supportsReasoning: true, + supportedThinkingLevels: ["off", "medium"], + modelConfig: { + source: "models.dev", + name: "Claude Opus 4.5", + baseUrl: "https://api.anthropic.com", + reasoning: true, + thinkingProtocol: "legacy", + input: ["text"], + contextWindow: 200_000, + maxTokens: 16_000, + }, + }; + const model = buildProviderModel(provider); + const requests: Record[] = []; + const fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + requests.push(JSON.parse(String(init?.body)) as Record); + return new Response("bad request", { status: 400 }); + }); + + await createProviderModels(provider, model) + .streamSimple( + model, + { systemPrompt: "system", messages: [{ role: "user", content: "hello", timestamp: Date.now() }], tools: [] }, + { reasoning: "medium", fetch }, + ) + .result(); + + expect(model.compat).toMatchObject({ forceAdaptiveThinking: false }); + expect(requests[0]).toMatchObject({ + thinking: { type: "enabled" }, + }); + expect((requests[0].thinking as Record).budget_tokens).toEqual(expect.any(Number)); + }); }); describe("buildProviderModel OpenAI-compatible role compatibility", () => { diff --git a/packages/agent-runtime/src/provider-binding.ts b/packages/agent-runtime/src/provider-binding.ts index 8e4b90a8c4..13022d990a 100644 --- a/packages/agent-runtime/src/provider-binding.ts +++ b/packages/agent-runtime/src/provider-binding.ts @@ -222,6 +222,9 @@ export function buildProviderModel( ...(copilotDefaults ?? {}), ...(catalogModel.headers ?? {}), }; + const thinkingProtocolCompat = catalogModel.thinkingProtocol + ? { forceAdaptiveThinking: catalogModel.thinkingProtocol === "adaptive" } + : undefined; // OpenAI-compatible gateways are not guaranteed to implement the newer // `developer` role, even when the selected model supports reasoning. Keep // the broadest Chat Completions wire shape as the default; a catalog/model @@ -232,11 +235,15 @@ export function buildProviderModel( binding.api === "openai-completions" ? { ...(catalogModel.compat ?? {}), + ...(thinkingProtocolCompat ?? {}), ...(zhipuCompat ?? {}), ...(deepseekCompat ?? {}), supportsDeveloperRole: catalogModel.compat?.supportsDeveloperRole === true, } - : catalogModel.compat; + : { + ...(catalogModel.compat ?? {}), + ...(thinkingProtocolCompat ?? {}), + }; return { ...catalogModel, id: provider.modelId, diff --git a/packages/agent-runtime/src/thinking-level.ts b/packages/agent-runtime/src/thinking-level.ts index 3ba6ad745d..8394305be5 100644 --- a/packages/agent-runtime/src/thinking-level.ts +++ b/packages/agent-runtime/src/thinking-level.ts @@ -6,6 +6,7 @@ import type { ModelModalities, ModelProviderMetadata, ModelReasoningOption, + ThinkingProtocol, ThinkingLevel, } from "@pi-desktop/shared"; @@ -28,6 +29,7 @@ export type ModelConfig = { attachment?: boolean; reasoning: boolean; reasoningOptions?: ModelReasoningOption[]; + thinkingProtocol?: ThinkingProtocol; supportedThinkingLevels?: readonly ThinkingLevel[]; thinkingLevelMap?: Partial>; toolCall?: boolean; diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 859e6f1202..4a9c473bd2 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -1153,6 +1153,9 @@ sklm: { "maxOutputShort": "aus", "supportedThinkingLevels": "Denkebenen", "defaultThinkingLevel": "Standard-Denkebene", + "thinkingProtocol": "Denkprotokoll", + "thinkingProtocolLegacy": "Legacy", + "thinkingProtocolAdaptive": "Adaptiv", "modelCapabilities": "Anhänge", "imageInput": "Bilder", "documentInput": "PDF", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index 4fff02cf1f..b133f34f17 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -1170,6 +1170,9 @@ sklm: { maxOutputShort: "out", supportedThinkingLevels: "Thinking levels", defaultThinkingLevel: "Default thinking level", + thinkingProtocol: "Thinking protocol", + thinkingProtocolLegacy: "Legacy", + thinkingProtocolAdaptive: "Adaptive", modelCapabilities: "Attachments", imageInput: "Images", documentInput: "PDF", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 954a26012d..c5fbacc923 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -1153,6 +1153,9 @@ sklm: { "maxOutputShort": "salida", "supportedThinkingLevels": "Niveles de pensamiento", "defaultThinkingLevel": "Nivel de pensamiento predeterminado", + "thinkingProtocol": "Protocolo de pensamiento", + "thinkingProtocolLegacy": "Heredado", + "thinkingProtocolAdaptive": "Adaptativo", "modelCapabilities": "Archivos adjuntos", "imageInput": "Imágenes", "documentInput": "PDF", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index 5b129034fb..7bbb2cfde1 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -1153,6 +1153,9 @@ sklm: { "maxOutputShort": "out", "supportedThinkingLevels": "Niveaux de réflexion", "defaultThinkingLevel": "Niveau de réflexion par défaut", + "thinkingProtocol": "Protocole de réflexion", + "thinkingProtocolLegacy": "Hérité", + "thinkingProtocolAdaptive": "Adaptatif", "modelCapabilities": "Pièces jointes", "imageInput": "Images", "documentInput": "PDF", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 723ade57bc..1d18b4d312 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -1172,6 +1172,9 @@ sklm: { maxOutputShort: "out", supportedThinkingLevels: "생각 수준", defaultThinkingLevel: "기본 생각 수준", + thinkingProtocol: "사고 프로토콜", + thinkingProtocolLegacy: "레거시", + thinkingProtocolAdaptive: "적응형", modelCapabilities: "첨부 파일", imageInput: "이미지", documentInput: "PDF", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index 2cc1c5910d..b364fa84df 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -1172,6 +1172,9 @@ sklm: { maxOutputShort: "out", supportedThinkingLevels: "Düşünme düzeyleri", defaultThinkingLevel: "Varsayılan düşünme düzeyi", + thinkingProtocol: "Düşünme protokolü", + thinkingProtocolLegacy: "Eski", + thinkingProtocolAdaptive: "Uyarlanabilir", modelCapabilities: "Ekler", imageInput: "Görüntüler", documentInput: "PDF", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index d50a639295..be8f7a4b6b 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -1160,6 +1160,9 @@ sklm: { maxOutputShort: "输出", supportedThinkingLevels: "思考等级", defaultThinkingLevel: "默认思考等级", + thinkingProtocol: "思考协议", + thinkingProtocolLegacy: "旧版", + thinkingProtocolAdaptive: "自适应", modelCapabilities: "附件", imageInput: "图片", documentInput: "PDF", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index 9a269ac620..1dc6dc084f 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -1160,6 +1160,9 @@ sklm: { maxOutputShort: "輸出", supportedThinkingLevels: "思考等級", defaultThinkingLevel: "預設思考等級", + thinkingProtocol: "思考協定", + thinkingProtocolLegacy: "舊版", + thinkingProtocolAdaptive: "自適應", modelCapabilities: "附件", imageInput: "圖片", documentInput: "PDF", diff --git a/packages/shared/src/model-catalog.ts b/packages/shared/src/model-catalog.ts index 60273d0806..518f94eaa1 100644 --- a/packages/shared/src/model-catalog.ts +++ b/packages/shared/src/model-catalog.ts @@ -184,6 +184,7 @@ export function bindingFromModelInfo(model: ModelInfo): ModelBinding { defaultThinkingLevel: thinkingLevels.includes("medium") ? "medium" : (thinkingLevels[0] ?? null), + ...(model.thinkingProtocol ? { thinkingProtocol: model.thinkingProtocol } : {}), // Absent overrides keep following models.dev, so a catalog correction still // reaches an already saved binding. supportsImages: null, diff --git a/packages/shared/src/types/models.ts b/packages/shared/src/types/models.ts index 0b00f23f76..2c049cdaa5 100644 --- a/packages/shared/src/types/models.ts +++ b/packages/shared/src/types/models.ts @@ -9,6 +9,8 @@ export const THINKING_LEVELS = [ "max", ] as const; export type ThinkingLevel = (typeof THINKING_LEVELS)[number]; +export const THINKING_PROTOCOLS = ["legacy", "adaptive"] as const; +export type ThinkingProtocol = (typeof THINKING_PROTOCOLS)[number]; /** Per-subagent selector values; omit leaves the provider's default untouched. */ export const SUBAGENT_THINKING_LEVELS = [...THINKING_LEVELS, "omit"] as const; export type SubagentThinkingLevel = (typeof SUBAGENT_THINKING_LEVELS)[number]; @@ -79,6 +81,8 @@ export type ModelBinding = { /** Explicit endpoint levels; an empty or off-only set disables thinking. */ thinkingLevels: ThinkingLevel[]; defaultThinkingLevel: ThinkingLevel | null; + /** Provider request protocol used when thinking is enabled. */ + thinkingProtocol?: ThinkingProtocol; /** * User override for image input. `null` or absent follows the published * models.dev capability; `true` forces image transport on for an endpoint the @@ -158,6 +162,7 @@ export type ModelInfo = { reasoning?: boolean; reasoningOptions?: ModelReasoningOption[]; thinkingLevelMap?: Partial>; + thinkingProtocol?: ThinkingProtocol; toolCall?: boolean; structuredOutput?: boolean; temperature?: boolean;