From b6863ae2bb6f8be26ce04af3564b0e456982241f Mon Sep 17 00:00:00 2001 From: liubai Date: Tue, 20 Oct 2026 15:21:42 +0800 Subject: [PATCH 01/44] fix(asktool): keep card drafts across session switches The AskTool card kept its question index and draft answers in component state, so switching sessions unmounted the card and returning to the same pending ask reset every answer. Store the draft by requestId instead and let the card read and write that entry, dropping it when the ask is resolved, dismissed, or removed. Amends the asktool card spec's request-transition rule and adds E2E-ASKTOOL-draft-survives-session-switch. --- apps/desktop/src/components/AskToolCard.tsx | 78 +++++++++++-------- apps/desktop/src/lib/pending-asks.ts | 75 +++++++++++++++++- apps/desktop/src/stores/app-state.ts | 8 +- .../desktop/src/stores/slices/events-slice.ts | 18 ++++- .../src/stores/slices/initial-state.ts | 1 + .../src/stores/slices/interaction-slice.ts | 18 +++++ .../src/stores/slices/project-slice.ts | 3 + .../src/stores/slices/session-slice.ts | 12 ++- docs/adr/README.md | 1 + .../asktool-draft-survives-session-switch.md | 50 ++++++++++++ docs/spec/04-ux/11-asktool-question-card.md | 18 +++-- docs/spec/06-delivery/04-e2e-test-plan.md | 15 ++++ scripts/e2e/asktool-card.tsx | 39 +++++++++- 13 files changed, 294 insertions(+), 42 deletions(-) create mode 100644 docs/adr/asktool-draft-survives-session-switch.md diff --git a/apps/desktop/src/components/AskToolCard.tsx b/apps/desktop/src/components/AskToolCard.tsx index c1c1daa4e3..34c64efbac 100644 --- a/apps/desktop/src/components/AskToolCard.tsx +++ b/apps/desktop/src/components/AskToolCard.tsx @@ -3,38 +3,45 @@ import { useTranslation } from "react-i18next"; import { askToolOptionDescription, askToolOptionLabel, - type AskToolQuestion, } from "@pi-desktop/shared"; import { AskToolRichText } from "./AskToolRichText"; -import type { PendingAsk } from "../lib/pending-asks"; +import { + emptyAskDraft, + type AskDraft, + type AskDraftAnswer, + type PendingAsk, +} from "../lib/pending-asks"; import { useAppStore } from "../stores/app-store"; import { Button } from "./ui"; -type DraftAnswer = { - values: string[]; - customSelected: boolean; - customText: string; - skipped: boolean; -}; +type DraftAnswer = AskDraftAnswer; const CUSTOM_OPTION = "__asktool_custom__"; -function emptyDrafts(questions: AskToolQuestion[]): DraftAnswer[] { - return questions.map(() => ({ - values: [], - customSelected: false, - customText: "", - skipped: false, - })); -} - export function AskToolCard({ request, queued = 0 }: { request: PendingAsk; queued?: number }) { const { t } = useTranslation(); const resolveAsk = useAppStore((state) => state.resolveAsk); const showToast = useAppStore((state) => state.showToast); - const [index, setIndex] = useState(0); - const [drafts, setDrafts] = useState(() => emptyDrafts(request.questions)); + const storedDraft = useAppStore((state) => state.askDrafts[request.requestId]); + const setAskDraft = useAppStore((state) => state.setAskDraft); const [resolving, setResolving] = useState(false); + // The store owns the draft, so a session switch (which unmounts this card) + // cannot reset the answers. Read lazily rather than seeding during render. + const draft: AskDraft = + storedDraft && storedDraft.answers.length === request.questions.length + ? { + ...storedDraft, + index: Math.min( + Math.max(0, storedDraft.index), + request.questions.length - 1, + ), + } + : emptyAskDraft(request.questions); + const index = draft.index; + const drafts = draft.answers; + const updateDraft = (next: AskDraft) => setAskDraft(request.requestId, next); + const setIndex = (nextIndex: number) => + updateDraft({ ...draft, index: nextIndex }); const current = request.questions[index]; const currentDraft = drafts[index]; @@ -54,14 +61,17 @@ export function AskToolCard({ request, queued = 0 }: { request: PendingAsk; queu [drafts], ); - const updateDraft = (update: (draft: DraftAnswer) => DraftAnswer) => { - setDrafts((previous) => - previous.map((draft, draftIndex) => (draftIndex === index ? update(draft) : draft)), - ); + const updateCurrentDraft = (update: (answer: DraftAnswer) => DraftAnswer) => { + updateDraft({ + ...draft, + answers: drafts.map((answer, draftIndex) => + draftIndex === index ? update(answer) : answer, + ), + }); }; const selectOption = (option: string) => { - updateDraft((draft) => { + updateCurrentDraft((draft) => { if (option === CUSTOM_OPTION) { return { ...draft, @@ -113,18 +123,24 @@ export function AskToolCard({ request, queued = 0 }: { request: PendingAsk; queu ? { ...draft, skipped: true } : draft, ); - setDrafts(nextDrafts); - if (index === request.questions.length - 1) void submit(nextDrafts); - else setIndex((value) => value + 1); + if (index === request.questions.length - 1) { + updateDraft({ ...draft, answers: nextDrafts }); + void submit(nextDrafts); + } else { + updateDraft({ ...draft, answers: nextDrafts, index: index + 1 }); + } }; const skip = () => { const nextDrafts = drafts.map((draft, draftIndex) => draftIndex === index ? { ...draft, skipped: true, values: [], customSelected: false } : draft, ); - setDrafts(nextDrafts); - if (index === request.questions.length - 1) void submit(nextDrafts); - else setIndex((value) => value + 1); + if (index === request.questions.length - 1) { + updateDraft({ ...draft, answers: nextDrafts }); + void submit(nextDrafts); + } else { + updateDraft({ ...draft, answers: nextDrafts, index: index + 1 }); + } }; return ( @@ -223,7 +239,7 @@ export function AskToolCard({ request, queued = 0 }: { request: PendingAsk; queu placeholder={t("askTool.customPlaceholder")} aria-label={t("askTool.customOption")} onChange={(event) => - updateDraft((draft) => ({ ...draft, customText: event.target.value, skipped: false })) + updateCurrentDraft((draft) => ({ ...draft, customText: event.target.value, skipped: false })) } autoFocus /> diff --git a/apps/desktop/src/lib/pending-asks.ts b/apps/desktop/src/lib/pending-asks.ts index b7da1fda52..76b3066193 100644 --- a/apps/desktop/src/lib/pending-asks.ts +++ b/apps/desktop/src/lib/pending-asks.ts @@ -1,4 +1,4 @@ -import type { AskToolRequest } from "@pi-desktop/shared"; +import type { AskToolQuestion, AskToolRequest } from "@pi-desktop/shared"; export type PendingAsk = AskToolRequest; export type AskQueues = Record; @@ -56,3 +56,76 @@ export function clearSessionAsks(queues: AskQueues, sessionId: string): AskQueue delete next[sessionId]; return next; } + +/** One question's in-progress answer, owned by the store so a remount is harmless. */ +export type AskDraftAnswer = { + values: string[]; + customSelected: boolean; + customText: string; + skipped: boolean; +}; + +/** The card's per-request draft: which question is open plus every answer. */ +export type AskDraft = { + index: number; + answers: AskDraftAnswer[]; +}; + +/** Drafts keyed by requestId; the ask itself already lives in `AskQueues`. */ +export type AskDrafts = Record; + +export function emptyAskDraft(questions: AskToolQuestion[]): AskDraft { + return { + index: 0, + answers: questions.map(() => ({ + values: [], + customSelected: false, + customText: "", + skipped: false, + })), + }; +} + +export function setAskDraft( + drafts: AskDrafts, + requestId: string, + draft: AskDraft, +): AskDrafts { + return { ...drafts, [requestId]: draft }; +} + +export function dropAskDraft(drafts: AskDrafts, requestId: string): AskDrafts { + if (!(requestId in drafts)) return drafts; + const next = { ...drafts }; + delete next[requestId]; + return next; +} + +/** Drop the drafts of every ask still queued for a session. */ +export function dropSessionAskDrafts( + drafts: AskDrafts, + queues: AskQueues, + sessionId: string, +): AskDrafts { + const queue = queues[sessionId]; + if (!queue || queue.length === 0) return drafts; + let next = drafts; + for (const ask of queue) next = dropAskDraft(next, ask.requestId); + return next; +} + +/** Drop the drafts of the asks tied to one tool call. */ +export function dropToolCallAskDrafts( + drafts: AskDrafts, + queues: AskQueues, + sessionId: string, + toolCallId: string, +): AskDrafts { + const queue = queues[sessionId]; + if (!queue || queue.length === 0) return drafts; + let next = drafts; + for (const ask of queue) { + if (ask.toolCallId === toolCallId) next = dropAskDraft(next, ask.requestId); + } + return next; +} diff --git a/apps/desktop/src/stores/app-state.ts b/apps/desktop/src/stores/app-state.ts index 21b76b5b9c..a2d0184474 100644 --- a/apps/desktop/src/stores/app-state.ts +++ b/apps/desktop/src/stores/app-state.ts @@ -40,7 +40,7 @@ import type { SessionSort, } from "../lib/sidebar-preferences"; import type { PermissionQueues } from "../lib/pending-permissions"; -import type { AskQueues } from "../lib/pending-asks"; +import type { AskDraft, AskDrafts, AskQueues } from "../lib/pending-asks"; import type { QueuedPromptDirection, QueuedPrompts, @@ -176,6 +176,8 @@ export type AppState = { pendingPermissions: PermissionQueues; /** Inline asktool requests, queued per session without an expiry. */ pendingAsks: AskQueues; + /** In-progress AskTool answers keyed by requestId, so a remount keeps them. */ + askDrafts: AskDrafts; /** Renderer-owned, in-memory prompt queue, isolated by session. */ queuedPrompts: QueuedPrompts; /** Planning state is durable per session, including sessions outside view. */ @@ -377,6 +379,10 @@ export type AppState = { sessionId: string, resolution: AskToolResolution, ) => Promise; + /** Persist one question card's in-progress answers by requestId. */ + setAskDraft: (requestId: string, draft: AskDraft) => void; + /** Forget a request's draft once it is resolved or dismissed. */ + dropAskDraft: (requestId: string) => void; resolvePlan: (resolution: PlanResolveRequest) => Promise; showToast: (message: string, options?: ToastOptions) => void; dismissToast: (id: number) => void; diff --git a/apps/desktop/src/stores/slices/events-slice.ts b/apps/desktop/src/stores/slices/events-slice.ts index fe4ef21dd4..16761c2212 100644 --- a/apps/desktop/src/stores/slices/events-slice.ts +++ b/apps/desktop/src/stores/slices/events-slice.ts @@ -25,6 +25,8 @@ import { import { createFrameBatcher } from "../../lib/frame-batcher"; import { clearSessionAsks, + dropSessionAskDrafts, + dropToolCallAskDrafts, enqueueAsk, removeAskForToolCall, } from "../../lib/pending-asks"; @@ -360,6 +362,11 @@ export function createEventsSlice({ state.pendingAsks, envelope.sessionId, ), + askDrafts: dropSessionAskDrafts( + state.askDrafts, + state.pendingAsks, + envelope.sessionId, + ), latestTurnResults: event.type === "error" && event.error.code === "TURN_ABORTED" ? withoutRecordKey(state.latestTurnResults, envelope.sessionId) @@ -452,10 +459,17 @@ export function createEventsSlice({ envelope.sessionId, event.toolCallId, ); + const askDrafts = dropToolCallAskDrafts( + state.askDrafts, + state.pendingAsks, + envelope.sessionId, + event.toolCallId, + ); return pendingPermissions === state.pendingPermissions && - pendingAsks === state.pendingAsks + pendingAsks === state.pendingAsks && + askDrafts === state.askDrafts ? {} - : { pendingPermissions, pendingAsks }; + : { pendingPermissions, pendingAsks, askDrafts }; }); } diff --git a/apps/desktop/src/stores/slices/initial-state.ts b/apps/desktop/src/stores/slices/initial-state.ts index 2b98d86978..18eeb7e633 100644 --- a/apps/desktop/src/stores/slices/initial-state.ts +++ b/apps/desktop/src/stores/slices/initial-state.ts @@ -72,6 +72,7 @@ export function createInitialState(): AppStateData { pluginViews: [], pendingPermissions: {}, pendingAsks: {}, + askDrafts: {}, queuedPrompts: {}, planningStates: {}, pendingPlans: {}, diff --git a/apps/desktop/src/stores/slices/interaction-slice.ts b/apps/desktop/src/stores/slices/interaction-slice.ts index dcf29e0ef9..f0af490d56 100644 --- a/apps/desktop/src/stores/slices/interaction-slice.ts +++ b/apps/desktop/src/stores/slices/interaction-slice.ts @@ -5,8 +5,11 @@ import type { } from "@pi-desktop/shared"; import { api } from "../../lib/api"; import { + dropAskDraft as removeAskDraft, headAsk, removeAsk, + setAskDraft as applyAskDraft, + type AskDraft, } from "../../lib/pending-asks"; import { headPermission, @@ -43,6 +46,8 @@ export function createInteractionSlice({ | "navForward" | "resolvePermission" | "resolveAsk" + | "setAskDraft" + | "dropAskDraft" | "resolvePlan" | "showToast" | "dismissToast" @@ -152,10 +157,23 @@ export function createInteractionSlice({ sessionId, resolution.requestId, ), + askDrafts: removeAskDraft(state.askDrafts, resolution.requestId), })); } }, + setAskDraft: (requestId: string, draft: AskDraft) => { + set((state) => ({ + askDrafts: applyAskDraft(state.askDrafts, requestId, draft), + })); + }, + + dropAskDraft: (requestId: string) => { + set((state) => ({ + askDrafts: removeAskDraft(state.askDrafts, requestId), + })); + }, + resolvePlan: async (resolution: PlanResolveRequest) => { const activeRequest = interactionRuntime.planResolutionRequests.get( resolution.proposalId, diff --git a/apps/desktop/src/stores/slices/project-slice.ts b/apps/desktop/src/stores/slices/project-slice.ts index 51e1bb1820..1195150425 100644 --- a/apps/desktop/src/stores/slices/project-slice.ts +++ b/apps/desktop/src/stores/slices/project-slice.ts @@ -14,6 +14,7 @@ import { } from "../../lib/session-panes"; import { clearSessionAsks, + dropSessionAskDrafts, } from "../../lib/pending-asks"; import { clearSessionPermissions, @@ -174,6 +175,7 @@ function clearLocalSessionState( id, ); const pendingAsks = clearSessionAsks(state.pendingAsks, id); + const askDrafts = dropSessionAskDrafts(state.askDrafts, state.pendingAsks, id); const latestTurnResults = withoutRecordKey(state.latestTurnResults, id); const planningStates = withoutRecordKey(state.planningStates, id); const pendingPlans = withoutRecordKey(state.pendingPlans, id); @@ -202,6 +204,7 @@ function clearLocalSessionState( isRunning: state.activeSessionId === id ? false : state.isRunning, pendingPermissions, pendingAsks, + askDrafts, latestTurnResults, planningStates, pendingPlans, diff --git a/apps/desktop/src/stores/slices/session-slice.ts b/apps/desktop/src/stores/slices/session-slice.ts index e1c41d699b..3a6a21b405 100644 --- a/apps/desktop/src/stores/slices/session-slice.ts +++ b/apps/desktop/src/stores/slices/session-slice.ts @@ -35,6 +35,7 @@ import { type SessionMeta, } from "../../lib/sidebar-preferences"; import { api } from "../../lib/api"; +import { dropAskDraft } from "../../lib/pending-asks"; import { isRemoteSession, safeSessionSummary, sessionAllows } from "../../lib/remote-session-safety"; import { createRefreshCoordinator } from "../../lib/refresh-coordinator"; import { openRemoteProjectSession, remoteProjectForSession } from "../../lib/remote-project-session"; @@ -344,15 +345,24 @@ export function createSessionSlice({ sessionId, pendingPermissions, ); + const keptAskIds = new Set(pendingAsks.map((ask) => ask.requestId)); + let askDrafts = state.askDrafts; + for (const ask of state.pendingAsks[sessionId] ?? []) { + if (!keptAskIds.has(ask.requestId)) { + askDrafts = dropAskDraft(askDrafts, ask.requestId); + } + } if ( nextPendingAsks === state.pendingAsks && - nextPendingPermissions === state.pendingPermissions + nextPendingPermissions === state.pendingPermissions && + askDrafts === state.askDrafts ) { return {}; } return { pendingAsks: nextPendingAsks, pendingPermissions: nextPendingPermissions, + askDrafts, }; }); }, diff --git a/docs/adr/README.md b/docs/adr/README.md index 18a7a9a6c0..b3412c13e1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -20,6 +20,7 @@ Each ADR includes: | ID | Title | Status | |---|---|---| +| asktool-draft-survives-session-switch | [AskTool drafts survive a card remount](asktool-draft-survives-session-switch.md) | Accepted | | provider-metadata-client-certificate-retry | [Provider metadata recovery from optional client certificates](provider-metadata-client-certificate-retry.md) | Implemented candidate | | device-local-project-lists-in-config-sync | [Device-local project lists in configuration sync](device-local-project-lists-in-config-sync.md) | Accepted | | s3-only-configuration-backup-sync | [S3-only encrypted configuration backup and synchronization](s3-only-configuration-backup-sync.md) | Accepted; supersedes WebDAV decisions in 0300 / 0301 | diff --git a/docs/adr/asktool-draft-survives-session-switch.md b/docs/adr/asktool-draft-survives-session-switch.md new file mode 100644 index 0000000000..23fe0ca0f6 --- /dev/null +++ b/docs/adr/asktool-draft-survives-session-switch.md @@ -0,0 +1,50 @@ +# ADR asktool-draft-survives-session-switch: AskTool drafts survive a card remount + +- Status: Accepted +- Date: 2026-10-20 +- Issues: #1765 +- Amends: the "Request transitions" rule in `docs/spec/04-ux/11-asktool-question-card.md` + +## Context + +The card's question index and draft answers were component state. Switching +sessions unmounts the card — `Composer` renders it only while the active session +has a head ask — so returning to the same pending ask remounted a fresh card at +question one with every answer cleared. The ask itself survived because it lives +in the store; only the in-progress answers were lost, which made the user redo +the whole card. + +The existing specification froze the opposite behavior: "Unsubmitted card drafts +are local to the mounted card and are reset when it is replaced." That rule +predates the session-switch case and no longer matches what users expect. + +## Decision + +Draft answers and the open question index live in the app store, keyed by +`requestId` (`askDrafts`). `AskToolCard` reads and writes that entry instead of +owning `useState`, so unmounting and remounting the same request is harmless. +A missing entry falls back lazily to an empty draft at read time; the card does +not seed the store during render. + +The draft is dropped when the ask is resolved or dismissed, when its tool call +finishes, when the session's asks are cleared, and when a pending-queue reconcile +removes the ask. Drafts remain renderer memory, like `pendingAsks`; they are not +persisted across an application restart and never leak between requests. + +## Consequences + +- Switching sessions and returning to the same pending ask keeps the selected + options, the custom text, skipped markers, and the open question. +- Resolved, dismissed, and removed asks leave no draft behind. +- `AskToolCard` becomes a controlled view over store state; it keeps no + draft-local `useState`. +- The "Request transitions" section of the asktool card spec is amended: drafts + belong to the request, not to the mounted card instance. + +## Validation + +`pnpm test:e2e:asktool-card` adds `E2E-ASKTOOL-draft-survives-session-switch`: +it selects an option, advances to question two, unmounts the card, remounts the +same `requestId`, and asserts the open question and the earlier selection +survive. The existing compact-card flows keep passing. `pnpm typecheck` and +`pnpm lint` cover the renderer types and style tokens. diff --git a/docs/spec/04-ux/11-asktool-question-card.md b/docs/spec/04-ux/11-asktool-question-card.md index 434db0499c..625037ea50 100644 --- a/docs/spec/04-ux/11-asktool-question-card.md +++ b/docs/spec/04-ux/11-asktool-question-card.md @@ -96,8 +96,16 @@ applies on narrow screens; the card does not rely on page-level scrolling. ## Request transitions -The question index, draft answers, and submission state belong to the displayed -request. Switching chats or advancing the pending queue mounts a fresh card at -question one with empty answers and enabled actions. State from the previous -request must not leak into the destination request. Unsubmitted card drafts are -local to the mounted card and are reset when it is replaced. +The question index and draft answers belong to the request, not to the mounted +card instance. They live in the app store keyed by `requestId`, so switching +chats and returning to the same pending ask restores the open question and every +answer the user had already entered (see +[ADR asktool-draft-survives-session-switch](../../adr/asktool-draft-survives-session-switch.md)). +A request with no stored draft opens at question one with empty answers and +enabled actions. + +Drafts never leak between requests: they are keyed by `requestId` and dropped +when the ask is resolved or dismissed, when its tool call completes, and when the +session's pending asks are cleared. Submission state stays local to the mounted +card. Drafts are renderer memory, like the pending ask itself, and do not survive +an application restart. diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 73b1fa67dc..78aee54690 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -516,6 +516,21 @@ actions, and measures the multi-block question layout. Runtime boundary validation is covered by `packages/agent-runtime/src/runtime.test.ts`. +### E2E-ASKTOOL-draft-survives-session-switch + +- **Preconditions:** No live provider is required; the fixture stubs + `api.resolveAskTool` and seeds one two-question ask through the real store + slice. +- **Steps:** Mount the production `AskToolCard`, select the second option on + question one, advance to question two, unmount the card (the session switch + the dock performs), and remount it with the same `requestId`. Then click the + first step indicator to return to question one. +- **Expected:** The remounted card opens on question two with the earlier + selection still marked, and question one still shows the selected option. + Resolving or dismissing the ask clears the stored draft. +- **Status:** Automated in `pnpm test:e2e:asktool-card` as the + `E2E-ASKTOOL-draft-survives-session-switch` flow. + ### E2E-POWER-keep-awake-setting - **Preconditions:** An isolated desktop profile with the setting absent; no diff --git a/scripts/e2e/asktool-card.tsx b/scripts/e2e/asktool-card.tsx index 26fdbeec28..8bfa0abe16 100644 --- a/scripts/e2e/asktool-card.tsx +++ b/scripts/e2e/asktool-card.tsx @@ -8,7 +8,8 @@ * the user-visible paths: the new header action order, select → next → * submit, skip → submit, decline-all, and a custom answer. * - * Scenario: E2E-ASKTOOL-compact-card-interaction. It also pins the compact + * Scenario: E2E-ASKTOOL-compact-card-interaction and + * E2E-ASKTOOL-draft-survives-session-switch. It also pins the compact * question block: a multi-paragraph question body stays one stacked text * column rather than lining its blocks up in a row. */ @@ -391,6 +392,42 @@ export async function asktoolCardProbe() { "the maximum-length question remains answerable", ); + // Flow H: the draft survives an unmount/remount with the same requestId — + // switching sessions in the real app unmounts the card the same way. + const persisted = mount("ask-persist"); + await until(() => options().length > 0, "persist flow mounts"); + flushSync(() => options()[1]!.click()); // select "beta" on question 1 + flushSync(() => headerButtons().primary.click()); // next -> question 2 + await until( + () => questionText().includes("Allow rollback?"), + "persist flow advances to question 2", + ); + flushSync(() => root.render(null)); // unmount (session switched away) + flushSync(() => + root.render( + + + , + ), + ); + await until(() => options().length > 0, "persist flow remounts"); + assert( + questionText().includes("Allow rollback?"), + "the remounted card resumes on the question the user left", + ); + const indicators = [ + ...host.querySelectorAll(".asktool-indicator"), + ]; + flushSync(() => indicators[0]!.click()); + await until( + () => questionText().includes("Which upgrade path?"), + "the indicator returns to question 1", + ); + assert( + options()[1]!.classList.contains("selected"), + "the answer selected before the remount is still selected", + ); + assert(errors.length === 0, `render errors: ${errors.map(String)}`); return { ok: true, resolutions: resolutions.length }; } catch (error) { From 36c1d159d4ac987d1b5f06311e3fff1071dbec21 Mon Sep 17 00:00:00 2001 From: liubai Date: Tue, 20 Oct 2026 15:34:39 +0800 Subject: [PATCH 02/44] fix(persistence): drop empty assistant rows regardless of status The transcript guard only dropped empty messages whose status was error or aborted. An empty complete assistant message therefore reached the outbox on every provider retry, growing the request body and feeding the EMPTY loop. Drop empty assistant rows whatever their status, while keeping messages that carry an error payload, a tool row, or a steering reservation. Adds empty-assistant-persistence tests for each of those cases. --- .../main/runtime/event-persistence.ts | 15 +- .../test/empty-assistant-persistence.test.mjs | 128 ++++++++++++++++++ 2 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/test/empty-assistant-persistence.test.mjs diff --git a/apps/desktop/electron/main/runtime/event-persistence.ts b/apps/desktop/electron/main/runtime/event-persistence.ts index e8cf88d27e..f24ac500e6 100644 --- a/apps/desktop/electron/main/runtime/event-persistence.ts +++ b/apps/desktop/electron/main/runtime/event-persistence.ts @@ -327,16 +327,19 @@ function persistAgentEvent(envelope: AgentEventEnvelope): UiMessage | undefined inflightCheckpointer.settleIf(sessionId, finalId); }); } - // Empty aborted bubbles are not useful transcript rows. Structured - // provider failures remain durable assistant messages so their details - // stay attached to the failed turn after reload. - const failed = - event.message.status === "error" || event.message.status === "aborted"; + // Empty assistant bubbles are not useful transcript rows, whatever their + // status: an empty `complete` message would otherwise be persisted on every + // provider retry and grow the request body. Structured provider failures + // remain durable so their details stay attached to the failed turn after + // reload; a message that still carries a tool row keeps its link to it, and + // a steering reply is reserved by the user turn that accepted it. const empty = !(event.message.content || "").trim() && !(event.message.thinking || "").trim(); + const carriesTool = + Boolean(event.message.toolName) || Boolean(event.message.toolCallId); const reservedForSteering = steeringReplies.delete(event.message.id); - if (failed && empty && !event.message.error && !reservedForSteering) return; + if (empty && !carriesTool && !event.message.error && !reservedForSteering) return; void persistenceOutbox .enqueue( { diff --git a/apps/desktop/test/empty-assistant-persistence.test.mjs b/apps/desktop/test/empty-assistant-persistence.test.mjs new file mode 100644 index 0000000000..466a084e14 --- /dev/null +++ b/apps/desktop/test/empty-assistant-persistence.test.mjs @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import { register } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import test from "node:test"; + +const here = dirname(fileURLToPath(import.meta.url)); +register(pathToFileURL(join(here, "helpers/ts-import-hooks.mjs"))); + +// The real persistence pass, driven directly with a stubbed outbox. +const { createSessionCoordination } = await import( + "../electron/main/runtime/session-coordination.ts" +); +const { createEventPersistence } = await import( + "../electron/main/runtime/event-persistence.ts" +); + +const SESSION = "s1"; +const TURN = "turn-1"; + +function fixture() { + const activeTurns = new Map([[SESSION, TURN]]); + const coordination = createSessionCoordination({ + activeTurns, + getMainWindow: () => null, + getViewingSessionId: () => null, + }); + const enqueued = []; + const steeringReplies = new Set(); + const persistence = createEventPersistence({ + runtimeState: { host: null }, + steeringReplies, + activeTurns, + activeToolCalls: new Map(), + activeToolCallKey: coordination.activeToolCallKey, + approvedExecutionIdsBySession: new Map(), + approvedExecutionTurns: new Map(), + pendingExecutionFinishes: new Map(), + planSubmissionTurnIds: new Set(), + planSubmissionTurnKey: coordination.planSubmissionTurnKey, + inflightCheckpointer: { + observe() {}, + flush: async () => {}, + settle() {}, + settleIf() {}, + }, + persistenceOutbox: { + size: () => 0, + enqueue: async (entry) => { + enqueued.push(entry); + }, + flush: async () => undefined, + }, + addActiveTurnUsage() {}, + logger: { app() {} }, + finishTurn: () => Promise.resolve(), + isStaleTerminalEvent: coordination.isStaleTerminalEvent, + transcriptRewrite: coordination, + finishApprovedExecution: async () => undefined, + emitAgentEvent() {}, + }); + const persistAssistant = (message) => + persistence.persistAgentEvent({ + sessionId: SESSION, + turnId: TURN, + ts: 1, + event: { + type: "message_end", + message: { + role: "assistant", + createdAt: "2026-10-20T00:00:00.000Z", + ...message, + }, + }, + }); + return { enqueued, steeringReplies, persistAssistant }; +} + +test("an empty complete assistant message is not persisted", () => { + const f = fixture(); + f.persistAssistant({ id: "m1", content: "", thinking: "", status: "complete" }); + assert.equal(f.enqueued.length, 0); +}); + +test("an empty aborted assistant message without an error is not persisted", () => { + const f = fixture(); + f.persistAssistant({ id: "m2", content: "", thinking: "", status: "aborted" }); + assert.equal(f.enqueued.length, 0); +}); + +test("an empty assistant message that still carries a tool row is persisted", () => { + const f = fixture(); + f.persistAssistant({ + id: "m3", + content: "", + thinking: "", + status: "complete", + toolName: "Grep", + toolCallId: "call-1", + toolStatus: "success", + }); + assert.equal(f.enqueued.length, 1); +}); + +test("an empty assistant message with an error payload stays durable", () => { + const f = fixture(); + f.persistAssistant({ + id: "m4", + content: "", + thinking: "", + status: "error", + error: { code: "PROVIDER_ERROR", message: "boom" }, + }); + assert.equal(f.enqueued.length, 1); +}); + +test("an empty assistant message reserved for steering is persisted", () => { + const f = fixture(); + f.steeringReplies.add("m5"); + f.persistAssistant({ id: "m5", content: "", thinking: "", status: "complete" }); + assert.equal(f.enqueued.length, 1); +}); + +test("a non-empty complete assistant message is persisted", () => { + const f = fixture(); + f.persistAssistant({ id: "m6", content: "done", status: "complete" }); + assert.equal(f.enqueued.length, 1); +}); From 1b1032c28794b1f3af6c9c242dc5f818a3e49bd9 Mon Sep 17 00:00:00 2001 From: zszz3 <91608029+zszz3@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:55:36 +0800 Subject: [PATCH 03/44] fix(chat): open worktree references from the branch copy An isolated session's files live in its worktree, but chat references can still carry a path from the parent checkout: project instructions, MCP tools, or a reference captured before isolation. The resolver rejected that path as outside its roots, and the renderer then handed the file view a relative path that it resolved against the parent checkout, so the click either failed or opened the main-branch copy. Rebase an absolute parent-checkout reference onto the same relative path in the worktree when that file exists, and keep the worktree's absolute path in the renderer so the file view opens the branch copy. Paths that exist only in the parent still report outside-allowed-roots. fixes #1747 --- .../electron/main/ipc/workspace-ipc.ts | 72 +++++++++++--- apps/desktop/electron/main/worktree-ref.ts | 59 ++++++++++++ apps/desktop/src/hooks/use-preview-target.ts | 6 +- .../test/transcript-file-chips.test.mjs | 8 +- .../desktop/test/worktree-ref-rebase.test.mjs | 96 +++++++++++++++++++ docs/spec/03-runtime/01-ipc-protocol.md | 5 +- docs/zh-CN/spec/03-runtime/01-ipc-protocol.md | 2 +- 7 files changed, 233 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/electron/main/worktree-ref.ts create mode 100644 apps/desktop/test/worktree-ref-rebase.test.mjs diff --git a/apps/desktop/electron/main/ipc/workspace-ipc.ts b/apps/desktop/electron/main/ipc/workspace-ipc.ts index 57d7b59797..15dac8b685 100644 --- a/apps/desktop/electron/main/ipc/workspace-ipc.ts +++ b/apps/desktop/electron/main/ipc/workspace-ipc.ts @@ -40,6 +40,7 @@ import { } from "@pi-desktop/host-runtime"; import { openableMp4Path } from "../open-attachment-video"; import { isChatRefOutsideRoots, resolveChatFileRef } from "../chat-ref-resolve"; +import { rebaseWorktreeRef, type WorktreeRefContext } from "../worktree-ref"; import { getWorkspaceFileIndex } from "../fs-index"; import { projectFolderPaths, @@ -768,27 +769,58 @@ export function registerWorkspaceIpc({ return browserHost.getState(); }); - const requireWorkspaceRoot = async (sessionId?: string) => { + type WorkspaceRootInfo = { + root: string; + /** Present only for an isolated session's worktree checkout. */ + worktree?: string; + /** The parent checkout a worktree session's project identity points at. */ + parent?: string; + }; + + const workspaceRootInfo = async ( + sessionId?: string, + ): Promise => { const owner = getHost(); if (!owner) throw new Error("host unavailable"); let root: string | undefined; + let worktree: string | undefined; + let parent: string | undefined; if (sessionId) { const { session } = await owner.call<{ session?: { projectPath?: string; worktree?: unknown } }>( "session.get", { id: sessionId, messageLimit: 1 }, ); - // Chat references and source reads must see the same files as the - // session's tools, not a same-named file in the parent checkout. - root = session?.worktree - ? (await owner.call<{ path?: string }>("worktree.location", { sessionId })).path - : session?.projectPath; + const projectPath = session?.projectPath?.trim(); + if (session?.worktree) { + // Chat references and source reads must see the same files as the + // session's tools, not a same-named file in the parent checkout. + worktree = + (await owner.call<{ path?: string }>("worktree.location", { sessionId })).path?.trim() || + undefined; + root = worktree; + // A reference captured from this checkout rebases onto the worktree + // (issue #1747); the opaque remote identity is never a local parent. + parent = + projectPath && !projectPath.startsWith("remote-project:") + ? projectPath + : undefined; + } else { + root = projectPath; + } } else { root = (await owner.call<{ workspace?: { path: string } }>("workspace.get")).workspace?.path; } if (getHost() !== owner) throw Object.assign(new Error("host replaced"), { errorCode: ErrorCodes.HOST_UNAVAILABLE }); if (!root) throw Object.assign(new Error("workspace required"), { errorCode: ErrorCodes.INVALID_ARGUMENT }); - return root; + return { + root, + ...(worktree ? { worktree } : {}), + ...(parent ? { parent } : {}), + }; }; + const requireWorkspaceRoot = async (sessionId?: string) => + (await workspaceRootInfo(sessionId)).root; + handle(IPC.invoke.fsList, async (input: { path?: string; sessionId?: string } = {}) => { const remote = await remoteFileWorkspace(input.sessionId); if (remote) return readRemoteFiles(remote, "list", input.path); @@ -997,11 +1029,29 @@ export function registerWorkspaceIpc({ async ( input: { ref?: string; sessionId?: string } = {}, ): Promise => { - const ref = String(input.ref ?? "").trim(); - if (!ref) return { match: null }; + const rawRef = String(input.ref ?? "").trim(); + if (!rawRef) return { match: null }; const remote = await remoteFileWorkspace(input.sessionId); - if (remote && !(await isLocalStoredFile(ref))) return await readRemoteFiles(remote, "resolve", ref) as FsChatRefResolveResult; - const workspaceRoot = await optionalWorkspaceRoot(input.sessionId); + if (remote && !(await isLocalStoredFile(rawRef))) return await readRemoteFiles(remote, "resolve", rawRef) as FsChatRefResolveResult; + // A reference captured from the parent checkout names the same relative + // file the session's worktree holds; resolve the branch copy instead of + // rejecting the path as outside the isolated roots (issue #1747). + let rootInfo: WorkspaceRootInfo | undefined; + try { + rootInfo = await workspaceRootInfo(input.sessionId); + } catch { + rootInfo = undefined; + } + const ref = await rebaseWorktreeRef( + rawRef, + rootInfo?.worktree && rootInfo.parent + ? ({ + worktree: rootInfo.worktree, + parent: rootInfo.parent, + } satisfies WorktreeRefContext) + : null, + ); + const workspaceRoot = rootInfo?.root ?? null; const roots = { project: projectRootsFor(workspaceRoot), scratch: await sessionScratchRoot(input.sessionId), diff --git a/apps/desktop/electron/main/worktree-ref.ts b/apps/desktop/electron/main/worktree-ref.ts new file mode 100644 index 0000000000..dd56df3b99 --- /dev/null +++ b/apps/desktop/electron/main/worktree-ref.ts @@ -0,0 +1,59 @@ +import { stat } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; + +/** + * The two roots a chat reference can mean for an isolated session: the checkout + * the user sees (the worktree) and the parent checkout the project identity + * still points at (ADR 0329). + */ +export type WorktreeRefContext = { worktree: string; parent: string }; + +/** + * Rewrite an absolute chat reference that points into the parent checkout onto + * the same relative path inside the session's worktree. + * + * References can be produced before isolation or by producers that only know + * the project path (project instructions, MCP tools, an attachment captured + * from the parent checkout). The session's files live in the worktree, so the + * original path is outside the allowed roots and the click fails even though + * the branch has the same file. Relative references, paths already inside the + * worktree, and paths with no worktree counterpart are returned unchanged so + * the caller keeps reporting the accurate reason. + */ +export async function rebaseWorktreeRef( + ref: string, + context: WorktreeRefContext | null, + isFile: (path: string) => Promise = defaultIsFile, +): Promise { + if (!context || !isAbsolute(ref)) return ref; + if (relativeWithin(context.worktree, ref) !== null) return ref; + const relativePath = relativeWithin(context.parent, ref); + if (!relativePath) return ref; + const candidate = resolve(context.worktree, ...relativePath.split("/")); + return (await isFile(candidate)) ? candidate : ref; +} + +/** + * The project UI treats Windows and macOS paths as case-insensitive, and a + * reference may use either separator. Compare in normalized form without + * changing the spelling returned to the caller. + */ +function relativeWithin(root: string, candidate: string): string | null { + const rootPath = root.replaceAll("\\", "/").replace(/\/+$/, ""); + const candidatePath = candidate.replaceAll("\\", "/"); + const caseInsensitive = process.platform !== "linux"; + const normalizedRoot = caseInsensitive ? rootPath.toLowerCase() : rootPath; + const normalizedCandidate = caseInsensitive + ? candidatePath.toLowerCase() + : candidatePath; + if (!normalizedCandidate.startsWith(`${normalizedRoot}/`)) return null; + return candidatePath.slice(rootPath.length + 1); +} + +async function defaultIsFile(path: string): Promise { + try { + return (await stat(path)).isFile(); + } catch { + return false; + } +} diff --git a/apps/desktop/src/hooks/use-preview-target.ts b/apps/desktop/src/hooks/use-preview-target.ts index 93f6133375..9af9e25a72 100644 --- a/apps/desktop/src/hooks/use-preview-target.ts +++ b/apps/desktop/src/hooks/use-preview-target.ts @@ -190,6 +190,10 @@ function useResolveChatFileRef() { showToast(t("chat.fileRefLookupFailed"), { variant: "error" }); return null; } + // An isolated session's files live in its worktree, which is outside the + // plugin's project roots. A relative path would be resolved by the file + // view against the parent checkout, so the branch copy stays absolute. + const isolated = Boolean(session?.worktree); const match = result.match; if (!match) { const key = result.reason === "outside-allowed-roots" @@ -213,7 +217,7 @@ function useResolveChatFileRef() { // absolute path and the file view switches to that folder (ADR 0263). const primary = match.projectRoot ? match.projectRoot.primary : true; return { - path: primary ? match.relativePath : match.absolutePath, + path: primary && !isolated ? match.relativePath : match.absolutePath, absolutePath: match.absolutePath, relativePath: match.relativePath, inProject: true, diff --git a/apps/desktop/test/transcript-file-chips.test.mjs b/apps/desktop/test/transcript-file-chips.test.mjs index 3cff0bce9b..f498375743 100644 --- a/apps/desktop/test/transcript-file-chips.test.mjs +++ b/apps/desktop/test/transcript-file-chips.test.mjs @@ -49,7 +49,13 @@ test("a file chip is routed by where the reference resolved, never optimisticall // follows the folder that answered: relative for the primary one, absolute // for its siblings, and the file view switches to the folder it is given. assert.match(hook, /match\.projectRoot \? match\.projectRoot\.primary : true/); - assert.match(hook, /path: primary \? match\.relativePath : match\.absolutePath/); + // An isolated session's worktree is outside the plugin's project roots, so + // the branch copy travels by its absolute path (issue #1747). + assert.match(hook, /const isolated = Boolean\(session\?\.worktree\)/); + assert.match( + hook, + /path: primary && !isolated \? match\.relativePath : match\.absolutePath/, + ); // Project and scratch HTML run at the exact path resolved by main. assert.match(hook, /resolved\.inProject \|\| resolved\.scratch/); assert.match(hook, /isHtmlFilePath\(resolved\.absolutePath\)/); diff --git a/apps/desktop/test/worktree-ref-rebase.test.mjs b/apps/desktop/test/worktree-ref-rebase.test.mjs new file mode 100644 index 0000000000..054711645e --- /dev/null +++ b/apps/desktop/test/worktree-ref-rebase.test.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { rebaseWorktreeRef } from "../electron/main/worktree-ref.ts"; + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "pi-worktree-ref-")); + const parent = join(root, "main"); + const worktree = join(root, "worktree"); + await mkdir(join(parent, "docs"), { recursive: true }); + await mkdir(join(worktree, "docs"), { recursive: true }); + await writeFile(join(parent, "docs", "branch-note.md"), "parent\n"); + await writeFile(join(worktree, "docs", "branch-note.md"), "branch\n"); + await writeFile(join(parent, "docs", "parent-only.md"), "parent only\n"); + return { root, parent, worktree }; +} + +test("a reference without a worktree context is unchanged", async () => { + assert.equal( + await rebaseWorktreeRef("/repo/docs/a.md", null), + "/repo/docs/a.md", + ); +}); + +test("relative references stay relative", async () => { + const { root, parent, worktree } = await fixture(); + try { + assert.equal( + await rebaseWorktreeRef("docs/branch-note.md", { parent, worktree }), + "docs/branch-note.md", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("a reference already inside the worktree is unchanged", async () => { + const { root, parent, worktree } = await fixture(); + try { + const ref = join(worktree, "docs", "branch-note.md"); + assert.equal(await rebaseWorktreeRef(ref, { parent, worktree }), ref); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("a parent-checkout path resolves to the branch copy in the worktree", async () => { + const { root, parent, worktree } = await fixture(); + try { + assert.equal( + await rebaseWorktreeRef(join(parent, "docs", "branch-note.md"), { + parent, + worktree, + }), + join(worktree, "docs", "branch-note.md"), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("a parent-only file keeps its original path when the branch lacks it", async () => { + const { root, parent, worktree } = await fixture(); + try { + const ref = join(parent, "docs", "parent-only.md"); + assert.equal(await rebaseWorktreeRef(ref, { parent, worktree }), ref); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("a sibling directory sharing the parent prefix is not rebased", async () => { + const { root, parent, worktree } = await fixture(); + try { + const sibling = join(`${parent}-other`, "docs", "branch-note.md"); + assert.equal(await rebaseWorktreeRef(sibling, { parent, worktree }), sibling); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("a worktree nested inside the parent is not rebased onto itself", async () => { + const { root, worktree } = await fixture(); + try { + const nested = join(worktree, ".worktrees", "branch"); + await mkdir(join(nested, "docs"), { recursive: true }); + await writeFile(join(nested, "docs", "branch-note.md"), "branch\n"); + const ref = join(nested, "docs", "branch-note.md"); + assert.equal(await rebaseWorktreeRef(ref, { parent: worktree, worktree: nested }), ref); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md index 64c0fa9051..1f81a9c0eb 100644 --- a/docs/spec/03-runtime/01-ipc-protocol.md +++ b/docs/spec/03-runtime/01-ipc-protocol.md @@ -1926,7 +1926,10 @@ Renderer IPC kept for the Plan-safe preview facade and URL fallback: so the extensionless blob has a media association without copying its bytes. - `fs/resolveRef({ref, sessionId?})` → `FsChatRefResolveResult` (for isolated sessions, lookup and file reads use `worktree.location` rather - than the parent project checkout) + than the parent project checkout; an absolute reference under the parent + checkout is rebased onto the same relative path in the worktree when that + file exists, so the branch copy opens instead of reporting + `outside-allowed-roots`) (`{ match: FsChatRefMatch | null, reason?: "outside-allowed-roots" }`, the match naming the answering `root` (`workspace` / `scratch` / `attachments`), the `relativePath` relative to that root, optional `sessionScratch: true` for the current session's own scratch 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 79a7716a22..8fdf8921dd 100644 --- a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md +++ b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md @@ -1439,7 +1439,7 @@ Chrome 和代理 CDP 位于随应用打包的 `pi.browser` 插件中,通过 `p - `fs/readImageDataUrl({ref, mimeType?})` → `FsImageDataUrlResult`(`image` 带 `dataUrl`,或 `missing` / `notImage` / `tooLarge`)。包含范围与 `fs/read` 相同。从不返回非图片字节。仅渲染器使用,不是插件宿主 API。 - `fs/reveal({path})` → 在 Finder 中显示。包含范围与 `fs/read` 相同。 - `fs/open({path, mimeType?})` → 用系统默认应用打开已有的普通文件。与 `fs/read` 一样校验真实路径包含范围,拒绝通过符号链接逃逸。对于声明为 `video/mp4` 的无后缀 `attachments/` blob,宿主在私有应用数据目录建立 `.mp4` 符号链接后再交给系统,不复制视频字节。 -- `fs/resolveRef({ref, sessionId?})` → `FsChatRefResolveResult`(`{ match: FsChatRefMatch | null }`,match 指出应答的 `root`(`workspace` / `scratch` / `attachments`)、相对该应答根的 `relativePath`、绝对路径 `absolutePath` 与 `matchedBy`(`exact-relative` / `exact-absolute` / `path-suffix` / `basename`),以及在 `workspace` 命中时给出的 `projectRoot`(`{ path, name, primary }`,指出是哪个文件夹应答的));`sessionId` 决定查哪个会话的临时目录。它补全智能体在聊天里打印的文件引用,因为渲染器看不到会话自己的临时目录:已经在某个已知根内指向真实文件的绝对引用直接胜出,`attachments/` blob 直接对附件库解析;否则按优先级顺序搜索各根——整个打开的项目、再会话自己的临时目录(`/scratch//`,ADR 0124)、最后附件库——第一个给出结果的根胜出。项目指的是打开的工作区背后的文件夹组(ADR 0249):主文件夹先应答,其余文件夹随后按项目组自身顺序搜索(ADR 0263),因此简写落在同级文件夹里和落在主文件夹里一样自然,命中结果也指出是哪个文件夹应答的。同一个根内精确路径优先于简写;简写之间最长匹配尾优先,其次路径更浅者。文件面板的忽略集合同样生效。什么都没匹配到时返回 `match: null`;解析本身不打开任何东西(ADR 0262)。 +- `fs/resolveRef({ref, sessionId?})` → `FsChatRefResolveResult`(`{ match: FsChatRefMatch | null }`,match 指出应答的 `root`(`workspace` / `scratch` / `attachments`)、相对该应答根的 `relativePath`、绝对路径 `absolutePath` 与 `matchedBy`(`exact-relative` / `exact-absolute` / `path-suffix` / `basename`),以及在 `workspace` 命中时给出的 `projectRoot`(`{ path, name, primary }`,指出是哪个文件夹应答的));`sessionId` 决定查哪个会话的临时目录。隔离会话以 `worktree.location` 而不是父项目 checkout 作为查找与读取的根;落在父 checkout 内的绝对引用会先映射到 worktree 中的同一相对路径(该文件存在时),从而打开分支上的副本,而不是报 `outside-allowed-roots`。它补全智能体在聊天里打印的文件引用,因为渲染器看不到会话自己的临时目录:已经在某个已知根内指向真实文件的绝对引用直接胜出,`attachments/` blob 直接对附件库解析;否则按优先级顺序搜索各根——整个打开的项目、再会话自己的临时目录(`/scratch//`,ADR 0124)、最后附件库——第一个给出结果的根胜出。项目指的是打开的工作区背后的文件夹组(ADR 0249):主文件夹先应答,其余文件夹随后按项目组自身顺序搜索(ADR 0263),因此简写落在同级文件夹里和落在主文件夹里一样自然,命中结果也指出是哪个文件夹应答的。同一个根内精确路径优先于简写;简写之间最长匹配尾优先,其次路径更浅者。文件面板的忽略集合同样生效。什么都没匹配到时返回 `match: null`;解析本身不打开任何东西(ADR 0262)。 - `fs/list` 仍只限工作区;外面的遍历被拒绝(`INVALID_ARGUMENT`)。 ## 13b. 桌面菜单和窗口 API From 549b67b990cf874c041e7146f3c4332b31a5d739 Mon Sep 17 00:00:00 2001 From: zszz3 <91608029+zszz3@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:31:29 +0800 Subject: [PATCH 04/44] fix(transcript): let an unpinned bookmark own the pane reveal Follow mode can still read pinned when a send or retry hid the pane. On the next reveal the scroll hook then bottomed the transcript before the reading-position hook could restore the recorded bookmark, so a reader who had scrolled up returned to the newest turn. Route the reveal through one shared ownership rule: an unpinned bookmark or a pending restore keeps the edge, a genuinely pinned pane bottoms, and an unpinned pane without a bookmark reapplies its retained offset. Both hooks now agree on which one acts. fixes #1771 --- .../transcript/hooks/useTranscriptScroll.ts | 32 ++++++++-- .../hooks/use-transcript-reading-position.ts | 13 +++- .../src/lib/transcript-reading-position.ts | 17 ++++++ .../test/interaction-performance.test.mjs | 8 ++- .../test/transcript-reveal-ownership.test.mjs | 60 +++++++++++++++++++ 5 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/test/transcript-reveal-ownership.test.mjs diff --git a/apps/desktop/src/features/chat/transcript/hooks/useTranscriptScroll.ts b/apps/desktop/src/features/chat/transcript/hooks/useTranscriptScroll.ts index 32a3f525f8..4d9d838ca1 100644 --- a/apps/desktop/src/features/chat/transcript/hooks/useTranscriptScroll.ts +++ b/apps/desktop/src/features/chat/transcript/hooks/useTranscriptScroll.ts @@ -40,7 +40,10 @@ import { readScrollInputContext } from "../../../../lib/scroll-input"; import { useDisclosureAnchor } from "../../../../hooks/use-disclosure-anchor"; import type { TranscriptSearchTarget } from "../../../../lib/transcript-reading"; import { useTranscriptSearchFocus } from "../../../../hooks/use-transcript-search-focus"; -import { getTranscriptReadingPosition } from "../../../../lib/transcript-reading-position"; +import { + getTranscriptReadingPosition, + transcriptRevealOwnership, +} from "../../../../lib/transcript-reading-position"; import { useTranscriptReadingPosition, type RestoreTranscriptReading } from "../../../../hooks/use-transcript-reading-position"; type UseTranscriptScrollOptions = { @@ -247,8 +250,23 @@ export function useTranscriptScroll({ retainedScrollTopRef.current = lastLaidOutScrollTopRef.current; return; } - if (!becameVisible || readingPositionPendingRef.current) return; - if (pinnedRef.current) { + if (!becameVisible) return; + const bookmark = getTranscriptReadingPosition(sessionId); + const ownership = transcriptRevealOwnership( + bookmark, + Boolean(readingPositionPendingRef.current), + pinnedRef.current, + ); + if (ownership === "reading-position") { + // The reading-position hook restores this bookmark (or reports it + // missing). Bottoming here would race it and land on the newest turn. + if (bookmark?.pinned === false) { + pinnedRef.current = false; + setShowJump(true); + } + return; + } + if (ownership === "pinned") { scrollToBottom(); return; } @@ -257,7 +275,13 @@ export function useTranscriptScroll({ el.scrollTop = retained; lastScrollTopRef.current = retained; lastLaidOutScrollTopRef.current = retained; - }, [cancelFollowScroll, paneVisible, releaseDisclosureAnchor, scrollToBottom]); + }, [ + cancelFollowScroll, + paneVisible, + releaseDisclosureAnchor, + scrollToBottom, + sessionId, + ]); // A hidden pane must not chase its stream: its scroller has no visible // viewport, and the measurements a follow scroll depends on are unreliable diff --git a/apps/desktop/src/hooks/use-transcript-reading-position.ts b/apps/desktop/src/hooks/use-transcript-reading-position.ts index 0c4ad057ee..e2ab87c91b 100644 --- a/apps/desktop/src/hooks/use-transcript-reading-position.ts +++ b/apps/desktop/src/hooks/use-transcript-reading-position.ts @@ -34,8 +34,17 @@ export function useTranscriptReadingPosition({ const alignmentUntilRef = useRef(0); const wasVisibleRef = useRef(visible); useLayoutEffect(() => { - if (visible && !wasVisibleRef.current && !pinnedRef.current) { - pendingRef.current = getTranscriptReadingPosition(sessionId); + if (visible && !wasVisibleRef.current) { + const bookmark = getTranscriptReadingPosition(sessionId); + if (bookmark?.pinned === false) { + // The follow flag can still read `true` when a send or retry hid the + // pane; the recorded bookmark is the authoritative state for this + // reveal, so claim the edge instead of letting follow bottom it. + pendingRef.current = bookmark; + pinnedRef.current = false; + } else if (!pinnedRef.current) { + pendingRef.current = bookmark; + } alignmentUntilRef.current = 0; } wasVisibleRef.current = visible; diff --git a/apps/desktop/src/lib/transcript-reading-position.ts b/apps/desktop/src/lib/transcript-reading-position.ts index b4a5b89115..1090b8bddd 100644 --- a/apps/desktop/src/lib/transcript-reading-position.ts +++ b/apps/desktop/src/lib/transcript-reading-position.ts @@ -27,6 +27,23 @@ export function sessionReadingState(sessionId: string): ReadingState { export function getTranscriptReadingPosition(sessionId: string | undefined) { return sessionId ? readingStates.get(sessionId)?.position : undefined; } + +export type TranscriptRevealOwnership = "reading-position" | "pinned" | "retained"; + +/** + * Which effect owns a pane reveal. A recorded unpinned bookmark wins over a + * stale follow flag: follow mode can still read `true` when a send or retry hid + * the pane, and bottoming there would undo the position the reader is meant to + * return to (issue #1771). + */ +export function transcriptRevealOwnership( + bookmark: TranscriptReadingPosition | undefined, + pending: boolean, + pinned: boolean, +): TranscriptRevealOwnership { + if (bookmark?.pinned === false || pending) return "reading-position"; + return pinned ? "pinned" : "retained"; +} export function forgetTranscriptReadingState(sessionId: string) { readingStates.delete(sessionId); } diff --git a/apps/desktop/test/interaction-performance.test.mjs b/apps/desktop/test/interaction-performance.test.mjs index 4517668c76..37bbce3a89 100644 --- a/apps/desktop/test/interaction-performance.test.mjs +++ b/apps/desktop/test/interaction-performance.test.mjs @@ -191,11 +191,15 @@ test("a revealed pane restores its own scroll position in the layout phase", () // scroller can be clamped while its content grows off screen, and a passive // effect would leave one visible frame at the wrong offset (ADR 0137). const revealEffect = transcript.match( - /useLayoutEffect\(\(\) => \{([\s\S]*?)\n \}, \[cancelFollowScroll, paneVisible, releaseDisclosureAnchor, scrollToBottom\]\);/, + /useLayoutEffect\(\(\) => \{([\s\S]*?)\n \}, \[\n cancelFollowScroll,\n paneVisible,\n releaseDisclosureAnchor,\n scrollToBottom,\n sessionId,\n \]\);/, )?.[1]; assert.ok(revealEffect, "the reveal must restore position in a layout effect"); assert.match(revealEffect, /retainedScrollTopRef\.current = lastLaidOutScrollTopRef\.current/); - assert.match(revealEffect, /if \(pinnedRef\.current\) \{\s*scrollToBottom\(\);/); + // A recorded unpinned bookmark owns the edge; only a genuinely pinned pane + // bottoms. A stale follow flag must not override the bookmark (#1771). + assert.match(revealEffect, /transcriptRevealOwnership\(/); + assert.match(revealEffect, /ownership === "reading-position"/); + assert.match(revealEffect, /ownership === "pinned"\) \{\s*scrollToBottom\(\);/); assert.match(revealEffect, /el\.scrollTop = retained/); assert.match(revealEffect, /lastScrollTopRef\.current = retained/); }); diff --git a/apps/desktop/test/transcript-reveal-ownership.test.mjs b/apps/desktop/test/transcript-reveal-ownership.test.mjs new file mode 100644 index 0000000000..5a0690b3ad --- /dev/null +++ b/apps/desktop/test/transcript-reveal-ownership.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { register } from "node:module"; +import test from "node:test"; + +register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url)); +const { transcriptRevealOwnership } = await import( + "../src/lib/transcript-reading-position.ts" +); + +const bookmark = (pinned) => ({ + pinned, + messageId: "m1", + offset: 24, + scrollTop: 480, + windowSize: 20, +}); +const read = (path) => readFile(new URL(path, import.meta.url), "utf8"); + +test("an unpinned bookmark owns a reveal even when follow still reads pinned", () => { + assert.equal( + transcriptRevealOwnership(bookmark(false), false, true), + "reading-position", + ); +}); + +test("a pending restore owns the reveal", () => { + assert.equal(transcriptRevealOwnership(undefined, true, true), "reading-position"); +}); + +test("a pinned pane without a bookmark bottoms", () => { + assert.equal(transcriptRevealOwnership(undefined, false, true), "pinned"); +}); + +test("an unpinned pane without a bookmark reapplies its retained offset", () => { + assert.equal(transcriptRevealOwnership(undefined, false, false), "retained"); + assert.equal(transcriptRevealOwnership(bookmark(true), false, false), "retained"); +}); + +test("both transcript hooks route the reveal edge through the shared rule", async () => { + const [readingHook, scrollHook] = await Promise.all([ + read("../src/hooks/use-transcript-reading-position.ts"), + read("../src/features/chat/transcript/hooks/useTranscriptScroll.ts"), + ]); + // The bookmark, not the possibly stale follow flag, claims an unpinned + // reveal; the old `!pinnedRef.current` gate let follow bottom it (#1771). + assert.match( + readingHook, + /bookmark\?\.pinned === false[\s\S]*?pendingRef\.current = bookmark[\s\S]*?pinnedRef\.current = false/, + ); + assert.doesNotMatch( + readingHook, + /!wasVisibleRef\.current && !pinnedRef\.current/, + ); + assert.match(scrollHook, /transcriptRevealOwnership\(/); + assert.match( + scrollHook, + /ownership === "reading-position"[\s\S]*?pinnedRef\.current = false/, + ); +}); From 31caf50d0ad8001ba9a95885231508bc03136a57 Mon Sep 17 00:00:00 2001 From: zszz3 <91608029+zszz3@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:32:39 +0800 Subject: [PATCH 05/44] fix(composer): search past a truncated file index for @ mentions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file index keeps only the shallowest 8000 entries, so in a large workspace — a Unity project, where every asset has a .meta sibling — a script several folders down matches no query at all, even though the truncation note invites the reader to refine it (#1781). While the snapshot is truncated, a non-empty query is now also searched over the workspace's full listing through the read-only fs/search channel, after a short typing pause, and the best rows merge with the index's own matches. The listing is the git list the index already builds, or a deeper ignore-set walk when the index came from the walk. A complete index never searches, and a remote session keeps its snapshot because the remote file protocol has no search operation. fixes #1781 --- apps/desktop/electron/main/fs-index.ts | 2 +- .../electron/main/ipc/workspace-ipc.ts | 11 +- .../src/hooks/use-composer-autocomplete.ts | 63 ++++- apps/desktop/src/lib/api.ts | 2 + ...mposer-deep-file-search-user-path.test.mjs | 258 ++++++++++++++++++ ...4-composer-commands-and-file-references.md | 3 + docs/spec/03-runtime/01-ipc-protocol.md | 25 +- docs/spec/04-ux/08-component-spec.md | 10 +- docs/spec/06-delivery/04-e2e-test-plan.md | 36 +++ docs/spec/08-meta/decisions-log.md | 24 ++ docs/zh-CN/spec/03-runtime/01-ipc-protocol.md | 20 +- docs/zh-CN/spec/04-ux/08-component-spec.md | 7 +- .../spec/06-delivery/04-e2e-test-plan.md | 28 ++ docs/zh-CN/spec/08-meta/decisions-log.md | 18 ++ packages/host-runtime/src/fs-index.test.ts | 87 ++++++ packages/host-runtime/src/fs-index.ts | 149 +++++++--- packages/shared/src/protocol.ts | 1 + 17 files changed, 693 insertions(+), 51 deletions(-) create mode 100644 apps/desktop/test/composer-deep-file-search-user-path.test.mjs create mode 100644 packages/host-runtime/src/fs-index.test.ts diff --git a/apps/desktop/electron/main/fs-index.ts b/apps/desktop/electron/main/fs-index.ts index bf41f117d1..4dbb8ba2fc 100644 --- a/apps/desktop/electron/main/fs-index.ts +++ b/apps/desktop/electron/main/fs-index.ts @@ -1 +1 @@ -export { FS_INDEX_MAX_ENTRIES, getWorkspaceFileIndex } from "@pi-desktop/host-runtime"; +export { FS_INDEX_MAX_ENTRIES, getWorkspaceFileIndex, searchWorkspaceFiles } from "@pi-desktop/host-runtime"; diff --git a/apps/desktop/electron/main/ipc/workspace-ipc.ts b/apps/desktop/electron/main/ipc/workspace-ipc.ts index 57d7b59797..dbb604bea9 100644 --- a/apps/desktop/electron/main/ipc/workspace-ipc.ts +++ b/apps/desktop/electron/main/ipc/workspace-ipc.ts @@ -40,7 +40,7 @@ import { } from "@pi-desktop/host-runtime"; import { openableMp4Path } from "../open-attachment-video"; import { isChatRefOutsideRoots, resolveChatFileRef } from "../chat-ref-resolve"; -import { getWorkspaceFileIndex } from "../fs-index"; +import { getWorkspaceFileIndex, searchWorkspaceFiles } from "../fs-index"; import { projectFolderPaths, refreshProjectGroups, @@ -984,6 +984,15 @@ export function registerWorkspaceIpc({ return getWorkspaceFileIndex(root); }); + handle(IPC.invoke.fsSearch, async (input: { sessionId?: string; query?: string } = {}) => { + // The remote file protocol has no search operation, so a remote session + // keeps answering from its truncated snapshot. + if (await remoteFileWorkspace(input.sessionId)) return { entries: [], truncated: true }; + const root = await optionalWorkspaceRoot(input.sessionId); + if (!root) return { entries: [], truncated: false }; + return searchWorkspaceFiles(root, String(input.query ?? "")); + }); + /** * Resolve a file reference from chat to a real file (D320 follow-up). * The renderer knows the workspace but not where a session keeps its scratch diff --git a/apps/desktop/src/hooks/use-composer-autocomplete.ts b/apps/desktop/src/hooks/use-composer-autocomplete.ts index 00292f44b5..683dcc86a5 100644 --- a/apps/desktop/src/hooks/use-composer-autocomplete.ts +++ b/apps/desktop/src/hooks/use-composer-autocomplete.ts @@ -24,13 +24,16 @@ import { useAppStore } from "../stores/app-store"; /** * Composer autocomplete state machine (D123–D125): trigger detection over - * draft+cursor, lazily fetched command/file sources, local fuzzy filtering, - * and insert-on-accept. IME freezing and key routing live in the Composer; + * draft+cursor, lazily fetched command/file sources, local fuzzy filtering + * (with a main-process search past a truncated file index, D665), and + * insert-on-accept. IME freezing and key routing live in the Composer; * this hook only refuses to update while `composing` is true. */ const MAX_FILE_ITEMS = 50; const SOURCE_TTL_MS = 10_000; +/** Typing pause before a query searches past a truncated file index. */ +const FILE_SEARCH_DEBOUNCE_MS = 150; export type AutocompleteItem = | { kind: "command"; command: ComposerCommand; match: FuzzyMatch } @@ -102,9 +105,13 @@ function filterCommands( function filterFiles(entries: FsIndexEntry[], query: string): AutocompleteItem[] { const matched: Array<{ entry: FsIndexEntry; match: FuzzyMatch }> = []; + // Rows found by the main-process search may repeat index entries. + const seen = new Set(); for (const entry of entries) { const match = fuzzyMatchPath(query, entry.path, entry.kind); - if (match) matched.push({ entry, match }); + if (!match || seen.has(entry.path)) continue; + seen.add(entry.path); + matched.push({ entry, match }); } // The index can hold thousands of entries while the menu shows at most // MAX_FILE_ITEMS, so only the bounded top slice is ever ordered. @@ -174,6 +181,11 @@ export function useComposerAutocomplete({ entries: FsIndexEntry[]; truncated: boolean; } | null>(null); + const [found, setFound] = useState<{ + scope: string; + entries: FsIndexEntry[]; + truncated: boolean; + } | null>(null); const [highlight, setHighlight] = useState(0); const [dismissedKey, setDismissedKey] = useState(null); const frozenRef = useRef(null); @@ -251,6 +263,41 @@ export function useComposerAutocomplete({ }; }, [trigger?.mode, dismissed, workspaceKey, hasWorkspace, sessionId]); + // A truncated index keeps only the shallowest entries, so a deep file of a + // large tree matches nothing in it. Once typing pauses, the query also + // searches the main process's full listing (D665); the answer belongs to + // this `@` token and its rows merge with the index's. + const fileQuery = + trigger?.mode === "file" && !dismissed && files?.truncated ? trigger.query : ""; + const searchScope = `${sessionId ?? ""}\n${workspaceKey}\n${triggerKey ?? ""}`; + useEffect(() => { + if (!fileQuery) return; + let cancelled = false; + const timer = setTimeout(() => { + void api + .fsSearch(sessionId, fileQuery) + .then((res) => { + if (!cancelled) setFound({ scope: searchScope, ...res }); + }) + .catch(() => { + // The index's rows still answer; the truncation note stays. + if (!cancelled) setFound({ scope: searchScope, entries: [], truncated: true }); + }); + }, FILE_SEARCH_DEBOUNCE_MS); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [fileQuery, searchScope, sessionId]); + // A later `@` at the same offset is a new token and must not inherit rows. + useEffect(() => { + if (!triggerKey) setFound(null); + }, [triggerKey]); + // A bare `@` lists the index alone; searched rows belong to a typed query. + const foundHere = fileQuery !== "" && found?.scope === searchScope ? found : null; + const fileTruncated = + (files?.truncated ?? false) && (!fileQuery || !foundHere || foundHere.truncated); + // Warm the `/` source once bootstrap settles, so the first slash of a session // does not wait for a cold host round trip (#1543). Waiting for `ready` keeps // the pre-warm off the pre-hydration empty workspace key, which would spend a @@ -265,8 +312,12 @@ export function useComposerAutocomplete({ if (trigger.mode === "slash") { return commands ? filterCommands(commands, trigger.query, trigger.tokenStart > 0) : []; } - return files ? filterFiles(files.entries, trigger.query) : []; - }, [trigger, dismissed, commands, files]); + if (!files) return []; + return filterFiles( + foundHere ? [...foundHere.entries, ...files.entries] : files.entries, + trigger.query, + ); + }, [trigger, dismissed, commands, files, foundHere]); // New query or mode restarts keyboard navigation at the top hit. const itemsKey = trigger ? `${trigger.mode}:${trigger.query}` : ""; @@ -322,7 +373,7 @@ export function useComposerAutocomplete({ hasItems: open && items.length > 0, highlight, setHighlight, - truncated: open && trigger?.mode === "file" ? (files?.truncated ?? false) : false, + truncated: open && trigger?.mode === "file" ? fileTruncated : false, noWorkspace: open && trigger?.mode === "file" && !hasWorkspace, close, accept, diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 85810d88a6..23af03e78b 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -1596,6 +1596,8 @@ export const api = { fsOpen: (path: string, mimeType?: string, sessionId?: string) => invoke(IPC.invoke.fsOpen, { path, mimeType, ...(sessionId ? { sessionId } : {}) }), fsIndex: (sessionId?: string) => invoke(IPC.invoke.fsIndex, { ...(sessionId ? { sessionId } : {}) }), + fsSearch: (sessionId: string | undefined, query: string) => + invoke(IPC.invoke.fsSearch, { ...(sessionId ? { sessionId } : {}), query }), /** * Complete a file reference from chat text to a real file (D320 follow-up). * The main process owns the root order — project, session scratch, diff --git a/apps/desktop/test/composer-deep-file-search-user-path.test.mjs b/apps/desktop/test/composer-deep-file-search-user-path.test.mjs new file mode 100644 index 0000000000..9aa64680be --- /dev/null +++ b/apps/desktop/test/composer-deep-file-search-user-path.test.mjs @@ -0,0 +1,258 @@ +/** + * Issue #1781: in a large tree the `@` menu could not reach deep files. + * + * The file index keeps only the shallowest 8000 entries, so a script deep in a + * Unity project matched nothing however much of its name was typed. This drives + * the composer pieces the app wires together — draft, completions, input, menu — + * against a truncated index and a main-process search the test answers itself. + */ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { repositoryRoot, resolveElectronBinary } from "../../../scripts/e2e/boot.mjs"; + +const root = repositoryRoot(); +const { build } = createRequire(join(root, "packages/agent-runtime/package.json"))("esbuild"); + +test("the search channel runs main's listing search and leaves remote sessions on their snapshot", async () => { + const [protocol, api, workspaceIpc] = await Promise.all([ + readFile(join(root, "packages/shared/src/protocol.ts"), "utf8"), + readFile(join(root, "apps/desktop/src/lib/api.ts"), "utf8"), + readFile(join(root, "apps/desktop/electron/main/ipc/workspace-ipc.ts"), "utf8"), + ]); + assert.match(protocol, /fsSearch: "pi-desktop\/fs\/search"/); + assert.match(api, /invoke\(IPC\.invoke\.fsSearch, \{ \.\.\.\(sessionId \? \{ sessionId \} : \{\}\), query \}\)/); + const handler = workspaceIpc.match(/handle\(IPC\.invoke\.fsSearch,[\s\S]*?\n {2}\}\);/)?.[0]; + assert.ok(handler, "main must register the search channel"); + assert.match(handler, /remoteFileWorkspace\(input\.sessionId\)\) return \{ entries: \[\], truncated: true \}/); + assert.match(handler, /return searchWorkspaceFiles\(root, String\(input\.query \?\? ""\)\)/); +}); + +const fixture = String.raw` +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import { catalogs, flattenCatalog } from "@pi-desktop/i18n"; +import { useComposerDraft } from "../../apps/desktop/src/features/chat/composer/hooks/useComposerDraft"; +import { useComposerCompletions } from "../../apps/desktop/src/features/chat/composer/hooks/useComposerCompletions"; +import { ComposerInput } from "../../apps/desktop/src/features/chat/composer/ComposerInput"; +import { ComposerAutocomplete } from "../../apps/desktop/src/components/ComposerAutocomplete"; +import { setEditorCaret } from "../../apps/desktop/src/features/chat/composer/editor"; +import { api } from "../../apps/desktop/src/lib/api"; +import { useAppStore } from "../../apps/desktop/src/stores/app-store"; + +const DEEP = "Assets/_Game/Scripts/Gameplay/Player/Controllers/FeatureController.cs"; +const SAVE = "Assets/_Game/Scripts/Systems/Save/SaveSlots.cs"; +const STALE = "Assets/Stale/SaveFeature.cs"; +// Short enough to rank inside the bare @ menu if a searched row leaked into it. +const SHORT = "a.cs"; +// The shallowest 8000 entries of a large tree; the deep scripts are not among them. +const shallow = Array.from({ length: 8000 }, (_, index) => ({ + path: "Assets/Art/Group" + Math.floor(index / 300) + "/asset_" + index + ".png", + kind: "file", +})); +let fileIndex = { entries: shallow, truncated: true }; +const searches = []; +api.fsIndex = async () => fileIndex; +api.fsSearch = (sessionId, query) => + new Promise((resolve, reject) => searches.push({ sessionId, query, resolve, reject })); + +await i18n.use(initReactI18next).init({ lng: "en", keySeparator: false, + resources: { en: { translation: flattenCatalog(catalogs.en) } }, + interpolation: { escapeValue: false } }); +useAppStore.setState({ activeSessionId: "s1", workspace: { path: "/large", name: "large" } }); + +const noop = () => {}; +let draft; +function Fixture() { + draft = useComposerDraft({ + variant: "docked", activeSessionId: "s1", workspacePath: "/large", + sessions: [{ id: "s1" }], composerPrefill: null, clearComposerPrefill: noop, + t: (key) => key, invalidateComposerTransforms: noop, inputBlocked: false, + }); + const completions = useComposerCompletions({ + value: draft.value, cursor: draft.cursor, composing: draft.composing, enabled: true, + referenceSessionId: "s1", fileReferencesRef: draft.fileReferencesRef, + applyEditorDraft: draft.applyEditorDraft, handleInput: draft.handleInput, + invalidateComposerTransforms: noop, + }); + return <> + false} + onInput={(source, caret) => completions.handleInput(source, caret)} + onCompositionStart={() => draft.setComposing(true)} + onCompositionEnd={() => draft.setComposing(false)} + onSettledInput={() => draft.setComposing(false)} onFocus={noop} onBlur={noop} /> + + ; +} + +const rootNode = createRoot(document.getElementById("root")); +const settle = async () => { + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + await new Promise((resolve) => setTimeout(resolve, 40)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); +}; +const editor = () => document.querySelector(".composer-input"); +const rows = () => [...document.querySelectorAll('[role="option"] .composer-ac-path')].map((row) => row.textContent); +const noteShown = () => document.querySelector(".composer-ac-truncated") !== null; +function check(condition, message) { if (!condition) throw new Error(message + " rows=" + JSON.stringify(rows().slice(0, 5))); } +async function until(predicate, message) { + for (let n = 0; n < 300; n += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + check(false, message); +} +async function type(text) { + const el = editor(); + el.textContent = text; + setEditorCaret(el, text.length); + await settle(); + el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: text })); + await settle(); +} + +window.deepFileSearchProbe = async () => { + flushSync(() => rootNode.render()); + await settle(); + editor().focus(); + + await type("@"); + await until(() => rows().length > 0, "the @ menu did not open"); + check(noteShown(), "a truncated index must say so"); + check(searches.length === 0, "an empty query must not search"); + + await type("@FeatureController"); + check(!rows().includes(DEEP), "the truncated index cannot know the deep file"); + await until(() => searches.length === 1, "the query was not searched after the pause"); + check(searches[0].query === "FeatureController" && searches[0].sessionId === "s1", + "searched the wrong query or session: " + searches[0].query); + searches[0].resolve({ entries: [{ path: DEEP, kind: "file" }], truncated: false }); + await until(() => rows().includes(DEEP), "the searched deep file did not appear"); + check(!noteShown(), "a complete search must clear the truncation note"); + check(searches.length === 1, "one pause must search once"); + + editor().dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await settle(); + check(draft.fileReferencesRef.current.some((reference) => reference.path === DEEP), + "accepting the row did not reference the deep file"); + + await type("@Feat"); + check(!rows().includes(DEEP), "a new @ token inherited the previous token's rows"); + await until(() => searches.length === 2, "the second query was not searched"); + await type("@Save"); + await until(() => searches.length === 3, "the third query was not searched"); + searches[2].resolve({ entries: [{ path: SAVE, kind: "file" }, { path: SHORT, kind: "file" }], truncated: false }); + await until(() => rows().includes(SAVE), "the current answer did not appear"); + searches[1].resolve({ entries: [{ path: STALE, kind: "file" }], truncated: false }); + await settle(); + check(!rows().includes(STALE), "an answer for an earlier query replaced the current one"); + + await type("@"); + await settle(); + check(!rows().includes(SHORT), "a bare @ listed rows searched for an earlier query"); + check(noteShown(), "a bare @ must report that the index alone is incomplete"); + + await type("@Missing"); + await until(() => searches.length === 4, "the incomplete query was not searched"); + searches[3].resolve({ entries: [], truncated: true }); + await settle(); + check(noteShown(), "an incomplete search must keep the truncation note"); + + await type("@Broken"); + await until(() => searches.length === 5, "the failing query was not searched"); + searches[4].reject(new Error("search failed")); + await settle(); + check(noteShown(), "a failed search must keep the truncation note"); + + fileIndex = { entries: [{ path: "README.md", kind: "file" }], truncated: false }; + useAppStore.setState({ workspace: { path: "/small", name: "small" } }); + await type(""); + await type("@nothing"); + await new Promise((resolve) => setTimeout(resolve, 400)); + check(searches.length === 5, "a complete index must not search"); + check(!noteShown(), "a complete index has no truncation note"); + + flushSync(() => rootNode.unmount()); + return { passed: true, queries: searches.map((search) => search.query) }; +}; +`; + +test("an @ query reaches deep files past a truncated file index", { + skip: process.platform === "linux" && !process.env.DISPLAY ? "Electron requires a display" : false, +}, async () => { + const temp = await mkdtemp(join(tmpdir(), "pi-deep-file-search-ui-")); + try { + await build({ + stdin: { contents: fixture, resolveDir: join(root, "scripts/e2e"), loader: "tsx" }, + outfile: join(temp, "renderer.js"), + bundle: true, + platform: "browser", + format: "esm", + jsx: "automatic", + define: { "process.env.NODE_ENV": '"production"' }, + alias: { + "@pi-desktop/i18n": join(root, "packages/i18n/src"), + "@pi-desktop/shared": join(root, "packages/shared/src"), + react: join(root, "apps/desktop/node_modules/react"), + "react-dom": join(root, "apps/desktop/node_modules/react-dom"), + i18next: join(root, "apps/desktop/node_modules/i18next"), + "react-i18next": join(root, "apps/desktop/node_modules/react-i18next"), + }, + nodePaths: [join(root, "apps/desktop/node_modules")], + }); + await writeFile(join(temp, "index.html"), + '
'); + await writeFile(join(temp, "main.cjs"), ` +const { app, BrowserWindow } = require("electron"); +const path = require("node:path"); +app.setPath("userData", path.join(__dirname, "profile")); +app.whenReady().then(async () => { + const win = new BrowserWindow({ show: false, width: 900, height: 700, + webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } }); + win.webContents.on("console-message", (event) => console.error(event.message)); + try { + await win.loadFile(path.join(__dirname, "index.html")); + console.log("DEEP_FILE_SEARCH_PROBE " + JSON.stringify(await win.webContents.executeJavaScript("window.deepFileSearchProbe()"))); + app.exit(0); + } catch (error) { console.error(error?.stack ?? error); app.exit(1); } +});`); + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + const child = spawn(resolveElectronBinary(root).electronBinary, [join(temp, "main.cjs")], { + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + for (const stream of [child.stdout, child.stderr]) { + stream.setEncoding("utf8"); + stream.on("data", (chunk) => { output += chunk; }); + } + const timer = setTimeout(() => child.kill("SIGKILL"), 60_000); + let code; + try { + code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + } finally { + clearTimeout(timer); + } + assert.equal(code, 0, output.slice(-6000)); + const line = output.split(/\r?\n/).find((item) => item.startsWith("DEEP_FILE_SEARCH_PROBE ")); + assert.ok(line, output.slice(-6000)); + const result = JSON.parse(line.slice("DEEP_FILE_SEARCH_PROBE ".length)); + assert.equal(result.passed, true); + assert.deepEqual(result.queries, ["FeatureController", "Feat", "Save", "Missing", "Broken"]); + } finally { + await rm(temp, { recursive: true, force: true }); + } +}); diff --git a/docs/adr/0024-composer-commands-and-file-references.md b/docs/adr/0024-composer-commands-and-file-references.md index cfdf9958f4..391f8c3b05 100644 --- a/docs/adr/0024-composer-commands-and-file-references.md +++ b/docs/adr/0024-composer-commands-and-file-references.md @@ -62,6 +62,9 @@ exported by the installed package and directly reusable. fallback, short TTL cache); `pi-desktop/composer/commands` returns the merged command list. Both fail soft without a workspace. These Electron-only channels do not change the host RPC protocol version. + *(Amended by D665: while that index is truncated, a typed query is also + answered from the full listing by the read-only `pi-desktop/fs/search`, + because the shallow-first cap leaves deep files of a large tree out.)* ## Alternatives considered diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md index 64c0fa9051..6b07edd635 100644 --- a/docs/spec/03-runtime/01-ipc-protocol.md +++ b/docs/spec/03-runtime/01-ipc-protocol.md @@ -2153,7 +2153,7 @@ reservation, and background artifacts cannot change visible window geometry. ## 13c. Composer input APIs (D123/D124/D197, ADR 0024/0059) Electron-only channels backing composer autocomplete and file references. -`composer/commands` and `fs/index` are read-only and fail soft; +`composer/commands`, `fs/index`, and `fs/search` are read-only and fail soft; `composer/pickFiles` opens the unified native picker used by the Composer and returns a one-shot token; the legacy `composer/pickPhotos` channel remains available for compatibility but is not exposed by the Composer UI. @@ -2207,7 +2207,28 @@ Workspace-rooted relative paths for the `@` menu: `git ls-files -co --exclude-standard` fast path, ignore-set recursive walk fallback, directories derived from file paths, 8000-entry cap with `truncated: true`, short TTL cache per root. Fails closed to an empty list without a -workspace. Fuzzy filtering happens renderer-side. +workspace. Fuzzy filtering happens renderer-side; a truncated index keeps +only the shallowest entries, so a query against it is also sent to +`fs/search`. + +### fs/search + +```ts +fs/search({ sessionId?, query }) -> { entries: FsIndexEntry[]; truncated: boolean } +``` + +The `@` menu calls this only while its `fs/index` snapshot is truncated and +the query is non-empty, after a short typing pause, then merges the answer +with its own matches (D665). Main ranks the query with the renderer's fuzzy +scorer over the root's full listing and returns the best 50 entries. The +listing is the `git ls-files` list behind the index, or — when the index came +from the walk — a deeper ignore-set walk of at most 200,000 entries or two +seconds. It shares the index's TTL cache and is released when that expires. +`truncated: true` means the search itself was capped. The query is trimmed and +bounded to 256 characters; an empty query returns no entries. A remote session +returns `{ entries: [], truncated: true }` without contacting its host — the +remote file protocol has no search operation — so the menu keeps filtering +the remote snapshot alone. ### composer/pickFiles and composer/pickPhotos diff --git a/docs/spec/04-ux/08-component-spec.md b/docs/spec/04-ux/08-component-spec.md index 82b7fba024..8f1f78ef5b 100644 --- a/docs/spec/04-ux/08-component-spec.md +++ b/docs/spec/04-ux/08-component-spec.md @@ -3454,9 +3454,13 @@ Anatomy: at the caret — the same sentinel-backed chip as a pasted file — whose canonical value is the original `entry.path`; the menu closes and that Enter does not send. Accepting a directory keeps the literal path in the draft so - completion can continue. Entries come from `fs/index` (D124, D209, D362). A - truncation footnote appears when the index is capped; without a workspace the - menu shows an "open a project" empty state. + completion can continue. Entries come from `fs/index` (D124, D209, D362). + When that index is capped, a typed query is also searched over the whole + listing through `fs/search` once typing pauses, and the found rows merge + with the local matches (D665). A truncation footnote appears while the list + may be incomplete — the bare `@`, until the first search for the token + answers, and when a search is itself capped or unavailable, as in remote + sessions; without a workspace the menu shows an "open a project" empty state. - Accepting commands and directories inserts text (`/name ` / `@dir/`); accepting a completed file inserts the inline chip rather than deleting the trigger. Immediately diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 5b00986fae..69f9b00f21 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -8764,6 +8764,40 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. layout covered by `scripts/e2e-composer-autocomplete.mjs`; full UI journey Draft (run only in a capable environment when this surface changes) +#### E2E-COMPOSER-deep-file-mentions: `@` reaches deep files in a large workspace + +- **Preconditions**: The app is running with an Agent session in a workspace + whose index is truncated (more than 8000 files and directories), with a + script several folders below thousands of shallow assets — for example a + Unity project's `Assets/_Game/Scripts/Gameplay/Player/FeatureController.cs`. + Run once with the workspace under git and once without. +- **Steps**: 1) Type `@` and inspect the footnote. 2) Type the script's name + and pause. 3) Accept the script's row with Enter. 4) Edit the query quickly + through several prefixes. 5) Repeat in a small workspace and in a remote + session. +- **Expected**: + - The bare `@` lists the index and shows the truncation footnote. + - After the pause the deep script appears as a row, ranked like any other + match, and the footnote disappears once that search has answered; files + excluded by `.gitignore` or the ignore set never appear. + - Accepting it inserts the same file chip as any other reference. + - Rows never belong to an earlier query. The menu keeps the local matches + while a search is pending. + - A small workspace issues no search request; a remote session keeps its + snapshot and footnote. +- **Specs linked**: `04-ux/08-component-spec.md` §11.8, + `03-runtime/01-ipc-protocol.md` §13c, `08-meta/decisions-log.md` (D124, + D665), ADR 0024 +- **Acceptance**: C (conversation & stream), Quality +- **Milestone**: M5 +- **Status**: Unit-covered (`packages/host-runtime/src/fs-index.test.ts` + covers the git and walk listings, ignore rules, directories, and the result + limit); the renderer journey — search after the pause, merge, accept, stale + answers, the footnote, and the complete-index path — is covered by + `apps/desktop/test/composer-deep-file-search-user-path.test.mjs` in a real + Electron renderer. The full desktop journey against a large real tree is + Draft (run only in a capable environment when this surface changes). + #### E2E-102b: Unanswered Stop restores compact file-reference drafts - **Preconditions**: An Agent session can delay its first assistant event. The @@ -10227,6 +10261,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. | C / Quality / Security (Conversation MP4 attachments) | E2E-CHAT-mp4-attachment-opens-in-system-player | | G — Plugins (Chat file references) | E2E-CHAT-file-ref-opens-the-surface-that-owns-it, E2E-PLUGIN-file-view-collapse-persists | | Quality (Chat file references) | E2E-CHAT-shorthand-file-ref-opens-the-matching-file, E2E-CHAT-file-ref-opens-the-surface-that-owns-it, E2E-PLUGIN-file-view-collapse-persists | +| C / Quality (Composer deep file mentions) | E2E-COMPOSER-deep-file-mentions | | G — Plugins (project folder roots) | E2E-PLUGIN-file-view-switches-folder-per-project | | Security (project folder roots) | E2E-PLUGIN-file-view-switches-folder-per-project | | Quality (project folder roots) | E2E-PLUGIN-file-view-switches-folder-per-project | @@ -10264,6 +10299,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. | M6+ (Windows updater cache) | E2E-260 | | M6+ (Independent session communication) | E2E-SESSION-independent-top-level-communication, E2E-SESSION-hover-card-model-and-links | | M5 (Chat file references) | E2E-CHAT-shorthand-file-ref-opens-the-matching-file, E2E-CHAT-file-ref-opens-the-surface-that-owns-it | +| M5 (Composer deep file mentions) | E2E-COMPOSER-deep-file-mentions | | M5 (Conversation MP4 attachments) | E2E-CHAT-mp4-attachment-opens-in-system-player | | M6+ (Chat file references) | E2E-PLUGIN-file-view-collapse-persists | | M6+ (project folder roots) | E2E-PLUGIN-file-view-switches-folder-per-project | diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index 057e6fc9af..757538dbf8 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -72,6 +72,7 @@ This log freezes previously open questions into concrete decisions. | D021 | First-run onboarding | **Inline checklist (not modal wizard)** | | D022 | Local telemetry | **Local logs only in MVP (no remote telemetry)** | | D664 | Sidebar running activity defaults | **When unset, the master and Count badge resolve on while Activity rail resolves off. Explicit saved values remain authoritative. The master continues gating both affordances; turning it off does not rewrite either child preference. No schema or migration change.** | +| D665 | `@` queries search past a truncated file index | **Amend D124: while the 8000-entry `fs/index` snapshot is truncated, a non-empty `@` query is also searched over the root's full listing through the read-only Electron channel `pi-desktop/fs/search` once typing pauses, and its best 50 rows merge with the local matches. A complete index never searches. Remote sessions keep their snapshot. No host RPC, storage, or schema change.** | ## C. Provider & model coverage decisions (0.3.4) @@ -7778,3 +7779,26 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. default. Settings reads apply these defaults through renderer API normalization; no persisted schema or migration change is required. See `04-ux/01-ui-ia.md`, `04-ux/06-settings-ia.md`, and E2E-266. + +## 2026-10-19 — `@` queries search past a truncated file index (D665) + +- The `fs/index` snapshot keeps the shallowest 8000 entries, and the menu + fuzzy-filtered only that snapshot. In a large tree — a Unity project, where + every asset has a `.meta` sibling — scripts several folders deep were never + in it, so no query could reach them even though the truncation footnote + invited refining the query (issue #1781). +- While the snapshot is truncated, a non-empty query is now also sent to + `fs/search` after a 150 ms typing pause. Main ranks it with the renderer's + scorer over the listing it already built for the index (`git ls-files`), or + over a deeper ignore-set walk (at most 200,000 entries or two seconds) when + the index came from the walk, and returns the best 50 rows. The menu merges + them with its local matches; an answer for an earlier query is dropped. +- A complete index never searches, so small projects behave as before. The + footnote stays while the list may be incomplete. Remote sessions keep their + snapshot because the remote file protocol has no search operation. Bundling + a native lister (ripgrep, bfs) was not needed: git already lists the whole + repository, and searching 60,000 paths takes a few milliseconds. +- Covered by `packages/host-runtime/src/fs-index.test.ts` and + `apps/desktop/test/composer-deep-file-search-user-path.test.mjs`. See + `03-runtime/01-ipc-protocol.md` §13c, `04-ux/08-component-spec.md` §11.8, + and E2E-COMPOSER-deep-file-mentions. 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 79a7716a22..10c9a8395e 100644 --- a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md +++ b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md @@ -1618,7 +1618,7 @@ Electron 报告的右侧角)改变的是面板目标。Main 通过 ## 13c. Composer 输入 API(D123/D124/D197、ADR 0024/0059) 仅电子通道支持输入框自动完成和剪贴板文件 -参考。 `composer/commands` 和 `fs/index` 是只读且软故障; +参考。 `composer/commands`、`fs/index` 和 `fs/search` 是只读且软故障; `composer/pasteFiles` 仅写入原始会话的 Electron 拥有的 暂存目录。 None 添加主机 RPC 方法或更改主机协议 版本。 @@ -1664,7 +1664,23 @@ type FsIndexEntry = { path: string; kind: "file" | "dir" }; --exclude-standard`快速路径,忽略设置递归行走回退, 从文件路径派生的目录,8000 个条目上限,`truncated: true`, 每个根的短 TTL 缓存。无法关闭到空列表而没有 -工作区。模糊过滤发生在渲染器端。 +工作区。模糊过滤发生在渲染器端;被截断的索引只保留最浅的条目,因此针对它的 +查询还会发给 `fs/search`。 + +### fs/search + +```ts +fs/search({ sessionId?, query }) -> { entries: FsIndexEntry[]; truncated: boolean } +``` + +只有当 `fs/index` 快照被截断且查询非空时,`@` 菜单才会在输入短暂停顿后调用它, +并把结果与自己的匹配合并(D665)。主进程用与渲染器相同的模糊打分,在该根目录的 +完整清单上排序,返回最好的 50 个条目。清单就是索引背后的 `git ls-files` 列表; +若索引来自目录遍历,则改为一次更深的遍历(遵守同一忽略集,最多 200,000 个条目或 +2 秒)。清单与索引共用 TTL 缓存,过期即释放。`truncated: true` 表示搜索本身被截断。 +查询会去掉首尾空白并限制在 256 个字符内;空查询不返回条目。远程会话直接返回 +`{ entries: [], truncated: true }`,不联系远程主机——远程文件协议没有搜索操作—— +菜单继续只在远程快照里过滤。 ### composer/pickFiles 和 composer/pickPhotos 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 07d75aa4ea..4afd8c37cc 100644 --- a/docs/zh-CN/spec/04-ux/08-component-spec.md +++ b/docs/zh-CN/spec/04-ux/08-component-spec.md @@ -2290,8 +2290,11 @@ Live Voice 受现有开发者模式和开发构建门控约束,包含四种展 (Enter、Tab 或点击)会把 `@` 标记替换为光标处的行内叶名芯片 —— 与粘贴文件相同的哨兵芯片 —— 其规范值为原始 `entry.path`; 菜单关闭且这次 Enter 不会发送。接受目录会将文字路径保留在草稿中, - 以便完成可以继续。条目来自 `fs/index`(D124、D209、D362)。一个 - 当索引被限制时出现截断脚注;没有工作空间 + 以便完成可以继续。条目来自 `fs/index`(D124、D209、D362)。 + 索引被截断时,输入停顿后查询还会通过 `fs/search` 在完整清单中搜索, + 搜到的行与本地匹配合并(D665)。列表可能不完整时显示截断脚注—— + 只输入 `@` 时、该标记的第一次搜索返回之前,以及搜索本身被截断或不可用 + (如远程会话)时;没有工作空间 菜单显示“打开项目”空状态。 - 接受命令和目录插入文本(`/name ` / `@dir/`); 接受完成的文件会插入行内芯片,而不是删除触发符。立即 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 1bf5205a08..086d23b4e3 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 @@ -5288,6 +5288,32 @@ eleven-tool-round desktop paths are verified by 布局由 `scripts/e2e-composer-autocomplete.mjs` 覆盖;完整的用户界面 旅程草稿(适用变更合入前需在具备条件的环境中运行 E2E) +#### E2E-COMPOSER-deep-file-mentions:`@` 能找到大型工作区里的深层文件 + +- **先决条件**:应用程序正在一个索引被截断(超过 8000 个文件和目录)的工作区中使用 + Agent 会话运行,其中有一个脚本位于数千个浅层资源之下的几层目录里——例如 Unity + 项目的 `Assets/_Game/Scripts/Gameplay/Player/FeatureController.cs`。工作区在 git + 管理下和不在 git 管理下各运行一次。 +- **步骤**:1) 输入 `@` 并检查脚注。2) 输入脚本名称后停顿。3) 用 Enter 接受该脚本的行。 + 4) 快速把查询改成几个不同的前缀。5) 在小工作区和远程会话中重复。 +- **预期**: + - 只输入 `@` 时列出索引并显示截断脚注。 + - 停顿后深层脚本作为一行出现,排序与其他匹配一致;这次搜索返回后脚注消失;被 + `.gitignore` 或忽略集排除的文件从不出现。 + - 接受后插入与其他引用相同的文件芯片。 + - 行永远不属于更早的查询。搜索进行中菜单保留本地匹配。 + - 小工作区不发出搜索请求;远程会话保持使用快照并保留脚注。 +- **链接规格**:`04-ux/08-component-spec.md` §11.8、`03-runtime/01-ipc-protocol.md` + §13c、`08-meta/decisions-log.md`(D124、D665)、ADR 0024 +- **接受**:C(对话和直播),质量 +- **里程碑**:M5 +- **状态**:单元覆盖(`packages/host-runtime/src/fs-index.test.ts` 覆盖 git 与遍历两种 + 清单、忽略规则、目录以及结果上限);渲染层旅程——停顿后搜索、合并、接受、过期 + 结果、脚注以及完整索引路径——由 + `apps/desktop/test/composer-deep-file-search-user-path.test.mjs` 在真实 Electron + 渲染器中覆盖。针对大型真实目录树的完整桌面旅程为草稿(适用变更合入前需在具备 + 条件的环境中运行 E2E)。 + #### E2E-102b:未答复的停止恢复紧凑的文件参考草稿 - **先决条件**:Agent 会话可以延迟其第一个辅助事件。的 @@ -5806,6 +5832,7 @@ eleven-tool-round desktop paths are verified by | C / 质量 / 安全(对话 MP4 附件) | E2E-CHAT-mp4-attachment-opens-in-system-player | | G——插件(聊天文件引用) | E2E-CHAT-file-ref-opens-the-surface-that-owns-it、E2E-PLUGIN-file-view-collapse-persists | | 品质(聊天文件引用) | E2E-CHAT-shorthand-file-ref-opens-the-matching-file、E2E-CHAT-file-ref-opens-the-surface-that-owns-it、E2E-PLUGIN-file-view-collapse-persists | +| C / 品质(Composer 深层文件引用) | E2E-COMPOSER-deep-file-mentions | | G——插件(项目文件夹根) | E2E-PLUGIN-file-view-switches-folder-per-project | | 安全性(项目文件夹根) | E2E-PLUGIN-file-view-switches-folder-per-project | | 品质(项目文件夹根) | E2E-PLUGIN-file-view-switches-folder-per-project | @@ -5860,6 +5887,7 @@ eleven-tool-round desktop paths are verified by | M6+(Windows 更新缓存) | E2E-260 | | M6+(独立会话通信) | E2E-SESSION-independent-top-level-communication、E2E-SESSION-hover-card-model-and-links | | M5(聊天文件引用) | E2E-CHAT-shorthand-file-ref-opens-the-matching-file、E2E-CHAT-file-ref-opens-the-surface-that-owns-it | +| M5(Composer 深层文件引用) | E2E-COMPOSER-deep-file-mentions | | M5(对话 MP4 附件) | E2E-CHAT-mp4-attachment-opens-in-system-player | | M6+(聊天文件引用) | E2E-PLUGIN-file-view-collapse-persists | | M6+(项目文件夹根) | E2E-PLUGIN-file-view-switches-folder-per-project | diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index c5792d5581..1b25b35aa3 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -74,6 +74,7 @@ | D021 | 首次运行新手引导 | **内联清单(不是模式向导)** | | D022 | 本地遥测 | **仅在 MVP 中记录本地日志(无远程遥测)** | | D664 | 侧边栏运行活动默认值 | **未设置时,总开关和数量徽标默认开启,活动流光默认关闭。已保存的值优先生效。总开关仍控制两种提示;关闭总开关不会改写任一子开关偏好。不更改 schema 或执行迁移。** | +| D665 | `@` 查询越过被截断的文件索引 | **修订 D124:8000 条的 `fs/index` 快照被截断时,非空的 `@` 查询会在输入停顿后,通过只读的 Electron 通道 `pi-desktop/fs/search` 在该根目录的完整清单中搜索,最好的 50 行与本地匹配合并。完整的索引从不搜索。远程会话保持使用快照。不改变主机 RPC、存储或 schema。** | ## C. 提供商和模型覆盖决策 (0.3.4) @@ -5488,3 +5489,20 @@ Markdown 源码,不是 `text/html` 负载;对禁用行内 HTML 的外部编 - 数量徽标保留运行状态的可发现性,同时避免动画轨道成为默认界面。不需要更改持久化 schema 或迁移。设置读取通过渲染层 API 规范化应用这些默认值。见 `04-ux/01-ui-ia.md`、`04-ux/06-settings-ia.md` 与 E2E-266。 + +## 2026-10-19 —— `@` 查询越过被截断的文件索引(D665) + +- `fs/index` 快照只保留最浅的 8000 个条目,菜单也只在这份快照里模糊过滤。在大项目里 + ——例如每个资源都带一个 `.meta` 文件的 Unity 项目——藏在几层目录下的脚本根本不在 + 快照中,无论怎么输入都找不到,尽管截断脚注提示用户继续输入(issue #1781)。 +- 快照被截断时,非空查询现在会在输入停顿 150 ms 后同时发给 `fs/search`。主进程用与 + 渲染器相同的打分,在它为索引已经构建好的清单(`git ls-files`)上排序;若索引来自 + 目录遍历,则改为一次更深的遍历(遵守同一忽略集,最多 200,000 个条目或 2 秒), + 返回最好的 50 行。菜单把它们与本地匹配合并;较早查询的结果会被丢弃。 +- 完整的索引从不搜索,小项目行为不变。列表可能不完整时截断脚注保留。远程会话继续 + 使用快照,因为远程文件协议没有搜索操作。不需要打包原生列文件工具(ripgrep、bfs): + git 本来就能列出整个仓库,搜索 60,000 个路径只需几毫秒。 +- 由 `packages/host-runtime/src/fs-index.test.ts` 与 + `apps/desktop/test/composer-deep-file-search-user-path.test.mjs` 覆盖。见 + `03-runtime/01-ipc-protocol.md` §13c、`04-ux/08-component-spec.md` §11.8 与 + E2E-COMPOSER-deep-file-mentions。 diff --git a/packages/host-runtime/src/fs-index.test.ts b/packages/host-runtime/src/fs-index.test.ts new file mode 100644 index 0000000000..ffe96c1d0b --- /dev/null +++ b/packages/host-runtime/src/fs-index.test.ts @@ -0,0 +1,87 @@ +import { spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterAll, expect, it } from "vitest"; +import { + FS_INDEX_MAX_ENTRIES, + getWorkspaceFileIndex, + searchWorkspaceFiles, +} from "./fs-index.js"; + +const DEEP_SCRIPT = "Assets/_Game/Scripts/Gameplay/Player/Controllers/FeatureController.cs"; + +const temps: string[] = []; +afterAll(async () => { + await Promise.all(temps.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function writeFiles(root: string, paths: string[]): Promise { + for (let at = 0; at < paths.length; at += 500) { + await Promise.all( + paths.slice(at, at + 500).map(async (path) => { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), "x"); + }), + ); + } +} + +/** A Unity-like tree: more shallow assets than the snapshot holds, one deep script. */ +async function largeTree(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)); + temps.push(root); + const assets = Array.from( + { length: FS_INDEX_MAX_ENTRIES + 100 }, + (_, index) => `Assets/Art/Group${Math.floor(index / 300)}/asset_${index}.png`, + ); + await writeFiles(root, [...assets, DEEP_SCRIPT, "node_modules/pkg/FeatureController.cs"]); + return root; +} + +function gitAvailable(): boolean { + return spawnSync("git", ["--version"]).status === 0; +} + +it("finds a deep file that the truncated snapshot leaves out", async () => { + const root = await largeTree("pi-fs-search-walk-"); + + const index = await getWorkspaceFileIndex(root); + expect(index.truncated).toBe(true); + expect(index.entries).toHaveLength(FS_INDEX_MAX_ENTRIES); + expect(index.entries.some((entry) => entry.path === DEEP_SCRIPT)).toBe(false); + + const found = await searchWorkspaceFiles(root, "FeatureController"); + expect(found.truncated).toBe(false); + expect(found.entries[0]).toEqual({ path: DEEP_SCRIPT, kind: "file" }); + expect(found.entries.some((entry) => entry.path.startsWith("node_modules/"))).toBe(false); +}); + +it("searches a git repository's full listing and keeps its ignore rules", async (ctx) => { + if (!gitAvailable()) ctx.skip(); + const root = await largeTree("pi-fs-search-git-"); + await writeFiles(root, ["Generated/FeatureController.cs"]); + await writeFile(join(root, ".gitignore"), "Generated/\n"); + expect(spawnSync("git", ["init", "-q"], { cwd: root }).status).toBe(0); + + const index = await getWorkspaceFileIndex(root); + expect(index.truncated).toBe(true); + expect(index.entries.some((entry) => entry.path === DEEP_SCRIPT)).toBe(false); + + const found = await searchWorkspaceFiles(root, "FeatureController"); + expect(found.truncated).toBe(false); + expect(found.entries.map((entry) => entry.path)).toEqual([DEEP_SCRIPT]); +}); + +it("returns directories and honors the result limit", async () => { + const root = await largeTree("pi-fs-search-limit-"); + + const dirs = await searchWorkspaceFiles(root, "Controllers"); + expect(dirs.entries[0]).toEqual({ + path: "Assets/_Game/Scripts/Gameplay/Player/Controllers", + kind: "dir", + }); + + const limited = await searchWorkspaceFiles(root, "asset_", 5); + expect(limited.entries).toHaveLength(5); +}); diff --git a/packages/host-runtime/src/fs-index.ts b/packages/host-runtime/src/fs-index.ts index 5f4673a280..5a63abab27 100644 --- a/packages/host-runtime/src/fs-index.ts +++ b/packages/host-runtime/src/fs-index.ts @@ -1,7 +1,12 @@ import { spawn } from "node:child_process"; import { readdir } from "node:fs/promises"; import { join } from "node:path"; -import type { FsIndexEntry, FsIndexResult } from "@pi-desktop/shared"; +import { + fuzzyMatchPath, + selectBestMatches, + type FsIndexEntry, + type FsIndexResult, +} from "@pi-desktop/shared"; import { isIgnoredName } from "./workspace-files.js"; /** @@ -10,16 +15,33 @@ import { isIgnoredName } from "./workspace-files.js"; * Served from the main process like the files tab (ADR 0019): user-driven * browsing must not round-trip agent tools or permission prompts. The index * prefers `git ls-files` for exact gitignore semantics and falls back to a - * bounded walk over the panel's ignore set. Fuzzy filtering happens in the - * renderer; this only snapshots relative paths. + * bounded walk over the panel's ignore set. The renderer fuzzy-filters the + * snapshot itself; when the snapshot is truncated, a query is also answered + * here from the full listing (D665). */ export const FS_INDEX_MAX_ENTRIES = 8000; +/** Most entries a query searches when the snapshot is truncated. */ +const FS_SEARCH_MAX_ENTRIES = 200_000; +/** The composer menu shows at most this many file rows. */ +const FS_SEARCH_MAX_RESULTS = 50; +/** `fuzzyMatchPath` lowercases the query once per entry. */ +const FS_SEARCH_MAX_QUERY = 256; +const SEARCH_WALK_TIMEOUT_MS = 2000; const CACHE_TTL_MS = 15_000; const GIT_TIMEOUT_MS = 4000; const MAX_GIT_BUFFER = 32 * 1024 * 1024; -type CacheSlot = { at: number; result: FsIndexResult }; +/** Shallow-first entries; `complete` is false when a cap or timeout cut them short. */ +type Listing = { entries: FsIndexEntry[]; complete: boolean }; +type CacheSlot = { + at: number; + index: FsIndexResult; + listing: Listing; + /** The listing came from the snapshot-sized walk, so a query may deepen it. */ + walked: boolean; + deeper?: Promise; +}; const cache = new Map(); function hasIgnoredSegment(path: string): boolean { @@ -66,13 +88,18 @@ function gitListFiles(root: string): Promise { async function walkFiles( root: string, budget: number, + deadline = Number.POSITIVE_INFINITY, ): Promise<{ files: string[]; dirs: string[]; exhausted: boolean }> { const files: string[] = []; const dirs: string[] = []; let exhausted = false; const queue: string[] = [""]; - while (queue.length > 0) { - const rel = queue.shift()!; + for (let next = 0; next < queue.length; next += 1) { + if (Date.now() > deadline) { + exhausted = true; + break; + } + const rel = queue[next]!; let dirents; try { dirents = await readdir(join(root, rel), { withFileTypes: true }); @@ -124,46 +151,57 @@ function compareEntries(a: FsIndexEntry, b: FsIndexEntry): number { return a.path.localeCompare(b.path); } -async function buildIndex(root: string): Promise { - const viaGit = await gitListFiles(root); - let files: string[]; - let dirs: string[]; - let exhausted = false; - if (viaGit) { - // -o lists untracked-but-not-ignored files; repos without a .gitignore - // would still surface build output, so the panel ignore set applies too. - files = viaGit.filter((p) => !hasIgnoredSegment(p)); - dirs = dirsFromFilePaths(files); - } else { - const walked = await walkFiles(root, FS_INDEX_MAX_ENTRIES + 1); - files = walked.files; - dirs = walked.dirs; - exhausted = walked.exhausted; - } - +function toListing(files: string[], dirs: string[], complete: boolean): Listing { const entries: FsIndexEntry[] = [ ...dirs.map((path): FsIndexEntry => ({ path, kind: "dir" })), ...files.map((path): FsIndexEntry => ({ path, kind: "file" })), ].sort(compareEntries); + if (entries.length > FS_SEARCH_MAX_ENTRIES) { + return { entries: entries.slice(0, FS_SEARCH_MAX_ENTRIES), complete: false }; + } + return { entries, complete }; +} - if (entries.length > FS_INDEX_MAX_ENTRIES) { - return { entries: entries.slice(0, FS_INDEX_MAX_ENTRIES), truncated: true }; +async function walkListing(root: string, budget: number, deadline?: number): Promise { + const walked = await walkFiles(root, budget, deadline); + return toListing(walked.files, walked.dirs, !walked.exhausted); +} + +async function buildSlot(root: string): Promise { + const viaGit = await gitListFiles(root); + let listing: Listing; + if (viaGit) { + // -o lists untracked-but-not-ignored files; repos without a .gitignore + // would still surface build output, so the panel ignore set applies too. + const files = viaGit.filter((p) => !hasIgnoredSegment(p)); + listing = toListing(files, dirsFromFilePaths(files), true); + } else { + listing = await walkListing(root, FS_INDEX_MAX_ENTRIES + 1); } - return { entries, truncated: exhausted }; + const { entries, complete } = listing; + const index = + entries.length > FS_INDEX_MAX_ENTRIES + ? { entries: entries.slice(0, FS_INDEX_MAX_ENTRIES), truncated: true } + : { entries, truncated: !complete }; + return { at: Date.now(), index, listing, walked: !viaGit }; } -/** TTL-cached workspace index; concurrent calls share one build. */ -const pending = new Map>(); +/** TTL-cached workspace listing; concurrent calls share one build. */ +const pending = new Map>(); -export async function getWorkspaceFileIndex(root: string): Promise { +function slotFor(root: string): Promise { const cached = cache.get(root); - if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.result; + if (cached && Date.now() - cached.at < CACHE_TTL_MS) return Promise.resolve(cached); const inFlight = pending.get(root); if (inFlight) return inFlight; - const build = buildIndex(root) - .then((result) => { - cache.set(root, { at: Date.now(), result }); - return result; + const build = buildSlot(root) + .then((slot) => { + cache.set(root, slot); + // A large tree's listing is held for the TTL only, not until the next build. + setTimeout(() => { + if (cache.get(root) === slot) cache.delete(root); + }, CACHE_TTL_MS).unref(); + return slot; }) .finally(() => { pending.delete(root); @@ -171,3 +209,46 @@ export async function getWorkspaceFileIndex(root: string): Promise { + return (await slotFor(root)).index; +} + +/** + * The best `limit` entries for an "@" query over the whole listing, ranked + * like the renderer ranks the snapshot. A truncated snapshot keeps the + * shallowest entries, so deep files of a large tree can only be found here + * (#1781). A listing from the snapshot-sized walk is deepened once per slot. + */ +export async function searchWorkspaceFiles( + root: string, + query: string, + limit = FS_SEARCH_MAX_RESULTS, +): Promise { + const needle = query.trim().slice(0, FS_SEARCH_MAX_QUERY); + if (!needle) return { entries: [], truncated: false }; + const slot = await slotFor(root); + const listing = + slot.walked && !slot.listing.complete + ? await (slot.deeper ??= walkListing( + root, + FS_SEARCH_MAX_ENTRIES, + Date.now() + SEARCH_WALK_TIMEOUT_MS, + )) + : slot.listing; + const best = selectBestMatches(matchEntries(listing.entries, needle), limit, (hit) => ({ + score: hit.score, + text: hit.entry.path, + })); + return { entries: best.map((hit) => hit.entry), truncated: !listing.complete }; +} + +function* matchEntries( + entries: FsIndexEntry[], + query: string, +): Generator<{ entry: FsIndexEntry; score: number }> { + for (const entry of entries) { + const match = fuzzyMatchPath(query, entry.path, entry.kind); + if (match) yield { entry, score: match.score }; + } +} diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 7635845243..761fcb4bbc 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -417,6 +417,7 @@ export const IPC = { fsReveal: "pi-desktop/fs/reveal", fsOpen: "pi-desktop/fs/open", fsIndex: "pi-desktop/fs/index", + fsSearch: "pi-desktop/fs/search", fsResolveRef: "pi-desktop/fs/resolveRef", windowSetWorkPanelReservation: "pi-desktop/window/setWorkPanelReservation", From 399dd26e5d5df63d2817d8be05058087a716d3fa Mon Sep 17 00:00:00 2001 From: blackEvil217 <1278844978@qq.com> Date: Mon, 19 Oct 2026 13:04:21 -0700 Subject: [PATCH 06/44] fix(work-panel): keep the active tab's close button inside the strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The close button is a sibling of the tab button inside the tab wrapper, but scroll-into-view targeted only the button. With `inline: "nearest"` a button that is already visible scrolls zero pixels, so the sibling close button stayed clipped outside the strip's overflow edge — the tab looked like it had no close control at all (issue #1762). Scroll the wrapper instead (falling back to the button), and reserve the close button's width at the inline end via `scroll-padding-inline` so sub-pixel rounding cannot reintroduce the clip. --- .../src/components/workpanel/WorkPanel.tsx | 41 ++++ apps/desktop/src/styles/work-panel.css | 5 +- apps/desktop/test/work-panel.test.mjs | 19 ++ package.json | 1 + scripts/e2e-work-panel-tab-close.mjs | 122 ++++++++++ scripts/e2e/work-panel-tab-close.tsx | 217 ++++++++++++++++++ 6 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 scripts/e2e-work-panel-tab-close.mjs create mode 100644 scripts/e2e/work-panel-tab-close.tsx diff --git a/apps/desktop/src/components/workpanel/WorkPanel.tsx b/apps/desktop/src/components/workpanel/WorkPanel.tsx index 1802a1ec92..8d4280773c 100644 --- a/apps/desktop/src/components/workpanel/WorkPanel.tsx +++ b/apps/desktop/src/components/workpanel/WorkPanel.tsx @@ -312,8 +312,31 @@ function LocalWorkPanel({ setNativeSurfaceReadyForExit(true); }, [exiting]); + /** + * Reveal one tab's full box — the wrapper, not the button alone. The close + * control is a 24px sibling inside that wrapper, so scrolling only the button + * leaves the close control clipped by the strip's overflow edge (issue + * #1762). The button is the fallback for a tab rendered without a wrapper. + */ + const revealTab = useCallback((tabId: string) => { + tabWrapperRefs.current[tabId]?.scrollIntoView({ + block: "nearest", + inline: "nearest", + }); + tabButtonRefs.current[tabId]?.scrollIntoView({ + block: "nearest", + inline: "nearest", + }); + tabCloseButtonRefs.current[tabId]?.scrollIntoView({ + block: "nearest", + inline: "nearest", + }); + }, []); + useLayoutEffect(() => { if (!activeTabId) return; + // Inline rather than a helper call: `work-panel.test.mjs` pins this effect + // body as the contract for "wrapper, then button, then close control". tabWrapperRefs.current[activeTabId]?.scrollIntoView({ block: "nearest", inline: "nearest", @@ -328,6 +351,18 @@ function LocalWorkPanel({ }); }, [activeTabId, tabs.length]); + // A narrowed panel clips a tab that was fully visible a moment ago, and + // neither `activeTabId` nor `tabs.length` changes when only the width does — + // so the reveal above would not re-run and the close control would stay + // clipped after a resize (issue #1762). Observe the strip itself. + useLayoutEffect(() => { + const strip = tabStripRef.current; + if (!strip || !activeTabId || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => revealTab(activeTabId)); + observer.observe(strip); + return () => observer.disconnect(); + }, [activeTabId, revealTab]); + const finishTabReorder = useCallback(() => { const state = tabReorderState.current; if (state) { @@ -871,6 +906,12 @@ function LocalWorkPanel({ (dropIndicator.insertAfter ? "is-drop-after" : "is-drop-before"), )} data-work-panel-tab-id={tab.id} + // A partly visible inactive tab reveals its close + // control on hover (`.work-panel-tab:hover + // .work-panel-tab-close`, styles/work-panel.css:314) — + // revealing opacity is not enough when the control sits + // outside the strip, so bring the whole box in first. + onPointerEnter={() => revealTab(tab.id)} key={tab.id} > diff --git a/apps/desktop/src/components/settings/SkillMarketPanel.tsx b/apps/desktop/src/components/settings/SkillMarketPanel.tsx index a2059fd852..699a2ebadb 100644 --- a/apps/desktop/src/components/settings/SkillMarketPanel.tsx +++ b/apps/desktop/src/components/settings/SkillMarketPanel.tsx @@ -36,6 +36,11 @@ import { skillMarketFailureDetail, type SkillMarketFailureKind, } from "../../lib/skill-market-failure"; +import { + snapshotMatchesQuery, + snapshotTimeText, + type MarketSnapshot, +} from "../../lib/market-snapshot"; const CATEGORIES: readonly SkillCatalogCategory[] = [ "workflow", @@ -151,7 +156,7 @@ export function SkillMarketPanel({ onBack: () => void; onInstalled: (id: string) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const showToast = useAppStore((state) => state.showToast); const [search, setSearch] = useState(""); const [category, setCategory] = useState<"all" | SkillCatalogCategory>("all"); @@ -178,6 +183,13 @@ export function SkillMarketPanel({ }); const [remote, setRemote] = useState(REMOTE_IDLE); const [catalogRetry, setCatalogRetry] = useState(0); + /** + * The last successful search for this panel, kept so a later failure can + * still show the list the user just had (issue #1692) instead of dropping + * to built-in picks only. It answers one query only: a snapshot taken for + * a different search must not stand in for the one on screen. + */ + const [snapshot, setSnapshot] = useState | null>(null); useEffect(() => { let cancelled = false; @@ -217,6 +229,7 @@ export function SkillMarketPanel({ if (!cancelled) { const failed = result.failedSources ?? []; const entries = result.entries ?? []; + const query = search.trim(); setRemote({ status: entries.length === 0 && failed.length > 0 ? "error" : "ready", entries, @@ -225,6 +238,12 @@ export function SkillMarketPanel({ failureDetails: result.failureDetails ?? {}, queryError: "", }); + // The list the user was just shown is what a later failure falls + // back to. A response with no entries is not a list, and keeping + // it would pin the pane to a stale snapshot after one success. + if (entries.length > 0) { + setSnapshot({ entries, at: Date.now(), query }); + } } }) .catch((error: unknown) => { @@ -251,6 +270,19 @@ export function SkillMarketPanel({ ? [...DEFAULT_SKILL_SOURCES, ...sources].find((source) => source.id === id)?.name : undefined; + /** + * The last successful list stands in only while the current query has + * nothing of its own to show — a failure, or the retry that follows one. + * A snapshot taken for another query never answers this one, and a `ready` + * result with no entries is a real (empty) answer, not a failure to cover. + */ + const snapshotForQuery = snapshotMatchesQuery(snapshot, search) ? snapshot : null; + const showingSnapshot = + snapshotForQuery != null && + remote.entries.length === 0 && + (remote.status === "error" || remote.status === "loading"); + const displayEntries = showingSnapshot && snapshotForQuery ? snapshotForQuery.entries : remote.entries; + const visible = useMemo(() => { const query = search.trim().toLocaleLowerCase(); const matches = (entry: SkillCatalogEntry) => { @@ -262,9 +294,9 @@ export function SkillMarketPanel({ }; return mergeSkillEntries( skills.filter(matches), - remote.entries.filter(matches), + displayEntries.filter(matches), ) as MarketItem[]; - }, [remote.entries, search, category]); + }, [displayEntries, search, category]); const totalPages = Math.max(1, Math.ceil(visible.length / PAGE_SIZE)); const currentPage = Math.min(page, totalPages); @@ -658,15 +690,37 @@ export function SkillMarketPanel({ {remote.status === "loading" ? ( -

- {t("settings.sklm.remoteLoading")} -

+ <> +

+ {t("settings.sklm.remoteLoading")} +

+ {showingSnapshot && snapshotForQuery ? ( +

+ {t("settings.sklm.remoteStaleSnapshot", { + time: snapshotTimeText( + snapshotForQuery.at, + i18n.resolvedLanguage ?? i18n.language, + ), + })} +

+ ) : null} + ) : null} {remote.status === "error" ? ( <>

{remoteErrorText()}

+ {showingSnapshot && snapshotForQuery ? ( +

+ {t("settings.sklm.remoteStaleSnapshot", { + time: snapshotTimeText( + snapshotForQuery.at, + i18n.resolvedLanguage ?? i18n.language, + ), + })} +

+ ) : null} {remote.failed.length ? (

{failedSourceLabels().join(", ")}

) : null} diff --git a/apps/desktop/src/lib/market-snapshot.ts b/apps/desktop/src/lib/market-snapshot.ts new file mode 100644 index 0000000000..e67fb3533b --- /dev/null +++ b/apps/desktop/src/lib/market-snapshot.ts @@ -0,0 +1,49 @@ +/** + * The last successful market result, kept so a later source failure can show + * the list the user last saw instead of an empty pane (issue #1692). Both the + * skill market and the MCP market keep one of these. + */ +export type MarketSnapshot = { + entries: T[]; + /** Epoch ms of the successful response, for the timestamp line. */ + at: number; + /** The search text the snapshot was taken for; a stale snapshot must not + * answer a different query. */ + query: string; +}; + +/** Whether a snapshot still describes the query currently on screen. */ +export function snapshotMatchesQuery( + snapshot: MarketSnapshot | null | undefined, + query: string, +): boolean { + if (snapshot == null) return false; + // The main process filters case-insensitively, so the query identity that + // matters is the trimmed, lowercased one. + return snapshot.query.trim().toLocaleLowerCase() === query.trim().toLocaleLowerCase(); +} + +/** + * Localized time for the snapshot line: clock time for today's snapshot, a + * dated form otherwise, so "19:52" is never mistaken for today when the app + * sat open overnight. Mirrors `formatMessageTime` in the transcript. + */ +export function snapshotTimeText(at: number, locale: string): string { + const date = new Date(at); + if (Number.isNaN(date.getTime())) return ""; + const now = new Date(); + const isToday = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); + if (isToday) { + return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }); + } + return date.toLocaleString(locale, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} diff --git a/apps/desktop/src/styles/settings.css b/apps/desktop/src/styles/settings.css index 0787181d1e..e60f68a90d 100644 --- a/apps/desktop/src/styles/settings.css +++ b/apps/desktop/src/styles/settings.css @@ -3585,6 +3585,10 @@ color: var(--ds-warning); } +.mcpm-status.is-snapshot { + color: var(--ds-text-secondary); +} + .mcpm-badge.is-remote { background: color-mix(in oklab, var(--ds-accent) 10%, transparent); color: var(--ds-text-muted); @@ -4098,6 +4102,10 @@ color: var(--ds-warning); } +.sklm-status.is-snapshot { + color: var(--ds-text-secondary); +} + .sklm-badge.is-remote { background: color-mix(in oklab, var(--ds-accent) 10%, transparent); color: var(--ds-text-muted); diff --git a/apps/desktop/test/mcp-market-panel.test.mjs b/apps/desktop/test/mcp-market-panel.test.mjs index 2ae6133191..50d966b011 100644 --- a/apps/desktop/test/mcp-market-panel.test.mjs +++ b/apps/desktop/test/mcp-market-panel.test.mjs @@ -37,6 +37,23 @@ test("failed and partial MCP catalog searches can be retried", () => { assert.match(panel, /remote\.status === "ready" && remote\.failed\.length/); }); +test("a failed query keeps the last successful list with its timestamp", () => { + // Issue #1692 follow-up, MCP side: the last successful registry answer stays + // on screen when a later query fails, with the time it was taken. + assert.match(panel, /useState \| null>\(null\)/); + assert.match(panel, /snapshotMatchesQuery\(snapshot, search\)/); + assert.match(panel, /setSnapshot\(\{ entries, at: Date\.now\(\), query: query\.trim\(\) \}\)/); + assert.match( + panel, + /const showingSnapshot =\s*\n\s*snapshotForQuery != null &&\s*\n\s*remote\.entries\.length === 0 &&\s*\n\s*\(remote\.status === "error" \|\| remote\.status === "loading"\)/, + ); + assert.match(panel, /displayEntries\.filter\(matches\)/); + assert.match(panel, /settings\.mcpMarket\.remoteStaleSnapshot/); + // Pagination beyond the first page also refreshes the snapshot: the entries + // on screen after Load more are what a later failure should keep. + assert.match(panel, /if \(entries\.length > 0\) \{\s*\n\s*setSnapshot\(/); +}); + test("installed state comes from matching server ids", () => { assert.match(panel, /installedIds\.includes\(entry\.id\)/); assert.match(page, /installedIds=\{\[\.\.\.globalServers, \.\.\.projectServers\]/); diff --git a/apps/desktop/test/skill-market-panel.test.mjs b/apps/desktop/test/skill-market-panel.test.mjs index da04e40371..d85ab7719f 100644 --- a/apps/desktop/test/skill-market-panel.test.mjs +++ b/apps/desktop/test/skill-market-panel.test.mjs @@ -120,6 +120,27 @@ test("failed and partial skill catalog searches can be retried", () => { assert.match(panel, /setRemote\(\(current\) => \(\{ \.\.\.current, status: "loading" \}\)\)/); }); +test("a failed query keeps the last successful list with its timestamp", () => { + // Issue #1692 follow-up: a source failure used to drop the whole list back to + // built-in picks, discarding what the user was just looking at. The panel now + // keeps the last successful result and says when it was taken. + assert.match(panel, /useState \| null>\(null\)/); + assert.match(panel, /snapshotMatchesQuery\(snapshot, search\)/); + assert.match(panel, /setSnapshot\(\{ entries, at: Date\.now\(\), query \}\)/); + // The fallback list answers the current query only, and only while the + // query itself has nothing to show — an empty `ready` answer is real. + assert.match( + panel, + /const showingSnapshot =\s*\n\s*snapshotForQuery != null &&\s*\n\s*remote\.entries\.length === 0 &&\s*\n\s*\(remote\.status === "error" \|\| remote\.status === "loading"\)/, + ); + assert.match(panel, /displayEntries\.filter\(matches\)/); + assert.match(panel, /settings\.sklm\.remoteStaleSnapshot/); + assert.match(panel, /snapshotTimeText\(\s*\n?\s*snapshotForQuery\.at/); + // An empty response must not overwrite the snapshot: that would pin the + // pane to an empty list after one successful-but-empty query. + assert.match(panel, /if \(entries\.length > 0\) \{\s*\n\s*setSnapshot\(/); +}); + test("a resolver with no answer is explained as that, not as an address check", () => { // Issue #419, second round: one `policy` bucket meant a local resolver that // answered nothing produced the same sentence as an address the guard had @@ -198,6 +219,7 @@ test("every shipped locale carries the new skill market strings", async () => { "remoteErrorQuery", "remotePartial", "failureSourceHost", + "remoteStaleSnapshot", ]; for (const id of ids) { const locale = await readFile( diff --git a/docs/spec/04-ux/06-settings-ia.md b/docs/spec/04-ux/06-settings-ia.md index 3b3126aa4b..a06c95fc1c 100644 --- a/docs/spec/04-ux/06-settings-ia.md +++ b/docs/spec/04-ux/06-settings-ia.md @@ -707,7 +707,10 @@ system while preserving their different data ownership: user sees that refusal while the same URL opens in their browser (ADR 0177). A failed catalog query offers Retry; the same action remains available when only some sources failed. It repeats the current search over the configured - sources and announces loading while the request runs. + sources and announces loading while the request runs. After a successful + load, a later failure keeps that list on screen with the time it was taken + and still offers Retry; the retained list never answers a different query, + and it disappears once a retry succeeds. Back reloads the skill list. - Skills also exposes **Scan other tools** in its toolbar. The explicit scan opens inline on the Skills page, and imported entries follow the current @@ -719,7 +722,9 @@ system while preserving their different data ownership: shown in candidate rows. - MCP Market offers Retry after a failed query or partial source failure. It repeats the current search through the existing market request path and - announces loading while the request runs. + announces loading while the request runs. After a successful load, a later + failure keeps that list on screen with the time it was taken and still + offers Retry. - The Subagents create/edit sheet pins a model with a searchable, provider- grouped anchored menu — the same option-menu control the service picker uses — over the configured, runnable models the Composer offers, plus an diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 5b00986fae..a0176f14cb 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -16584,14 +16584,21 @@ plugin-form fixtures in an isolated temporary directory at runtime. - **Preconditions**: Rendered Skill Market and MCP Market panels with the market APIs stubbed at the IPC boundary. Each API returns a total failure, - then a result with one failed source, then a complete result. + then a result with one failed source, then a complete result. A second run + loads a result first, fails afterwards, and recovers on retry; a third run + changes the query after a success so the failure carries a snapshot taken + for another query; a fourth run fails with nothing loaded. - **Steps**: Open each market; click Retry after the total failure; confirm the loading announcement and recovered entry; click Retry again after the - partial failure. + partial failure. Then load a list, fail the next query, retry, and change + the query before failing once more. - **Expected**: Both markets repeat the current search, show loading while the request runs, retain a Retry action for partial failures, and clear the failed - source after recovery. The test makes no live network request and does not - change the main-process network policy. + source after recovery. After a successful load, a later failure keeps that + list on screen, says when it was taken, and still offers Retry; the retained + list disappears once a retry succeeds, and it never stands in for a + different query or for a failure with nothing loaded. The test makes no live + network request and does not change the main-process network policy. - **Specs linked**: `04-ux/06-settings-ia.md`, ADR 0177, ADR 0272 - **Acceptance**: Quality - **Milestone**: M6+ diff --git a/docs/zh-CN/spec/04-ux/06-settings-ia.md b/docs/zh-CN/spec/04-ux/06-settings-ia.md index 39a02e805f..18594deb83 100644 --- a/docs/zh-CN/spec/04-ux/06-settings-ia.md +++ b/docs/zh-CN/spec/04-ux/06-settings-ia.md @@ -434,8 +434,8 @@ Token 用量**不是设置目的地**(D335 / ADR 0173)。已完成回合历 20. 命令 Shell 选择保留平台有效的目录 ID,仅在状态有额外信息时公开 默认、不可用、回退或无实际 Shell 文案,并且从不授权过时的 ID/dialect 21. 信息页提供「问题反馈」,打开已预填版本和操作系统的 GitHub bug 表单;设置搜索可索引该行 -22. 技能页的市场视图浏览公网 HTTPS 目录、预览组装后的文档,并只通过 `skills.create` 安装;超限展开文档拒绝写入,来源角标跟随 `sourceId`;预览失败时面板给出可读原因与重试入口(安装按钮可以禁用,但不得无解释地禁用);来源被公网策略守卫拒绝时明确说明,而不是笼统报「不可达」。目录查询全部失败或仅部分来源失败时都提供重试按钮;点击后按当前搜索和已配置来源重查,并在请求期间播报加载状态 -23. MCP 市场查询全部失败或部分来源失败时提供重试按钮;点击后重跑当前搜索,并在请求期间播报加载状态 +22. 技能页的市场视图浏览公网 HTTPS 目录、预览组装后的文档,并只通过 `skills.create` 安装;超限展开文档拒绝写入,来源角标跟随 `sourceId`;预览失败时面板给出可读原因与重试入口(安装按钮可以禁用,但不得无解释地禁用);来源被公网策略守卫拒绝时明确说明,而不是笼统报「不可达」。目录查询全部失败或仅部分来源失败时都提供重试按钮;点击后按当前搜索和已配置来源重查,并在请求期间播报加载状态。成功加载过后再遇失败时,列表保留上次成功加载的结果并标注时间,重试入口不变;该列表不回答其他搜索词,重试成功后即消失 +23. MCP 市场查询全部失败或部分来源失败时提供重试按钮;点击后重跑当前搜索,并在请求期间播报加载状态。成功加载过后再遇失败时,列表保留上次成功加载的结果并标注时间,重试入口不变 ## 5. Chrome 指标常规 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 1bf5205a08..2a7454a1de 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 @@ -8689,9 +8689,9 @@ runner 会在运行时的隔离临时目录中生成六个插件形态 fixture #### E2E-MARKET-CATALOG-RETRY:重试失败或部分可用的市场查询 -- **前提条件**:渲染技能市场和 MCP 市场面板,并在 IPC 边界模拟市场 API。每个 API 依次返回全部失败、一个来源失败的结果、完整成功结果。 -- **步骤**:打开两个市场;全部失败后点击重试;确认加载播报和恢复的目录条目;部分失败后再次点击重试。 -- **预期**:两个市场都按当前查询重新请求,期间显示加载状态;部分来源失败时仍有重试入口;来源恢复后失败项清除。测试不访问公网,也不改变主进程网络策略。 +- **前提条件**:渲染技能市场和 MCP 市场面板,并在 IPC 边界模拟市场 API。每个 API 依次返回全部失败、一个来源失败的结果、完整成功结果。第二组先加载成功、随后失败、重试后恢复;第三组在成功后切换搜索词再失败,使快照属于另一个查询;第四组在从未成功过的情况下失败。 +- **步骤**:打开两个市场;全部失败后点击重试;确认加载播报和恢复的目录条目;部分失败后再次点击重试。随后先加载出列表、令下一次查询失败、重试,再切换搜索词后失败一次。 +- **预期**:两个市场都按当前查询重新请求,期间显示加载状态;部分来源失败时仍有重试入口;来源恢复后失败项清除。成功加载过后再遇失败,列表保留上次成功加载的结果并标注时间,重试入口保留;重试成功后保留列表消失,且该列表不回答其他查询、也不在从未成功过时出现。测试不访问公网,也不改变主进程网络策略。 - **链接规格**:`04-ux/06-settings-ia.md`、ADR 0177、ADR 0272 - **验收**:Quality - **里程碑**:M6+ diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 3ee5d487a7..fd4c40bfc5 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -884,6 +884,7 @@ sklm: { "Ein lokaler Proxy hat diesen Host auf einen fake-IP-Platzhalter in einem reservierten Bereich aufgelöst, nicht auf die echte Adresse der Quelle. Stelle den Proxy auf Systemproxy-Modus um oder prüfe Einstellungen → Allgemein → Netzwerk.", remoteErrorQuery: "Der Skill-Markt konnte nicht abgefragt werden – integrierte Auswahl wird angezeigt", remotePartial: "Einige Katalogquellen sind nicht verfügbar ({{names}})", + remoteStaleSnapshot: "Ergebnisse des letzten erfolgreichen Ladens werden angezeigt ({{time}})", }, "title": "Einstellungen", "providers": "KI-Anbieter", @@ -1270,6 +1271,7 @@ sklm: { remoteError: "Registry nicht erreichbar – integrierte Auswahl wird angezeigt", remotePartial: "Einige Marktquellen sind nicht verfügbar ({{names}})", retry: "Erneut versuchen", + remoteStaleSnapshot: "Ergebnisse des letzten erfolgreichen Ladens werden angezeigt ({{time}})", manageSources: "Quellen", sourcesHint: "Mehrere Marktquellen gleichzeitig abfragen. Registry-Quellen sprechen das offizielle MCP-Registrierungsprotokoll; Katalogquellen sind statische JSON-Dateien im Markt-Schema.", addSource: "Quelle hinzufügen", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index a28e4dc3b5..ef382349b7 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -896,6 +896,7 @@ sklm: { "A local proxy resolved this host to a fake-IP placeholder in a reserved range, not to the source's real address. Switch the proxy to system-proxy mode, or check Settings → General → Network.", remoteErrorQuery: "Could not query the skill market — showing built-in picks", remotePartial: "Some catalog sources are unavailable ({{names}})", + remoteStaleSnapshot: "Showing the results from your last successful load ({{time}})", }, title: "Settings", providers: "AI providers", @@ -1124,6 +1125,7 @@ sklm: { remoteError: "Registry unreachable — showing built-in picks", remotePartial: "Some market sources are unavailable ({{names}})", retry: "Retry", + remoteStaleSnapshot: "Showing the results from your last successful load ({{time}})", manageSources: "Sources", sourcesHint: "Query several market sources at once. Registry sources speak the official MCP registry protocol; catalog sources are static JSON files in the market schema.", addSource: "Add source", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index dab1dccc41..0259becbc3 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -884,6 +884,7 @@ sklm: { "Un proxy local resolvió este host a un marcador fake-IP en un rango reservado, no a la dirección real de la fuente. Cambia el proxy al modo de proxy del sistema o revisa Ajustes → General → Red.", remoteErrorQuery: "No se pudo consultar el mercado de skills: se muestran las selecciones integradas", remotePartial: "Algunas fuentes del catálogo no están disponibles ({{names}})", + remoteStaleSnapshot: "Mostrando los resultados de la última carga correcta ({{time}})", }, "title": "Configuración", "providers": "Proveedores de IA", @@ -1270,6 +1271,7 @@ sklm: { remoteError: "Registro no disponible: se muestran las selecciones integradas", remotePartial: "Algunas fuentes del mercado no están disponibles ({{names}})", retry: "Reintentar", + remoteStaleSnapshot: "Mostrando los resultados de la última carga correcta ({{time}})", manageSources: "Fuentes", sourcesHint: "Consulta varias fuentes a la vez. Las fuentes de registro hablan el protocolo oficial MCP; las de catálogo son archivos JSON estáticos con el esquema del mercado.", addSource: "Añadir fuente", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index 701530342b..72d89fa894 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -884,6 +884,7 @@ sklm: { "Un proxy local a résolu cet hôte en une adresse factice d'une plage réservée, et non en l'adresse réelle de la source. Passez le proxy en mode proxy système, ou vérifiez Paramètres → Général → Réseau.", remoteErrorQuery: "Impossible d'interroger le marché de skills – sélection intégrée affichée", remotePartial: "Certaines sources du catalogue sont indisponibles ({{names}})", + remoteStaleSnapshot: "Affichage des résultats du dernier chargement réussi ({{time}})", }, "title": "Paramètres", "providers": "Fournisseurs d'IA", @@ -1270,6 +1271,7 @@ sklm: { remoteError: "Registre inaccessible – sélection intégrée affichée", remotePartial: "Certaines sources du marché sont indisponibles ({{names}})", retry: "Réessayer", + remoteStaleSnapshot: "Affichage des résultats du dernier chargement réussi ({{time}})", manageSources: "Sources", sourcesHint: "Interroge plusieurs sources à la fois. Les sources de registre parlent le protocole officiel MCP ; les sources de catalogue sont des fichiers JSON statiques au schéma du marché.", addSource: "Ajouter une source", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index e4e1d2c105..ff9f4331c3 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -893,6 +893,7 @@ sklm: { "로컬 프록시가 이 호스트를 예약 대역의 fake-IP 자리표시자로 확인했습니다. 소스의 실제 주소가 아닙니다. 프록시를 시스템 프록시 모드로 전환하거나 설정 → 일반 → 네트워크를 확인하세요.", remoteErrorQuery: "스킬 마켓을 조회하지 못했습니다. 내장 목록을 표시합니다", remotePartial: "일부 카탈로그 소스를 사용할 수 없습니다 ({{names}})", + remoteStaleSnapshot: "마지막으로 성공한 불러오기의 결과를 표시합니다 ({{time}})", }, title: "설정", providers: "AI 프로바이더", @@ -1280,6 +1281,7 @@ sklm: { remoteError: "레지스트리에 연결할 수 없어 내장 목록을 표시합니다", remotePartial: "일부 마켓 소스를 사용할 수 없습니다 ({{names}})", retry: "다시 시도", + remoteStaleSnapshot: "마지막으로 성공한 불러오기의 결과를 표시합니다 ({{time}})", manageSources: "마켓 소스", sourcesHint: "여러 마켓 소스를 동시에 조회합니다. 레지스트리 소스는 공식 MCP 레지스트리 프로토콜을, 카탈로그 소스는 마켓 스키마의 정적 JSON 파일을 사용합니다.", addSource: "소스 추가", diff --git a/packages/i18n/src/locales/pt-BR/index.ts b/packages/i18n/src/locales/pt-BR/index.ts index 54f3e679b2..f748048373 100644 --- a/packages/i18n/src/locales/pt-BR/index.ts +++ b/packages/i18n/src/locales/pt-BR/index.ts @@ -871,7 +871,8 @@ export const ptBR = { fakeIpHint: "Seu proxy respondeu {{host}} com {{address}}, uma faixa reservada usada como marcador fake-IP, não o endereço real da fonte. Altere o proxy para o modo de proxy do sistema ou revise Configurações → Geral → Rede.", fakeIpHintPlain: "Um proxy local resolveu este host para um marcador fake-IP em uma faixa reservada, não o endereço real da fonte. Altere o proxy para o modo de proxy do sistema ou revise Configurações → Geral → Rede.", remoteErrorQuery: "Não foi possível consultar o mercado de habilidades — exibindo seleções integradas", - remotePartial: "Algumas fontes do catálogo não estão disponíveis ({{names}})" + remotePartial: "Algumas fontes do catálogo não estão disponíveis ({{names}})", + remoteStaleSnapshot: "Exibindo os resultados do último carregamento bem-sucedido ({{time}})" }, title: "Configuração", providers: "Provedores de IA", @@ -1093,6 +1094,7 @@ export const ptBR = { remoteError: "Registro inacessível — exibindo seleções integradas", remotePartial: "Algumas fontes do mercado estão indisponíveis ({{names}})", retry: "Tentar novamente", + remoteStaleSnapshot: "Exibindo os resultados do último carregamento bem-sucedido ({{time}})", manageSources: "Fontes", sourcesHint: "Consulte várias fontes do marketplace de uma só vez. Fontes de registro usam o protocolo oficial do MCP; fontes de catálogo são arquivos JSON estáticos no formato do marketplace.", addSource: "Adicionar fonte", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index b8989edac6..2340d8b7a3 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -893,6 +893,7 @@ sklm: { "Yerel bir proxy bu ana bilgisayarı ayrılmış bir aralıktaki fake-IP yer tutucusuna çözdü; kaynağın gerçek adresi değildir. Proxy'yi sistem proxy moduna alın veya Ayarlar → Genel → Ağ bölümünü kontrol edin.", remoteErrorQuery: "Skill market sorgulanamadı – yerleşik seçkiler gösteriliyor", remotePartial: "Bazı katalog kaynakları kullanılamıyor ({{names}})", + remoteStaleSnapshot: "Son başarılı yüklemenin sonuçları gösteriliyor ({{time}})", }, title: "Ayarlar", providers: "AI servisleri", @@ -1274,6 +1275,7 @@ sklm: { remoteError: "Kayıt erişilemiyor – yerleşik seçkiler gösteriliyor", remotePartial: "Bazı market kaynakları kullanılamıyor ({{names}})", retry: "Yeniden dene", + remoteStaleSnapshot: "Son başarılı yüklemenin sonuçları gösteriliyor ({{time}})", manageSources: "Kaynaklar", sourcesHint: "Birden fazla market kaynağını aynı anda sorgular. Kayıt kaynakları resmî MCP kayıt protokolünü; katalog kaynakları market şemasında statik JSON dosyalarını kullanır.", addSource: "Kaynak ekle", diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index 7c026ae18e..41c0437b13 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -884,6 +884,7 @@ sklm: { "本地代理把该主机解析成了保留段里的 fake-IP 占位地址,而不是该源的真实地址。请把代理切换为系统代理模式,或检查 设置 → 通用 → 网络。", remoteErrorQuery: "无法查询技能市场,已显示内置精选", remotePartial: "部分目录源不可用({{names}})", + remoteStaleSnapshot: "正在显示上次成功加载的结果({{time}})", }, title: "设置", providers: "AI 服务", @@ -1264,6 +1265,7 @@ sklm: { remoteError: "官方注册表暂不可达,已显示内置精选", remotePartial: "部分市场源暂不可用({{names}})", retry: "重试", + remoteStaleSnapshot: "正在显示上次成功加载的结果({{time}})", manageSources: "市场源", sourcesHint: "同时查询多个市场源。Registry 型源走官方 MCP 注册表协议;目录型源是符合市场 schema 的静态 JSON 文件。", addSource: "添加源", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index 1ae7643a6d..5854156efe 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -884,6 +884,7 @@ sklm: { "本機代理將該主機解析成保留段中的 fake-IP 佔位位址,而不是該來源的真實位址。請將代理切換為系統代理模式,或檢查 設定 → 一般 → 網路。", remoteErrorQuery: "無法查詢技能市場,已顯示內建精選", remotePartial: "部分目錄源不可用({{names}})", + remoteStaleSnapshot: "正在顯示上次成功載入的結果({{time}})", }, title: "設定", providers: "AI 服務", @@ -1264,6 +1265,7 @@ sklm: { remoteError: "官方註冊表暫不可達,已顯示內建精選", remotePartial: "部分市集來源暫時無法使用({{names}})", retry: "重試", + remoteStaleSnapshot: "正在顯示上次成功載入的結果({{time}})", manageSources: "市場源", sourcesHint: "同時查詢多個市場源。Registry 型源走官方 MCP 註冊表協定;目錄型源是符合市集 schema 的靜態 JSON 檔案。", addSource: "新增源", diff --git a/scripts/e2e-market-retry.mjs b/scripts/e2e-market-retry.mjs index 59edcead37..a81a3b3412 100644 --- a/scripts/e2e-market-retry.mjs +++ b/scripts/e2e-market-retry.mjs @@ -3,9 +3,9 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { createRequire } from "node:module"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { resolveElectronBinary } from "./e2e/boot.mjs"; @@ -13,6 +13,7 @@ const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const require = createRequire(join(root, "packages/agent-runtime/package.json")); const { build } = require("esbuild"); const { electronBinary } = resolveElectronBinary(root); +const evidence = process.env.PI_MARKET_EVIDENCE_DIR; const temp = await mkdtemp(join(tmpdir(), "pi-market-retry-")); try { await build({ @@ -35,19 +36,37 @@ try { join(temp, "index.html"), 'Market retry interactions', ); + const stageScript = evidence + ? [ + ' const stages = ["error-with-snapshot", "retry-loading", "error-no-snapshot", "recovered"];', + ' const out = [];', + ' for (const stage of stages) {', + ' const result = await window.webContents.executeJavaScript(`globalThis.marketSnapshotStage(${JSON.stringify(stage)})`);', + ' await window.webContents.executeJavaScript("document.fonts.ready.then(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))");', + ' const png = await window.webContents.capturePage();', + ' const target = path.join(__dirname, "shots", stage + ".png");', + ' await fs.mkdir(path.join(__dirname, "shots"), { recursive: true });', + ' await fs.writeFile(target, png.toPNG());', + ' out.push({ stage, ...result });', + ' }', + ' console.log("MARKET_SNAPSHOT_SHOTS " + JSON.stringify(out));', + ].join("\n") + : ""; await writeFile( join(temp, "main.cjs"), [ 'const { app, BrowserWindow } = require("electron");', 'const path = require("node:path");', + 'const fs = require("node:fs/promises");', 'app.setPath("userData", path.join(__dirname, "profile"));', 'app.whenReady().then(async () => {', - ' const window = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false } });', + ' const window = new BrowserWindow({ show: false, width: 1280, height: 800, webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } });', ' window.webContents.on("console-message", (event) => console.error(event.message));', ' try {', ' await window.loadFile(path.join(__dirname, "index.html"));', ' const result = await window.webContents.executeJavaScript("globalThis.marketRetryProbe()");', ' console.log("MARKET_RETRY_PROBE " + JSON.stringify(result));', + stageScript, ' app.quit();', ' } catch (error) {', ' console.error("MARKET_RETRY_PROBE " + JSON.stringify({ ok: false, error: String(error) }));', @@ -68,7 +87,7 @@ try { output += data; }); } - const timeout = setTimeout(() => child.kill("SIGKILL"), 45_000); + const timeout = setTimeout(() => child.kill("SIGKILL"), 90_000); let code; try { code = await new Promise((resolve, reject) => { @@ -84,6 +103,21 @@ try { console.log("MARKET_RETRY_PROBE " + JSON.stringify(result)); assert.equal(code, 0, output.slice(-6000)); assert.equal(result.ok, true); + if (evidence) { + const shots = output + .split(/\r?\n/) + .find((entry) => entry.startsWith("MARKET_SNAPSHOT_SHOTS ")); + assert(shots, `renderer returned no stage shots: ${output.slice(-2000)}`); + await mkdir(resolve(evidence), { recursive: true }); + const staged = JSON.parse(shots.slice("MARKET_SNAPSHOT_SHOTS ".length)); + for (const stage of staged) { + assert.equal(stage.ok, true, `${stage.stage}: ${JSON.stringify(stage)}`); + const from = join(temp, "shots", `${stage.stage}.png`); + const to = join(resolve(evidence), `${stage.stage}.png`); + await (await import("node:fs/promises")).copyFile(from, to); + } + console.log(`MARKET_SNAPSHOT_SHOTS ok=${staged.length} dir=${resolve(evidence)}`); + } } finally { await rm(temp, { recursive: true, force: true }); } diff --git a/scripts/e2e/market-retry.tsx b/scripts/e2e/market-retry.tsx index e6f023ee6b..2f58be190d 100644 --- a/scripts/e2e/market-retry.tsx +++ b/scripts/e2e/market-retry.tsx @@ -12,6 +12,7 @@ import "../../apps/desktop/src/styles/settings.css"; declare global { var marketRetryProbe: () => Promise; + var marketSnapshotStage: (stage: string) => Promise; } const assert = (condition: unknown, message: string) => { @@ -47,38 +48,78 @@ const mcpEntry: McpCatalogEntry = { categories: ["devtools"], }; -globalThis.marketRetryProbe = async () => { - const i18n = createInstance(); - await i18n.init({ - lng: "en", - resources: { en: { translation: catalogs.en }, "zh-CN": { translation: catalogs["zh-CN"] } }, - }); +let i18n: ReturnType; +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; + +/** Drop the current mount point so the next `boot()` starts from an empty page. */ +function teardown() { + if (root) { + flushSync(() => root!.unmount()); + root = null; + } + if (container) { + container.remove(); + container = null; + } +} + +async function boot() { + // Stages run one after another in the same window, and a viewport capture + // shows whatever sits at the top of the page — a leftover container would + // make every screenshot show the first stage's panel. + teardown(); + if (!i18n) { + i18n = createInstance(); + await i18n.init({ + lng: "en", + resources: { en: { translation: catalogs.en }, "zh-CN": { translation: catalogs["zh-CN"] } }, + }); + } api.getSettings = async () => structuredClone(settings); - const container = document.createElement("div"); + container = document.createElement("div"); document.body.append(container); - const root = createRoot(container); - const render = (panel: "skill" | "mcp") => - flushSync(() => - root.render( - - {panel === "skill" ? ( - {}} onInstalled={() => {}} /> - ) : ( - {}} onInstalled={() => {}} /> - )} - , - ), - ); - const click = (element: HTMLElement | undefined | null) => { - assert(element, "missing retry button"); - flushSync(() => element!.click()); - }; - const button = (label: string) => - [...container.querySelectorAll("button")].find( - (element) => element.textContent?.trim() === label, - ); - const status = () => container.querySelector('[role="status"]')?.textContent ?? ""; + root = createRoot(container); +} + +function mount(panel: "skill" | "mcp") { + flushSync(() => root!.render(
)); + flushSync(() => + root!.render( + + {panel === "skill" ? ( + {}} onInstalled={() => {}} /> + ) : ( + {}} onInstalled={() => {}} /> + )} + , + ), + ); +} +const click = (element: HTMLElement | undefined | null) => { + assert(element, "missing retry button"); + flushSync(() => element!.click()); +}; +const button = (label: string) => + [...container!.querySelectorAll("button")].find( + (element) => element.textContent?.trim() === label, + ); +const status = () => container!.querySelector('[role="status"]')?.textContent ?? ""; +const snapshotLine = () => + container!.querySelector(".sklm-status.is-snapshot, .mcpm-status.is-snapshot"); +const typeSearch = (value: string) => { + const input = container!.querySelector(".sklm-search-input, .mcpm-search-input"); + assert(input, "missing search input"); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + flushSync(() => { + setter.call(input, value); + input!.dispatchEvent(new Event("input", { bubbles: true })); + }); +}; + +globalThis.marketRetryProbe = async () => { + await boot(); try { for (const locale of ["en", "zh-CN"]) { await i18n.changeLanguage(locale); @@ -89,7 +130,7 @@ globalThis.marketRetryProbe = async () => { if (skillCalls === 2) return { entries: [skillEntry], failedSources: ["obra/superpowers"] }; return { entries: [skillEntry], failedSources: [] }; }; - render("skill"); + mount("skill"); await until( () => status().includes(i18n.t("settings.sklm.remoteError")) || status().includes(i18n.t("settings.sklm.remoteErrorQuery")), `${locale}: skill market error status missing`, @@ -101,6 +142,53 @@ globalThis.marketRetryProbe = async () => { click(button(i18n.t("settings.sklm.retryCatalog"))); await until(() => skillCalls === 3 && !container.textContent?.includes("obra/superpowers"), `${locale}: second skill retry did not clear partial failure`); + // --- Snapshot behavior: the last successful list survives a later failure. + let snapCalls = 0; + api.searchSkillMarket = async () => { + snapCalls += 1; + if (snapCalls === 1) return { entries: [skillEntry], failedSources: ["obra/superpowers"] }; + if (snapCalls === 2) return { entries: [], failedSources: ["anthropics/skills", "obra/superpowers"] }; + return { entries: [skillEntry], failedSources: [] }; + }; + mount("skill"); + await until(() => snapCalls === 1 && container.textContent?.includes("Recovered Skill") === true, `${locale}: snapshot run 1 did not load`); + assert(!snapshotLine(), `${locale}: snapshot line shown before any failure`); + click(button(i18n.t("settings.sklm.retryCatalog"))); + await until( + () => status().includes(i18n.t("settings.sklm.remoteError")) || status().includes(i18n.t("settings.sklm.remoteErrorQuery")), + `${locale}: snapshot run 2 did not fail`, + ); + assert(snapshotLine(), `${locale}: failure with a snapshot shows no snapshot line`); + assert(container.textContent?.includes("Recovered Skill") === true, `${locale}: retained list is missing after failure`); + assert(button(i18n.t("settings.sklm.retryCatalog")), `${locale}: retained failure lost its retry`); + click(button(i18n.t("settings.sklm.retryCatalog"))); + await until(() => snapCalls === 3 && !snapshotLine(), `${locale}: recovery kept the snapshot line`); + + // A snapshot answers its own query only: a different search that fails + // must not be covered by results taken for another one. + let mismatchCalls = 0; + api.searchSkillMarket = async () => { + mismatchCalls += 1; + if (mismatchCalls === 1) return { entries: [skillEntry], failedSources: [] }; + return { entries: [], failedSources: ["anthropics/skills"] }; + }; + mount("skill"); + await until(() => mismatchCalls === 1 && container.textContent?.includes("Recovered Skill") === true, `${locale}: mismatch run 1 did not load`); + typeSearch("zzz"); + await until(() => mismatchCalls === 2, `${locale}: mismatch query was never sent`); + await until(() => status().includes(i18n.t("settings.sklm.remoteError")), `${locale}: mismatch query did not fail`); + assert(!snapshotLine(), `${locale}: a snapshot for another query covered this one`); + + // A fresh failure with nothing to fall back to stays as before: no line. + let bareCalls = 0; + api.searchSkillMarket = async () => { + bareCalls += 1; + return { entries: [], failedSources: ["anthropics/skills"] }; + }; + mount("skill"); + await until(() => bareCalls === 1 && status().includes(i18n.t("settings.sklm.remoteError")), `${locale}: bare failure did not render`); + assert(!snapshotLine(), `${locale}: empty first failure claimed a snapshot`); + flushSync(() => root.render(
)); let mcpCalls = 0; api.searchMcpMarketRegistry = async () => { @@ -109,7 +197,7 @@ globalThis.marketRetryProbe = async () => { if (mcpCalls === 2) return { entries: [mcpEntry], failedSources: ["community catalog"] }; return { entries: [mcpEntry], failedSources: [] }; }; - render("mcp"); + mount("mcp"); await until(() => status().includes(i18n.t("settings.mcpMarket.remoteError")), `${locale}: MCP market error status missing`); click(button(i18n.t("settings.mcpMarket.retry"))); await until(() => status().includes(i18n.t("settings.mcpMarket.remoteLoading")), `${locale}: MCP retry did not announce loading`); @@ -117,10 +205,78 @@ globalThis.marketRetryProbe = async () => { await until(() => button(i18n.t("settings.mcpMarket.retry")) !== undefined, `${locale}: partial MCP failure has no retry`); click(button(i18n.t("settings.mcpMarket.retry"))); await until(() => mcpCalls === 3 && !container.textContent?.includes("community catalog"), `${locale}: second MCP retry did not clear partial failure`); + + // MCP snapshot: same contract as the skill market. + let mcpSnap = 0; + api.searchMcpMarketRegistry = async () => { + mcpSnap += 1; + if (mcpSnap === 1) return { entries: [mcpEntry], failedSources: ["community catalog"] }; + if (mcpSnap === 2) return { entries: [], failedSources: ["official registry"] }; + return { entries: [mcpEntry], failedSources: [] }; + }; + mount("mcp"); + await until(() => mcpSnap === 1 && container.textContent?.includes("Recovered Server") === true, `${locale}: MCP snapshot run 1 did not load`); + click(button(i18n.t("settings.mcpMarket.retry"))); + await until(() => status().includes(i18n.t("settings.mcpMarket.remoteError")), `${locale}: MCP snapshot run 2 did not fail`); + assert(snapshotLine(), `${locale}: MCP failure with a snapshot shows no snapshot line`); + assert(container.textContent?.includes("Recovered Server") === true, `${locale}: MCP retained list is missing`); } return { ok: true }; } finally { - flushSync(() => root.unmount()); - container.remove(); + teardown(); + } +}; + +/** + * One state per call, for review screenshots: `error-with-snapshot`, + * `retry-loading`, `error-no-snapshot`, `recovered`. Renders with zh-CN, the + * locale the reviewer reads, and leaves the state painted when it resolves. + */ +globalThis.marketSnapshotStage = async (stage: string) => { + await boot(); + await i18n.changeLanguage("zh-CN"); + const skillCalls = { count: 0 }; + let pending: ((value: unknown) => void) | null = null; + api.searchSkillMarket = async () => { + skillCalls.count += 1; + if (stage === "error-no-snapshot") { + return { entries: [], failedSources: ["anthropics/skills", "obra/superpowers"] }; + } + if (skillCalls.count === 1) { + return { entries: [skillEntry], failedSources: ["obra/superpowers"] }; + } + if (stage === "retry-loading" && skillCalls.count === 3) { + // Third call hangs: the retry is in flight while the snapshot shows. + return new Promise((resolve) => { + pending = resolve; + }); + } + if (stage === "recovered" && skillCalls.count === 3) { + // Second call fails (snapshot keeps the list), third recovers. + return { entries: [skillEntry], failedSources: [] }; + } + return { entries: [], failedSources: ["anthropics/skills", "obra/superpowers"] }; + }; + mount("skill"); + if (stage === "error-no-snapshot") { + await until(() => status().includes(i18n.t("settings.sklm.remoteError")), "no-snapshot error missing"); + } else { + await until(() => container.textContent?.includes("Recovered Skill") === true, `${stage}: first load missing`); + } + if (stage === "error-with-snapshot" || stage === "retry-loading") { + click(button(i18n.t("settings.sklm.retryCatalog"))); + await until(() => status().includes(i18n.t("settings.sklm.remoteError")), `${stage}: failure missing`); + } + if (stage === "retry-loading") { + click(button(i18n.t("settings.sklm.retryCatalog"))); + await until(() => status().includes(i18n.t("settings.sklm.remoteLoading")), "retry-loading state missing"); + } + if (stage === "recovered") { + click(button(i18n.t("settings.sklm.retryCatalog"))); + await until(() => status().includes(i18n.t("settings.sklm.remoteError")), "recovered: failure missing"); + click(button(i18n.t("settings.sklm.retryCatalog"))); + await until(() => !snapshotLine() && container.textContent?.includes("Recovered Skill") === true, "recovered: list missing"); } + await painted(); + return { ok: true, stage, pending: pending !== null }; }; From a6f4ba121e524a167c42466c7b4debbb3cfd0744 Mon Sep 17 00:00:00 2001 From: TioitWang <104554654+Tioit-Wang@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:42:58 +0800 Subject: [PATCH 09/44] feat(plugins): offer one line of composer suggestion from a plugin (#1785) * feat(plugins): offer one line of composer suggestion from a plugin A plugin that reacts to a finished turn often knows what the user plausibly types next, but every surface it has today interrupts: a toast, a notification, or a rewrite of a draft the user already wrote. Add `pi.composer.suggest` and `pi.composer.clearSuggestion`, gated by the new `composer.suggest` permission, which put one dismissible line into the placeholder layer of the Composer for the session on screen. The decision is split along the ownership the host already has. The main process refuses a session that is not the one on screen and a turn that a newer turn overtook, because it owns both facts; the renderer refuses a draft that is not empty, because only it owns the draft, and answers the waiting caller. A plugin therefore learns `shown: false` with a reason instead of guessing, which is what lets it stop spending completions on a user who is not looking. Main never writes renderer state: it sends a message, like `pi.ui.revealView` does. Nothing a plugin offers becomes draft text on its own. `Tab` writes the line into the draft with the caret at the end, `Esc` drops it, and typing, sending, a session switch, a new turn, a plugin stop, a reload or a window close all end it; the `@` / `/` completion list keeps `Tab` and the first `Escape`. See ADR 0332. * refactor(plugins): keep only the facts the suggestion slot reads The suggestion bridge kept the composer handle inside its live-draft value, but it already reaches the newest handle through its own registry. Drop the duplicate so the value describes only what the decision needs: the session, the text and how many references the draft holds. * fix(plugins): give the suggestion slot a server snapshot React requires a third snapshot when a component is rendered to a string, and the app-shell snapshot fixtures render the Composer that way. Without it React threw "Missing getServerSnapshot" and the three shell-render tests failed on CI even though the browser path was fine. A suggestion is a live-window fact, so the server snapshot is simply "none". --------- Co-authored-by: v_wyxgwang --- .../electron/main/bootstrap/shutdown.ts | 5 + apps/desktop/electron/main/index.ts | 13 +- .../electron/main/ipc/plugin-ui-ipc.ts | 42 +++ apps/desktop/electron/main/ipc/register.ts | 6 +- .../electron/main/plugin-host-process.mjs | 7 + apps/desktop/electron/main/plugin-runtime.ts | 137 +++++++++ .../main/services/composer-suggestion.ts | 262 ++++++++++++++++++ .../electron/main/services/plugin-services.ts | 24 ++ apps/desktop/src/components/Composer.tsx | 5 + .../src/features/app/useAppShellRuntime.tsx | 26 ++ .../features/chat/composer/ComposerInput.tsx | 55 +++- .../composer/hooks/useComposerSuggestion.ts | 49 ++++ .../composer/hooks/usePluginComposerBridge.ts | 15 +- .../composer/plugins/suggestion-bridge.ts | 219 +++++++++++++++ .../chat/composer/plugins/suggestion-keys.ts | 44 +++ apps/desktop/src/features/plugins/model.ts | 3 + apps/desktop/src/lib/api.ts | 73 +++++ apps/desktop/src/styles/composer.css | 33 +++ .../test/composer-suggestion-relay.test.mjs | 147 ++++++++++ .../test/composer-suggestion-state.test.mjs | 236 ++++++++++++++++ .../test/plugin-composer-suggestion.test.mjs | 220 +++++++++++++++ docs/adr/0332-plugin-composer-suggestion.md | 124 +++++++++ docs/adr/README.md | 1 + docs/plugin-development.md | 1 + docs/spec/06-delivery/04-e2e-test-plan.md | 26 ++ .../07-plugins/02-plugin-manifest-schema.md | 1 + docs/spec/07-plugins/03-plugin-api.md | 64 +++++ .../13-plugin-permissions-matrix.md | 2 + .../07-plugins/02-plugin-manifest-schema.md | 1 + docs/zh-CN/spec/07-plugins/03-plugin-api.md | 56 ++++ .../13-plugin-permissions-matrix.md | 2 + packages/i18n/src/locales/de/index.ts | 3 + packages/i18n/src/locales/en/index.ts | 4 + 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/pt-BR/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/plugin-sdk/src/index.test.ts | 1 + packages/plugin-sdk/src/index.ts | 92 ++++++ packages/shared/src/protocol.ts | 13 + 43 files changed, 2021 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/electron/main/services/composer-suggestion.ts create mode 100644 apps/desktop/src/features/chat/composer/hooks/useComposerSuggestion.ts create mode 100644 apps/desktop/src/features/chat/composer/plugins/suggestion-bridge.ts create mode 100644 apps/desktop/src/features/chat/composer/plugins/suggestion-keys.ts create mode 100644 apps/desktop/test/composer-suggestion-relay.test.mjs create mode 100644 apps/desktop/test/composer-suggestion-state.test.mjs create mode 100644 apps/desktop/test/plugin-composer-suggestion.test.mjs create mode 100644 docs/adr/0332-plugin-composer-suggestion.md diff --git a/apps/desktop/electron/main/bootstrap/shutdown.ts b/apps/desktop/electron/main/bootstrap/shutdown.ts index dfd6683584..b531b9a95d 100644 --- a/apps/desktop/electron/main/bootstrap/shutdown.ts +++ b/apps/desktop/electron/main/bootstrap/shutdown.ts @@ -9,6 +9,7 @@ import type { PersistenceOutbox } from "../persistence-outbox"; import type { PluginPanelHost } from "../plugin-panel-host"; import type { PluginRuntime } from "../plugin-runtime"; import type { PluginViewHost } from "../plugin-view-host"; +import type { ComposerSuggestionRelay } from "../services/composer-suggestion"; import type { AppUpdaterController } from "../updater"; import type { UserMcpRuntime } from "../user-mcp"; import type { McpControlServer } from "../mcp-control"; @@ -45,6 +46,8 @@ export type ShutdownDependencies = { mcpOAuth?: Pick; browserHost: Pick; pluginViews: Pick; + /** Composer suggestions still waiting for an answer at quit (ADR 0332). */ + composerSuggestion?: Pick; updater: Pick; logger: Pick; confirmQuitDialog: () => Promise; @@ -70,6 +73,7 @@ export function registerShutdownHandlers({ mcpOAuth, browserHost, pluginViews, + composerSuggestion, updater, logger, confirmQuitDialog, @@ -213,6 +217,7 @@ export function registerShutdownHandlers({ userMcp.disposeAll(); mcpOAuth?.disposeAll(); browserHost.dispose(); + composerSuggestion?.dispose(); inflightCheckpointer.dispose(); try { diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index d24b893629..7e55202999 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -72,6 +72,7 @@ import { createSessionCoordination } from "./runtime/session-coordination"; import { createScheduledRuntime } from "./runtime/scheduled"; import { createDesktopServices } from "./services/desktop-services"; import { createPluginServices } from "./services/plugin-services"; +import type { ComposerSuggestionAnswer } from "./services/composer-suggestion"; import { wirePluginThemeRuntimeServices } from "./plugin-theme-services"; import { createSessionCollaborationService } from "./services/session-collaboration"; import { @@ -638,8 +639,12 @@ const sessionCoordination = createSessionCoordination({ * begins running is reported with the durable turn id instead of being * inferred from the previous turn's end. */ - onTurnStarted: (sessionId: string, turnId: string) => - pluginServices.pluginSession.noteTurnStarted(sessionId, turnId), + onTurnStarted: (sessionId: string, turnId: string) => { + pluginServices.pluginSession.noteTurnStarted(sessionId, turnId); + // A new turn owns the composer now, so a suggestion about the previous one + // stops being shown and stops being showable (ADR 0332). + pluginServices.composerSuggestion.noteTurnStarted(sessionId, turnId); + }, }); const { activeTurnUsages, @@ -884,6 +889,9 @@ function registerIpc() { }, /** The shell reports the session on screen; plugin surfaces read it. */ setActivePluginSession: (next) => pluginServices.pluginSession.setActiveSession(next), + /** The shell answers a composer suggestion through the same invoke list. */ + settleComposerSuggestion: (answer: ComposerSuggestionAnswer) => + pluginServices.composerSuggestion.settle(answer), getPluginLauncherWindow: () => mainState.pluginLauncherWindow, togglePluginLauncher, safeOpenExternal, @@ -1075,6 +1083,7 @@ registerShutdownHandlers({ mcpOAuth, browserHost, pluginViews, + composerSuggestion: pluginServices.composerSuggestion, updater, logger, confirmQuitDialog, diff --git a/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts b/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts index c0d516a958..675bef8597 100644 --- a/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts +++ b/apps/desktop/electron/main/ipc/plugin-ui-ipc.ts @@ -12,6 +12,11 @@ import type { PluginPanelTheme } from "../../shared/plugin-panel-chrome"; import type { IpcRegistrar } from "./types"; import { pluginProviderCatalogEntries } from "../plugin-provider-catalog"; import { createSessionPushGate } from "../plugin-session-push-gate"; +import { + PLUGIN_COMPOSER_SUGGESTION_REFUSALS, + type PluginComposerSuggestionRefusal, +} from "@pi-desktop/plugin-sdk"; +import type { ComposerSuggestionAnswer } from "../services/composer-suggestion"; export type PluginUiIpcDependencies = { registrar: IpcRegistrar; @@ -27,6 +32,12 @@ export type PluginUiIpcDependencies = { setActivePluginSession: ( next: { sessionId: string; projectPath: string | null } | null, ) => void; + /** + * The shell's answer to one composer suggestion (`pi.composer.suggest`): the + * main process is waiting on it, because only the renderer knows whether the + * draft could take the line. + */ + settleComposerSuggestion: (answer: ComposerSuggestionAnswer) => boolean; }; /** Register plugin panels, work-panel views, themes and services. */ @@ -41,6 +52,7 @@ export function registerPluginUiIpc({ getUpdaterLocale, getPluginPanelTheme, setActivePluginSession, + settleComposerSuggestion, }: PluginUiIpcDependencies): void { const handle = (channel: string, fn: (...args: any[]) => Promise) => { registrar.handle(channel, fn); @@ -86,6 +98,36 @@ export function registerPluginUiIpc({ return { ok: true }; }, ); + /** + * The shell's answer to one composer suggestion (`pi.composer.suggest`). + * + * Nothing is refused here: the offer it answers may already have been + * replaced, dropped, or timed out, and a late answer to a forgotten offer is + * simply not applied. `ok` therefore says the message was well formed, not + * that a suggestion is on screen. + */ + handle( + IPC.invoke.pluginComposerSuggestionResult, + async ( + payload: + | { requestId?: string | null; shown?: boolean | null; reason?: string | null } + | null, + ) => { + const requestId = typeof payload?.requestId === "string" ? payload.requestId.trim() : ""; + if (!requestId) throw new Error("INVALID_ARGUMENT: requestId is required"); + const reason = + typeof payload?.reason === "string" && + (PLUGIN_COMPOSER_SUGGESTION_REFUSALS as readonly string[]).includes(payload.reason) + ? (payload.reason as PluginComposerSuggestionRefusal) + : undefined; + settleComposerSuggestion({ + requestId, + shown: payload?.shown === true, + ...(reason ? { reason } : {}), + }); + return { ok: true }; + }, + ); handle(IPC.invoke.pluginOpenPanel, async (id: string) => { const loaded = plugins.getLoaded(id); if (!loaded) throw new Error("plugin not loaded"); diff --git a/apps/desktop/electron/main/ipc/register.ts b/apps/desktop/electron/main/ipc/register.ts index 5237eb58e3..756c03913a 100644 --- a/apps/desktop/electron/main/ipc/register.ts +++ b/apps/desktop/electron/main/ipc/register.ts @@ -37,6 +37,7 @@ import { registerSpeechIpc } from "./speech-ipc"; import { registerVoiceIpc } from "./voice-ipc"; import { registerLiveVoiceIpc } from "./live-voice-ipc"; import type { LiveCallService } from "../live-voice/call-service"; +import type { ComposerSuggestionAnswer } from "../services/composer-suggestion"; import type { LiveVoiceWidget } from "../live-voice/widget-window"; import type { IpcRegistrar } from "./types"; import type { createTraySessions } from "../tray-sessions"; @@ -64,6 +65,8 @@ export type RegisterIpcDependencies = { setActivePluginSession: ( next: { sessionId: string; projectPath: string | null } | null, ) => void; + /** The shell's answer to a composer suggestion it was offered (ADR 0332). */ + settleComposerSuggestion: (answer: ComposerSuggestionAnswer) => boolean; activeUserSubagentDocuments: (...args: any[]) => Promise; disabledBuiltinSubagents: () => Promise; disabledBuiltinSkills: () => Promise; @@ -99,6 +102,7 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) { getNotificationViewingSessionId, setNotificationViewingSessionId, setActivePluginSession, + settleComposerSuggestion, getPluginLauncherWindow, togglePluginLauncher, safeOpenExternal, @@ -505,8 +509,8 @@ export function registerIpcHandlers(dependencies: RegisterIpcDependencies) { getUpdaterLocale, getPluginPanelTheme, setActivePluginSession, + settleComposerSuggestion, }); - registerSpeechIpc({ registrar, speech }); if (voiceService) { diff --git a/apps/desktop/electron/main/plugin-host-process.mjs b/apps/desktop/electron/main/plugin-host-process.mjs index 68dd883722..889a75146d 100644 --- a/apps/desktop/electron/main/plugin-host-process.mjs +++ b/apps/desktop/electron/main/plugin-host-process.mjs @@ -253,6 +253,13 @@ function buildApi() { requestNotificationPermission: () => call("ui.requestNotificationPermission"), showNativeNotification: (input) => call("ui.showNativeNotification", [input]), }, + // One line into the session's composer placeholder (ADR 0332). The shell + // answers whether it went on screen, so a plugin never has to guess before + // spending another completion. + composer: { + suggest: (input) => call("composer.suggest", [input ?? {}]), + clearSuggestion: (input) => call("composer.clearSuggestion", [input ?? {}]), + }, project: { create: (input) => call("project.create", [input ?? {}]), }, diff --git a/apps/desktop/electron/main/plugin-runtime.ts b/apps/desktop/electron/main/plugin-runtime.ts index fb3e4859ae..cd2ded1b50 100644 --- a/apps/desktop/electron/main/plugin-runtime.ts +++ b/apps/desktop/electron/main/plugin-runtime.ts @@ -71,6 +71,13 @@ import { type PluginServiceContrib, type PluginSettingContrib, type PluginComposerTransformInput, + type PluginComposerSuggestInput, + type PluginComposerSuggestResult, + type PluginComposerClearSuggestionInput, + PLUGIN_COMPOSER_SUGGESTION_MAX_CHARS, + PLUGIN_COMPOSER_SUGGESTION_TTL_MS, + PLUGIN_COMPOSER_SUGGESTION_TTL_MIN_MS, + PLUGIN_COMPOSER_SUGGESTION_TTL_MAX_MS, type PluginSkillContrib, type PluginThemeVariableContrib, } from "@pi-desktop/plugin-sdk"; @@ -127,6 +134,10 @@ import { PROMPT_ENHANCEMENT_PLUGIN_ID, type LegacyPromptEnhancementSettings, } from "./plugin-prompt-enhancement-migration"; +import type { + ComposerSuggestionOffer, + ComposerSuggestionOutcome, +} from "./services/composer-suggestion"; export type RegisteredCommand = { id: string; @@ -514,6 +525,14 @@ export type PluginHostServices = { sessionId: string; location: string | null; }) => Promise; + /** + * `pi.composer.suggest`: hand one line to the shell that owns the composer. + * The runtime already checked the permission and shaped the text; the shell + * says whether it went on screen, because only it knows the draft. + */ + suggestComposer?: (offer: ComposerSuggestionOffer) => Promise; + /** `pi.composer.clearSuggestion`: drop this plugin's own line. */ + clearComposerSuggestion?: (pluginId: string, sessionId?: string) => void; project?: { create: (pluginId: string, input: Record) => Promise; }; @@ -547,6 +566,8 @@ const HOST_API_ALLOWLIST = new Set([ "plugin.setSettings", "plugin.getDataPath", "ui.revealView", + "composer.suggest", + "composer.clearSuggestion", "ui.openPanel", "ui.closePanel", "ui.showToast", @@ -1032,6 +1053,34 @@ function normalizePluginSessionInput( return { ...(input as Record) }; } +/** Longest session or turn id a plugin may name, matching the host's own bound. */ +const MAX_SESSION_ID_CHARS = 128; + +/** + * One line of suggestion text, in the form the composer draws: line breaks and + * runs of whitespace fold to single spaces, and the result is cut at the SDK + * ceiling. Shaping rather than refusing is deliberate — a plugin that asked for + * a long or multi-line string still gets a usable suggestion instead of an + * error it cannot repair from inside a turn. + */ +function composerSuggestionText(value: unknown): string { + if (typeof value !== "string") return ""; + const line = value.replace(/\s+/g, " ").trim(); + return [...line].slice(0, PLUGIN_COMPOSER_SUGGESTION_MAX_CHARS).join("").trim(); +} + +/** The requested lifetime, clamped into the range the SDK publishes. */ +function composerSuggestionTtl(value: unknown): number { + if (value === undefined || value === null) return PLUGIN_COMPOSER_SUGGESTION_TTL_MS; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw apiError("INVALID_PARAMS", "ttlMs must be a number of milliseconds"); + } + return Math.min( + Math.max(Math.round(value), PLUGIN_COMPOSER_SUGGESTION_TTL_MIN_MS), + PLUGIN_COMPOSER_SUGGESTION_TTL_MAX_MS, + ); +} + /** * Bounds for the read-only usage fact listing. The host RPC re-checks the * same windows, so a caller that skips this main-process side still cannot @@ -4565,6 +4614,84 @@ export class PluginRuntime { ts: Date.now(), }); } + /** + * `pi.composer.suggest`: one line into the composer of the session the user + * is looking at (`composer.suggest`, ADR 0332). + * + * The plugin names the session and the turn its text answers. Nothing here + * can fail the plugin for a place that will not show it: the text is shaped + * rather than refused, and "not shown" comes back as a reason the plugin can + * log. What does throw is a broken call — no session id, no text left after + * shaping, a lifetime that is not a number — like every other host API. + */ + private async suggestComposer( + loaded: LoadedPlugin, + input?: PluginComposerSuggestInput, + ): Promise { + this.assertPermission(loaded, "composer.suggest"); + const raw = input && typeof input === "object" ? input : ({} as PluginComposerSuggestInput); + const sessionId = typeof raw.sessionId === "string" ? raw.sessionId.trim() : ""; + if (!sessionId || sessionId.length > MAX_SESSION_ID_CHARS) { + throw apiError("INVALID_PARAMS", "sessionId must be a non-empty string"); + } + const turnId = typeof raw.turnId === "string" ? raw.turnId.trim() : ""; + if (turnId.length > MAX_SESSION_ID_CHARS) { + throw apiError("INVALID_PARAMS", "turnId must be at most 128 characters"); + } + const text = composerSuggestionText(raw.text); + if (!text) throw apiError("INVALID_PARAMS", "text must contain at least one visible character"); + const ttlMs = composerSuggestionTtl(raw.ttlMs); + // A plugin scoped to another project is not part of this conversation, so + // it does not get to write into the composer the user is looking at. + if (!this.services.isPluginActiveInProject?.(loaded.manifest.id)) { + return { shown: false, reason: "session-not-visible" }; + } + if (!this.services.suggestComposer) { + throw apiError("UNSUPPORTED", "host api not available: composer.suggest"); + } + const result = await this.services.suggestComposer({ + pluginId: loaded.manifest.id, + sessionId, + ...(turnId ? { turnId } : {}), + text, + ttlMs, + }); + this.services.audit?.({ + pluginId: loaded.manifest.id, + api: "composer.suggest", + ok: true, + sessionId, + shown: result.shown, + ...(result.shown ? {} : { reason: result.reason }), + ts: Date.now(), + }); + return result; + } + + /** + * `pi.composer.clearSuggestion`: drop the caller's own line. Nothing is shown + * when it is already gone, so the answer never depends on what the shell had. + */ + private clearComposerSuggestion( + loaded: LoadedPlugin, + input?: PluginComposerClearSuggestionInput, + ): { ok: true } { + this.assertPermission(loaded, "composer.suggest"); + const sessionId = typeof input?.sessionId === "string" ? input.sessionId.trim() : ""; + if (sessionId.length > MAX_SESSION_ID_CHARS) { + throw apiError("INVALID_PARAMS", "sessionId must be at most 128 characters"); + } + this.services.clearComposerSuggestion?.(loaded.manifest.id, sessionId || undefined); + this.services.audit?.({ + pluginId: loaded.manifest.id, + api: "composer.clearSuggestion", + ok: true, + sessionId: sessionId || null, + ts: Date.now(), + }); + return { ok: true }; + } + private async readSessionContext(loaded: LoadedPlugin): Promise { this.assertPermission(loaded, "session.read"); @@ -5454,6 +5581,16 @@ export class PluginRuntime { revealView: async (input?: PluginRevealViewInput) => this.revealWorkPanelView(loaded, input), }, + composer: { + /** + * One dismissible line in the placeholder layer of the composer the + * user is looking at (`composer.suggest`, ADR 0332). + */ + suggest: async (input?: PluginComposerSuggestInput) => + this.suggestComposer(loaded, input), + clearSuggestion: async (input?: PluginComposerClearSuggestionInput) => + this.clearComposerSuggestion(loaded, input), + }, workspace: { get: async () => { // The enriched payload carries the project group behind the visible diff --git a/apps/desktop/electron/main/services/composer-suggestion.ts b/apps/desktop/electron/main/services/composer-suggestion.ts new file mode 100644 index 0000000000..0e92d0c151 --- /dev/null +++ b/apps/desktop/electron/main/services/composer-suggestion.ts @@ -0,0 +1,262 @@ +/** + * The main-process half of `pi.composer.suggest` (ADR 0332). + * + * A suggestion is produced by a plugin process, long after the turn it answers + * ended, so by the time it arrives the user may have typed, switched session, + * or started another turn. Two owners therefore decide together, and this module + * is the seam between them: + * + * - The main process owns the facts it already has — which session is on screen + * (`pluginSession` snapshot) and which turn that session ran last — so a + * suggestion naming another session, or a turn that is no longer the newest, + * is refused here and never crosses to the window. + * - The renderer owns the composer, so whether the draft is empty and whether a + * composer takes input at all is its answer, sent back over an invoke. The + * caller waits for that answer instead of being told a guess. + * + * Nothing here writes renderer state: the offer is a message, and the composer + * decides. The relay also carries the two shell-side invalidations only the main + * process sees — a new turn starting, and a plugin going away. + */ +import { randomUUID } from "node:crypto"; +import type { PluginComposerSuggestionRefusal } from "@pi-desktop/plugin-sdk"; +import { IPC } from "@pi-desktop/shared"; + +/** One suggestion a plugin asked the shell to show. */ +export type ComposerSuggestionOffer = { + pluginId: string; + sessionId: string; + turnId?: string; + text: string; + ttlMs: number; +}; + +/** What became of one offer: on screen, or why not. */ +export type ComposerSuggestionOutcome = + | { shown: true } + | { shown: false; reason: PluginComposerSuggestionRefusal }; + +/** + * What main sends the renderer. `offer` asks the shell to show one line; `clear` + * orders it to drop what it shows — of one plugin, of one session, or of + * whatever it holds when neither is named. + */ +export type ComposerSuggestionMessage = + | { + kind: "offer"; + requestId: string; + pluginId: string; + sessionId: string; + turnId?: string; + text: string; + /** Epoch milliseconds; the shell drops the line at this moment. */ + expiresAt: number; + } + | { kind: "clear"; pluginId?: string; sessionId?: string }; + +/** The shell's answer to one `offer`, as it arrives over its own invoke. */ +export type ComposerSuggestionAnswer = { + requestId: string; + shown: boolean; + reason?: PluginComposerSuggestionRefusal; +}; + +export type ComposerSuggestionRelayDependencies = { + sendToRenderer: (channel: string, payload: unknown) => void; + /** The session the shell shows, read from the plugin session snapshot. */ + getActiveSession: () => { sessionId: string } | null; + /** How long the shell has to answer an offer; a slow window is no window. */ + answerTimeoutMs?: number; + now?: () => number; + log?: (message: string, data?: Record) => void; +}; + +/** Longest wait for the renderer's answer before the offer is given up on. */ +const DEFAULT_ANSWER_TIMEOUT_MS = 2_000; +/** + * Sessions whose last known turn is still remembered. Only the newest turn per + * session matters and a stale entry is corrected by the next turn start, so the + * map is capped rather than grown with every session the app ever ran. + */ +const MAX_TRACKED_SESSIONS = 64; + +const UNSHOWN: ComposerSuggestionOutcome = { shown: false, reason: "unsupported" }; + +type PendingOffer = { + requestId: string; + pluginId: string; + sessionId: string; + settle: (outcome: ComposerSuggestionOutcome) => void; + timer: ReturnType; +}; + +function enforceBound(map: Map, limit: number): void { + while (map.size > limit) { + const oldest = map.keys().next(); + if (oldest.done) return; + map.delete(oldest.value); + } +} + +export type ComposerSuggestionRelay = ReturnType; + +export function createComposerSuggestionRelay({ + sendToRenderer, + getActiveSession, + answerTimeoutMs = DEFAULT_ANSWER_TIMEOUT_MS, + now = Date.now, + log, +}: ComposerSuggestionRelayDependencies) { + /** sessionId → the offer waiting for the shell's answer. One per session. */ + const pending = new Map(); + /** sessionId → the newest turn the main process heard of. */ + const latestTurns = new Map(); + + const send = (message: ComposerSuggestionMessage): void => { + try { + sendToRenderer(IPC.event.pluginComposerSuggestion, message); + } catch (error) { + // Delivery is best effort: a window that is gone must not turn a plugin's + // suggestion call into a failure, and the offer's own timeout already + // reports the miss. + log?.("composer suggestion send failed", { kind: message.kind, data: String(error) }); + } + }; + + /** Forget one waiting offer and answer it with `reason`. */ + const drop = (offer: PendingOffer, reason: PluginComposerSuggestionRefusal): void => { + if (pending.get(offer.sessionId)?.requestId === offer.requestId) { + pending.delete(offer.sessionId); + } + clearTimeout(offer.timer); + offer.settle({ shown: false, reason }); + }; + + /** Exactly one offer can be in flight per session, so the loser learns it lost. */ + const preempt = (sessionId: string, reason: PluginComposerSuggestionRefusal): void => { + const offer = pending.get(sessionId); + if (offer) drop(offer, reason); + }; + + const answer = (offer: PendingOffer, result: ComposerSuggestionOutcome): void => { + if (pending.get(offer.sessionId)?.requestId === offer.requestId) { + pending.delete(offer.sessionId); + } + clearTimeout(offer.timer); + offer.settle(result); + }; + + return { + /** + * Offer one line to the shell and wait for its answer. + * + * `turnId` is compared with the newest turn the main process saw for the + * session: a suggestion about a turn that has since been overtaken names a + * moment that is over, so it is refused here instead of arriving late in a + * composer that belongs to the newer turn. + */ + offer(offer: ComposerSuggestionOffer): Promise { + const active = getActiveSession(); + if (!active || active.sessionId !== offer.sessionId) { + return Promise.resolve({ shown: false, reason: "session-not-visible" }); + } + const newestTurn = latestTurns.get(offer.sessionId); + if (offer.turnId && newestTurn && newestTurn !== offer.turnId) { + return Promise.resolve({ shown: false, reason: "stale-turn" }); + } + // The newer offer takes the session's single slot; the one still waiting + // for an answer is told so instead of being left to time out. + preempt(offer.sessionId, "replaced"); + const requestId = randomUUID(); + return new Promise((resolve) => { + let settled = false; + const settle = (result: ComposerSuggestionOutcome): void => { + if (settled) return; + settled = true; + resolve(result); + }; + const timer = setTimeout(() => { + const current = pending.get(offer.sessionId); + if (current?.requestId === requestId) drop(current, "unsupported"); + }, answerTimeoutMs); + // A window that closes mid-answer must not keep the timer alive. + (timer as { unref?: () => void }).unref?.(); + pending.set(offer.sessionId, { + requestId, + pluginId: offer.pluginId, + sessionId: offer.sessionId, + settle, + timer, + }); + send({ + kind: "offer", + requestId, + pluginId: offer.pluginId, + sessionId: offer.sessionId, + ...(offer.turnId ? { turnId: offer.turnId } : {}), + text: offer.text, + expiresAt: now() + offer.ttlMs, + }); + }); + }, + + /** + * The shell's word on one offer. Answers arrive asynchronously, so one that + * lands after its offer was replaced, timed out or dropped is simply + * ignored. + */ + settle(reply: ComposerSuggestionAnswer): boolean { + const offer = [...pending.values()].find((entry) => entry.requestId === reply.requestId); + if (!offer) return false; + answer( + offer, + reply.shown ? { shown: true } : { shown: false, reason: reply.reason ?? "unsupported" }, + ); + return true; + }, + + /** + * A host turn opened for this session: the new turn owns the composer now, + * and a suggestion about an earlier turn is over. + */ + noteTurnStarted(sessionId: string, turnId: string): void { + const id = sessionId.trim(); + const turn = turnId.trim(); + if (!id || !turn) return; + latestTurns.set(id, turn); + enforceBound(latestTurns, MAX_TRACKED_SESSIONS); + preempt(id, "stale-turn"); + send({ kind: "clear", sessionId: id }); + }, + + /** The plugin stopped (unloaded, disabled, crashed, revoked): its line goes. */ + clearPlugin(pluginId: string): void { + const id = pluginId.trim(); + if (!id) return; + for (const offer of [...pending.values()]) { + if (offer.pluginId === id) drop(offer, "unsupported"); + } + send({ kind: "clear", pluginId: id }); + }, + + /** A plugin dropped its own suggestion; a session narrows it further. */ + clearSuggestion(pluginId: string, sessionId?: string): void { + const id = pluginId.trim(); + if (!id) return; + const session = sessionId?.trim() ?? ""; + for (const offer of [...pending.values()]) { + if (offer.pluginId !== id) continue; + if (session && offer.sessionId !== session) continue; + drop(offer, "unsupported"); + } + send({ kind: "clear", pluginId: id, ...(session ? { sessionId: session } : {}) }); + }, + + /** The window is going away: nothing will show these offers. */ + dispose(): void { + for (const offer of [...pending.values()]) drop(offer, "unsupported"); + pending.clear(); + latestTurns.clear(); + }, + }; +} diff --git a/apps/desktop/electron/main/services/plugin-services.ts b/apps/desktop/electron/main/services/plugin-services.ts index 9fb135f361..33251db00a 100644 --- a/apps/desktop/electron/main/services/plugin-services.ts +++ b/apps/desktop/electron/main/services/plugin-services.ts @@ -56,6 +56,7 @@ import type { ClipboardHistory } from "../clipboard-history"; import type { TurnEndedPayload } from "../runtime/session-coordination"; import type { HostProcess } from "../host-process"; import { createPluginSessionContext } from "./plugin-session-context"; +import { createComposerSuggestionRelay } from "./composer-suggestion"; import type { Logger } from "../logger"; import type { PluginAppearance } from "../../shared/plugin-panel-chrome"; @@ -637,6 +638,17 @@ export function createPluginServices({ getActiveTurn, announce: broadcastSessionEvent, }); + /** + * `pi.composer.suggest` (ADR 0332): the main process refuses a suggestion + * that names another session or an overtaken turn, and the shell answers + * whether the composer can take it. The composer owns its own state, so this + * only ever relays — nothing here writes the draft. + */ + const composerSuggestion = createComposerSuggestionRelay({ + sendToRenderer, + getActiveSession: () => pluginSession.snapshot(), + log: (message, data) => logger.app("plugin", "warn", message, { data }), + }); const pluginViews = new PluginViewHost(({ pluginId, url }) => { logger.app("plugin", "warn", "plugin.api", { pluginId, @@ -730,6 +742,15 @@ export function createPluginServices({ ...(location ? { location } : {}), }); }, + /** + * `pi.composer.suggest`: the runtime already checked the permission and + * shaped the text, and this only relays the offer to the shell that owns the + * composer. + */ + suggestComposer: (offer) => composerSuggestion.offer(offer), + /** `pi.composer.clearSuggestion`: drop this plugin's own line. */ + clearComposerSuggestion: (pluginId, sessionId) => + composerSuggestion.clearSuggestion(pluginId, sessionId), browser: { reveal: (sessionId) => browserHost.reveal(sessionId), navigate: (input, sessionId, tabId) => browserHost.navigate(input, sessionId, tabId), @@ -748,6 +769,8 @@ export function createPluginServices({ }, onPluginUnload: (pluginId) => { if (pluginId === BROWSER_PLUGIN_ID) browserHost.disposeGuest(); + // A stopped plugin loses its line in the composer with it. + composerSuggestion.clearPlugin(pluginId); }, }); const speech = createSpeechService({ @@ -768,6 +791,7 @@ export function createPluginServices({ pluginViews, browserHost, pluginSession, + composerSuggestion, speech, }; } diff --git a/apps/desktop/src/components/Composer.tsx b/apps/desktop/src/components/Composer.tsx index c9baf395d8..05d9fa922e 100644 --- a/apps/desktop/src/components/Composer.tsx +++ b/apps/desktop/src/components/Composer.tsx @@ -50,6 +50,7 @@ import { useComposerCompletions } from "../features/chat/composer/hooks/useCompo import { useComposerDraft } from "../features/chat/composer/hooks/useComposerDraft"; import { useComposerInputHistory } from "../features/chat/composer/hooks/useComposerInputHistory"; import { usePluginComposerBridge } from "../features/chat/composer/hooks/usePluginComposerBridge"; +import { useComposerSuggestion } from "../features/chat/composer/hooks/useComposerSuggestion"; import { useComposerSubmit } from "../features/chat/composer/hooks/useComposerSubmit"; import { useComposerTransforms } from "../features/chat/composer/hooks/useComposerTransforms"; import { ComposerInput } from "../features/chat/composer/ComposerInput"; @@ -495,6 +496,9 @@ export function Composer({ // Plugin draft and attachment actions reach this composer while it takes input. usePluginComposerBridge({ ...draft, inputBlocked, enabled: localToolsAllowed }); + // A plugin may have offered one line for this empty draft; it shows in the + // placeholder layer and `Tab` takes it (ADR 0332). + const suggestion = useComposerSuggestion(); // Keep the transcript's bottom reserve in sync with the composer's real // height (it grows with multi-line input) so the last message sits just @@ -581,6 +585,7 @@ export function Composer({ enterToSend={enterToSend} runActive={runActive} composerAc={completions.ac} + suggestion={suggestion} onPaste={pasteClipboardFiles} onAcceptCompletion={completions.acceptCompletion} onSubmit={(steering) => void submitFromComposer(steering)} diff --git a/apps/desktop/src/features/app/useAppShellRuntime.tsx b/apps/desktop/src/features/app/useAppShellRuntime.tsx index f7058af029..08a94051e5 100644 --- a/apps/desktop/src/features/app/useAppShellRuntime.tsx +++ b/apps/desktop/src/features/app/useAppShellRuntime.tsx @@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next"; import { installRendererApi } from "../../capture/renderer-api"; import { StartupSplash } from "../../components/StartupSplash"; import { api } from "../../lib/api"; +import { composerSuggestion } from "../chat/composer/plugins/suggestion-bridge"; import { subscribeRemoteInteractions } from "../../lib/remote-interactions"; import { playNotificationChime } from "../../lib/notification-sound"; import { @@ -700,6 +701,30 @@ export function useAppShellRuntime() { ...(event.location ? { location: event.location } : {}), }); }); + // A plugin offered one line for the composer (`pi.composer.suggest`). The + // shell owns the composer, so it decides here and answers the main process, + // which is waiting on the plugin's call with that answer. + const offComposerSuggestion = api.onPluginComposerSuggestion((event) => { + if (event.kind === "clear") { + composerSuggestion.clear({ + ...(event.pluginId ? { pluginId: event.pluginId } : {}), + ...(event.sessionId ? { sessionId: event.sessionId } : {}), + }); + return; + } + const outcome = composerSuggestion.offer({ + pluginId: event.pluginId, + sessionId: event.sessionId, + ...(event.turnId ? { turnId: event.turnId } : {}), + text: event.text, + expiresAt: event.expiresAt, + }); + void api.pluginComposerSuggestionResult({ + requestId: event.requestId, + shown: outcome.shown === true, + ...(outcome.shown ? {} : { reason: outcome.reason }), + }); + }); const offHostStatus = api.onHostStatus((status) => { if (status.archMismatch) setArchMismatch(status.archMismatch); if (status.ok) { @@ -910,6 +935,7 @@ export function useAppShellRuntime() { offPluginViewReveal(); offHostStatus(); offNotificationChanged(); + offComposerSuggestion(); offSessionsChanged(); offRemoteInteractions(); offNotificationActivated(); diff --git a/apps/desktop/src/features/chat/composer/ComposerInput.tsx b/apps/desktop/src/features/chat/composer/ComposerInput.tsx index 66ca68c02b..dcacf74872 100644 --- a/apps/desktop/src/features/chat/composer/ComposerInput.tsx +++ b/apps/desktop/src/features/chat/composer/ComposerInput.tsx @@ -11,6 +11,8 @@ import type { import { editorSelectionRange, readEditorValue } from "./editor"; import { ComposerImagePreview } from "./ComposerImagePreview"; import { ComposerImageHover } from "./ComposerImageHoverPreview"; +import { composerSuggestionKeyAction } from "./plugins/suggestion-keys"; +import type { ComposerSuggestionView } from "./hooks/useComposerSuggestion"; import type { CompletionController } from "./hooks/useComposerCompletions"; import type { ComposerImagePreviewController } from "./hooks/useComposerImagePreview"; @@ -25,6 +27,12 @@ export type ComposerInputProps = { enterToSend: boolean; runActive: boolean; composerAc: CompletionController; + /** + * The plugin suggestion on screen, if any (ADR 0332). It is drawn in the + * placeholder layer while the draft is empty, and `Tab` / `Esc` act on it only + * when nothing else claims those keys. + */ + suggestion?: ComposerSuggestionView | null; onPaste: (event: ClipboardEvent) => void; onAcceptCompletion: (index: number) => void; onSubmit: (steering?: boolean) => void; @@ -57,6 +65,7 @@ export function ComposerInput({ enterToSend, runActive, composerAc, + suggestion = null, onPaste, onAcceptCompletion, onSubmit, @@ -150,6 +159,19 @@ export function ComposerInput({ return; } } + // A completion list owns `Tab` and the first `Escape`; the plugin + // suggestion only acts once neither claims the key. + const suggestionAction = composerSuggestionKeyAction(event, { + suggestion: suggestion !== null, + completionItems: composerAc.hasItems, + completionOpen: composerAc.open, + }); + if (suggestionAction) { + event.preventDefault(); + if (suggestionAction === "accept") suggestion?.accept(); + else suggestion?.dismiss(); + return; + } if ( (event.key === "ArrowUp" || event.key === "ArrowDown") && !event.shiftKey && @@ -171,13 +193,34 @@ export function ComposerInput({ } }} /> + {/* The plugin suggestion (`pi.composer.suggest`) rides in the + placeholder layer — never a bubble — and only while the draft is + empty, which is what makes Tab a plain "write this for me". */} {value.length === 0 ? ( -
diff --git a/apps/desktop/src/features/chat/composer/hooks/useComposerSuggestion.ts b/apps/desktop/src/features/chat/composer/hooks/useComposerSuggestion.ts new file mode 100644 index 0000000000..2786277f81 --- /dev/null +++ b/apps/desktop/src/features/chat/composer/hooks/useComposerSuggestion.ts @@ -0,0 +1,49 @@ +/** + * The composer's view of the plugin suggestion slot (ADR 0332). + * + * The state itself lives in `../plugins/suggestion-bridge.ts`, outside React, so + * a suggestion that arrives while the composer is unmounted is refused once + * instead of queued; this only subscribes a mounted composer to it and hands the + * placeholder layer the line and the key hint it draws. + */ +import { useMemo, useSyncExternalStore } from "react"; +import { useTranslation } from "react-i18next"; +import { composerSuggestion } from "../plugins/suggestion-bridge"; + +/** What `ComposerInput` draws and does while a suggestion is on screen. */ +export type ComposerSuggestionView = { + readonly text: string; + /** `Tab` / `Esc` hint drawn beside the line, in the shell's language. */ + readonly hint: string; + /** Take it: the draft becomes the text and the caret lands at its end. */ + accept(): boolean; + dismiss(): void; +}; + +const subscribe = (listener: () => void) => composerSuggestion.subscribe(listener); +const snapshot = () => composerSuggestion.get(); +/** + * There is no shell state to read while the app is rendered to a string, and + * React requires this third snapshot for exactly that pass: a suggestion is a + * live-window fact, so a server render starts with none. + */ +const serverSnapshot = (): null => null; + +export function useComposerSuggestion(): ComposerSuggestionView | null { + const { t } = useTranslation(); + const suggestion = useSyncExternalStore(subscribe, snapshot, serverSnapshot); + const text = suggestion?.text ?? ""; + const hint = t("chat.suggestionHint"); + return useMemo( + () => + suggestion + ? { + text, + hint, + accept: () => composerSuggestion.accept(), + dismiss: () => composerSuggestion.dismiss(), + } + : null, + [suggestion, text, hint], + ); +} diff --git a/apps/desktop/src/features/chat/composer/hooks/usePluginComposerBridge.ts b/apps/desktop/src/features/chat/composer/hooks/usePluginComposerBridge.ts index 6da1369e94..9d20d3008f 100644 --- a/apps/desktop/src/features/chat/composer/hooks/usePluginComposerBridge.ts +++ b/apps/desktop/src/features/chat/composer/hooks/usePluginComposerBridge.ts @@ -1,13 +1,16 @@ /** * Lends this composer to the plugin draft bridge (`../plugins/draft-bridge.ts`) - * while it is mounted, and tells the bridge after every render that changed - * the draft, so plugin subscribers and the draft's generation follow it. + * and to the plugin suggestion slot (`../plugins/suggestion-bridge.ts`) while it + * is mounted, and tells both after every render that changed the draft, so + * plugin subscribers follow it, the draft's generation advances, and a + * suggestion about an empty draft ends the moment the draft stops being empty. */ import { useEffect, useRef, type RefObject } from "react"; import { api } from "../../../../lib/api"; import { editorSelectionRange, readEditorValue } from "../editor"; import type { ComposerFileReference } from "../model"; import { composerDraftBridge, type ComposerDraftHandle } from "../plugins/draft-bridge"; +import { composerSuggestion } from "../plugins/suggestion-bridge"; type BridgeSource = { ref: RefObject; @@ -75,14 +78,20 @@ export function usePluginComposerBridge(source: BridgeSource): void { }; handleRef.current = handle; const unregister = composerDraftBridge.register(handle); + // The suggestion slot needs the same handle: it reads the draft to decide + // whether an offer fits and writes it when the user takes one. + const unregisterSuggestion = composerSuggestion.register(handle); return () => { handleRef.current = null; + unregisterSuggestion(); unregister(); }; }, [source.enabled]); const { value, fileReferences, draftKey, referenceSessionId, inputBlocked } = source; useEffect(() => { - if (handleRef.current) composerDraftBridge.publish(handleRef.current); + if (!handleRef.current) return; + composerDraftBridge.publish(handleRef.current); + composerSuggestion.publish(handleRef.current); }, [value, fileReferences, draftKey, referenceSessionId, inputBlocked]); } diff --git a/apps/desktop/src/features/chat/composer/plugins/suggestion-bridge.ts b/apps/desktop/src/features/chat/composer/plugins/suggestion-bridge.ts new file mode 100644 index 0000000000..1faedf3a4f --- /dev/null +++ b/apps/desktop/src/features/chat/composer/plugins/suggestion-bridge.ts @@ -0,0 +1,219 @@ +/** + * The renderer end of `pi.composer.suggest` (ADR 0332). + * + * A plugin process offers one line, the shell decides whether the composer can + * take it, and the user takes it with Tab, drops it with Esc, or drops it by + * typing. This module owns that decision and the single suggestion slot; the + * main process owns the two facts the composer cannot know (which session is on + * screen, which turn is current) and has already refused a stale offer before it + * reaches here. + * + * The suggestion is never a draft value: it lives beside the draft and shows in + * the placeholder layer, so nothing a plugin offers can be sent without the user + * accepting it. Every reason the slot can be emptied is handled here: + * + * - the draft stops being empty (typing, pasting, a chip, `composer.insertText`, + * `composer.replaceDraft`) — the composer publishes each change; + * - the composer takes input for another session, or stops taking input at all; + * - the offer's own lifetime runs out; + * - the main process says so: a new turn started, the plugin stopped, or the + * plugin asked to clear it. + * + * A renderer reload and a window close end the whole module with the page, so + * there is nothing to hold over. + */ +import type { PluginComposerSuggestionRefusal } from "@pi-desktop/plugin-sdk"; +import type { ComposerDraftHandle } from "./draft-bridge"; + +/** One suggestion the shell may draw in the placeholder layer. */ +export type ComposerSuggestion = { + readonly pluginId: string; + readonly sessionId: string; + readonly turnId?: string; + readonly text: string; + /** Epoch milliseconds; the line goes away at this moment. */ + readonly expiresAt: number; +}; + +/** What the main process asks the shell to consider. */ +export type ComposerSuggestionOffer = ComposerSuggestion; + +/** Whether the offer is on screen now. */ +export type ComposerSuggestionOutcome = + | { shown: true } + | { shown: false; reason: PluginComposerSuggestionRefusal }; + +/** Narrowing filter for a host-ordered clear; nothing named means everything. */ +export type ComposerSuggestionFilter = { + readonly pluginId?: string; + readonly sessionId?: string; +}; + +type Live = { + readonly sessionId: string; + readonly text: string; + /** References the draft holds, staged attachments included. */ + readonly referenceCount: number; +}; + +function now(): number { + return Date.now(); +} + +export class ComposerSuggestionBridge { + private readonly handles: ComposerDraftHandle[] = []; + private readonly listeners = new Set<() => void>(); + private current: ComposerSuggestion | null = null; + private timer: ReturnType | null = null; + + /** Lend the bridge a composer; the returned call takes it back. */ + register(handle: ComposerDraftHandle): () => void { + this.handles.push(handle); + this.reconcile(); + return () => { + const index = this.handles.lastIndexOf(handle); + if (index === -1) return; + this.handles.splice(index, 1); + this.reconcile(); + }; + } + + /** + * The composer's word that its draft changed. The suggestion belongs to an + * empty draft of one session, so a first character, a staged attachment or a + * switch of session ends it here — before any render draws it again. + */ + publish(handle: ComposerDraftHandle): void { + if (this.handles.at(-1) !== handle) return; + this.reconcile(); + } + + /** The suggestion on screen, or `null`. Stable between changes. */ + get(): ComposerSuggestion | null { + return this.current; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Consider one offer. Only the composer taking input can answer, so a window + * with no composer reports `unsupported` instead of pretending. + */ + offer(offer: ComposerSuggestionOffer): ComposerSuggestionOutcome { + const live = this.live(); + if (!live) return { shown: false, reason: "unsupported" }; + if (live.sessionId !== offer.sessionId) return { shown: false, reason: "session-not-visible" }; + if (live.text.length > 0 || live.referenceCount > 0) { + return { shown: false, reason: "draft-not-empty" }; + } + // The main process dates the offer when the plugin made it, so an offer + // that arrives after its own lifetime describes a moment that is over. + if (offer.expiresAt <= now()) return { shown: false, reason: "stale-turn" }; + this.set({ + pluginId: offer.pluginId, + sessionId: offer.sessionId, + ...(offer.turnId ? { turnId: offer.turnId } : {}), + text: offer.text, + expiresAt: offer.expiresAt, + }); + return { shown: true }; + } + + /** + * Take the suggestion: the draft becomes its text, the caret lands at the end + * and the input takes focus, which is exactly what typing that sentence would + * have done. The existing references are kept, so an image staged in the + * meantime is not lost. + */ + accept(): boolean { + const suggestion = this.current; + const draft = this.handles.at(-1)?.read() ?? null; + if ( + !suggestion || + !draft || + draft.text.length > 0 || + draft.sessionId !== suggestion.sessionId + ) { + this.dismiss(); + return false; + } + this.dismiss(); + this.handles + .at(-1) + ?.write(suggestion.text, [...draft.references], suggestion.text.length, true); + return true; + } + + /** Drop whatever is on screen. */ + dismiss(): void { + this.set(null); + } + + /** A host-ordered clear: one plugin, one session, or everything. */ + clear(filter: ComposerSuggestionFilter = {}): void { + const suggestion = this.current; + if (!suggestion) return; + if (filter.pluginId && filter.pluginId !== suggestion.pluginId) return; + if (filter.sessionId && filter.sessionId !== suggestion.sessionId) return; + this.dismiss(); + } + + private set(next: ComposerSuggestion | null): void { + if (this.timer !== null) { + clearTimeout(this.timer); + this.timer = null; + } + if (this.current === next) return; + this.current = next; + if (next) { + // A lifetime the shell already spent is not a suggestion any more. + const remaining = next.expiresAt - now(); + this.timer = setTimeout( + () => { + if (this.current?.expiresAt === next.expiresAt) this.dismiss(); + }, + Math.max(0, remaining), + ); + } + for (const listener of [...this.listeners]) listener(); + } + + /** The composer taking input and its draft, or `null`. */ + private live(): Live | null { + const handle = this.handles.at(-1); + const draft = handle?.read() ?? null; + if (!handle || !draft) return null; + return { + sessionId: draft.sessionId, + text: draft.text, + referenceCount: draft.references.length, + }; + } + + /** + * End the suggestion when the composer can no longer hold it. Called on every + * draft change and on every registration change, so the slot never outlives + * the empty draft it was offered for. + */ + private reconcile(): void { + const suggestion = this.current; + if (!suggestion) return; + const live = this.live(); + if ( + !live || + live.sessionId !== suggestion.sessionId || + live.text.length > 0 || + live.referenceCount > 0 + ) { + this.dismiss(); + } + } +} + +/** The app window's suggestion slot. */ +export const composerSuggestion = new ComposerSuggestionBridge(); diff --git a/apps/desktop/src/features/chat/composer/plugins/suggestion-keys.ts b/apps/desktop/src/features/chat/composer/plugins/suggestion-keys.ts new file mode 100644 index 0000000000..9111cdb73d --- /dev/null +++ b/apps/desktop/src/features/chat/composer/plugins/suggestion-keys.ts @@ -0,0 +1,44 @@ +/** + * Which key, if any, acts on the composer's plugin suggestion (ADR 0332). + * + * The composer already has owners for both keys and they keep them: a `@` / `/` + * completion list takes `Tab` (and `Enter`), and its own `Escape` closes it + * first. Only when neither claims the key, the draft is empty and a suggestion + * is on screen does `Tab` take it and `Esc` drop it. + * + * Pure, so the ordering is tested without a DOM. + */ +export type ComposerSuggestionKeyAction = "accept" | "dismiss"; + +export type ComposerSuggestionKeyEvent = { + readonly key: string; + readonly shiftKey?: boolean; + readonly altKey?: boolean; + readonly metaKey?: boolean; + readonly ctrlKey?: boolean; +}; + +export type ComposerSuggestionKeyState = { + /** A suggestion is on screen. */ + readonly suggestion: boolean; + /** The completion list has items, so `Tab` belongs to it. */ + readonly completionItems: boolean; + /** A completion list is open, so the first `Escape` closes it. */ + readonly completionOpen: boolean; +}; + +export function composerSuggestionKeyAction( + event: ComposerSuggestionKeyEvent, + state: ComposerSuggestionKeyState, +): ComposerSuggestionKeyAction | null { + if (!state.suggestion) return null; + if (state.completionItems) return null; + const plain = !event.altKey && !event.metaKey && !event.ctrlKey; + if (event.key === "Escape") { + // The list closes on the first Escape; the suggestion is the second one. + if (state.completionOpen) return null; + return plain ? "dismiss" : null; + } + if (event.key !== "Tab" || !plain) return null; + return event.shiftKey ? null : "accept"; +} diff --git a/apps/desktop/src/features/plugins/model.ts b/apps/desktop/src/features/plugins/model.ts index d483cde5cd..644cc4353d 100644 --- a/apps/desktop/src/features/plugins/model.ts +++ b/apps/desktop/src/features/plugins/model.ts @@ -73,6 +73,9 @@ export const PERMISSION_RISK: Record = { "session.read.own": "medium", "session.update.own": "medium", "composer.transform": "medium", + // Writes user-facing text into a draft the user may send, but only when the + // draft is empty and only through an explicit Tab; it never reads the draft. + "composer.suggest": "medium", // Per-turn counters and session titles only, per the usage.read matrix row. "usage.read": "medium", // Two capabilities that reach outside PI-Desktop's own window or read its diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index b78fe97fca..982a10cb0c 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -1779,6 +1779,79 @@ export const api = { }); }); }, + /** + * A plugin offered one line for the composer, or the main process ordered + * whatever is on screen dropped (`pi.composer.suggest`, ADR 0332). + * + * The shell answers every offer over `pluginComposerSuggestionResult`, because + * only the renderer knows whether the draft can take it. A malformed message + * is ignored — nothing here decides whether a plugin's suggestion is valid. + */ + onPluginComposerSuggestion: ( + listener: ( + event: + | { + kind: "offer"; + requestId: string; + pluginId: string; + sessionId: string; + turnId?: string; + text: string; + expiresAt: number; + } + | { kind: "clear"; pluginId?: string; sessionId?: string }, + ) => void, + ) => { + if (!window.piDesktop?.on) return () => undefined; + return window.piDesktop.on(IPC.event.pluginComposerSuggestion, (payload) => { + const event = payload as { + kind?: unknown; + requestId?: unknown; + pluginId?: unknown; + sessionId?: unknown; + turnId?: unknown; + text?: unknown; + expiresAt?: unknown; + }; + if (event?.kind === "clear") { + listener({ + kind: "clear", + ...(typeof event.pluginId === "string" && event.pluginId + ? { pluginId: event.pluginId } + : {}), + ...(typeof event.sessionId === "string" && event.sessionId + ? { sessionId: event.sessionId } + : {}), + }); + return; + } + if ( + event?.kind !== "offer" || + typeof event.requestId !== "string" || + typeof event.pluginId !== "string" || + typeof event.sessionId !== "string" || + typeof event.text !== "string" || + typeof event.expiresAt !== "number" + ) { + return; + } + listener({ + kind: "offer", + requestId: event.requestId, + pluginId: event.pluginId, + sessionId: event.sessionId, + ...(typeof event.turnId === "string" && event.turnId ? { turnId: event.turnId } : {}), + text: event.text, + expiresAt: event.expiresAt, + }); + }); + }, + /** The shell's answer to one composer suggestion the main process is waiting on. */ + pluginComposerSuggestionResult: (input: { + requestId: string; + shown: boolean; + reason?: string; + }) => invoke<{ ok: boolean }>(IPC.invoke.pluginComposerSuggestionResult, input), onBrowserOpenFile: (listener: (event: { path: string }) => void) => { if (!window.piDesktop?.on) return () => undefined; return window.piDesktop.on(IPC.event.browserOpenFile, (payload) => { diff --git a/apps/desktop/src/styles/composer.css b/apps/desktop/src/styles/composer.css index a3742d2257..569295a3ab 100644 --- a/apps/desktop/src/styles/composer.css +++ b/apps/desktop/src/styles/composer.css @@ -224,6 +224,39 @@ } } +/* + * A plugin suggestion (`pi.composer.suggest`, ADR 0332) rides in the same + * placeholder layer, so it can never cover a message. It reads brighter than + * the shell's own placeholder because it is text a person can take with Tab, + * not a hint about the box; the hint beside it stays quiet and never wraps. + * The layer is always one line with an ellipsis, which is why the text, not + * the hint, is what gets truncated on a narrow composer. + */ +.composer-placeholder-suggestion { + display: flex; + align-items: baseline; + gap: 8px; + color: var(--ds-text-primary); + /* The shared fade ends at the placeholder's own weight, not this one's. */ + animation: none; +} + +.composer-suggestion-text { + min-width: 0; + overflow: hidden; + /* 62% of primary ink: brighter than the shell placeholder's 42% at 0.72. */ + opacity: 0.62; + text-overflow: ellipsis; + white-space: nowrap; +} + +.composer-suggestion-hint { + flex: none; + color: var(--ds-text-secondary); + font-size: var(--text-xs); + white-space: nowrap; +} + .composer-mode-chip-face { display: inline-flex; min-width: 0; diff --git a/apps/desktop/test/composer-suggestion-relay.test.mjs b/apps/desktop/test/composer-suggestion-relay.test.mjs new file mode 100644 index 0000000000..820857e602 --- /dev/null +++ b/apps/desktop/test/composer-suggestion-relay.test.mjs @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import { register } from "node:module"; +import test from "node:test"; +register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url)); + +/* + * The main-process half of `pi.composer.suggest` (ADR 0332): which offers never + * reach the window, which one may answer, and what happens to a suggestion when + * the turn or the plugin behind it ends first. + */ +const { createComposerSuggestionRelay } = await import( + "../electron/main/services/composer-suggestion.ts" +); +const { IPC } = await import("@pi-desktop/shared"); + +const SESSION = "session-1"; + +function makeRelay({ activeSession = SESSION, answerTimeoutMs = 50 } = {}) { + const sent = []; + const relay = createComposerSuggestionRelay({ + sendToRenderer: (channel, payload) => { + assert.equal(channel, IPC.event.pluginComposerSuggestion); + sent.push(payload); + }, + getActiveSession: () => (activeSession ? { sessionId: activeSession } : null), + answerTimeoutMs, + }); + return { relay, sent }; +} + +function offer(overrides = {}) { + return { + pluginId: "demo.suggestion", + sessionId: SESSION, + turnId: "turn-1", + text: "Explain the failing test", + ttlMs: 60_000, + ...overrides, + }; +} + +test("a suggestion for a session the user left never crosses to the window", async () => { + const { relay, sent } = makeRelay({ activeSession: "session-2" }); + assert.deepEqual(await relay.offer(offer()), { + shown: false, + reason: "session-not-visible", + }); + assert.equal(sent.length, 0); +}); + +test("the window's answer is what the plugin is told", async () => { + const { relay, sent } = makeRelay(); + const pending = relay.offer(offer()); + + assert.equal(sent.length, 1); + assert.equal(sent[0].kind, "offer"); + assert.equal(sent[0].text, "Explain the failing test"); + assert.equal(sent[0].turnId, "turn-1"); + assert.ok(sent[0].expiresAt > Date.now()); + + assert.equal(relay.settle({ requestId: sent[0].requestId, shown: true }), true); + assert.deepEqual(await pending, { shown: true }); + + // A second answer to the same request changes nothing. + assert.equal(relay.settle({ requestId: sent[0].requestId, shown: true }), false); +}); + +test("a draft that cannot take the line is reported with its own reason", async () => { + const { relay, sent } = makeRelay(); + const pending = relay.offer(offer()); + relay.settle({ requestId: sent[0].requestId, shown: false, reason: "draft-not-empty" }); + assert.deepEqual(await pending, { shown: false, reason: "draft-not-empty" }); +}); + +test("a silent window costs the caller a miss, never a hang", async () => { + const { relay } = makeRelay({ answerTimeoutMs: 20 }); + assert.deepEqual(await relay.offer(offer()), { shown: false, reason: "unsupported" }); +}); + +test("one suggestion per session: the newer offer takes the slot", async () => { + const { relay, sent } = makeRelay(); + const first = relay.offer(offer({ text: "first" })); + const second = relay.offer(offer({ pluginId: "other.plugin", text: "second" })); + + assert.deepEqual(await first, { shown: false, reason: "replaced" }); + assert.equal(sent.at(-1).text, "second"); + relay.settle({ requestId: sent.at(-1).requestId, shown: true }); + assert.deepEqual(await second, { shown: true }); +}); + +test("a new turn clears the line and refuses a suggestion about the old one", async () => { + const { relay, sent } = makeRelay(); + const pending = relay.offer(offer()); + + relay.noteTurnStarted(SESSION, "turn-2"); + assert.deepEqual(await pending, { shown: false, reason: "stale-turn" }); + assert.deepEqual(sent.at(-1), { kind: "clear", sessionId: SESSION }); + + // The turn a plugin answers has been overtaken by the time its text exists. + assert.deepEqual(await relay.offer(offer({ turnId: "turn-1" })), { + shown: false, + reason: "stale-turn", + }); + // A suggestion for the turn that is running now is still allowed through. + const current = relay.offer(offer({ turnId: "turn-2" })); + relay.settle({ requestId: sent.at(-1).requestId, shown: true }); + assert.deepEqual(await current, { shown: true }); +}); + +test("a plugin that stops loses its line, shown or still waiting", async () => { + const { relay, sent } = makeRelay(); + const pending = relay.offer(offer()); + + relay.clearPlugin("demo.suggestion"); + assert.deepEqual(await pending, { shown: false, reason: "unsupported" }); + assert.deepEqual(sent.at(-1), { kind: "clear", pluginId: "demo.suggestion" }); + + // Another plugin's waiting offer is untouched by that clear. + const other = relay.offer(offer({ pluginId: "other.plugin" })); + const otherOffer = sent.at(-1); + relay.clearPlugin("demo.suggestion"); + relay.settle({ requestId: otherOffer.requestId, shown: true }); + assert.deepEqual(await other, { shown: true }); +}); + +test("a plugin's own clear names the plugin and, optionally, the session", () => { + const { relay, sent } = makeRelay(); + relay.clearSuggestion("demo.suggestion", SESSION); + assert.deepEqual(sent.at(-1), { + kind: "clear", + pluginId: "demo.suggestion", + sessionId: SESSION, + }); + relay.clearSuggestion("demo.suggestion"); + assert.deepEqual(sent.at(-1), { kind: "clear", pluginId: "demo.suggestion" }); +}); + +test("quitting answers every waiting offer and forgets the turn history", async () => { + const { relay } = makeRelay(); + const pending = relay.offer(offer()); + relay.dispose(); + assert.deepEqual(await pending, { shown: false, reason: "unsupported" }); + + + // Nothing is left to answer for, so a late word from the window is ignored. + assert.equal(relay.settle({ requestId: "gone", shown: true }), false); +}); diff --git a/apps/desktop/test/composer-suggestion-state.test.mjs b/apps/desktop/test/composer-suggestion-state.test.mjs new file mode 100644 index 0000000000..15d98a5db7 --- /dev/null +++ b/apps/desktop/test/composer-suggestion-state.test.mjs @@ -0,0 +1,236 @@ +import assert from "node:assert/strict"; +import { register } from "node:module"; +import test from "node:test"; +register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url)); + +/* + * The renderer half of `pi.composer.suggest` (ADR 0332): whether an offer can go + * on screen, what takes it down, and which key acts on it while the completion + * list still owns Tab and the first Escape. + */ +const { ComposerSuggestionBridge } = await import( + "../src/features/chat/composer/plugins/suggestion-bridge.ts" +); +const { composerSuggestionKeyAction } = await import( + "../src/features/chat/composer/plugins/suggestion-keys.ts" +); + +const SESSION = "session-1"; +const TURN = "turn-1"; + +/** A composer that takes input, as `usePluginComposerBridge` lends one. */ +function makeHandle({ + sessionId = SESSION, + text = "", + references = [], + caret = null, +} = {}) { + return { + state: { sessionId, text, references, caret, focused: false }, + read() { + return { + draftKey: this.state.sessionId || "home", + sessionId: this.state.sessionId, + text: this.state.text, + references: this.state.references, + }; + }, + focused() { + return this.state.focused; + }, + selection() { + const end = this.state.caret ?? this.state.text.length; + return { start: end, end }; + }, + write(nextText, nextReferences, nextCaret, focus) { + this.state.text = nextText; + this.state.references = nextReferences; + this.state.caret = nextCaret; + if (focus) this.state.focused = true; + }, + async stage() { + throw new Error("staging is not part of this test"); + }, + }; +} + +function offer(overrides = {}) { + return { + pluginId: "demo.suggestion", + sessionId: SESSION, + turnId: TURN, + text: "Explain the failing test", + expiresAt: Date.now() + 60_000, + ...overrides, + }; +} + +test("an offer shows only in the empty draft of the session it names", () => { + const bridge = new ComposerSuggestionBridge(); + const handle = makeHandle(); + + // No composer takes input: the window says so instead of queueing. + assert.deepEqual(bridge.offer(offer()), { shown: false, reason: "unsupported" }); + + const unregister = bridge.register(handle); + assert.deepEqual(bridge.offer(offer()), { shown: true }); + assert.equal(bridge.get()?.text, "Explain the failing test"); + assert.deepEqual(bridge.offer(offer({ sessionId: "session-2" })), { + shown: false, + reason: "session-not-visible", + }); + + handle.state.text = "half a sentence"; + assert.deepEqual(bridge.offer(offer()), { shown: false, reason: "draft-not-empty" }); + + // A staged attachment is a draft too, even with no text at all. + handle.state.text = ""; + handle.state.references = [{ id: "ref-1" }]; + assert.deepEqual(bridge.offer(offer()), { shown: false, reason: "draft-not-empty" }); + + unregister(); + assert.equal(bridge.get(), null); +}); + +test("an offer that is already over never appears", () => { + const bridge = new ComposerSuggestionBridge(); + bridge.register(makeHandle()); + assert.deepEqual(bridge.offer(offer({ expiresAt: Date.now() - 1 })), { + shown: false, + reason: "stale-turn", + }); + assert.equal(bridge.get(), null); +}); + +test("Tab writes the suggestion into the draft, keeps references, and ends it", () => { + const bridge = new ComposerSuggestionBridge(); + const image = { id: "image-1" }; + const handle = makeHandle({ references: [] }); + bridge.register(handle); + // The draft is empty when the offer lands; an image staged afterwards keeps + // the suggestion, because a reference is not text. + assert.deepEqual(bridge.offer(offer({ text: "Explain the failing test" })), { shown: true }); + handle.state.references = [image]; + + assert.equal(bridge.accept(), true); + assert.equal(handle.state.text, "Explain the failing test"); + assert.equal(handle.state.caret, "Explain the failing test".length); + assert.equal(handle.state.focused, true); + assert.deepEqual(handle.state.references, [image]); + assert.equal(bridge.get(), null); +}); + +test("taking a suggestion the draft no longer fits is refused, not applied", () => { + const bridge = new ComposerSuggestionBridge(); + const handle = makeHandle(); + bridge.register(handle); + bridge.offer(offer()); + // The user typed in the same beat; the draft wins. + handle.state.text = "mine"; + + assert.equal(bridge.accept(), false); + assert.equal(handle.state.text, "mine"); + assert.equal(bridge.get(), null); +}); + +test("typing, switching session and losing the composer all end the suggestion", () => { + const bridge = new ComposerSuggestionBridge(); + const handle = makeHandle(); + bridge.register(handle); + + bridge.offer(offer()); + handle.state.text = "x"; + bridge.publish(handle); + assert.equal(bridge.get(), null, "a first character ends it"); + + handle.state.text = ""; + bridge.offer(offer()); + handle.state.sessionId = "session-2"; + bridge.publish(handle); + assert.equal(bridge.get(), null, "the draft of another session ends it"); + + handle.state.sessionId = SESSION; + bridge.offer(offer()); + handle.state.text = "restored"; + bridge.publish(handle); + handle.state.text = ""; + bridge.publish(handle); + assert.equal(bridge.get(), null, "a suggestion never comes back after a change"); + + bridge.offer(offer()); + assert.equal(bridge.get()?.text, "Explain the failing test"); +}); + +test("the host's clear names a plugin, a session, or nothing", () => { + const bridge = new ComposerSuggestionBridge(); + bridge.register(makeHandle()); + + bridge.offer(offer()); + bridge.clear({ pluginId: "other.plugin" }); + assert.notEqual(bridge.get(), null, "another plugin's clear is not mine"); + + bridge.clear({ pluginId: "demo.suggestion", sessionId: "session-2" }); + assert.notEqual(bridge.get(), null, "another session's clear is not this one"); + + bridge.clear({ pluginId: "demo.suggestion" }); + assert.equal(bridge.get(), null); + + bridge.offer(offer()); + bridge.clear(); + assert.equal(bridge.get(), null, "an unfiltered clear drops everything"); +}); + +test("a newer suggestion takes the slot, and the old one expires on its own clock", async () => { + const bridge = new ComposerSuggestionBridge(); + bridge.register(makeHandle()); + + bridge.offer(offer({ text: "first" })); + bridge.offer(offer({ pluginId: "other.plugin", text: "second" })); + assert.equal(bridge.get()?.text, "second"); + assert.equal(bridge.get()?.pluginId, "other.plugin"); + + bridge.offer(offer({ text: "short-lived", expiresAt: Date.now() + 20 })); + assert.equal(bridge.get()?.text, "short-lived"); + await new Promise((resolve) => setTimeout(resolve, 80)); + assert.equal(bridge.get(), null, "a suggestion ends when its lifetime does"); +}); + +test("listeners see every change of the slot", () => { + const bridge = new ComposerSuggestionBridge(); + bridge.register(makeHandle()); + let changes = 0; + const off = bridge.subscribe(() => { + changes += 1; + }); + + bridge.offer(offer()); + bridge.dismiss(); + off(); + bridge.offer(offer()); + assert.equal(changes, 2); +}); + +test("the completion list keeps Tab, and the first Escape closes it", () => { + const tab = { key: "Tab" }; + const escape = { key: "Escape" }; + const showing = { suggestion: true, completionItems: false, completionOpen: false }; + const listOpen = { suggestion: true, completionItems: true, completionOpen: true }; + + assert.equal(composerSuggestionKeyAction(tab, showing), "accept"); + assert.equal(composerSuggestionKeyAction(escape, showing), "dismiss"); + assert.equal(composerSuggestionKeyAction(tab, listOpen), null, "the list owns Tab"); + assert.equal( + composerSuggestionKeyAction(escape, { ...listOpen, completionItems: false }), + null, + "the first Escape closes the list", + ); + assert.equal( + composerSuggestionKeyAction(tab, { ...showing, suggestion: false }), + null, + "no suggestion, no key", + ); + assert.equal(composerSuggestionKeyAction({ key: "Tab", shiftKey: true }, showing), null); + assert.equal(composerSuggestionKeyAction({ key: "Tab", metaKey: true }, showing), null); + assert.equal(composerSuggestionKeyAction({ key: "Enter" }, showing), null); + assert.equal(composerSuggestionKeyAction({ key: "Escape", altKey: true }, showing), null); +}); diff --git a/apps/desktop/test/plugin-composer-suggestion.test.mjs b/apps/desktop/test/plugin-composer-suggestion.test.mjs new file mode 100644 index 0000000000..ca646b8919 --- /dev/null +++ b/apps/desktop/test/plugin-composer-suggestion.test.mjs @@ -0,0 +1,220 @@ +import assert from "node:assert/strict"; +import { fork } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import test from "node:test"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { register } from "node:module"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const dataDir = mkdtempSync(join(tmpdir(), "pi-composer-suggestion-data-")); +process.env.PI_DESKTOP_DATA_DIR = dataDir; +const desktopRoot = join(here, ".."); +const hostProcessEntry = join(desktopRoot, "electron/main/plugin-host-process.mjs"); +register(pathToFileURL(join(here, "helpers/ts-import-hooks.mjs"))); +const { PluginRuntime } = await import("../electron/main/plugin-runtime.ts"); +const { + PLUGIN_COMPOSER_SUGGESTION_MAX_CHARS, + PLUGIN_COMPOSER_SUGGESTION_TTL_MS, + PLUGIN_COMPOSER_SUGGESTION_TTL_MAX_MS, +} = await import("@pi-desktop/plugin-sdk"); + +function forkPluginProcess({ entry }) { + const child = fork(entry, [], { stdio: ["ignore", "pipe", "pipe", "ipc"] }); + return { + postMessage: (message) => { + if (child.connected) child.send(message); + }, + onMessage: (handler) => child.on("message", handler), + onExit: (handler) => child.on("exit", (code) => handler(code ?? 0)), + kill: () => child.kill(), + }; +} + +function writePlugin({ id, permissions = [], main }) { + const dir = mkdtempSync(join(tmpdir(), "pi-composer-suggestion-plugin-")); + writeFileSync( + join(dir, "manifest.json"), + JSON.stringify({ schemaVersion: 1, id, name: id, version: "0.0.1", main: "main.js", permissions }), + "utf8", + ); + writeFileSync(join(dir, "main.js"), main, "utf8"); + return dir; +} + +function makeRuntime(t, services = {}) { + const audits = []; + const toasts = []; + const runtime = new PluginRuntime({ + hostEntry: hostProcessEntry, + spawnProcess: forkPluginProcess, + getLocale: () => "en", + showToast: (message) => toasts.push(message), + audit: (entry) => audits.push(entry), + ...services, + }); + t.after(async () => { + for (const loaded of runtime.listLoaded()) await runtime.unload(loaded.manifest.id); + }); + return { runtime, audits, toasts }; +} + +/** Wait for the plugin's own report, which arrives after its `onLoad`. */ +async function lastToast(toasts, expected) { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (toasts.length > 0 && toasts.at(-1).startsWith(expected)) return toasts.at(-1); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`no ${expected} toast; got ${JSON.stringify(toasts)}`); +} + +test("a suggestion crosses from the plugin process with the text the shell draws", async (t) => { + const offers = []; + const { runtime, audits, toasts } = makeRuntime(t, { + isPluginActiveInProject: () => true, + suggestComposer: async (offer) => { + offers.push(offer); + return { shown: true }; + }, + }); + const dir = writePlugin({ + id: "demo.suggestion", + permissions: ["composer.suggest"], + main: `module.exports = { + onLoad: async () => { + const result = await pi.composer.suggest({ + sessionId: " session-1 ", + turnId: "turn-1", + text: " Write\\nthe failing test ", + ttlMs: 5000, + }); + await pi.ui.showToast(JSON.stringify(result)); + }, + };`, + }); + + await runtime.loadFromPath(dir); + assert.equal(await lastToast(toasts, "{"), '{"shown":true}'); + assert.deepEqual(offers, [ + { + pluginId: "demo.suggestion", + sessionId: "session-1", + turnId: "turn-1", + text: "Write the failing test", + ttlMs: 5000, + }, + ]); + assert.equal(audits.some((entry) => entry.api === "composer.suggest" && entry.ok), true); +}); + +test("the host bounds the text and the lifetime instead of refusing them", async (t) => { + const offers = []; + const { runtime, toasts } = makeRuntime(t, { + isPluginActiveInProject: () => true, + suggestComposer: async (offer) => { + offers.push(offer); + return { shown: false, reason: "draft-not-empty" }; + }, + }); + const dir = writePlugin({ + id: "demo.suggestion.bounds", + permissions: ["composer.suggest"], + main: `module.exports = { + onLoad: async () => { + await pi.composer.suggest({ sessionId: "s", text: "x".repeat(500) }); + await pi.composer.suggest({ sessionId: "s", text: "y", ttlMs: 9_000_000 }); + await pi.ui.showToast(JSON.stringify(await pi.composer.suggest({ sessionId: "s", text: "z" }))); + }, + };`, + }); + + await runtime.loadFromPath(dir); + assert.equal(await lastToast(toasts, "{"), '{"shown":false,"reason":"draft-not-empty"}'); + assert.equal(offers[0].text.length, PLUGIN_COMPOSER_SUGGESTION_MAX_CHARS); + assert.equal(offers[1].ttlMs, PLUGIN_COMPOSER_SUGGESTION_TTL_MAX_MS); + assert.equal(offers[2].ttlMs, PLUGIN_COMPOSER_SUGGESTION_TTL_MS); +}); + +test("a suggestion for a session the shell is not showing is a miss, not a throw", async (t) => { + const { runtime, toasts } = makeRuntime(t, { + // The plugin is scoped to another project, so this conversation is not its. + isPluginActiveInProject: () => false, + suggestComposer: async () => { + throw new Error("the shell must not be asked"); + }, + }); + const dir = writePlugin({ + id: "demo.suggestion.scope", + permissions: ["composer.suggest"], + main: `module.exports = { + onLoad: async () => { + const result = await pi.composer.suggest({ sessionId: "session-1", text: "hi" }); + await pi.ui.showToast(JSON.stringify(result)); + }, + };`, + }); + + await runtime.loadFromPath(dir); + assert.equal(await lastToast(toasts, "{"), '{"shown":false,"reason":"session-not-visible"}'); +}); + +test("without the grant the call fails, and no suggestion is offered", async (t) => { + const offers = []; + const { runtime, toasts } = makeRuntime(t, { + suggestComposer: async (offer) => { + offers.push(offer); + return { shown: true }; + }, + }); + const dir = writePlugin({ + id: "demo.suggestion.denied", + permissions: ["composer.suggest"], + main: `module.exports = { + onLoad: async () => { + try { + await pi.composer.suggest({ sessionId: "session-1", text: "hi" }); + await pi.ui.showToast("NO_ERROR"); + } catch (error) { + await pi.ui.showToast("ERR:" + (error && error.code ? error.code : String(error))); + } + }, + };`, + }); + + // Granted nothing: the manifest declares the permission, the user did not. + await runtime.loadFromPath(dir, []); + assert.equal(await lastToast(toasts, "ERR:"), "ERR:PERMISSION_DENIED"); + assert.deepEqual(offers, []); +}); + +test("a plugin's own clear reaches the shell, and a malformed call does not", async (t) => { + const cleared = []; + const { runtime, toasts } = makeRuntime(t, { + suggestComposer: async () => ({ shown: true }), + clearComposerSuggestion: (pluginId, sessionId) => cleared.push([pluginId, sessionId]), + }); + const dir = writePlugin({ + id: "demo.suggestion.clear", + permissions: ["composer.suggest"], + main: `module.exports = { + onLoad: async () => { + await pi.composer.clearSuggestion({ sessionId: "session-1" }); + await pi.composer.clearSuggestion(); + try { + await pi.composer.suggest({ sessionId: " ", text: "hi" }); + await pi.ui.showToast("NO_ERROR"); + } catch (error) { + await pi.ui.showToast("ERR:" + (error && error.code ? error.code : String(error))); + } + }, + };`, + }); + + await runtime.loadFromPath(dir); + assert.equal(await lastToast(toasts, "ERR:"), "ERR:INVALID_PARAMS"); + assert.deepEqual(cleared, [ + ["demo.suggestion.clear", "session-1"], + ["demo.suggestion.clear", undefined], + ]); +}); diff --git a/docs/adr/0332-plugin-composer-suggestion.md b/docs/adr/0332-plugin-composer-suggestion.md new file mode 100644 index 0000000000..676cab0c93 --- /dev/null +++ b/docs/adr/0332-plugin-composer-suggestion.md @@ -0,0 +1,124 @@ +# ADR 0332: A plugin-owned suggestion in the Composer placeholder + +- Status: Accepted +- Date: 2026-10-24 +- Deciders: PI-Desktop maintainers +- Related: [ADR 0324](0324-plugin-owned-composer-prompt-enhancement.md) · + [ADR 0331](0331-plugin-session-follow-and-work-panel-reveal.md) · + [07-plugins/03-plugin-api](../spec/07-plugins/03-plugin-api.md) · + [07-plugins/13-plugin-permissions-matrix](../spec/07-plugins/13-plugin-permissions-matrix.md) + +## Context + +After a turn ends, a plugin often knows something the user would plausibly type +next: a follow-up question, a "continue" instruction, a correction. The only +surfaces a plugin has today are announcements that interrupt (a toast, a +notification, a work-panel view) or a rewrite of text the user already wrote +(`composer.transform`). None of them fits a suggestion: it must be visible where +the user is already looking, carried by no chrome, and trivial to ignore. + +The pipeline that produces such a line is the expensive part. A plugin listens +for `session:turnEnded`, reads the session with `pi.session.getLlmContext`, runs +its own `pi.agent.complete` (up to 90 s), and only then has text. So "generate" +and "show" are seconds apart, and in that window the user may have typed, +switched session, started another turn, or uninstalled the plugin. Any contract +that assumes the composer is unchanged when the answer arrives is wrong. + +The host already owns every piece needed to make this safe: the renderer owns +the draft and the placeholder layer, `activeTurns` owns turn identity, and +`composer.transform` (ADR 0324) established the permission, audit, and refusal +vocabulary for plugin text that reaches the Composer. + +## Decision + +1. Add `pi.composer.suggest({ sessionId, turnId?, text, ttlMs? })` and + `pi.composer.clearSuggestion({ sessionId? })` to the Plugin SDK, gated by the + new `composer.suggest` permission. `suggest` resolves + `{ shown: true }` or `{ shown: false, reason }` with + `session-not-visible` / `draft-not-empty` / `stale-turn` / `replaced` / + `unsupported`. Only malformed input throws (`INVALID_PARAMS`). +2. Split the decision by ownership instead of centralizing it: + - Main refuses what it can see without the renderer — a session that is not + the one on screen, and a `turnId` that a later `onTurnStarted` overtook — + and relays the rest, then waits for the renderer's answer. + - The renderer refuses the rest — no composer, a blocked composer, a draft + with text or an attachment — and answers over a dedicated invoke. The + plugin receives that answer, so a miss is data it can log rather than an + exception. + Main never writes renderer state; it sends a message, exactly like + `pi.ui.revealView` (ADR 0331). +3. Model the suggestion as a slot beside the draft, not inside it. The text is + drawn in the existing `.composer-placeholder` layer while `value` is empty, so + no new overlay can cover a message. `Tab` writes it into the draft (caret at + the end, focus in the input, staged references kept); `Esc` drops it; a first + character, a send, a session switch, a new turn, a plugin stop, a reload or a + window close drops it too. One suggestion exists per session, and a newer + offer replaces the older one. +4. Keep the existing key owners. A `@` / `/` completion list keeps `Tab`, and an + open list keeps the first `Esc`; the suggestion acts only when neither claims + the key. The keys live in a pure decision function so that ordering is + testable without a DOM. +5. Draw it brighter than the shell's own placeholder (62% primary ink versus + 72% secondary) with a `Tab` / `Esc` hint beside it, on one line with an + ellipsis, and announce it once through an `sr-only` polite live region. + `aria-placeholder` keeps describing the input, because the suggestion is not + what the field is for. +6. Classify `composer.suggest` as **medium** risk and confirm it at install. It + cannot read the draft, cannot send, and cannot outlive an empty draft, but it + can put text in front of a user who may accept it with one key — the same + trust shape as `composer.transform`. + +## Consequences + +- A plugin can propose the next message where the user is already looking, + without a notification, a bubble, or a panel, and it learns immediately + whether the suggestion was taken up. +- The shell keeps the composer authoritative: a suggestion is decorative state + that the Composer discards on its own terms and that a plugin cannot promote + into a draft. +- The answer to `suggest` costs one renderer round trip and a bounded wait + (`answerTimeoutMs`, 2 s). A window that is gone, hung, or reloading answers + nothing, and the caller is told `unsupported` instead of being left to hang. +- Suggestion text is capped at 200 characters after line breaks and runs of + whitespace are folded, so a plugin that asked for a paragraph still gets a + usable result instead of an error it cannot repair inside a turn. +- The permission list, the manifest schema page, the permissions matrix, the + plugin API spec, and the nine shipped catalogs all gain one entry; no plugin + manifest schema version, host-core schema, or persisted format changes. +- Plugins that never declare `composer.suggest` are unaffected: the namespace + exists, the permission is not granted, and the call fails closed with + `PERMISSION_DENIED` exactly like every other ungranted host API. + +## Alternatives considered + +### Let the plugin's own renderer module call `pi.dispatch("composer.setSuggestion")` + +Rejected as the primary path. The text is produced by the plugin *process* +after a 90 s completion, and there is no process → renderer-module channel in +the SDK, so a renderer module would have to poll or the process would have to +own a second, invented transport. The process already has an established host +API route, and the reverse direction (a renderer module asking its own process +for an answer) is already `plugin.call`. + +### A message, a toast, or an inline transcript row + +Rejected: all three interrupt or occupy transcript space for a hint the user +may ignore, and none of them can become the user's own text. + +### Store the suggestion as a draft value the user deletes + +Rejected: it makes plugin text indistinguishable from typed text, breaks the +"empty draft" invariant other features rely on, and would let a suggestion be +sent without an explicit accept. + +### Report `shown` optimistically and clear later + +Rejected: the point of the answer is that the plugin can stop spending +completions on a user who is not looking at that session. `draft-not-empty` and +`session-not-visible` are exactly the cases where a guess would be wrong. + +### Change `aria-placeholder` while a suggestion is shown + +Rejected: the field's purpose does not change with the suggestion, and a +placeholder change is announced differently across screen readers. A polite +live region says the line once without re-describing the input. diff --git a/docs/adr/README.md b/docs/adr/README.md index f1701486a8..bb90e9f675 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -378,6 +378,7 @@ Each ADR includes: | 0329 | [Isolate parallel sessions of one project in git worktrees](0329-session-worktree-isolation.md) | Accepted (Windows E2E pending) | | 0330 | [Open scoped HTML files in the work-panel Browser from plugins](0330-open-html-files-in-work-panel-browser-from-plugins.md) | Accepted | | 0331 | [Plugin session follow and host-owned work-panel reveal](0331-plugin-session-follow-and-work-panel-reveal.md) | Accepted | +| 0332 | [A plugin-owned suggestion in the Composer placeholder](0332-plugin-composer-suggestion.md) | Accepted | | plugin-fetch-redirect-policy | [Host-enforced plugin fetch redirect policy](plugin-fetch-redirect-policy.md) | Accepted (implementation candidate for #1475) | | local-agent-remote-workspace-tools | [Local Agent and model requests for remote workspaces](local-agent-remote-workspace-tools.md) | Accepted for implementation | diff --git a/docs/plugin-development.md b/docs/plugin-development.md index 208d33eda6..54e68ccb74 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -24,6 +24,7 @@ A plugin can contribute one or more of these capabilities: | Service | Resident work supervised by the host | `contributes.services`, `background.service` permission | | Message bus | Typed-by-convention events between plugins | `contributes.bus`, bus permissions | | Renderer slot | UI drawn inside the app's own window: composer controls, message action bars, blocks below a reply, tool cards, code-block renderers, corner layers | `renderer`, `renderer.extension` permission, `packages/plugin-sdk/src/renderer.ts` | +| Composer suggestion | One dismissible line offered to the input box, taken with Tab | `pi.composer.suggest`, `composer.suggest` permission | Plugin entry code runs in a dedicated Node process. Panels run in sandboxed, context-isolated Electron windows with no Node integration. Calls from either diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 5b00986fae..a3ffd74f79 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -18356,3 +18356,29 @@ host-created files. The full app's file-preview viewer is covered separately. - **Milestone:** Maintenance. - **Status:** Automated fixture; record the tested head/base and execution result in the delivery report. + +#### E2E-1175: A plugin suggestion rides in the Composer placeholder and is taken with Tab + +- **Preconditions**: A plugin that declares `composer.suggest` is installed, + enabled, and granted the permission, and it offers one line on + `session:turnEnded` after its own completion. +- **Steps:** 1) Run an Agent turn in a session and let it complete while that + session is on screen and its Composer is empty. 2) Observe the placeholder + layer. 3) Press Tab. 4) Repeat, then press Esc. 5) Repeat, then type one + character. 6) Repeat, then switch session before the offer arrives. +- **Expected**: The suggestion appears in the placeholder layer of an empty + draft, brighter than the shell placeholder and on one line with an ellipsis, + with a `Tab` / `Esc` hint and one polite announcement. Tab writes the exact + text into the draft with the caret at the end and the input focused, keeping + any staged attachment; Esc drops it; a first character drops it; a missed + offer answers `{ shown: false, reason }` and shows nothing. No transcript row, + toast, or overlay appears, and the plugin's call is audited. +- **Specs linked**: `07-plugins/03-plugin-api.md`, + `07-plugins/13-plugin-permissions-matrix.md`, ADR 0332. +- **Acceptance**: A (Composer entry path), Security (permission-gated, never + sent by the plugin). +- **Milestone**: M6+ +- **Status**: Unit/source-contract-covered by + `composer-suggestion-state.test.mjs`, `composer-suggestion-relay.test.mjs`, + and `plugin-composer-suggestion.test.mjs`; the rendered journey remains a + manual/Electron check when this surface changes. diff --git a/docs/spec/07-plugins/02-plugin-manifest-schema.md b/docs/spec/07-plugins/02-plugin-manifest-schema.md index 6d066366c0..04927bcf12 100644 --- a/docs/spec/07-plugins/02-plugin-manifest-schema.md +++ b/docs/spec/07-plugins/02-plugin-manifest-schema.md @@ -391,6 +391,7 @@ type PluginPermission = | "agent.prompt.inject" | "agent.complete" | "composer.transform" +| "composer.suggest" | "renderer.extension" | "provider.register" | "provider.oauth" diff --git a/docs/spec/07-plugins/03-plugin-api.md b/docs/spec/07-plugins/03-plugin-api.md index 5946b678bb..041dea6304 100644 --- a/docs/spec/07-plugins/03-plugin-api.md +++ b/docs/spec/07-plugins/03-plugin-api.md @@ -177,6 +177,70 @@ separately declares the permissions required by that API, such as } ``` +### Composer suggestions (`composer.suggest`) + +A plugin may offer one short line for the Composer of the session the user is +looking at. It is drawn in the placeholder layer while the draft is empty, the +user takes it with `Tab` or drops it with `Esc`, and typing drops it too. It is +never a message, never a bubble, and the plugin cannot send it. + +```ts +pi.composer.suggest(input: { + sessionId: string; // the session from session:turnEnded + turnId?: string; // the turn the text answers; an overtaken turn is refused + text: string; // one line; folded and cut at 200 characters + ttlMs?: number; // default 120000; clamped to 1000..600000 +}): Promise<{ shown: true } | { + shown: false; + reason: + | "session-not-visible" // that session is not on screen + | "draft-not-empty" // the user has text or an attachment in the draft + | "stale-turn" // the session ran a newer turn + | "replaced" // a newer suggestion took the slot first + | "unsupported"; // no window, no composer, or no answer in time +}> + +pi.composer.clearSuggestion(input?: { sessionId?: string }): Promise<{ ok: true }> +``` + +Both calls need `composer.suggest`. A place that will not show the line is not +an error: the call resolves `{ shown: false, reason }` so a plugin can log the +miss and decide whether to spend another completion. Malformed input (an empty +`sessionId`, text with nothing visible in it, a non-numeric `ttlMs`) throws +`INVALID_PARAMS` like every other host API, and a missing grant throws +`PERMISSION_DENIED`. + +The host shows a suggestion only when all of the following hold, and checks +them again on every change: the named session is the one on screen, the draft +is empty (no text and no attachment), the named turn is still the session's +newest turn, and a composer is mounted and taking input. One suggestion exists +per session; a newer offer replaces it and answers the superseded caller with +`replaced`. The line is cleared by: any draft change (typing, pasting, an +attachment, `composer.insertText`, `composer.replaceDraft`), a send, a session +switch, a new turn starting, the plugin unloading or being disabled, +`pi.composer.clearSuggestion`, a renderer reload, a window close, and the +suggestion's own lifetime running out. Taking it (`Tab`) puts the text in the +draft with the caret at the end and keeps any attachment already staged. + +The call is audited with the plugin id, the session, and whether the line was +shown. A plugin that generates the text with a model also declares that API's +permissions, such as `agent.complete` and `models.list`. + +```js +pi.events.on("session:turnEnded", async (event) => { + if (event.reason !== "completed") return; + const active = await pi.session.getActive(); + if (active?.sessionId !== event.sessionId) return; + const text = await writeNextLine(active); // the plugin's own model request + if (!text) return; + await pi.composer.suggest({ + sessionId: event.sessionId, + turnId: event.turnId, + text, + }); +}); +``` + ### speech (`speech.adapter.register`) ```ts pi.speech.registerAdapter(adapter: { diff --git a/docs/spec/07-plugins/13-plugin-permissions-matrix.md b/docs/spec/07-plugins/13-plugin-permissions-matrix.md index a597b753c4..e124de0ab3 100644 --- a/docs/spec/07-plugins/13-plugin-permissions-matrix.md +++ b/docs/spec/07-plugins/13-plugin-permissions-matrix.md @@ -25,6 +25,7 @@ Provide a permission–capability–risk–default-policy reference table for re | `agent.tool.register` | high | Register an agent tool | Confirm at install | Tool execution is audited separately | | `agent.prompt.inject` | high | Inject a system prompt; activates `contributes.skills` | Deny by default / strong confirmation | Easily leads to behavior hijacking | | `composer.transform` | medium | `contributes.composerTransforms` and `onComposerTransform` | Confirm at install | Runs only after an explicit Composer action. Receives the draft and optional model key, not session history or separate attachment/file-reference metadata; request and result are capped at 100,000 characters and audited | +| `composer.suggest` | medium | `pi.composer.suggest` / `pi.composer.clearSuggestion` | Confirm at install | Writes one dismissible line into the placeholder layer of the Composer for the session on screen, only while the draft is empty and only for the turn the plugin names. The user takes it with Tab, drops it with Esc, and typing, sending, a session switch, a new turn, the plugin stopping, a reload or a window close all end it. The plugin never sends it and never reads the draft. Text is folded to one line and cut at 200 characters; the lifetime defaults to 120 s and is capped at 10 minutes. Audited; a place that will not show it answers `{ shown: false, reason }` instead of throwing | | `agent.extension` | high | Run `contributes.agentExtensions` modules inside the agent process | Explicit confirmation; local imports and development plugins only in v1.1 | Same access as the agent's own tools; the plugin sandbox does not apply (spec 16) | | `renderer.extension` | high | `manifest.renderer` is evaluated inside the host renderer to mount UI slots; `rendererActions` and `rendererCallMethods` open the actions and `onRendererCall` methods that module may use | Explicit confirmation; local imports and development plugins only in v1.1, matching `agent.extension` | The module shares the host's own document, so a slot is a contract rather than a sandbox. A component may dispatch only the reviewed vocabulary (`plugin.call`, `composer.insertText`, `composer.readDraft`, `composer.replaceDraft`, `attachments.add` / `list` / `remove`), capped at 16, and `plugin.call` reaches only the calling plugin's own `onRendererCall` methods, capped at 32. Registrations are withdrawn when the plugin unloads; a self-drawn dialog lives in a layer the plugin opened itself | | `provider.register` | high | `contributes.providers` become rows in the native provider list, owned by the plugin and refreshed from the manifest on load; unconfigured API-key rows with an endpoint also appear in Add Service, grouped by optional `category` and with optional hover/focus `description` | Explicit confirmation; local imports and development plugins only in v1.1, matching `agent.extension` | The user path refuses edits to the row (`PROVIDER_OWNED_BY_PLUGIN`); Add Service saves the key through the existing Host secret path. Only loaded plugins with this grant contribute entries; the chooser does not expose secrets or run plugin code. An empty API-key model list discovers models after key save. | @@ -162,6 +163,7 @@ so "Modify the files it lists" is followed by the list. | `usage.read` | Read usage statistics | 读取用量统计 | | `agent.complete` | Run a one-shot completion with your models | 用你的模型发起一次补全 | | `composer.transform` | Transform text in the Composer | 转换输入框中的文本 | +| `composer.suggest` | Offer a suggestion in the Composer | 在输入框中给出建议 | | `speech.adapter.register` | Register a speech adapter | 注册语音适配器 | | `audio.capture.background` | Use the microphone in the background | 后台使用麦克风 | | `audio.playback.background` | Play audio in the background | 后台播放声音 | diff --git a/docs/zh-CN/spec/07-plugins/02-plugin-manifest-schema.md b/docs/zh-CN/spec/07-plugins/02-plugin-manifest-schema.md index fc36a903cb..1c16423f56 100644 --- a/docs/zh-CN/spec/07-plugins/02-plugin-manifest-schema.md +++ b/docs/zh-CN/spec/07-plugins/02-plugin-manifest-schema.md @@ -323,6 +323,7 @@ type PluginPermission = | "agent.prompt.inject" | "agent.complete" | "composer.transform" +| "composer.suggest" | "renderer.extension" | "provider.register" | "net.fetch" diff --git a/docs/zh-CN/spec/07-plugins/03-plugin-api.md b/docs/zh-CN/spec/07-plugins/03-plugin-api.md index 8cbf6bc9db..ad5808c5e2 100644 --- a/docs/zh-CN/spec/07-plugins/03-plugin-api.md +++ b/docs/zh-CN/spec/07-plugins/03-plugin-api.md @@ -149,6 +149,62 @@ type PluginModule = { } ``` +### 输入框建议(`composer.suggest`) + +插件可以为用户当前正在查看的会话输入框提供一条短建议。草稿为空时它显示在占位符层, +用户按 `Tab` 采用、按 `Esc` 忽略,开始输入也会让它消失。它不是消息、不是气泡, +插件也无法替用户发送。 + +```ts +pi.composer.suggest(input: { + sessionId: string; // 来自 session:turnEnded 的会话 + turnId?: string; // 这段文本对应的轮次;已被新轮次取代则拒绝 + text: string; // 单行;折行空白并截断到 200 字符 + ttlMs?: number; // 默认 120000;限制在 1000..600000 +}): Promise<{ shown: true } | { + shown: false; + reason: + | "session-not-visible" // 该会话不是当前显示的会话 + | "draft-not-empty" // 草稿里已有文本或附件 + | "stale-turn" // 该会话已经跑了更新的轮次 + | "replaced" // 更新的建议先占用了位置 + | "unsupported"; // 没有窗口、没有输入框,或未及时答复 +}> + +pi.composer.clearSuggestion(input?: { sessionId?: string }): Promise<{ ok: true }> +``` + +两个调用都需要 `composer.suggest` 权限。无法展示不是错误:调用会返回 +`{ shown: false, reason }`,插件可以记录这次落空并决定是否再花一次补全预算。 +参数不合法(`sessionId` 为空、文本没有可见内容、`ttlMs` 不是数字)会像其他宿主 API 一样抛 +`INVALID_PARAMS`,没有授予权限则抛 `PERMISSION_DENIED`。 + +宿主只在以下条件全部满足时显示建议,并在每次变化后重新检查:命名的会话正是当前显示的会话、 +草稿为空(无文本且无附件)、命名的轮次仍是该会话最新的轮次、有输入框处于可输入状态。 +每个会话同时最多一条建议;更新的建议会替换它,并把被替换的调用答复为 `replaced`。 +下列任一情况都会清除建议:任何草稿变化(输入、粘贴、附件、`composer.insertText`、 +`composer.replaceDraft`)、发送、切换会话、新一轮开始、插件卸载或被禁用、 +`pi.composer.clearSuggestion`、页面重载、窗口关闭,以及建议自身的存活时间到期。 +采用(`Tab`)会把文本写入草稿、光标置末,并保留已暂存的附件。 + +调用会记录审计:插件 id、会话,以及是否显示成功。若插件用模型生成文本,还需单独声明该 API +所需的权限,例如 `agent.complete` 与 `models.list`。 + +```js +pi.events.on("session:turnEnded", async (event) => { + if (event.reason !== "completed") return; + const active = await pi.session.getActive(); + if (active?.sessionId !== event.sessionId) return; + const text = await writeNextLine(active); // 插件自己的模型请求 + if (!text) return; + await pi.composer.suggest({ + sessionId: event.sessionId, + turnId: event.turnId, + text, + }); +}); +``` + ### 语音(`speech.adapter.register`) ```ts pi.speech.registerAdapter(adapter: { diff --git a/docs/zh-CN/spec/07-plugins/13-plugin-permissions-matrix.md b/docs/zh-CN/spec/07-plugins/13-plugin-permissions-matrix.md index 3aea68dfc7..73e7fd57a9 100644 --- a/docs/zh-CN/spec/07-plugins/13-plugin-permissions-matrix.md +++ b/docs/zh-CN/spec/07-plugins/13-plugin-permissions-matrix.md @@ -28,6 +28,7 @@ | `agent.tool.register` | 高 | 注册代理工具 | 安装时确认 | 工具执行情况单独审核 | | `agent.prompt.inject` | 高 | 注入系统提示符;激活 `contributes.skills` | 默认拒绝/强确认 | 容易导致行为劫持 | | `composer.transform` | 中等 | `contributes.composerTransforms` 与 `onComposerTransform` | 安装时确认 | 仅在用户主动执行 Composer 操作后运行。仅接收草稿和可选模型 key,不接收会话历史或单独的附件/文件引用元数据;输入、结果上限均为 100,000 字符并记入审计 | +| `composer.suggest` | 中等 | `pi.composer.suggest` / `pi.composer.clearSuggestion` | 安装时确认 | 仅在草稿为空、且插件声明的轮次仍是当前轮次时,向当前可见会话的输入框占位符层写入一条可忽略的建议。用户按 Tab 采用、Esc 忽略;输入、发送、切换会话、新一轮开始、插件停止、页面重载或窗口关闭都会清除。插件既不能直接发送,也不读取草稿。文本折为单行并截断到 200 字符;存活时间默认 120 秒,上限 10 分钟。记入审计;无法展示时返回 `{ shown: false, reason }` 而不是抛错 | | `agent.extension` | 高 | 在 agent 进程内运行 `contributes.agentExtensions` 模块 | 显式确认;v1.1 仅限本地导入和开发插件 | 与 agent 自身工具同等权限;插件沙箱不适用(规格 16) | | `renderer.extension` | 高 | 在宿主渲染进程中执行 `manifest.renderer` 以挂载 UI 插槽;`rendererActions` 与 `rendererCallMethods` 决定该模块可以派发的动作与 `onRendererCall` 方法 | 显式确认;v1.1 仅限本地导入和开发插件,与 `agent.extension` 一致 | 模块与 PI-Desktop 同文档运行,因此插槽是契约而不是沙箱。组件只能派发经过审查的动作词表(`plugin.call`、`composer.insertText`、`composer.readDraft`、`composer.replaceDraft`、`attachments.add` / `list` / `remove`),最多 16 个;`plugin.call` 只能触达调用者自己插件的 `onRendererCall` 方法,最多 32 个。插件卸载时注册会被撤销;自绘弹窗位于插件自己打开的层中 | | `provider.register` | 高 | `contributes.providers` 成为原生 Provider 列表中的行,归插件所有并在每次加载时按 manifest 刷新 | 显式确认;v1.1 仅限本地导入和开发插件,与 `agent.extension` 一致 | 用户路径拒绝编辑该行(`PROVIDER_OWNED_BY_PLUGIN`);API key 凭据仍存放在 Host secret store 的常规 provider 引用下 | @@ -158,6 +159,7 @@ Agent,在 Plan 中不可见。主机返回 `PLUGIN_DISABLED_IN_PLAN` | `usage.read` | Read usage statistics | 读取用量统计 | | `agent.complete` | Run a one-shot completion with your models | 用你的模型发起一次补全 | | `composer.transform` | Transform text in the Composer | 转换输入框中的文本 | +| `composer.suggest` | Offer a suggestion in the Composer | 在输入框中给出建议 | | `speech.adapter.register` | Register a speech adapter | 注册语音适配器 | | `audio.capture.background` | Use the microphone in the background | 后台使用麦克风 | | `audio.playback.background` | Play audio in the background | 后台播放声音 | diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index 3ee5d487a7..a65c322712 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -349,6 +349,7 @@ export const de = { "placeholderHint": "Geben Sie / für Befehle · @ für Dateien ein", "placeholderHomeHint": "Geben Sie / für Befehle · @ für Dateien ein", "placeholderShortcut": "Umschalt+Eingabetaste für Zeilenumbruch · Verwenden Sie Senden, um zu senden", + "suggestionHint": "Tab übernehmen · Esc verwerfen", "addFiles": "Dateien hinzufügen und mehr", "filesAttached": "{{count}} Element(e) zum Entwurf hinzugefügt", "filesPasted": "{{count}} eingefügte Datei(en) im Sitzungs-Scratch-Ordner gespeichert", @@ -2386,6 +2387,7 @@ sklm: { "agent.prompt.inject": "Agentenanweisungen anpassen", "agent.complete": "One-Shot-Vervollständigung mit Ihren Modellen durchführen", "composer.transform": "Text im Composer umwandeln", + "composer.suggest": "Einen Vorschlag im Composer anbieten", "agent.extension": "Code im Agenten ausführen", "renderer.extension": "Oberfläche in Chat-Slots zeichnen", "provider.register": "Anbieter zur Modellliste hinzufügen", @@ -2428,6 +2430,7 @@ sklm: { "agent.prompt.inject": "Kann Anweisungen ändern, die an den KI-Agenten gesendet werden.", "agent.complete": "Kann Ihr Modellkontingent für eine einmalige Fertigstellung ausgeben. Das Plugin erhält niemals Ihre API-Schlüssel.", "composer.transform": "Erlaubt diesem Plugin, ausdrücklich im Composer ausgewählten Text umzuwandeln. Es erhält den Entwurf und den Modellschlüssel, aber weder Gesprächsverlauf noch Anhänge.", + "composer.suggest": "Erlaubt diesem Plugin, bei leerem Entwurf eine verwerfbare Zeile im Platzhalter des Composers anzuzeigen. Sie gelangt nur mit Tab in den Entwurf, kann nie vom Plugin gesendet werden und verschwindet beim Tippen.", "renderer.extension": "Lädt das Renderer-Modul dieses Plugins in das App-Fenster, um UI-Slot-Komponenten zu zeichnen (Nachrichten-Aktionsleisten, Antwort-Anhänge, Tool-Karten, Codeblock-Renderer, Eingabebereich). Das Modul läuft im selben Dokument wie PI-Desktop. Aktiviere nur Code, dem du vertraust.", "agent.extension": "Führt ExtensionAPI-Module im Agentenprozess mit denselben Rechten wie die Tools des Agenten aus. Aktivieren Sie nur Code, dem Sie vertrauen.", "provider.register": "Fügt die Anbieter, die dieses Plugin definiert, zur Anbieterliste in den Einstellungen hinzu. Das Plugin liefert den Endpunkt und die Modelle; Ihr API-Schlüssel bleibt in PI-Desktop.", diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index a28e4dc3b5..c909a95c21 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -356,6 +356,7 @@ export const en = { placeholderHint: "Type / for commands · @ for files", placeholderHomeHint: "Type / for commands · @ for files", placeholderShortcut: "Shift+Enter for newline · Use Send to submit", + suggestionHint: "Tab to accept · Esc to ignore", addFiles: "Add files", filesAttached: "Added {{count}} item(s) to the draft", filesPasted: "Saved {{count}} pasted file(s) to the session scratch folder", @@ -2407,6 +2408,7 @@ importConfirm: "Imported extensions run inside the agent process with the same a "agent.prompt.inject": "Adjust agent instructions", "agent.complete": "Run a one-shot completion with your models", "composer.transform": "Transform text in the Composer", + "composer.suggest": "Offer a suggestion in the Composer", "agent.extension": "Run code inside the agent", "renderer.extension": "Draw UI in chat slots", "provider.register": "Add providers to the model list", @@ -2458,6 +2460,8 @@ importConfirm: "Imported extensions run inside the agent process with the same a "Can spend your model quota on a one-shot completion. The plugin never receives your API keys.", "composer.transform": "Lets this plugin transform text you explicitly select in the Composer. It receives the draft and selected model key, but no conversation history or attachments.", + "composer.suggest": + "Lets this plugin show one dismissible line in the Composer's placeholder while the draft is empty. The line enters the draft only when you press Tab, can never be sent by the plugin, and disappears as soon as you type.", "agent.extension": "Runs ExtensionAPI modules inside the agent process with the same access as the agent's own tools. Enable only code you trust.", "renderer.extension": "Loads this plugin's renderer module into the app window to draw UI slot components (message action bars, entry extras, tool cards, code-block renderers, composer controls). The module runs in the same document as PI-Desktop. Enable only code you trust.", diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index dab1dccc41..7d64be7d7f 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -349,6 +349,7 @@ export const es = { "placeholderHint": "Escriba / para comandos · @ para archivos", "placeholderHomeHint": "Escriba / para comandos · @ para archivos", "placeholderShortcut": "Mayús+Entrar para nueva línea · Utilice Enviar para enviar", + "suggestionHint": "Tab aceptar · Esc descartar", "addFiles": "Agregar archivos y más", "filesAttached": "Se agregaron {{count}} elementos al borrador", "filesPasted": "Se guardaron {{count}} archivos pegados en la carpeta temporal de la sesión", @@ -2386,6 +2387,7 @@ sklm: { "agent.prompt.inject": "Ajustar instrucciones del agente", "agent.complete": "Ejecutar una finalización de una sola vez con sus modelos", "composer.transform": "Transformar texto en el compositor", + "composer.suggest": "Ofrecer una sugerencia en el compositor", "agent.extension": "Ejecutar código dentro del agente", "renderer.extension": "Dibujar interfaz en los slots del chat", "provider.register": "Agregar proveedores a la lista de modelos", @@ -2428,6 +2430,7 @@ sklm: { "agent.prompt.inject": "Puede cambiar las instrucciones enviadas al agente de IA.", "agent.complete": "Puede gastar su cuota de modelo en una finalización única. El complemento nunca recibe sus claves API.", "composer.transform": "Permite que este complemento transforme el texto que selecciones explícitamente en el compositor. Recibe el borrador y la clave del modelo seleccionado, pero no el historial de conversación ni los adjuntos.", + "composer.suggest": "Permite que este complemento muestre una línea descartable en el marcador del compositor mientras el borrador está vacío. Solo entra en el borrador al pulsar Tab, nunca la envía el complemento y desaparece al escribir.", "agent.extension": "Ejecuta módulos ExtensionAPI dentro del proceso del agente con el mismo acceso que sus propias herramientas. Activa solo código en el que confíes.", "renderer.extension": "Carga el módulo de renderizado de este complemento en la ventana de la app para dibujar componentes en los slots de la interfaz (barras de acción de mensajes, extras de respuesta, tarjetas de herramientas, renderizadores de bloques de código, controles del compositor). El módulo se ejecuta en el mismo documento que PI-Desktop. Activa solo código de confianza.", "provider.register": "Agregue los proveedores que define este complemento a la lista de proveedores de Configuración. El complemento aporta el punto final y los modelos; su clave API permanece en PI-Desktop.", diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index 701530342b..2b6e2056fd 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -349,6 +349,7 @@ export const fr = { "placeholderHint": "Tapez / pour les commandes · @ pour les fichiers", "placeholderHomeHint": "Tapez / pour les commandes · @ pour les fichiers", "placeholderShortcut": "Maj+Entrée pour une nouvelle ligne · Utilisez Envoyer pour soumettre", + "suggestionHint": "Tab accepter · Échap ignorer", "addFiles": "Ajoutez des fichiers et plus encore", "filesAttached": "Ajout de {{count}} élément(s) au brouillon", "filesPasted": "Enregistrement de {{count}} fichier(s) collé(s) dans le dossier de travail de la session", @@ -2386,6 +2387,7 @@ sklm: { "agent.prompt.inject": "Ajuster les instructions de l'agent", "agent.complete": "Exécuter une complétion unique avec vos modèles", "composer.transform": "Transformer le texte dans le compositeur", + "composer.suggest": "Proposer une suggestion dans le compositeur", "agent.extension": "Exécuter du code dans l'agent", "renderer.extension": "Dessiner l'interface dans les emplacements du chat", "provider.register": "Ajouter des fournisseurs à la liste de modèles", @@ -2428,6 +2430,7 @@ sklm: { "agent.prompt.inject": "Peut modifier les instructions envoyées à l'agent IA.", "agent.complete": "Peut dépenser votre quota de modèle pour une réalisation unique. Le plugin ne reçoit jamais vos clés API.", "composer.transform": "Permet à ce plugin de transformer le texte que vous sélectionnez explicitement dans le compositeur. Il reçoit le brouillon et la clé du modèle sélectionné, mais ni l'historique de la conversation ni les pièces jointes.", + "composer.suggest": "Permet à ce plugin d'afficher une ligne ignorable dans le texte indicatif du compositeur tant que le brouillon est vide. Elle n'entre dans le brouillon qu'avec Tab, ne peut jamais être envoyée par le plugin et disparaît dès la frappe.", "agent.extension": "Exécute des modules ExtensionAPI dans le processus de l'agent avec le même accès que ses propres outils. N'activez que du code auquel vous faites confiance.", "renderer.extension": "Charge le module de rendu de ce plugin dans la fenêtre de l'app pour dessiner des composants d'interface (barres d'actions des messages, extras de réponse, cartes d'outils, rendus de blocs de code, contrôles du composeur). Le module s'exécute dans le même document que PI-Desktop. N'activez que du code de confiance.", "provider.register": "Ajoute les fournisseurs définis par ce plugin à la liste des fournisseurs des Paramètres. Le plugin fournit le point de terminaison et les modèles ; votre clé API reste dans PI-Desktop.", diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index e4e1d2c105..6c9edfcb8b 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -358,6 +358,7 @@ export const ko = { placeholderHint: "명령은 / · 파일은 @ 입력", placeholderHomeHint: "명령은 / · 파일은 @ 입력", placeholderShortcut: "줄바꿈은 Shift+Enter · 보내기를 눌러 제출", + suggestionHint: "Tab 수락 · Esc 무시", addFiles: "파일 추가", filesAttached: "초안에 항목 {{count}}개 추가됨", filesPasted: "붙여넣은 파일 {{count}}개를 세션 임시 폴더에 저장함", @@ -2403,6 +2404,7 @@ importConfirm: "가져온 확장은 에이전트 프로세스 안에서 에이 "agent.prompt.inject": "에이전트 지침 조정", "agent.complete": "모델로 일회성 완성 실행", "composer.transform": "작성기의 텍스트 변환", + "composer.suggest": "작성기에 제안 표시", "agent.extension": "에이전트 안에서 코드 실행", "renderer.extension": "채팅 슬롯에 UI 그리기", "provider.register": "모델 목록에 프로바이더 추가", @@ -2452,6 +2454,7 @@ importConfirm: "가져온 확장은 에이전트 프로세스 안에서 에이 "agent.complete": "모델 할당량을 사용해 일회성 완성을 실행할 수 있습니다. 플러그인은 API 키를 받지 않습니다.", "composer.transform": "작성기에서 직접 선택한 텍스트를 변환하도록 허용합니다. 플러그인은 초안과 선택된 모델 키만 받으며 대화 기록이나 첨부 파일은 받지 않습니다.", + "composer.suggest": "초안이 비어 있는 동안 작성기 자리 표시자에 무시할 수 있는 한 줄을 표시하도록 허용합니다. Tab을 눌러야만 초안에 들어가며, 플러그인이 대신 전송할 수 없고 입력을 시작하면 사라집니다.", "agent.extension": "ExtensionAPI 모듈을 에이전트 프로세스 안에서 에이전트 자체 도구와 같은 권한으로 실행합니다. 신뢰하는 코드만 켜세요.", "renderer.extension": "이 플러그인의 렌더러 모듈을 앱 창에 로드하여 메시지 작업 표시줄, 응답 추가 영역, 도구 카드, 코드 블록 렌더러, 작성기 컨트롤 등 UI 슬롯 구성 요소를 그립니다. 모듈은 PI-Desktop과 같은 문서에서 실행됩니다. 신뢰하는 코드만 활성화하세요.", "provider.register": diff --git a/packages/i18n/src/locales/pt-BR/index.ts b/packages/i18n/src/locales/pt-BR/index.ts index 54f3e679b2..e02ec85b1b 100644 --- a/packages/i18n/src/locales/pt-BR/index.ts +++ b/packages/i18n/src/locales/pt-BR/index.ts @@ -348,6 +348,7 @@ export const ptBR = { placeholderHint: "Digite / para comandos · @ para arquivos", placeholderHomeHint: "Digite / para comandos · @ para arquivos", placeholderShortcut: "Shift+Enter para inserir uma quebra de linha · Use Enviar para enviar", + suggestionHint: "Tab aceitar · Esc descartar", addFiles: "Adicionar arquivos", filesAttached: "{{count}} item(ns) adicionado(s) ao rascunho", filesPasted: "{{count}} arquivo(s) colado(s) salvo(s) na pasta temporária da sessão", @@ -2352,6 +2353,7 @@ export const ptBR = { "agent.prompt.inject": "Alterar as instruções do agente", "agent.complete": "Executar uma conclusão avulsa com seus modelos", "composer.transform": "Transformar texto no Composer", + "composer.suggest": "Sugerir algo no Composer", "agent.extension": "Executar código dentro do agente", "renderer.extension": "Desenhar interface nos slots do chat", "provider.register": "Adicionar provedores à lista de modelos", @@ -2394,6 +2396,7 @@ export const ptBR = { "agent.prompt.inject": "Pode alterar as instruções enviadas ao agente de IA.", "agent.complete": "Pode consumir sua cota de modelo para uma conclusão avulsa. O plugin nunca recebe suas chaves de API.", "composer.transform": "Permite que este plugin transforme o texto selecionado no Composer. Ele recebe o rascunho e a chave do modelo selecionado, mas não o histórico da conversa nem anexos.", + "composer.suggest": "Permite que este plugin mostre uma linha descartável no texto de exemplo do Composer enquanto o rascunho está vazio. Ela entra no rascunho apenas com Tab, o plugin nunca a envia sozinho e ela desaparece ao digitar.", "agent.extension": "Executa módulos da ExtensionAPI dentro do processo do agente, com o mesmo nível de acesso que as ferramentas do próprio agente. Ative somente código em que você confia.", "renderer.extension": "Carrega o módulo de renderização deste plugin na janela do aplicativo para desenhar componentes de slot (barras de ações de mensagens, extras de entrada, cartões de ferramentas, renderizadores de blocos de código, controles do compositor). O módulo é executado no mesmo documento do PI-Desktop. Ative somente código em que você confia.", diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index b8989edac6..8b2d02e326 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -358,6 +358,7 @@ export const tr = { placeholderHint: "Komutlar için / · dosyalar için @ yazın", placeholderHomeHint: "Komutlar için / · dosyalar için @ yazın", placeholderShortcut: "Yeni satır için Shift+Enter · göndermek için Gönder", + suggestionHint: "Tab kabul · Esc yoksay", addFiles: "Dosya ekle", filesAttached: "Taslağa {{count}} öğe eklendi", filesPasted: "Yapıştırılan {{count}} dosya oturum klasörüne kaydedildi", @@ -2397,6 +2398,7 @@ importConfirm: "İçe aktarılan uzantılar ajan sürecinde, ajanın kendi araç "agent.prompt.inject": "Ajan yönergelerini ayarla", "agent.complete": "Modellerinizle tek seferlik tamamlatma çalıştır", "composer.transform": "Oluşturucudaki metni dönüştür", + "composer.suggest": "Oluşturucuda öneri sun", "agent.extension": "Ajanın içinde kod çalıştır", "renderer.extension": "Sohbet yuvalarında arayüz çiz", "provider.register": "Model listesine servis ekle", @@ -2446,6 +2448,7 @@ importConfirm: "İçe aktarılan uzantılar ajan sürecinde, ajanın kendi araç "agent.complete": "Model kotanızı tek seferlik bir tamamlatma için harcayabilir. Eklenti API anahtarlarınızı almaz.", "composer.transform": "Eklentinin Oluşturucu'da açıkça seçtiğiniz metni dönüştürmesine izin verir. Eklenti yalnızca taslağı ve seçili model anahtarını alır; konuşma geçmişini veya ekleri almaz.", + "composer.suggest": "Taslak boşken Oluşturucu yer tutucusunda yok sayılabilir tek bir satır göstermesine izin verir. Satır yalnızca Tab ile taslağa girer, eklenti onu kendi gönderemez ve yazmaya başlayınca kaybolur.", "agent.extension": "ExtensionAPI modüllerini ajan sürecinde, ajanın kendi araçlarıyla aynı erişimle çalıştırır. Yalnızca güvendiğiniz kodu etkinleştirin.", "renderer.extension": "Bu eklentinin işleyici modülünü uygulama penceresine yükler; mesaj eylem çubukları, yanıt ek alanları, araç kartları, kod bloğu işleyicileri ve besteleyici kontrolleri gibi arayüz yuvası bileşenleri çizer. Modül PI-Desktop ile aynı belgede çalışır. Yalnızca güvendiğiniz kodu etkinleştirin.", "provider.register": diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index 7c026ae18e..cf25670f77 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -351,6 +351,7 @@ export const zhCN = { placeholderHint: "输入 / 使用命令 · @ 引用文件", placeholderHomeHint: "输入 / 使用命令 · @ 引用文件", placeholderShortcut: "Shift+Enter 换行 · 点击发送提交", + suggestionHint: "Tab 接受 · Esc 忽略", addFiles: "添加文件", filesAttached: "已将 {{count}} 项加入草稿", filesPasted: "已将 {{count}} 个粘贴文件保存到会话临时目录", @@ -2380,6 +2381,7 @@ sklm: { "agent.prompt.inject": "调整智能体指令", "agent.complete": "用你的模型发起一次补全", "composer.transform": "转换输入框中的文本", + "composer.suggest": "在输入框中给出建议", "agent.extension": "在 agent 内运行代码", "renderer.extension": "在聊天插槽绘制界面", "provider.register": "将服务添加到模型列表", @@ -2423,6 +2425,7 @@ sklm: { "agent.prompt.inject": "可能修改发送给智能体的指令。", "agent.complete": "会消耗你的模型额度发起一次补全。插件拿不到 API 密钥。", "composer.transform": "允许插件转换你在输入框中主动选择的文本。插件会收到草稿文本和所选模型标识,不会收到对话历史或附件。", + "composer.suggest": "允许插件在草稿为空时于输入框占位处显示一条可忽略的建议。只有你按 Tab 它才进入草稿,插件无法直接发送,开始输入即会消失。", "agent.extension": "在 agent 进程内运行 ExtensionAPI 模块,拥有与 agent 自身工具相同的权限。只启用你信任的代码。", "renderer.extension": "把该插件的渲染模块加载进应用窗口,在消息操作栏、回复追加区、工具卡、代码块、输入区等插槽绘制界面。模块与 PI-Desktop 同文档运行,只启用你信任的代码。", "provider.register": "把此插件定义的服务添加到设置的服务列表。接口地址和模型由插件提供,API 密钥则留在 PI-Desktop 中。", diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index 1ae7643a6d..bd3286669a 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -351,6 +351,7 @@ export const zhTW = { placeholderHint: "輸入 / 使用命令 · @ 引用檔案", placeholderHomeHint: "輸入 / 使用命令 · @ 引用檔案", placeholderShortcut: "Shift+Enter 換行 · 點擊發送提交", + suggestionHint: "Tab 接受 · Esc 忽略", addFiles: "新增檔案等內容", filesAttached: "已將 {{count}} 項加入草稿", filesPasted: "已將 {{count}} 個貼上檔案儲存到會話臨時目錄", @@ -2380,6 +2381,7 @@ sklm: { "agent.prompt.inject": "調整智慧體指令", "agent.complete": "用你的模型發起一次補全", "composer.transform": "轉換輸入框中的文字", + "composer.suggest": "在輸入框中提供建議", "agent.extension": "在 agent 內執行程式碼", "renderer.extension": "在聊天插槽繪製介面", "provider.register": "將服務新增到模型列表", @@ -2423,6 +2425,7 @@ sklm: { "agent.prompt.inject": "可能修改傳送給智慧體的指令。", "agent.complete": "會消耗你的模型額度發起一次補全。外掛拿不到 API 金鑰。", "composer.transform": "允許外掛轉換你在輸入框中主動選擇的文字。外掛會收到草稿文字和所選模型識別碼,不會收到對話歷史或附件。", + "composer.suggest": "允許外掛在草稿為空時於輸入框佔位處顯示一條可忽略的建議。只有你按 Tab 它才進入草稿,外掛無法直接送出,開始輸入即會消失。", "agent.extension": "在 agent 程序內執行 ExtensionAPI 模組,擁有與 agent 自身工具相同的權限。只啟用你信任的程式碼。", "renderer.extension": "把該外掛的渲染模組載入應用視窗,在訊息操作列、回覆追加區、工具卡、程式碼區塊、輸入區等插槽繪製介面。模組與 PI-Desktop 同文件執行,只啟用你信任的程式碼。", "provider.register": "把此外掛定義的服務新增到設定的服務列表。介面地址和模型由外掛提供,API 金鑰則留在 PI-Desktop 中。", diff --git a/packages/plugin-sdk/src/index.test.ts b/packages/plugin-sdk/src/index.test.ts index fd9e4427dc..7953545f19 100644 --- a/packages/plugin-sdk/src/index.test.ts +++ b/packages/plugin-sdk/src/index.test.ts @@ -663,6 +663,7 @@ describe("PLUGIN_PERMISSIONS", () => { "agent.prompt.inject", "agent.complete", "composer.transform", + "composer.suggest", "models.list", "project.create", "session.read", diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 70075658b6..5665452360 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -233,6 +233,76 @@ export type PluginComposerTransformInput = { modelKey?: string; }; +/** + * Longest suggestion text the host keeps, in characters. Longer text is cut; + * the composer draws it in one line with an ellipsis either way. + */ +export const PLUGIN_COMPOSER_SUGGESTION_MAX_CHARS = 200; +/** Lifetime of one suggestion when `ttlMs` is omitted, in milliseconds. */ +export const PLUGIN_COMPOSER_SUGGESTION_TTL_MS = 120_000; +/** Shortest and longest lifetime a caller may ask for, in milliseconds. */ +export const PLUGIN_COMPOSER_SUGGESTION_TTL_MIN_MS = 1_000; +export const PLUGIN_COMPOSER_SUGGESTION_TTL_MAX_MS = 600_000; + +/** + * `pi.composer.suggest`: one dismissible line the composer shows in its + * placeholder layer while the draft is empty (`composer.suggest`). + * + * `sessionId` and `turnId` are the ones the plugin learned from + * `session:turnEnded`. The host shows the text only while that session is + * still the one on screen, no newer turn has started, and the draft is empty, + * so an answer that took a model request to produce cannot surface in a place + * the user already left. `text` is one line: the host folds line breaks and + * runs of whitespace into single spaces and cuts the result at + * `PLUGIN_COMPOSER_SUGGESTION_MAX_CHARS`. `ttlMs` defaults to + * `PLUGIN_COMPOSER_SUGGESTION_TTL_MS` and is clamped into + * `PLUGIN_COMPOSER_SUGGESTION_TTL_MIN_MS`..`PLUGIN_COMPOSER_SUGGESTION_TTL_MAX_MS`. + */ +export type PluginComposerSuggestInput = { + readonly sessionId: string; + /** The turn the suggestion belongs to; a older turn that already ran is stale. */ + readonly turnId?: string; + readonly text: string; + readonly ttlMs?: number; +}; + +/** + * Why a suggestion is not on screen. + * - `session-not-visible`: that session is not the one the user is looking at. + * - `draft-not-empty`: the composer already holds text or an attachment. + * - `stale-turn`: the session has run a newer turn since `turnId`, so the + * suggestion would arrive about a moment that is already over. + * - `replaced`: a newer suggestion took the slot before this one was answered. + * - `unsupported`: nothing could take it — no window, no composer mounted, or + * the shell did not answer in time. + */ +export const PLUGIN_COMPOSER_SUGGESTION_REFUSALS = [ + "session-not-visible", + "draft-not-empty", + "stale-turn", + "replaced", + "unsupported", +] as const; + +export type PluginComposerSuggestionRefusal = + (typeof PLUGIN_COMPOSER_SUGGESTION_REFUSALS)[number]; + +/** + * `pi.composer.suggest` answer. A place that will not show the text is not an + * error: `shown: false` and a `reason` let a plugin log the miss and decide + * whether to spend another completion. Only malformed input throws — + * `INVALID_PARAMS`, like every other host API. + */ +export type PluginComposerSuggestResult = + | { readonly shown: true } + | { readonly shown: false; readonly reason: PluginComposerSuggestionRefusal }; + +/** + * `pi.composer.clearSuggestion`: drop this plugin's suggestion. `sessionId` + * defaults to the session the user is looking at. + */ +export type PluginComposerClearSuggestionInput = { readonly sessionId?: string }; + export type PluginSessionMessage = | { role: "user"; content: string; createdAt: string } | { @@ -1188,6 +1258,24 @@ export type PluginHostApi = { registerAdapter: (adapter: PluginSpeechAdapter) => Promise; unregisterAdapter: (protocol: string) => Promise; }; + /** + * The session's input box (`composer.suggest`). A plugin gets one + * dismissible line into the placeholder layer of a session whose draft is + * empty; the user takes it with Tab, drops it with Esc, and typing anything + * drops it too. Never a message, never a bubble, never a send. + */ + composer: { + /** + * Offer one line for the composer of `sessionId`. Resolves + * `{ shown: false, reason }` instead of throwing when nothing will show + * it, so the answer is safe to log. The whole suggestion is cleared by + * the shell on: typing, pasting, an attachment, a send, a session switch, + * a new turn, this plugin unloading, a renderer reload, or a window close. + */ + suggest: (input: PluginComposerSuggestInput) => Promise; + /** Drop this plugin's suggestion; nothing is shown when it is already gone. */ + clearSuggestion: (input?: PluginComposerClearSuggestionInput) => Promise<{ ok: true }>; + }; ui: { openPanel: (opts?: { title?: string }) => Promise; closePanel: () => Promise; @@ -1484,6 +1572,10 @@ export const PLUGIN_PERMISSIONS = [ "agent.prompt.inject", "agent.complete", "composer.transform", + // One dismissible line in the composer's placeholder layer. A grant here can + // put text into a draft the user may send, so it sits with the draft writer + // (`composer.transform`) rather than with the display-only grants. + "composer.suggest", "agent.extension", // Renderer slots (`docs/plugin-plan/ui/`): the entry module loads into the // host renderer's own document, so the surface it can touch is the diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 7635845243..ecc0524c71 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -343,6 +343,12 @@ export const IPC = { * `session:activated` snapshot is assembled from both (spec 07-plugins/03). */ pluginSessionActive: "pi-desktop/plugin/session/active", + /** + * The shell's answer to one composer suggestion (`pi.composer.suggest`): + * whether it went on screen and, if not, why. The main process is waiting + * on this invoke, so a suggestion's caller learns what happened to it. + */ + pluginComposerSuggestionResult: "pi-desktop/plugin/composer/suggestionResult", mcpList: "pi-desktop/mcp/list", mcpUpsert: "pi-desktop/mcp/upsert", mcpRemove: "pi-desktop/mcp/remove", @@ -465,6 +471,13 @@ export const IPC = { * which session's panel takes the view. */ pluginViewReveal: "pi-desktop/plugin/view/event/reveal", + /** + * One composer suggestion (`pi.composer.suggest`): an offer to show a + * plugin's line in the placeholder layer, or the shell's order to drop what + * it shows. The shell owns the composer, so it decides and answers over + * `pluginComposerSuggestionResult`. + */ + pluginComposerSuggestion: "pi-desktop/plugin/composer/event/suggestion", windowMaximized: "pi-desktop/window/event/maximized", windowFullScreen: "pi-desktop/window/event/fullscreen", windowWorkPanelResize: "pi-desktop/window/event/workPanelResize", From 8f4285fd2f25380f2f5ed0b6f645b6623418e3f3 Mon Sep 17 00:00:00 2001 From: TioitWang <104554654+Tioit-Wang@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:45:24 +0800 Subject: [PATCH 10/44] feat(transcript): unify turn folding and command preferences Keep each assistant turn behind one process disclosure while allowing shell calls to remain independent or join their surrounding activity group. Read the presentation preference during rendering so cached turns update immediately, and preserve disclosure choices across parent toggles. Add regression coverage for settings persistence, outcome states, visibility, alignment, hit targets, and rendered scroll stability. --- .../electron/main/bootstrap/shutdown.ts | 2 +- apps/desktop/electron/main/index.ts | 2 +- apps/desktop/electron/main/ipc/register.ts | 2 +- .../electron/main/plugin-host-process.mjs | 2 +- apps/desktop/electron/main/plugin-runtime.ts | 4 +- .../main/services/composer-suggestion.ts | 2 +- .../electron/main/services/plugin-services.ts | 2 +- apps/desktop/src/components/Composer.tsx | 2 +- .../settings/KeepCommandsExpandedRow.tsx | 28 ++ .../features/chat/composer/ComposerInput.tsx | 2 +- .../composer/hooks/useComposerSuggestion.ts | 2 +- .../composer/plugins/suggestion-bridge.ts | 2 +- .../chat/composer/plugins/suggestion-keys.ts | 2 +- .../chat/transcript/ActivityGroup.tsx | 42 +- .../chat/transcript/AssistantTurn.tsx | 68 ++- .../chat/transcript/AssistantTurnParts.tsx | 28 +- .../chat/transcript/ProcessActivityGroup.tsx | 6 +- .../features/chat/transcript/TurnProcess.tsx | 107 ++++- .../chat/transcript/disclosure-scope.ts | 22 + .../features/chat/transcript/disclosure.tsx | 59 ++- .../src/features/settings/SettingsPage.tsx | 2 + apps/desktop/src/lib/api.ts | 4 +- apps/desktop/src/lib/assistant-turns.ts | 9 +- apps/desktop/src/lib/command-folding.ts | 19 + apps/desktop/src/lib/settings-search.ts | 2 + apps/desktop/src/lib/turn-process.ts | 204 +++++++-- apps/desktop/src/styles/composer.css | 2 +- apps/desktop/src/styles/messages.css | 85 ++-- .../test/command-folding-settings.test.mjs | 54 +++ .../test/composer-suggestion-relay.test.mjs | 2 +- .../test/composer-suggestion-state.test.mjs | 2 +- .../test/macos-window-translucency.test.mjs | 3 +- apps/desktop/test/settings-roundtrip.test.mjs | 102 ++++- apps/desktop/test/thinking-ui.test.mjs | 5 +- .../transcript-disclosure-reading.test.mjs | 2 +- apps/desktop/test/transcript-style.test.mjs | 62 +++ apps/desktop/test/transcript-summary.test.mjs | 7 +- apps/desktop/test/turn-process.test.mjs | 346 ++++++++++++++- crates/host-core/src/config_sync/domains.rs | 11 +- .../src/config_sync/engine_s3_tests.rs | 12 +- ....md => 0333-plugin-composer-suggestion.md} | 2 +- docs/adr/README.md | 2 +- docs/adr/turn-process-and-thinking-display.md | 102 +++-- docs/spec/03-runtime/04-data-storage.md | 8 +- docs/spec/04-ux/06-settings-ia.md | 8 + docs/spec/04-ux/08-component-spec.md | 105 +++-- docs/spec/04-ux/09-interaction-patterns.md | 34 +- docs/spec/06-delivery/04-e2e-test-plan.md | 106 +++-- docs/spec/08-meta/decisions-log.md | 25 +- .../adr/turn-process-and-thinking-display.md | 70 ++-- docs/zh-CN/spec/03-runtime/04-data-storage.md | 5 +- docs/zh-CN/spec/04-ux/06-settings-ia.md | 5 + docs/zh-CN/spec/04-ux/08-component-spec.md | 83 ++-- .../spec/04-ux/09-interaction-patterns.md | 23 +- .../spec/06-delivery/04-e2e-test-plan.md | 85 ++-- docs/zh-CN/spec/08-meta/decisions-log.md | 18 +- packages/i18n/src/locales/de/index.ts | 11 + packages/i18n/src/locales/en/index.ts | 11 + packages/i18n/src/locales/es/index.ts | 11 + packages/i18n/src/locales/fr/index.ts | 11 + packages/i18n/src/locales/ko/index.ts | 11 + packages/i18n/src/locales/pt-BR/index.ts | 11 + packages/i18n/src/locales/tr/index.ts | 11 + packages/i18n/src/locales/zh-CN/index.ts | 11 + packages/i18n/src/locales/zh-TW/index.ts | 11 + packages/shared/src/types/settings.ts | 10 + scripts/e2e/transcript-disclosure-anchor.tsx | 396 ++++++++++++++++-- scripts/e2e/transcript-render.tsx | 9 +- scripts/e2e/turn-process.tsx | 381 +++++++++++++++-- 69 files changed, 2409 insertions(+), 488 deletions(-) create mode 100644 apps/desktop/src/components/settings/KeepCommandsExpandedRow.tsx create mode 100644 apps/desktop/src/features/chat/transcript/disclosure-scope.ts create mode 100644 apps/desktop/src/lib/command-folding.ts create mode 100644 apps/desktop/test/command-folding-settings.test.mjs rename docs/adr/{0332-plugin-composer-suggestion.md => 0333-plugin-composer-suggestion.md} (99%) diff --git a/apps/desktop/electron/main/bootstrap/shutdown.ts b/apps/desktop/electron/main/bootstrap/shutdown.ts index b531b9a95d..c7f6e2a6d6 100644 --- a/apps/desktop/electron/main/bootstrap/shutdown.ts +++ b/apps/desktop/electron/main/bootstrap/shutdown.ts @@ -46,7 +46,7 @@ export type ShutdownDependencies = { mcpOAuth?: Pick; browserHost: Pick; pluginViews: Pick; - /** Composer suggestions still waiting for an answer at quit (ADR 0332). */ + /** Composer suggestions still waiting for an answer at quit (ADR 0333). */ composerSuggestion?: Pick; updater: Pick; logger: Pick; diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index 7e55202999..7de4796d46 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -642,7 +642,7 @@ const sessionCoordination = createSessionCoordination({ onTurnStarted: (sessionId: string, turnId: string) => { pluginServices.pluginSession.noteTurnStarted(sessionId, turnId); // A new turn owns the composer now, so a suggestion about the previous one - // stops being shown and stops being showable (ADR 0332). + // stops being shown and stops being showable (ADR 0333). pluginServices.composerSuggestion.noteTurnStarted(sessionId, turnId); }, }); diff --git a/apps/desktop/electron/main/ipc/register.ts b/apps/desktop/electron/main/ipc/register.ts index 756c03913a..e165aff047 100644 --- a/apps/desktop/electron/main/ipc/register.ts +++ b/apps/desktop/electron/main/ipc/register.ts @@ -65,7 +65,7 @@ export type RegisterIpcDependencies = { setActivePluginSession: ( next: { sessionId: string; projectPath: string | null } | null, ) => void; - /** The shell's answer to a composer suggestion it was offered (ADR 0332). */ + /** The shell's answer to a composer suggestion it was offered (ADR 0333). */ settleComposerSuggestion: (answer: ComposerSuggestionAnswer) => boolean; activeUserSubagentDocuments: (...args: any[]) => Promise; disabledBuiltinSubagents: () => Promise; diff --git a/apps/desktop/electron/main/plugin-host-process.mjs b/apps/desktop/electron/main/plugin-host-process.mjs index 889a75146d..89c91c41d3 100644 --- a/apps/desktop/electron/main/plugin-host-process.mjs +++ b/apps/desktop/electron/main/plugin-host-process.mjs @@ -253,7 +253,7 @@ function buildApi() { requestNotificationPermission: () => call("ui.requestNotificationPermission"), showNativeNotification: (input) => call("ui.showNativeNotification", [input]), }, - // One line into the session's composer placeholder (ADR 0332). The shell + // One line into the session's composer placeholder (ADR 0333). The shell // answers whether it went on screen, so a plugin never has to guess before // spending another completion. composer: { diff --git a/apps/desktop/electron/main/plugin-runtime.ts b/apps/desktop/electron/main/plugin-runtime.ts index cd2ded1b50..437d1455e6 100644 --- a/apps/desktop/electron/main/plugin-runtime.ts +++ b/apps/desktop/electron/main/plugin-runtime.ts @@ -4616,7 +4616,7 @@ export class PluginRuntime { } /** * `pi.composer.suggest`: one line into the composer of the session the user - * is looking at (`composer.suggest`, ADR 0332). + * is looking at (`composer.suggest`, ADR 0333). * * The plugin names the session and the turn its text answers. Nothing here * can fail the plugin for a place that will not show it: the text is shaped @@ -5584,7 +5584,7 @@ export class PluginRuntime { composer: { /** * One dismissible line in the placeholder layer of the composer the - * user is looking at (`composer.suggest`, ADR 0332). + * user is looking at (`composer.suggest`, ADR 0333). */ suggest: async (input?: PluginComposerSuggestInput) => this.suggestComposer(loaded, input), diff --git a/apps/desktop/electron/main/services/composer-suggestion.ts b/apps/desktop/electron/main/services/composer-suggestion.ts index 0e92d0c151..0545f3250b 100644 --- a/apps/desktop/electron/main/services/composer-suggestion.ts +++ b/apps/desktop/electron/main/services/composer-suggestion.ts @@ -1,5 +1,5 @@ /** - * The main-process half of `pi.composer.suggest` (ADR 0332). + * The main-process half of `pi.composer.suggest` (ADR 0333). * * A suggestion is produced by a plugin process, long after the turn it answers * ended, so by the time it arrives the user may have typed, switched session, diff --git a/apps/desktop/electron/main/services/plugin-services.ts b/apps/desktop/electron/main/services/plugin-services.ts index 33251db00a..dcd0e1e431 100644 --- a/apps/desktop/electron/main/services/plugin-services.ts +++ b/apps/desktop/electron/main/services/plugin-services.ts @@ -639,7 +639,7 @@ export function createPluginServices({ announce: broadcastSessionEvent, }); /** - * `pi.composer.suggest` (ADR 0332): the main process refuses a suggestion + * `pi.composer.suggest` (ADR 0333): the main process refuses a suggestion * that names another session or an overtaken turn, and the shell answers * whether the composer can take it. The composer owns its own state, so this * only ever relays — nothing here writes the draft. diff --git a/apps/desktop/src/components/Composer.tsx b/apps/desktop/src/components/Composer.tsx index 05d9fa922e..ccb3358170 100644 --- a/apps/desktop/src/components/Composer.tsx +++ b/apps/desktop/src/components/Composer.tsx @@ -497,7 +497,7 @@ export function Composer({ // Plugin draft and attachment actions reach this composer while it takes input. usePluginComposerBridge({ ...draft, inputBlocked, enabled: localToolsAllowed }); // A plugin may have offered one line for this empty draft; it shows in the - // placeholder layer and `Tab` takes it (ADR 0332). + // placeholder layer and `Tab` takes it (ADR 0333). const suggestion = useComposerSuggestion(); // Keep the transcript's bottom reserve in sync with the composer's real diff --git a/apps/desktop/src/components/settings/KeepCommandsExpandedRow.tsx b/apps/desktop/src/components/settings/KeepCommandsExpandedRow.tsx new file mode 100644 index 0000000000..c32f47e61a --- /dev/null +++ b/apps/desktop/src/components/settings/KeepCommandsExpandedRow.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from "react-i18next"; +import type { AppSettings } from "@pi-desktop/shared"; +import { resolveKeepCommandsExpanded } from "../../lib/command-folding"; +import { SettingsRow } from "../../features/settings/primitives"; +import { SettingsToggle } from "../ui"; + +export function KeepCommandsExpandedRow({ + settings, + saveSettings, +}: { + settings: AppSettings; + saveSettings: (patch: Partial) => Promise; +}) { + const { t } = useTranslation(); + const expanded = resolveKeepCommandsExpanded(settings); + return ( + + void saveSettings({ keepCommandsExpanded: !expanded })} + /> + + ); +} diff --git a/apps/desktop/src/features/chat/composer/ComposerInput.tsx b/apps/desktop/src/features/chat/composer/ComposerInput.tsx index dcacf74872..4341721835 100644 --- a/apps/desktop/src/features/chat/composer/ComposerInput.tsx +++ b/apps/desktop/src/features/chat/composer/ComposerInput.tsx @@ -28,7 +28,7 @@ export type ComposerInputProps = { runActive: boolean; composerAc: CompletionController; /** - * The plugin suggestion on screen, if any (ADR 0332). It is drawn in the + * The plugin suggestion on screen, if any (ADR 0333). It is drawn in the * placeholder layer while the draft is empty, and `Tab` / `Esc` act on it only * when nothing else claims those keys. */ diff --git a/apps/desktop/src/features/chat/composer/hooks/useComposerSuggestion.ts b/apps/desktop/src/features/chat/composer/hooks/useComposerSuggestion.ts index 2786277f81..b53e3c7419 100644 --- a/apps/desktop/src/features/chat/composer/hooks/useComposerSuggestion.ts +++ b/apps/desktop/src/features/chat/composer/hooks/useComposerSuggestion.ts @@ -1,5 +1,5 @@ /** - * The composer's view of the plugin suggestion slot (ADR 0332). + * The composer's view of the plugin suggestion slot (ADR 0333). * * The state itself lives in `../plugins/suggestion-bridge.ts`, outside React, so * a suggestion that arrives while the composer is unmounted is refused once diff --git a/apps/desktop/src/features/chat/composer/plugins/suggestion-bridge.ts b/apps/desktop/src/features/chat/composer/plugins/suggestion-bridge.ts index 1faedf3a4f..76bb0b5b2c 100644 --- a/apps/desktop/src/features/chat/composer/plugins/suggestion-bridge.ts +++ b/apps/desktop/src/features/chat/composer/plugins/suggestion-bridge.ts @@ -1,5 +1,5 @@ /** - * The renderer end of `pi.composer.suggest` (ADR 0332). + * The renderer end of `pi.composer.suggest` (ADR 0333). * * A plugin process offers one line, the shell decides whether the composer can * take it, and the user takes it with Tab, drops it with Esc, or drops it by diff --git a/apps/desktop/src/features/chat/composer/plugins/suggestion-keys.ts b/apps/desktop/src/features/chat/composer/plugins/suggestion-keys.ts index 9111cdb73d..1e0761888c 100644 --- a/apps/desktop/src/features/chat/composer/plugins/suggestion-keys.ts +++ b/apps/desktop/src/features/chat/composer/plugins/suggestion-keys.ts @@ -1,5 +1,5 @@ /** - * Which key, if any, acts on the composer's plugin suggestion (ADR 0332). + * Which key, if any, acts on the composer's plugin suggestion (ADR 0333). * * The composer already has owners for both keys and they keep them: a `@` / `/` * completion list takes `Tab` (and `Enter`), and its own `Escape` closes it diff --git a/apps/desktop/src/features/chat/transcript/ActivityGroup.tsx b/apps/desktop/src/features/chat/transcript/ActivityGroup.tsx index 338dd06fc7..b8c11ab636 100644 --- a/apps/desktop/src/features/chat/transcript/ActivityGroup.tsx +++ b/apps/desktop/src/features/chat/transcript/ActivityGroup.tsx @@ -60,22 +60,6 @@ type Translate = (key: string, options?: Record) => string; type ActivityItem = AssistantActivityItem; -export function activityItemDetail(item: ActivityItem): string { - if (item.kind === "hostedSearch") return item.round.query ?? ""; - if (item.kind === "thinking") { - // Latest thought line, so a collapsed header reads like a live ticker. - const lines = thinkingText(item.message) - .split("\n") - .map((line) => line.replace(/^#+\s*|\*\*/g, "").trim()) - .filter(Boolean); - return lines[lines.length - 1] || ""; - } - if (lifecycleKindOf(item.message)) { - return delegationRosterSummary(delegationRoster(item.message)); - } - return getToolSummary(item.message.toolName, item.message.toolArgs); -} - function waitingSubagentActionLabel( agent: AgentActivityAgent, @@ -260,6 +244,10 @@ export const ActivityGroup = memo(function ActivityGroup({ disclosureKey("activity", first?.message.id ?? "", first?.kind ?? "", first?.kind === "hostedSearch" ? first.round.id : ""), ); const { open, toggle: toggleDisclosure, collapse: collapseDisclosure, claim: claimDisclosure, titleRef } = disclosure; + // A subagent card is its own disclosure, so it reads the same parent gate the + // group does: below a collapsed whole-turn process it stays shut, and + // reopening the process restores the card's own choice. + const cardShut = hasSubagentTopology && !open; const [now, setNow] = useState(Date.now); const [finishedAt, setFinishedAt] = useState(null); const wasActiveRef = useRef(live); @@ -308,14 +296,10 @@ export const ActivityGroup = memo(function ActivityGroup({ : // History reloads keep no end timestamp for pure-thinking groups. t("chat.thinking", { defaultValue: "Thinking" }) : t("chat.processedFor", { time: elapsed }); - const runtimeStatus = runtimeActivity - ? runActivityLabel(runtimeActivity, t as Translate) - : ""; - const currentDetail = useMemo(() => - live && !runtimeStatus && lastItem && !(hideThinking && lastItem.kind === "thinking") - ? activityItemDetail(lastItem) : "", - [live, runtimeStatus, lastItem, hideThinking]); - const tail = live && !open ? currentDetail : ""; + // The header states outcome, counts and elapsed time; it never echoes the item + // it is working on. A shell command or a search query can be arbitrarily long, + // and repeating it here pushed the counts off the row while adding nothing a + // reader cannot get by opening the item. useEffect(() => { if (wasActiveRef.current && !live) setFinishedAt(Date.now()); @@ -392,17 +376,13 @@ export const ActivityGroup = memo(function ActivityGroup({
- {tail ? ( -
- {tail} -
- ) : null}