From 3e757ea20a84a104b9cc7e7dc2b3384e5f7e6fa4 Mon Sep 17 00:00:00 2001 From: chaxus Date: Wed, 14 Oct 2026 21:53:58 +0800 Subject: [PATCH 1/7] docs(assistant): plan contextual workspace experience --- .../plans/2026-10-14-assistant-workspace.md | 72 ++++++++++++++ .../2026-10-14-assistant-workspace-design.md | 93 +++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-14-assistant-workspace.md create mode 100644 docs/superpowers/specs/2026-10-14-assistant-workspace-design.md diff --git a/docs/superpowers/plans/2026-10-14-assistant-workspace.md b/docs/superpowers/plans/2026-10-14-assistant-workspace.md new file mode 100644 index 00000000..b7e2786b --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-assistant-workspace.md @@ -0,0 +1,72 @@ +# Assistant Workspace Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development. Steps use checkbox syntax for tracking. + +**Goal:** Make document tasks discoverable and make generated results and reviewed changes clearer, within one new PR. + +**Architecture:** The panel retains submission and consent ownership. An app-owned task module stages bound request intent, ChatView improves generic result rendering, and ActionPreview improves its existing review composition. No persistence or model-provider contract is replaced. + +**Tech Stack:** TypeScript, ranui builders/components/tokens, Vitest/jsdom, Playwright and the existing OnlyOffice editor bridge. + +**Specification:** `docs/superpowers/specs/2026-10-14-assistant-workspace-design.md`. + +## Chunk 1: Contextual task entry and routing + +Files: new `lib/agent-plugin/ui/task-entry.ts`; integration in `panel.ts` and +`panel-context.ts`; task-entry styles in `styles/assistant.css`; locale tables +and `i18n/types.ts`; unit tests and `test/e2e/agent-workspace.spec.ts`. + +- [ ] Write failing tests for format/selection suggestions, no automatic sends, + draft preservation, expired scope and edited-request fallback. +- [ ] Run focused Vitest; verify behavioral failures before implementation. +- [ ] Build the contextual task entry using existing bounded context reads. + Stage `{ text, intent, target, session }` only on a user task click; consume + after explicit Send and fresh scope checks. Preserve preparation/consent. +- [ ] Integrate known intent into routing without altering arbitrary typed + requests, native tools, literal commands, cancellation or refinement. +- [ ] Verify a ready staged writing task makes zero classification calls and + one writing call. Add real Word flow through native change and Undo. +- [ ] Commit as `feat(assistant): add contextual tasks and direct request routing`. + +## Chunk 2: Conversation and result hierarchy + +Files: `packages/chat-ui/src/{chat-view,styles,types}.ts`; existing chat-view +unit suites; relevant browser result/streaming tests. + +- [ ] Write failing result tests: artifact identity, interrupted/restored + semantics, original source for copy/apply, accessible actual activity. +- [ ] Run red tests, then improve typography, document-draft framing, compact + actions and truthful streaming/activity states through optional labels. +- [ ] Preserve editor independence and all existing public method behavior. + Coordinate any new localized labels with the task-entry owner. +- [ ] Verify long Markdown/table/code, keyboard focus, draft editing, Stop and + reader scroll position. Honor reduced motion and narrow layouts. +- [ ] Commit as `feat(chat-ui): clarify drafts and conversation results`. + +## Chunk 3: Review and completion + +Files: `lib/agent-plugin/ui/action-preview.ts`, review styles only in +`styles/assistant.css`, action-preview units and content-review E2E. + +- [ ] Add failing tests for clear primary/secondary action hierarchy and + accessible secondary controls; retain all existing review behaviors. +- [ ] Improve target/comparison composition and action grouping using ranui. + Use actual review data and actual execution outcomes. Preserve expiration, + cancellation, refinement, current-target checks and draft editing. +- [ ] Verify Apply/Cancel/Edit/Refine/Copy/Locate, selection invalidation, + sent versus verified outcomes, error states and restored focus. +- [ ] Run native Word review/application/Undo E2E and screenshot validation. +- [ ] Commit as `feat(assistant): refine change review and completion`. + +## Chunk 4: Integration and delivery + +- [ ] Spec compliance review, then code-quality review for each functional unit. + Fix verified findings and check final consumers/state owners. +- [ ] Integrate functional commits; keep root and subagent checkouts isolated. +- [ ] Inspect final light/dark/narrow screenshots and measure shared alignment + and stable composer geometry; record unverified acceptance surfaces. +- [ ] Update changelog and concise exploration record. Run reverse regressions. +- [ ] Run full unit suite, format, lint/typecheck and production build, then + selected real-editor E2E including Stop, history and dictation regressions. +- [ ] Fetch latest main, verify clean checkout and ahead/behind, push the feature + branch, create/attach one new PR, and inspect checks for the pushed SHA. diff --git a/docs/superpowers/specs/2026-10-14-assistant-workspace-design.md b/docs/superpowers/specs/2026-10-14-assistant-workspace-design.md new file mode 100644 index 00000000..d59a5d16 --- /dev/null +++ b/docs/superpowers/specs/2026-10-14-assistant-workspace-design.md @@ -0,0 +1,93 @@ +# Contextual document assistant workspace + +## Approved direction and evidence + +The user approved implementing the overall UX/design direction discussed in the +conversation: document-aware entry points, clearer context, stronger answer and +review hierarchy, less uninformative waiting, and direct completion in the editor. +Use one new PR from current main, with functional commits. PR #268 is merged. + +Current code facts: + +- `panel-context.ts` and `composer-context.ts` already own bounded selection reads + and event-driven refresh. The welcome view currently offers text hints only. +- `panel.ts` routes ready non-native requests through `resolveRequestIntent` + before generating an answer or writing proposal. Exact host-selected tasks + can carry their known intent without this extra model classification. +- `ActionPreview` already owns target validity, comparison, confirmation, + cancellation, refinement and verified/sent receipts. Improve its composition; + retain these execution safeguards. +- `ChatView` is editor-independent. The host marks document artifacts; generic + rendering must never infer write permission from prose or show interrupted + results as completed artifacts. + +No claim is made about actual model latency or answer quality without measurement. + +## Experience + +1. Open the assistant to see the current document scope and a compact set of + useful tasks for Word, Excel, PowerPoint or PDF. Selected text changes the + suggestions. Empty, oversized/unavailable selection and read-only contexts + must remain honest about scope and supported operations. +2. Clicking a task stages an editable request and focuses the composer. It does + not invoke a model, download a model, overwrite a nonempty draft, or queue work. + Explain the staging interaction briefly. Explicit Send starts the operation. +3. A staged task is host-owned metadata, consumed once only if the request and + captured session/editor scope still match. Edited requests use normal routing; + stale selection/context gives visible guidance and preserves the request. + Never skip classification solely by matching arbitrary user-authored text. +4. Preserve current preparation, endpoint consent and local writing consent. + Ready staged writing tasks avoid exactly one routing model call. Prove this + with request counts, not an invented percentage or timing promise. +5. Distinguish ordinary answers, document drafts, interrupted output and activity + with restrained typography, surfaces and labels. Main content remains readable; + copy and apply retain the original source and completion guards. +6. A pending review shows its actual target and original/proposed changes, with + one clear Apply decision, Cancel, and a discoverable secondary-actions area. + Preserve Edit, Refine, Copy and Locate. Completion shows only the actual + verified/sent outcome, location and existing Undo guidance. + +## Scope and ownership + +Allowed: assistant UI modules and integration in `lib/agent-plugin/ui/panel.ts`, +`styles/assistant.css`, generic presentation in `packages/chat-ui/src`, seven +locale tables/types, relevant tests, changelog and design/verification records. + +Ownership: panel coordinates submission/consent/session state; a dedicated task +entry module owns staged-task provenance and UI; existing context reads remain +single-owned; ChatView owns rendering; ActionPreview owns review state. + +Compatibility boundaries: preserve storage formats, provider defaults, explicit +Send, experimental local-writing consent, cloud consent, read-only behavior, +selection bounds, model/tool cancellation, target validity and truthful receipts. +No vendor edits, new renderer dependency, GPU visual framework, background model +calls, synthetic thinking, fabricated audio waveforms or automatic document writes. +Existing browser-local inference continues using WebGPU where supported. + +## Visual acceptance + +Use the repository's ranui components/tokens. GPUI-native APIs do not apply to +this TypeScript browser UI. Common leading edges, a stable composer and compact +secondary chrome give the document/result space priority. Use CSS/SVG for state +transitions, honor reduced motion, and retain keyboard operation/focus restoration. + +Check real editor layouts at 320px sidebar width and default/wide widths, light +and dark themes, long/CJK content, empty/loading/error/read-only states. Review +actual screenshots. Avoid generic welcome screens repeated for every format. + +## Acceptance + +- Context suggestions update through existing SDK events and perform no model + call until explicit Send; drafts survive setup, Stop and state changes. +- Host-selected writing request: classification count decreases from one to + zero; writing still executes once, then waits for review. Edited/stale/session + switched requests cannot reuse a privileged or stale task hint. +- Real Word: select text, stage polish, Send, review old/new, Apply, verify native + text and Undo. Exercise a follow-up refinement and Stop/retry. +- Excel/PPT/PDF suggestions describe only available scope; read-only/oversized + selection does not promise unsupported mutation. +- Copy/apply/interrupted/restored result semantics and history/speech regressions + remain passing. Reverse-disable new behavior to prove regressions fail. +- Full unit suite, format, lint/typecheck, production build and appropriate real + editor E2E pass before push/PR. Physical model quality and microphone accuracy + remain separate acceptance surfaces. From af5b6f4aff924eea7206f2293fa6519c91747a41 Mon Sep 17 00:00:00 2001 From: chaxus Date: Wed, 14 Oct 2026 23:28:57 +0800 Subject: [PATCH 2/7] feat(assistant): add contextual tasks and direct request routing --- lib/agent-plugin/ui/panel-context.ts | 4 +- lib/agent-plugin/ui/panel.ts | 57 ++++- lib/agent-plugin/ui/task-entry.ts | 180 ++++++++++++++ packages/shared/src/i18n/messages/de.ts | 29 +++ packages/shared/src/i18n/messages/en.ts | 29 +++ packages/shared/src/i18n/messages/es.ts | 30 +++ packages/shared/src/i18n/messages/ja.ts | 30 +++ packages/shared/src/i18n/messages/ko.ts | 29 +++ packages/shared/src/i18n/messages/pt.ts | 29 +++ packages/shared/src/i18n/messages/zh-CN.ts | 29 +++ packages/shared/src/i18n/types.ts | 29 +++ styles/assistant.css | 89 +++++++ test/e2e/agent-workspace.spec.ts | 259 +++++++++++++++++++++ test/unit/agent-task-entry.test.ts | 126 ++++++++++ 14 files changed, 941 insertions(+), 8 deletions(-) create mode 100644 lib/agent-plugin/ui/task-entry.ts create mode 100644 test/e2e/agent-workspace.spec.ts create mode 100644 test/unit/agent-task-entry.test.ts diff --git a/lib/agent-plugin/ui/panel-context.ts b/lib/agent-plugin/ui/panel-context.ts index 66439dd2..2a80a92d 100644 --- a/lib/agent-plugin/ui/panel-context.ts +++ b/lib/agent-plugin/ui/panel-context.ts @@ -1,5 +1,5 @@ import { getEditorApi, type EditorApi } from '../editor-bridge'; -import { captureComposerContext } from './composer-context'; +import { captureComposerContext, type ComposerContext } from './composer-context'; import { t } from '@ranuts/shared/i18n'; // SDK events verified in the bundled Word, spreadsheet and slide runtimes. @@ -160,8 +160,10 @@ export function syncPanelContext(options: { welcomeHint: HTMLElement; settings: HTMLElement; ready: () => boolean; + onSnapshot?: (snapshot: ComposerContext) => void; }): void { const snapshot = captureComposerContext(); + options.onSnapshot?.(snapshot); const context = snapshot.context; options.quoteContext.hidden = !context || !snapshot.text.trim(); const label = diff --git a/lib/agent-plugin/ui/panel.ts b/lib/agent-plugin/ui/panel.ts index 3c9a26fb..8f22756e 100644 --- a/lib/agent-plugin/ui/panel.ts +++ b/lib/agent-plugin/ui/panel.ts @@ -27,6 +27,7 @@ import { getEditorApi } from '../editor-bridge'; import { mountReviewSettings } from './review-settings'; import { createModelCacheSettings } from './model-cache-settings'; import { mountPanelContext, syncPanelContext } from './panel-context'; +import { createTaskEntry } from './task-entry'; import { agentTools } from '../tools'; import { parseDirectDocumentIntent } from '../direct-intent'; import { captureDocumentContext, resolveContextCommand } from '../document-context'; @@ -614,6 +615,12 @@ export function createAgentPanel( return { label: prepareAction.textContent || t('agentSettings'), run: prepareSelectedSource }; }, beforeSend: () => { + if (!taskEntry.check(chat.getInput())) { + stagedGuidance.hidden = false; + stagedGuidance.textContent = t('agentEntryChanged'); + return false; + } + stagedGuidance.hidden = true; if (!speech?.isBusy()) return true; const target = (() => { try { @@ -634,6 +641,7 @@ export function createAgentPanel( appendTurn({ role: 'status', text: t('agentSendContextChanged') }); return false; } + if (ready && !taskEntry.check(chat.getInput())) return false; return ready; }); }, @@ -746,7 +754,24 @@ export function createAgentPanel( const welcomeHint = document.createElement('p'); welcomeHint.className = 'agent-welcome-hint'; welcomeHint.textContent = t('agentWelcomeHint'); - chat.emptyActionsEl.prepend(welcomeHint); + const taskEntry = createTaskEntry({ + getInput: () => chat.getInput(), + setInput: (text) => chat.setInput(text), + focus: () => chat.focus(), + session: () => `${conversations.activeId}:${controllerGeneration}:${conversationRevision}`, + readonly: getReadonlyMode, + capture: captureComposerContext, + captureTarget: captureDocumentToolTarget, + onStage: (intent) => { + writingSelect.value = intent.task === 'compose' ? 'chat' : intent.task; + proposalMode = !['chat', 'compose'].includes(intent.task); + syncRuntimeStatus(); + }, + }); + chat.emptyActionsEl.prepend(welcomeHint, taskEntry.el); + chat.el.querySelector('textarea')!.addEventListener('input', (event) => { + if (event.isTrusted || chat.getInput()) taskEntry.inputChanged(chat.getInput()); + }); let quotedSelection = ''; const quoteContext = document.createElement('div'); quoteContext.className = 'agent-quote-context'; @@ -758,6 +783,11 @@ export function createAgentPanel( quoteDetails.append(quoteSummary, quoteText); quoteContext.append(quoteDetails); chat.contextEl.append(quoteContext); + const stagedGuidance = document.createElement('p'); + stagedGuidance.className = 'agent-staged-guidance'; + stagedGuidance.hidden = true; + stagedGuidance.setAttribute('role', 'status'); + chat.contextEl.append(stagedGuidance); const refinement = new ProposalContext(); const refinementRow = Div().class('agent-refinement-context').build(); const refinementLabel = Span().build(); @@ -848,6 +878,7 @@ export function createAgentPanel( welcomeHint, settings, ready: () => !!webllmProvider?.isReady(), + onSnapshot: (snapshot) => taskEntry.update(snapshot), }); }; let historyControls: ReturnType | undefined; @@ -1627,6 +1658,15 @@ export function createAgentPanel( const submit = async (text: string): Promise => { const trimmed = text.trim(); if (!enabled || !trimmed) return; + // Context may change while speech finalization or send preparation yields. + if (!taskEntry.check(text)) { + chat.setInput(text); + stagedGuidance.hidden = false; + stagedGuidance.textContent = t('agentEntryChanged'); + chat.focus(); + return; + } + const stagedIntent = taskEntry.consume(text); const refinementContext = refinement.capture(); if (refinementContext.kind === 'expired') { clearRefinement(); @@ -1639,7 +1679,7 @@ export function createAgentPanel( // data only: a fresh target is still captured before the next model request. const previousPreview = preview.pending() ? preview : undefined; const pendingProposal = refinementContext.kind === 'selected' ? refinementContext.plan : preview.pending()?.plan; - let request = classifyRequest(trimmed); + let request = stagedIntent ?? classifyRequest(trimmed); if (refinementContext.kind === 'selected') request.task = 'tools'; writingSelect.value = request.task; languageSelect.value = request.language; @@ -1678,7 +1718,7 @@ export function createAgentPanel( chat.focus(); return; } - const intent = refinementContext.kind === 'selected' ? null : directIntent(trimmed); + const intent = stagedIntent || refinementContext.kind === 'selected' ? null : directIntent(trimmed); if (intent) { const answer = chat.getLastDocumentBody(); const chinese = getLanguage().startsWith('zh'); @@ -1852,6 +1892,7 @@ export function createAgentPanel( } else if ( routingProvider?.isReady() && routingProvider.toolCallingMode === 'native' && + !stagedIntent && !pendingProposal && !['rewrite', 'summarize', 'translate'].includes(request.task) && !literalToolRequest && @@ -1872,10 +1913,12 @@ export function createAgentPanel( planning = abort; routingStarted = true; chat.setActivity('planning'); - request = await resolveRequestIntent(routingProvider, trimmed, snapshot.context, abort.signal, { - hasSelection: !!snapshot.text.trim() && !snapshot.truncated, - pendingProposal, - }); + request = + stagedIntent ?? + (await resolveRequestIntent(routingProvider, trimmed, snapshot.context, abort.signal, { + hasSelection: !!snapshot.text.trim() && !snapshot.truncated, + pendingProposal, + })); abort.signal.throwIfAborted(); if (generation !== controllerGeneration || conversation !== conversationRevision) throw new Error(t('agentPlanExpired')); diff --git a/lib/agent-plugin/ui/task-entry.ts b/lib/agent-plugin/ui/task-entry.ts new file mode 100644 index 00000000..ddec90f4 --- /dev/null +++ b/lib/agent-plugin/ui/task-entry.ts @@ -0,0 +1,180 @@ +import { ButtonBuilder, Div, Span, View } from 'ranui/builder'; +import { t, getLanguage } from '@ranuts/shared/i18n'; +import { WRITING_LANGUAGES, type WritingLanguage } from '@ranuts/agent-core/llm/writing-task'; +import type { ComposerContext } from './composer-context'; +import type { DocumentToolTarget } from '../document-tool-action'; +import type { RequestIntent } from './request-intent'; +interface Task { + id: string; + label: Parameters[0]; + prompt: Parameters[0]; + task: RequestIntent['task']; +} +const polish: Task = { id: 'polish', label: 'agentEntryPolish', prompt: 'agentEntryPolishPrompt', task: 'rewrite' }; +const summarize: Task = { + id: 'summarize', + label: 'agentEntrySummarize', + prompt: 'agentEntrySummarizePrompt', + task: 'summarize', +}; +const compose: Task = { id: 'compose', label: 'agentEntryCompose', prompt: 'agentEntryComposePrompt', task: 'compose' }; +const read: Task = { id: 'read', label: 'agentEntryRead', prompt: 'agentEntryReadPrompt', task: 'tools' }; +const explain: Task = { id: 'explain', label: 'agentEntryExplain', prompt: 'agentEntryExplainPrompt', task: 'tools' }; +const page: Task = { id: 'page', label: 'agentEntryPage', prompt: 'agentEntryPagePrompt', task: 'chat' }; +const points: Task = { id: 'points', label: 'agentEntryPoints', prompt: 'agentEntryPointsPrompt', task: 'chat' }; +const analyze: Task = { id: 'analyze', label: 'agentEntryAnalyze', prompt: 'agentEntryAnalyzePrompt', task: 'tools' }; +const outline: Task = { id: 'outline', label: 'agentEntryOutline', prompt: 'agentEntryOutlinePrompt', task: 'chat' }; +const slides: Task = { id: 'slides', label: 'agentEntrySlides', prompt: 'agentEntrySlidesPrompt', task: 'tools' }; +/** Host-owned provenance. Clicking only stages a draft; explicit Send owns execution. */ +export function createTaskEntry(options: { + getInput(): string; + setInput(text: string): void; + focus(): void; + session(): string; + readonly(): boolean; + capture(): ComposerContext; + captureTarget(): DocumentToolTarget; + onStage(intent: RequestIntent): void; +}) { + const el = View('section').class('agent-task-entry').build(); + const scope = Span().class('agent-task-scope').build(); + const tasks = Div().class('agent-task-list').build(); + const help = View('p').class('agent-task-help').attr('role', 'status').build(); + el.append(scope, tasks, help); + let staged: { + text: string; + intent: RequestIntent; + session: string; + snapshot: string; + target: DocumentToolTarget; + readonly: boolean; + } | null = null; + let signature = ''; + const fingerprint = (snapshot: ComposerContext) => JSON.stringify(snapshot); + const fresh = (snapshot?: ComposerContext) => + !!staged && + staged.session === options.session() && + staged.readonly === options.readonly() && + staged.target.isCurrent(false) && + staged.snapshot === fingerprint(snapshot ?? options.capture()); + const inputChanged = (text: string) => { + if (staged && staged.text !== text) staged = null; + }; + const check = (text: string) => { + inputChanged(text); + if (!staged) return true; + if (fresh()) return true; + help.textContent = t('agentEntryChanged'); + return false; + }; + const consume = (text: string): RequestIntent | null => { + inputChanged(text); + const intent = staged && fresh() ? staged.intent : null; + staged = null; + return intent; + }; + const eligible = (snapshot: ComposerContext, readonly: boolean): Task[] => { + const context = snapshot.context; + const selected = !!snapshot.text.trim() && !snapshot.truncated; + return !context + ? [] + : snapshot.truncated + ? [read, explain] + : context.kind === 'pdf' + ? [page, points] + : context.kind === 'cell' + ? selected + ? [analyze, summarize, explain] + : [read, explain] + : context.kind === 'slide' + ? selected && !readonly + ? [polish, summarize, slides] + : [slides, outline] + : selected + ? readonly + ? [summarize, explain] + : [polish, summarize, explain] + : readonly + ? [read, explain] + : [compose, read, explain]; + }; + const update = (snapshot: ComposerContext) => { + const context = snapshot.context; + const selected = !!snapshot.text.trim() && !snapshot.truncated; + const readonly = options.readonly(); + scope.textContent = + context?.kind === 'cell' + ? `${context.sheet ?? 'Excel'} · ${context.range ?? t('agentCurrentDocument')}` + : context?.kind === 'slide' + ? t('agentSlideContext', { page: context.page ?? '—' }) + : context?.kind === 'pdf' + ? t('agentEntryPdfScope', { page: context.page ?? '—' }) + : selected + ? t('agentSelectionContext', { count: snapshot.text.length }) + : t('agentCurrentDocument'); + const items = eligible(snapshot, readonly); + const next = JSON.stringify([items.map((item) => item.id), getLanguage()]); + if (next !== signature) { + signature = next; + tasks.replaceChildren( + ...items.map((item) => { + const button = ButtonBuilder() + .attr('type', 'button') + .attr('data-task', item.id) + .class('agent-task-action') + .text(t(item.label)) + .build(); + button.addEventListener('click', () => { + if (options.getInput().trim()) { + help.textContent = t('agentEntryDraftPresent'); + options.focus(); + return; + } + const snapshot = options.capture(); + if (!eligible(snapshot, options.readonly()).some((task) => task.id === item.id)) { + update(snapshot); + help.textContent = t('agentEntryChanged'); + return; + } + try { + const target = options.captureTarget(); + const shellLanguage = getLanguage(); + const locale = shellLanguage === 'zh' ? 'zh-CN' : shellLanguage; + const language = ( + WRITING_LANGUAGES.includes(locale as WritingLanguage) ? locale : 'en' + ) as WritingLanguage; + const intent = { task: item.task, language }; + const text = t(item.prompt); + options.setInput(text); + staged = { + text, + intent, + session: options.session(), + snapshot: fingerprint(snapshot), + target, + readonly: options.readonly(), + }; + options.onStage(intent); + help.textContent = t('agentEntryStaging'); + options.focus(); + } catch { + help.textContent = t('agentEditorLoading'); + } + }); + return button; + }), + ); + } + help.textContent = + staged && !fresh(snapshot) + ? t('agentEntryChanged') + : !context + ? t('agentEditorLoading') + : snapshot.truncated + ? t('agentEntrySmaller') + : readonly + ? t('agentEntryReadOnly') + : t('agentEntryStaging'); + }; + return { el, update, inputChanged, check, consume }; +} diff --git a/packages/shared/src/i18n/messages/de.ts b/packages/shared/src/i18n/messages/de.ts index b32d3620..e96203f3 100644 --- a/packages/shared/src/i18n/messages/de.ts +++ b/packages/shared/src/i18n/messages/de.ts @@ -428,4 +428,33 @@ export const de: Partial = { historyClearSearch: 'Suche zurücksetzen', historyDeleteTitle: 'Dieses Dokument löschen', historyClearTitle: 'Alles löschen', + agentEntryPolish: 'Auswahl verbessern', + agentEntryPolishPrompt: 'Verbessere die ausgewählte Passage. Behalte die Bedeutung und formuliere klar.', + agentEntrySummarize: 'Auswahl zusammenfassen', + agentEntrySummarizePrompt: 'Fasse den ausgewählten Inhalt kurz zusammen.', + agentEntryCompose: 'Projektbericht entwerfen', + agentEntryComposePrompt: 'Entwirf einen kurzen Projektbericht mit Fortschritt, Risiken und nächsten Schritten.', + agentEntryRead: 'Dokument erkunden', + agentEntryReadPrompt: 'Lies das aktuelle Dokument und gib mir einen kurzen Überblick.', + agentEntryExplain: 'Inhalt erklären', + agentEntryExplainPrompt: 'Erkläre den ausgewählten Inhalt, oder ohne Auswahl das aktuelle Dokument.', + agentEntryPage: 'Seite zusammenfassen', + agentEntryPagePrompt: 'Fasse die aktuelle PDF-Seite kurz zusammen.', + agentEntryPoints: 'Kernaussagen finden', + agentEntryPointsPrompt: 'Liste die Kernaussagen der aktuellen PDF-Seite auf.', + agentEntryPdfScope: 'PDF · Seite {page}', + agentEntryChanged: 'Dokument oder Auswahl geändert. Leere den Entwurf und wähle die Aufgabe erneut.', + agentEntryDraftPresent: 'Dein Entwurf bleibt erhalten. Sende oder leere ihn, bevor du eine Aufgabe wählst.', + agentEntryStaging: 'Aufgabe wählen, Anfrage anpassen, dann senden.', + agentEntrySmaller: 'Wähle für Textänderungen eine kleinere Passage oder einen kleineren Bereich.', + agentEntryReadOnly: 'Schreibgeschützt · Inhalte erkunden und zusammenfassen', + agentDocumentDraft: 'Dokumententwurf', + agentIncompleteResponse: 'Unvollständige Antwort', + agentReviewActions: 'Weitere Aktionen', + agentEntryAnalyze: 'Ausgewählte Daten erkunden', + agentEntryAnalyzePrompt: 'Lies die ausgewählten Zellen und beschreibe Muster und prüfenswerte Werte.', + agentEntryOutline: 'Gliederung vorschlagen', + agentEntryOutlinePrompt: 'Schlage anhand des Präsentationstexts eine klarere Gliederung vor. Ändere keine Folien.', + agentEntrySlides: 'Präsentation erkunden', + agentEntrySlidesPrompt: 'Lies den Präsentationstext und gib mir einen kurzen Überblick.', }; diff --git a/packages/shared/src/i18n/messages/en.ts b/packages/shared/src/i18n/messages/en.ts index afd610e0..741aa4ac 100644 --- a/packages/shared/src/i18n/messages/en.ts +++ b/packages/shared/src/i18n/messages/en.ts @@ -404,4 +404,33 @@ export const en: I18nMessages = { historyOpenFile: 'Open a file', historyRailSettings: 'Settings', historyRailRetention: 'Retention', + agentEntryPolish: 'Polish selection', + agentEntryPolishPrompt: 'Polish the selected passage. Keep its meaning and make the wording clear.', + agentEntrySummarize: 'Summarize selection', + agentEntrySummarizePrompt: 'Summarize the selected content concisely.', + agentEntryCompose: 'Draft an update', + agentEntryComposePrompt: 'Draft a short project update with progress, risks and next steps.', + agentEntryRead: 'Explore document', + agentEntryReadPrompt: 'Read the current document and give me a concise overview.', + agentEntryExplain: 'Explain content', + agentEntryExplainPrompt: 'Explain the selected content, or the current document when nothing is selected.', + agentEntryPage: 'Summarize page', + agentEntryPagePrompt: 'Summarize the current PDF page concisely.', + agentEntryPoints: 'Find key points', + agentEntryPointsPrompt: 'List the key points from the current PDF page.', + agentEntryPdfScope: 'PDF · Page {page}', + agentEntryChanged: 'The document or selection changed. Clear this draft and choose a task again.', + agentEntryDraftPresent: 'Your draft is kept. Send it or clear it to choose a task.', + agentEntryStaging: 'Choose a task, edit the request, then send.', + agentEntrySmaller: 'Select a smaller passage or range for text changes.', + agentEntryReadOnly: 'Read-only · You can explore and summarize content.', + agentDocumentDraft: 'Document draft', + agentIncompleteResponse: 'Incomplete response', + agentReviewActions: 'More actions', + agentEntryAnalyze: 'Explore selected data', + agentEntryAnalyzePrompt: 'Read the selected cells and describe their patterns and any values worth checking.', + agentEntryOutline: 'Suggest an outline', + agentEntryOutlinePrompt: 'Based on the presentation text, suggest a clearer outline. Do not change the slides.', + agentEntrySlides: 'Review presentation', + agentEntrySlidesPrompt: 'Read the presentation text and give me a concise overview.', }; diff --git a/packages/shared/src/i18n/messages/es.ts b/packages/shared/src/i18n/messages/es.ts index 11d0437b..3f691c2c 100644 --- a/packages/shared/src/i18n/messages/es.ts +++ b/packages/shared/src/i18n/messages/es.ts @@ -415,4 +415,34 @@ export const es: Partial = { historyClearSearch: 'Borrar la búsqueda', historyDeleteTitle: 'Eliminar este documento', historyClearTitle: 'Eliminarlo todo', + agentEntryPolish: 'Mejorar selección', + agentEntryPolishPrompt: 'Mejora el pasaje seleccionado. Conserva su significado y aclara la redacción.', + agentEntrySummarize: 'Resumir selección', + agentEntrySummarizePrompt: 'Resume brevemente el contenido seleccionado.', + agentEntryCompose: 'Redactar informe', + agentEntryComposePrompt: 'Redacta un informe breve del proyecto con avances, riesgos y próximos pasos.', + agentEntryRead: 'Explorar documento', + agentEntryReadPrompt: 'Lee el documento actual y dame una visión general breve.', + agentEntryExplain: 'Explicar contenido', + agentEntryExplainPrompt: 'Explica el contenido seleccionado, o el documento actual si no hay selección.', + agentEntryPage: 'Resumir página', + agentEntryPagePrompt: 'Resume brevemente la página PDF actual.', + agentEntryPoints: 'Extraer puntos clave', + agentEntryPointsPrompt: 'Enumera los puntos clave de la página PDF actual.', + agentEntryPdfScope: 'PDF · Página {page}', + agentEntryChanged: 'El documento o la selección cambiaron. Borra este borrador y elige la tarea de nuevo.', + agentEntryDraftPresent: 'Tu borrador se conserva. Envíalo o bórralo para elegir una tarea.', + agentEntryStaging: 'Elige una tarea, ajusta la solicitud y envíala.', + agentEntrySmaller: 'Selecciona un pasaje o rango menor para modificar el texto.', + agentEntryReadOnly: 'Solo lectura · Puedes explorar y resumir el contenido', + agentDocumentDraft: 'Borrador del documento', + agentIncompleteResponse: 'Respuesta incompleta', + agentReviewActions: 'Más acciones', + agentEntryAnalyze: 'Explorar datos seleccionados', + agentEntryAnalyzePrompt: 'Lee las celdas seleccionadas y describe los patrones y valores que conviene revisar.', + agentEntryOutline: 'Sugerir estructura', + agentEntryOutlinePrompt: + 'Basándote en el texto de la presentación, sugiere una estructura más clara. No cambies las diapositivas.', + agentEntrySlides: 'Explorar presentación', + agentEntrySlidesPrompt: 'Lee el texto de la presentación y dame una visión general breve.', }; diff --git a/packages/shared/src/i18n/messages/ja.ts b/packages/shared/src/i18n/messages/ja.ts index f162f786..29fd2723 100644 --- a/packages/shared/src/i18n/messages/ja.ts +++ b/packages/shared/src/i18n/messages/ja.ts @@ -409,4 +409,34 @@ export const ja: Partial = { historyClearSearch: '検索をクリア', historyDeleteTitle: 'このドキュメントを削除', historyClearTitle: 'すべて削除', + agentEntryPolish: '選択を推敲', + agentEntryPolishPrompt: '選択した文章の意味を保ち、表現を明確にしてください。', + agentEntrySummarize: '選択を要約', + agentEntrySummarizePrompt: '選択した内容を簡潔に要約してください。', + agentEntryCompose: '進捗を下書き', + agentEntryComposePrompt: '進捗、リスク、次の手順を含む短いプロジェクト報告を作成してください。', + agentEntryRead: '文書を読む', + agentEntryReadPrompt: '現在の文書を読み、簡潔な概要を示してください。', + agentEntryExplain: '内容を説明', + agentEntryExplainPrompt: '選択した内容を説明してください。選択がない場合は現在の文書を説明してください。', + agentEntryPage: 'ページを要約', + agentEntryPagePrompt: '現在の PDF ページを簡潔に要約してください。', + agentEntryPoints: '要点を抽出', + agentEntryPointsPrompt: '現在の PDF ページの要点を列挙してください。', + agentEntryPdfScope: 'PDF · {page} ページ', + agentEntryChanged: '文書または選択が変わりました。下書きを消してタスクを選び直してください。', + agentEntryDraftPresent: '下書きは保持されています。送信するか消してタスクを選んでください。', + agentEntryStaging: 'タスクを選び、依頼を調整して送信します。', + agentEntrySmaller: '文章を変更するには、より小さい範囲を選択してください。', + agentEntryReadOnly: '読み取り専用 · 内容の確認と要約ができます', + agentDocumentDraft: '文書の下書き', + agentIncompleteResponse: '未完了の回答', + agentReviewActions: 'その他の操作', + agentEntryAnalyze: '選択データを確認', + agentEntryAnalyzePrompt: '選択したセルを読み、傾向と確認すべき値を説明してください。', + agentEntryOutline: '構成を提案', + agentEntryOutlinePrompt: + 'プレゼンテーションの文章を基に、より明確な構成を提案してください。スライドは変更しないでください。', + agentEntrySlides: 'プレゼンを読む', + agentEntrySlidesPrompt: 'プレゼンテーションの文章を読み、簡潔な概要を示してください。', }; diff --git a/packages/shared/src/i18n/messages/ko.ts b/packages/shared/src/i18n/messages/ko.ts index ff9cfaf1..b07144c0 100644 --- a/packages/shared/src/i18n/messages/ko.ts +++ b/packages/shared/src/i18n/messages/ko.ts @@ -400,4 +400,33 @@ export const ko: Partial = { historyClearSearch: '검색 지우기', historyDeleteTitle: '이 문서 삭제', historyClearTitle: '전체 삭제', + agentEntryPolish: '선택 내용 다듬기', + agentEntryPolishPrompt: '선택한 글의 의미를 유지하면서 표현을 명확하게 다듬어 주세요.', + agentEntrySummarize: '선택 내용 요약', + agentEntrySummarizePrompt: '선택한 내용을 간결하게 요약해 주세요.', + agentEntryCompose: '진행 상황 초안', + agentEntryComposePrompt: '진행 상황, 위험 및 다음 단계를 포함한 짧은 프로젝트 보고서를 작성해 주세요.', + agentEntryRead: '문서 살펴보기', + agentEntryReadPrompt: '현재 문서를 읽고 간결한 개요를 알려 주세요.', + agentEntryExplain: '내용 설명', + agentEntryExplainPrompt: '선택한 내용을 설명해 주세요. 선택한 내용이 없으면 현재 문서를 설명해 주세요.', + agentEntryPage: '페이지 요약', + agentEntryPagePrompt: '현재 PDF 페이지를 간결하게 요약해 주세요.', + agentEntryPoints: '핵심 추출', + agentEntryPointsPrompt: '현재 PDF 페이지의 핵심 내용을 나열해 주세요.', + agentEntryPdfScope: 'PDF · {page}페이지', + agentEntryChanged: '문서나 선택 영역이 변경되었습니다. 초안을 지우고 작업을 다시 선택해 주세요.', + agentEntryDraftPresent: '초안을 유지했습니다. 보내거나 지운 뒤 작업을 선택하세요.', + agentEntryStaging: '작업을 선택하고 요청을 수정한 뒤 보내세요.', + agentEntrySmaller: '글을 수정하려면 더 작은 영역을 선택하세요.', + agentEntryReadOnly: '읽기 전용 · 내용을 살펴보고 요약할 수 있습니다', + agentDocumentDraft: '문서 초안', + agentIncompleteResponse: '미완성 답변', + agentReviewActions: '추가 작업', + agentEntryAnalyze: '선택 데이터 분석', + agentEntryAnalyzePrompt: '선택한 셀을 읽고 데이터 패턴과 확인할 값을 설명해 주세요.', + agentEntryOutline: '개요 제안', + agentEntryOutlinePrompt: '프레젠테이션 내용을 바탕으로 더 명확한 개요를 제안해 주세요. 슬라이드는 변경하지 마세요.', + agentEntrySlides: '프레젠테이션 살펴보기', + agentEntrySlidesPrompt: '프레젠테이션 글을 읽고 간결한 개요를 알려 주세요.', }; diff --git a/packages/shared/src/i18n/messages/pt.ts b/packages/shared/src/i18n/messages/pt.ts index 15405e81..f30a7315 100644 --- a/packages/shared/src/i18n/messages/pt.ts +++ b/packages/shared/src/i18n/messages/pt.ts @@ -411,4 +411,33 @@ export const pt: Partial = { historyClearSearch: 'Limpar a procura', historyDeleteTitle: 'Eliminar este documento', historyClearTitle: 'Eliminar tudo', + agentEntryPolish: 'Melhorar seleção', + agentEntryPolishPrompt: 'Melhore o trecho selecionado. Preserve o significado e torne a redação clara.', + agentEntrySummarize: 'Resumir seleção', + agentEntrySummarizePrompt: 'Resuma brevemente o conteúdo selecionado.', + agentEntryCompose: 'Redigir atualização', + agentEntryComposePrompt: 'Redija uma atualização curta do projeto com progresso, riscos e próximos passos.', + agentEntryRead: 'Explorar documento', + agentEntryReadPrompt: 'Leia o documento atual e forneça uma visão geral breve.', + agentEntryExplain: 'Explicar conteúdo', + agentEntryExplainPrompt: 'Explique o conteúdo selecionado ou, sem seleção, o documento atual.', + agentEntryPage: 'Resumir página', + agentEntryPagePrompt: 'Resuma brevemente a página PDF atual.', + agentEntryPoints: 'Extrair pontos principais', + agentEntryPointsPrompt: 'Liste os pontos principais da página PDF atual.', + agentEntryPdfScope: 'PDF · Página {page}', + agentEntryChanged: 'O documento ou a seleção mudou. Limpe este rascunho e escolha a tarefa novamente.', + agentEntryDraftPresent: 'Seu rascunho foi mantido. Envie ou limpe antes de escolher uma tarefa.', + agentEntryStaging: 'Escolha uma tarefa, ajuste o pedido e envie.', + agentEntrySmaller: 'Selecione um trecho ou intervalo menor para alterar o texto.', + agentEntryReadOnly: 'Somente leitura · Você pode explorar e resumir o conteúdo', + agentDocumentDraft: 'Rascunho do documento', + agentIncompleteResponse: 'Resposta incompleta', + agentReviewActions: 'Mais ações', + agentEntryAnalyze: 'Explorar dados selecionados', + agentEntryAnalyzePrompt: 'Leia as células selecionadas e descreva padrões e valores que merecem revisão.', + agentEntryOutline: 'Sugerir estrutura', + agentEntryOutlinePrompt: 'Com base no texto da apresentação, sugira uma estrutura mais clara. Não altere os slides.', + agentEntrySlides: 'Explorar apresentação', + agentEntrySlidesPrompt: 'Leia o texto da apresentação e forneça uma visão geral breve.', }; diff --git a/packages/shared/src/i18n/messages/zh-CN.ts b/packages/shared/src/i18n/messages/zh-CN.ts index 788fa432..865dc482 100644 --- a/packages/shared/src/i18n/messages/zh-CN.ts +++ b/packages/shared/src/i18n/messages/zh-CN.ts @@ -387,4 +387,33 @@ export const zhCN: I18nMessages = { historyExpiresIn: '剩 {days} 天', historyExpiresInOne: '剩 1 天', historyExpiresToday: '今天', + agentEntryPolish: '润色选中文字', + agentEntryPolishPrompt: '润色选中的文字,保留原意,让表达更清晰。', + agentEntrySummarize: '总结选中内容', + agentEntrySummarizePrompt: '简洁总结选中的内容。', + agentEntryCompose: '起草项目进展', + agentEntryComposePrompt: '起草一份简短的项目进展,包含进展、风险和下一步。', + agentEntryRead: '了解文档', + agentEntryReadPrompt: '读取当前文档,给我一份简洁的概览。', + agentEntryExplain: '解释内容', + agentEntryExplainPrompt: '解释选中的内容;未选中时,解释当前文档。', + agentEntryPage: '总结当前页', + agentEntryPagePrompt: '简洁总结当前 PDF 页。', + agentEntryPoints: '提取要点', + agentEntryPointsPrompt: '列出当前 PDF 页的主要要点。', + agentEntryPdfScope: 'PDF · 第 {page} 页', + agentEntryChanged: '文档或选区已变化。清空这份草稿后重新选择任务。', + agentEntryDraftPresent: '已保留你的草稿。发送或清空后再选择任务。', + agentEntryStaging: '选择任务,调整要求,再发送。', + agentEntrySmaller: '如需修改文字,请选择更小的段落或范围。', + agentEntryReadOnly: '只读 · 可以了解和总结内容', + agentDocumentDraft: '文档草稿', + agentIncompleteResponse: '回答未完成', + agentReviewActions: '更多操作', + agentEntryAnalyze: '分析选中数据', + agentEntryAnalyzePrompt: '读取选中的单元格,描述数据趋势和值得检查的值。', + agentEntryOutline: '建议演示大纲', + agentEntryOutlinePrompt: '根据演示文稿的文字,建议一份更清晰的大纲,不修改幻灯片。', + agentEntrySlides: '了解演示文稿', + agentEntrySlidesPrompt: '读取演示文稿的文字,给我一份简洁的概览。', }; diff --git a/packages/shared/src/i18n/types.ts b/packages/shared/src/i18n/types.ts index 98d4c53a..d1c7990e 100644 --- a/packages/shared/src/i18n/types.ts +++ b/packages/shared/src/i18n/types.ts @@ -402,4 +402,33 @@ export interface I18nMessages { historyOpenFile: string; historyRailSettings: string; historyRailRetention: string; + agentEntryPolish: string; + agentEntryPolishPrompt: string; + agentEntrySummarize: string; + agentEntrySummarizePrompt: string; + agentEntryCompose: string; + agentEntryComposePrompt: string; + agentEntryRead: string; + agentEntryReadPrompt: string; + agentEntryExplain: string; + agentEntryExplainPrompt: string; + agentEntryPage: string; + agentEntryPagePrompt: string; + agentEntryPoints: string; + agentEntryPointsPrompt: string; + agentEntryPdfScope: string; + agentEntryChanged: string; + agentEntryDraftPresent: string; + agentEntryStaging: string; + agentEntrySmaller: string; + agentEntryReadOnly: string; + agentDocumentDraft: string; + agentIncompleteResponse: string; + agentReviewActions: string; + agentEntryAnalyze: string; + agentEntryAnalyzePrompt: string; + agentEntryOutline: string; + agentEntryOutlinePrompt: string; + agentEntrySlides: string; + agentEntrySlidesPrompt: string; } diff --git a/styles/assistant.css b/styles/assistant.css index 73aac09d..c20ef3a5 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -1684,3 +1684,92 @@ r-dropdown[role='listbox'] { .agent-history-options button:hover:not(:disabled) { background: var(--ran-color-bg-hover); } + +/* Contextual document tasks share the transcript and composer's leading edge. */ +.agent-panel .cui-empty { + margin: auto 0; + font-size: 24px; + line-height: 1.25; + letter-spacing: -0.04em; +} +.agent-panel .cui-empty-actions { + display: block; + margin-top: var(--ran-space-3); +} +.agent-welcome-hint { + margin: 0 0 var(--ran-space-5); + font-size: 13px; + line-height: 1.65; +} +.agent-task-entry { + font-weight: 400; + letter-spacing: 0; + font-size: 13px; + width: 100%; + min-width: 0; +} +.agent-task-scope { + display: inline-flex; + align-items: center; + max-width: 100%; + box-sizing: border-box; + padding: 5px 8px; + border: 1px solid var(--ran-color-border); + border-radius: var(--ran-radius-sm); + background: var(--ran-color-bg-muted); + color: var(--ran-color-text-secondary); + overflow-wrap: anywhere; + font-size: 11px; + line-height: 1.5; +} +.agent-task-list { + display: grid; + gap: var(--ran-space-2); + margin-top: var(--ran-space-4); +} +.agent-task-action { + display: flex; + align-items: center; + text-align: start; + min-height: 40px; + padding: 10px 12px; + border: 1px solid var(--ran-color-border); + border-radius: var(--ran-radius-md); + background: var(--ran-color-bg-elevated); + color: var(--ran-color-text); + font: inherit; + cursor: pointer; + transition: + background-color 120ms, + border-color 120ms; +} +.agent-task-action::after { + content: '↗'; + margin-inline-start: auto; + padding-inline-start: 12px; + color: var(--ran-color-text-secondary); +} +.agent-task-action:hover { + background: var(--ran-color-bg-muted); + border-color: var(--ran-color-text-secondary); +} +.agent-task-action:focus-visible { + outline: 2px solid var(--ran-color-primary); + outline-offset: 3px; +} +.agent-task-help, +.agent-staged-guidance { + margin: var(--ran-space-3) 0 0; + font-size: 11px; + line-height: 1.6; + color: var(--ran-color-text-secondary); + overflow-wrap: anywhere; +} +.agent-staged-guidance { + margin: 0; +} +@media (prefers-reduced-motion: reduce) { + .agent-task-action { + transition: none; + } +} diff --git a/test/e2e/agent-workspace.spec.ts b/test/e2e/agent-workspace.spec.ts new file mode 100644 index 00000000..44d60020 --- /dev/null +++ b/test/e2e/agent-workspace.spec.ts @@ -0,0 +1,259 @@ +import { expect, test } from './lib/l0'; +import type { Page } from '@playwright/test'; + +async function open(page: Page, ext = 'docx', kind = 'loopback') { + await page.addInitScript((kind) => { + const baseUrl = kind === 'loopback' ? 'http://localhost:11434' : 'https://assistant.example.invalid/v1'; + if (kind !== 'loopback') localStorage.setItem(`agent_endpoint_key_${encodeURIComponent(baseUrl)}`, 'test-key'); + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem( + 'agent-writing-endpoint', + JSON.stringify({ + version: 1, + kind, + baseUrl, + model: 'workspace-test', + preference: 'device-first', + localWritingConsent: false, + }), + ); + }, kind); + await page.goto(`/editor?new=${ext}&locale=en`); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); +} +async function selectWord(page: Page) { + await page.evaluate(() => { + const win = window.__ooFrames.readyEditor() as any; + const api = win.Asc?.editor ?? win.editor; + api.pluginMethod_InputText('The project is going well and we are making good progress.'); + api.asc_EditSelectAll(); + }); + await expect(page.locator('.agent-quoted-text')).toContainText('The project'); +} + +test('selected Word task stages without calls, skips classification, reviews and undoes', async ({ + page, +}, testInfo) => { + test.setTimeout(120_000); + let classification = 0, + writing = 0; + await page.route('http://localhost:11434/api/**', async (route) => { + if (route.request().method() === 'OPTIONS') { + await route.fulfill({ + status: 204, + headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type' }, + }); + return; + } + if (route.request().url().endsWith('/tags')) { + await route.fulfill({ json: { models: [{ name: 'workspace-test' }] } }); + return; + } + const body = route.request().postDataJSON(); + const routing = !!body.format?.properties?.task; + if (routing) classification++; + else writing++; + await route.fulfill({ + json: { + done: true, + message: { + role: 'assistant', + content: routing + ? JSON.stringify({ task: 'rewrite', language: 'en' }) + : JSON.stringify({ text: 'The project is progressing well.' }), + }, + }, + }); + }); + await open(page); + await selectWord(page); + await page.locator('[data-task="polish"]').click(); + expect(classification + writing).toBe(0); + await expect(page.locator('.cui-input')).toHaveValue(/Polish/); + await expect(page.locator('.cui-input')).toBeFocused(); + const original = await page.locator('.cui-input').inputValue(); + // Setup preserves the staged draft; choose the task again after connecting. + await page.locator('.cui-send').click(); + await page.locator('.agent-panel-endpoint-connect').click(); + await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Connected'); + await page.locator('.agent-panel-settings-toggle').click(); + await expect(page.locator('.cui-send')).toHaveAccessibleName('Send'); + await expect(page.locator('.cui-input')).toHaveValue(original); + await page.locator('.cui-input').fill(''); + await page.locator('[data-task="polish"]').click(); + for (const [width, theme] of [ + [432, 'light'], + [520, 'light'], + [320, 'dark'], + ] as const) { + await page.evaluate( + ({ width, theme }) => { + document.documentElement.style.setProperty('--agent-panel-width', `${width}px`); + document.documentElement.setAttribute('data-ran-theme', theme); + }, + { width, theme }, + ); + await page.screenshot({ path: testInfo.outputPath(`workspace-word-${width}-${theme}.png`) }); + } + await page.locator('.cui-send').click(); + const preview = page.locator('.agent-plan-preview').last(); + await expect(preview).toHaveAttribute('data-state', 'pending'); + expect(classification).toBe(0); + expect(writing).toBe(1); + await expect(preview.locator('.agent-plan-diff')).toContainText('progressing well.'); + const beforeApply = await page.evaluate(() => { + const win = window.__ooFrames.readyEditor() as any; + return (win.Asc?.editor ?? win.editor).pluginMethod_GetSelectedText({ Numbering: false }); + }); + expect(beforeApply).toContain('we are making good progress'); + await preview.locator('.agent-plan-apply').click(); + await expect(preview).toHaveAttribute('data-state', 'verified'); + const read = () => + page.evaluate(() => { + const win = window.__ooFrames.readyEditor() as any; + const api = win.Asc?.editor ?? win.editor; + api.asc_EditSelectAll(); + return api.pluginMethod_GetSelectedText({ Numbering: false }); + }); + expect(await read()).toContain('The project is progressing well.'); + await page.evaluate(() => { + const win = window.__ooFrames.readyEditor() as any; + (win.Asc?.editor ?? win.editor).Undo(); + }); + expect(await read()).toContain('we are making good progress'); + await page.screenshot({ path: testInfo.outputPath('workspace-review.png') }); + await page.locator('.agent-panel-clear').click(); + await expect(page.locator('[data-task="polish"]')).toBeVisible(); + await page.locator('[data-task="polish"]').click(); + await page.locator('.cui-input').fill('Polish the selection, making it shorter.'); + await page.locator('.cui-send').click(); + await expect(page.locator('.agent-plan-preview').last()).toHaveAttribute('data-state', 'pending'); + expect(classification).toBe(1); + expect(writing).toBe(2); +}); + +test('stale selected task retains draft and requests reselection before any model call', async ({ page }) => { + test.setTimeout(120_000); + let calls = 0; + await page.route('http://localhost:11434/api/**', async (route) => { + if (route.request().url().endsWith('/tags')) { + await route.fulfill({ json: { models: [{ name: 'workspace-test' }] } }); + return; + } + calls++; + await route.fulfill({ json: { done: true, message: { role: 'assistant', content: 'unexpected' } } }); + }); + await open(page); + await page.locator('.agent-panel-settings-toggle').click(); + await page.locator('.agent-panel-endpoint-connect').click(); + await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Connected'); + await page.locator('.agent-panel-settings-toggle').click(); + await selectWord(page); + await page.locator('[data-task="polish"]').click(); + const draft = await page.locator('.cui-input').inputValue(); + await page.evaluate(() => { + const win = window.__ooFrames.readyEditor() as any; + const api = win.Asc?.editor ?? win.editor; + api.pluginMethod_InputText('Replacement source'); + }); + await page.locator('.cui-send').click(); + expect(calls).toBe(0); + await expect(page.locator('.agent-staged-guidance')).toContainText('changed'); + await expect(page.locator('.cui-input')).toHaveValue(draft); + await expect(page.locator('.agent-task-help')).toContainText('changed'); +}); + +for (const ext of ['xlsx', 'pptx']) + test(`${ext} entry describes the actual current scope`, async ({ page }, testInfo) => { + test.setTimeout(120_000); + await open(page, ext); + await expect(page.locator('.agent-task-action')).toHaveCount(2); + await expect(page.locator('.agent-task-scope')).toContainText(ext === 'xlsx' ? 'Sheet1' : 'Slide'); + for (const [width, theme] of [ + [432, 'light'], + [520, 'light'], + [320, 'dark'], + ] as const) { + await page.evaluate( + ({ width, theme }) => { + document.documentElement.style.setProperty('--agent-panel-width', `${width}px`); + document.documentElement.setAttribute('data-ran-theme', theme); + }, + { width, theme }, + ); + await page.screenshot({ path: testInfo.outputPath(`workspace-${ext}-${width}-${theme}.png`) }); + const overflow = await page.locator('.agent-task-entry').evaluate((el) => el.scrollWidth > el.clientWidth + 1); + expect(overflow).toBe(false); + } + }); + +test('native provider still routes staged polish through writing and review', async ({ page }) => { + test.setTimeout(120_000); + let classifications = 0, + writing = 0; + await page.route('https://assistant.example.invalid/v1/chat/completions', async (route) => { + const body = route.request().postDataJSON(); + const routing = !!body.response_format?.json_schema?.schema?.properties?.task; + if (routing) classifications++; + else writing++; + expect(body.tools ?? []).toHaveLength(0); + await route.fulfill({ + json: { + choices: [ + { + message: { + content: JSON.stringify( + routing ? { task: 'rewrite', language: 'en' } : { text: 'The project is progressing well.' }, + ), + }, + finish_reason: 'stop', + }, + ], + }, + }); + }); + await open(page, 'docx', 'openai-compatible'); + await page.locator('.agent-panel-settings-toggle').click(); + await page.locator('.agent-panel-endpoint-connect').click(); + await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Configured'); + await page.locator('.agent-panel-settings-toggle').click(); + await selectWord(page); + await page.locator('[data-task="polish"]').click(); + await page.locator('.cui-send').click(); + await expect(page.locator('.agent-plan-preview')).toHaveAttribute('data-state', 'pending'); + expect(classifications).toBe(0); + expect(writing).toBe(1); +}); + +test('PDF entry offers current-page summaries without claiming body editing', async ({ page }, testInfo) => { + test.setTimeout(120_000); + await page.addInitScript(() => { + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem( + 'agent-writing-endpoint', + JSON.stringify({ + version: 1, + kind: 'loopback', + baseUrl: 'http://localhost:11434', + model: 'workspace-test', + preference: 'device-first', + localWritingConsent: false, + }), + ); + }); + await page.goto('/'); + const chooser = page.waitForEvent('filechooser'); + await page.locator('#hero-open').click(); + const pdf = + '%PDF-1.4\n1 0 obj<>endobj\n2 0 obj<>endobj\n3 0 obj<>endobj\nxref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n0000000052 00000 n \n0000000101 00000 n \ntrailer<>\nstartxref\n160\n%%EOF'; + await (await chooser).setFiles({ name: 'entry.pdf', mimeType: 'application/pdf', buffer: Buffer.from(pdf) }); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + await expect(page.locator('.agent-runtime-panel')).toHaveAttribute('data-view', 'chat'); + await expect(page.locator('.agent-task-scope')).toContainText('PDF · Page 1'); + await expect(page.locator('[data-task="page"]')).toBeVisible(); + await expect(page.locator('[data-task="points"]')).toBeVisible(); + await expect(page.locator('[data-task="polish"]')).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath('workspace-pdf.png') }); +}); diff --git a/test/unit/agent-task-entry.test.ts b/test/unit/agent-task-entry.test.ts new file mode 100644 index 00000000..a851352f --- /dev/null +++ b/test/unit/agent-task-entry.test.ts @@ -0,0 +1,126 @@ +import { LanguageCode, setLanguage } from '@ranuts/shared/i18n'; +import { afterEach, expect, it, vi } from 'vitest'; +import { createTaskEntry } from '../../lib/agent-plugin/ui/task-entry'; +import type { ComposerContext } from '../../lib/agent-plugin/ui/composer-context'; +import type { DocumentToolTarget } from '../../lib/agent-plugin/document-tool-action'; +let context: ComposerContext; +let draft = ''; +let session = 'one'; +let current = true; +let readonly = false; +const focus = vi.fn(); +const capture = vi.fn(() => context); +function entry() { + context = { context: { kind: 'word', selectionCharacters: 6 }, text: 'source', truncated: false }; + const instance = createTaskEntry({ + getInput: () => draft, + setInput: (text) => { + draft = text; + }, + focus, + session: () => session, + readonly: () => readonly, + capture, + captureTarget: () => ({ isCurrent: () => current }) as DocumentToolTarget, + onStage: vi.fn(), + }); + document.body.append(instance.el); + instance.update(context); + return instance; +} +afterEach(() => { + document.body.replaceChildren(); + draft = ''; + session = 'one'; + current = true; + readonly = false; + focus.mockClear(); + capture.mockClear(); + setLanguage(LanguageCode.EN); +}); +it('stages a host-selected task without sending and consumes it only once', () => { + const task = entry(); + task.el.querySelector('[data-task="polish"]')!.click(); + expect(draft).toContain('Polish'); + expect(focus).toHaveBeenCalledOnce(); + expect(task.check(draft)).toBe(true); + expect(task.consume(draft)?.task).toBe('rewrite'); + expect(task.consume(draft)).toBeNull(); +}); +it('preserves an existing user draft', () => { + const task = entry(); + draft = 'my work'; + task.el.querySelector('[data-task="polish"]')!.click(); + expect(draft).toBe('my work'); + expect(task.consume(draft)).toBeNull(); +}); +it('edited requests lose host intent even when changed back', () => { + const task = entry(); + task.el.querySelector('[data-task="polish"]')!.click(); + const original = draft; + task.inputChanged(`${draft} differently`); + expect(task.consume(original)).toBeNull(); +}); +it.each(['session', 'target', 'text', 'readonly'])('refuses stale %s and preserves staged text', (cause) => { + const task = entry(); + task.el.querySelector('[data-task="polish"]')!.click(); + const original = draft; + if (cause === 'session') session = 'two'; + if (cause === 'target') current = false; + if (cause === 'text') context = { ...context, text: 'changed' }; + if (cause === 'readonly') readonly = true; + expect(task.check(draft)).toBe(false); + expect(draft).toBe(original); + expect(task.el.textContent).toContain('changed'); + expect(task.consume(draft)).toBeNull(); +}); +it('does not grant intent to an arbitrary typed copy of a task prompt', () => { + const task = entry(); + expect(task.consume('Polish the selected passage. Keep its meaning and make the wording clear.')).toBeNull(); +}); +it('offers accurate tasks for empty, read-only, oversized and other formats', () => { + const task = entry(); + task.update({ context: { kind: 'word' }, text: '', truncated: false }); + expect(task.el.querySelector('[data-task="compose"]')).not.toBeNull(); + readonly = true; + task.update(context); + expect(task.el.querySelector('[data-task="polish"]')).toBeNull(); + task.update({ ...context, truncated: true }); + expect(task.el.textContent).toContain('smaller'); + for (const kind of ['cell', 'slide', 'pdf'] as const) { + task.update({ context: { kind, page: 2, range: 'B2:C4', sheet: 'Budget' }, text: '', truncated: false }); + expect(task.el.querySelectorAll('[data-task]').length).toBeGreaterThanOrEqual(2); + expect(task.el.querySelector('[data-task="compose"]')).toBeNull(); + } + task.update({ context: null, text: '', truncated: false }); + expect(task.el.querySelectorAll('[data-task]')).toHaveLength(0); +}); +it('revalidates eligibility when context changed between render and click', () => { + const task = entry(); + context = { context: { kind: 'pdf', page: 1 }, text: 'source', truncated: false }; + task.el.querySelector('[data-task="polish"]')!.click(); + expect(draft).toBe(''); + expect(task.consume('')).toBeNull(); +}); +it('invalidates target freshness even when equal text was selected elsewhere', () => { + const task = entry(); + task.el.querySelector('[data-task="polish"]')!.click(); + current = false; + expect(task.check(draft)).toBe(false); +}); + +it('uses the already captured snapshot during staged context refresh', () => { + const task = entry(); + task.el.querySelector('[data-task="polish"]')!.click(); + capture.mockClear(); + task.update(context); + expect(capture).not.toHaveBeenCalled(); +}); + +it('normalizes the Chinese shell locale for a trusted writing intent', () => { + setLanguage(LanguageCode.ZH); + const task = entry(); + task.el.querySelector('[data-task="polish"]')!.click(); + expect(draft).toContain('润色'); + expect(task.consume(draft)?.language).toBe('zh-CN'); +}); From 21b7a57aa12cec79556dfe79cdde68773105e3d4 Mon Sep 17 00:00:00 2001 From: chaxus Date: Wed, 14 Oct 2026 23:21:28 +0800 Subject: [PATCH 3/7] feat(chat-ui): clarify drafts and conversation results --- packages/chat-ui/CLAUDE.md | 8 ++ packages/chat-ui/README.md | 14 +++- packages/chat-ui/src/chat-view.ts | 26 +++++- packages/chat-ui/src/styles.ts | 48 +++++++---- packages/chat-ui/src/types.ts | 4 + test/e2e/agent-composer-ux.spec.ts | 11 +++ test/e2e/agent-result-hierarchy.spec.ts | 103 ++++++++++++++++++++++++ test/unit/chat-view-results.test.ts | 89 ++++++++++++++++++++ 8 files changed, 284 insertions(+), 19 deletions(-) create mode 100644 test/e2e/agent-result-hierarchy.spec.ts create mode 100644 test/unit/chat-view-results.test.ts diff --git a/packages/chat-ui/CLAUDE.md b/packages/chat-ui/CLAUDE.md index 167bebd6..3ab62749 100644 --- a/packages/chat-ui/CLAUDE.md +++ b/packages/chat-ui/CLAUDE.md @@ -84,6 +84,14 @@ onSend: async (text) => { - Message arrivals consume the current following state. They must not recompute it from post-reflow geometry: scroll handling owns reader attachment, while jumping to the end and clearing reset it. - Use `appendMany` for history restoration. It keeps the same message rendering and tool grouping as `append`, while deferring intermediate scroll measurements. Completed and interrupted streams publish their original source when finalized; streaming actions stay hidden. - Copy feedback uses fixed-size icons and a separate polite announcement. Clipboard failure leaves the action available to retry; feedback never replaces running activity. +- Explicit `documentArtifact: true` identifies a document-draft surface. Ordinary + prose and tool receipts never acquire this identity by mentioning a document. + `labels.documentDraft` and `labels.incomplete` localize the result header; + updating labels also updates existing results. Interrupted streams keep their + source available for copying and show the incomplete identity, with no write + action. Set `requireDocumentArtifact: true` when a host must restrict its + explicit `onApplyMessage` callback to marked document content. Result labels + never become part of the original source used for copy or apply. - Restyle via CSS custom properties on `.cui-root` (`--cui-accent`, `--cui-user-bg`, …) — don't depend on internal `cui-*` class names. - DOM is built with the ranui `builder` (View/Div/Span/ButtonBuilder) and the scroll handler is throttled via ranuts; the package depends on `ranui` + `ranuts`. diff --git a/packages/chat-ui/README.md b/packages/chat-ui/README.md index c6fc931a..92857869 100644 --- a/packages/chat-ui/README.md +++ b/packages/chat-ui/README.md @@ -42,12 +42,24 @@ Override the look via the `--cui-*` custom properties on `.cui-root`. - `append({ role, text })` — add a finished message (`user` / `agent` / `tool` / `status` / `error`). - `appendDelta(text)` — stream text into a live agent bubble (auto-created). - `endStream()` — finalise the streaming bubble. -- `setRunning(bool)` — toggle Send⇄Stop and lock the input. +- `setRunning(bool)` — toggle Send⇄Stop while keeping the next draft editable; never queue it. - `clear()` — remove all messages. - `getInput()` / `setInput(text)` / `focus()` — input helpers. - `setLabels(labels)` — update labels live (e.g. language change). - `actionsEl` — host-populated slot above the input (compose toolbar); collapses when empty. +Hosts may mark validated document content with `documentArtifact: true` in +`append(...)` or the metadata argument to `appendDelta(...)`. It receives a +document-draft header; ordinary chat prose and tool receipts remain distinct. +Localize the header through `labels.documentDraft` and `labels.incomplete`. +Interrupted output remains copyable and is labeled incomplete, with no write +action. Header text is never included in copied or applied content. + +An explicit `onApplyMessage` callback owns writing. Use +`requireDocumentArtifact: true` to restrict this callback to host-marked content; +streaming, interrupted and `copyOnly` results cannot offer it. Only explicit +`endStream()` makes a completed streamed result available to that host action. + Intentional cancellation can use `status`: plain muted text with the same draft recovery as an error, without an error border. Status text is never returned by `getLastAnswer()` or offered as document content. Hosts may provide `canSend(text)` to keep drafts editable while a dependency is unavailable. Call `refreshSendAvailability()` when readiness changes. This blocks both Send and Enter without clearing or queuing the draft; Stop remains available during a running turn. diff --git a/packages/chat-ui/src/chat-view.ts b/packages/chat-ui/src/chat-view.ts index b54bdb19..c24c7828 100644 --- a/packages/chat-ui/src/chat-view.ts +++ b/packages/chat-ui/src/chat-view.ts @@ -16,6 +16,8 @@ const ICON_COPIED = ''; const ICON_COPY_ERROR = ''; +const ICON_DRAFT = + ''; /** Distance (px) from the bottom within which we keep auto-scrolling on new content. */ const STICK_THRESHOLD = 60; @@ -311,7 +313,7 @@ export class ChatView { : (this.labels.checkDocument ?? 'Check document'); apply.disabled = result !== 'retry' || this.running; }); - actions.append(apply); + actions.prepend(apply); } } if ((message.role === 'error' || message.role === 'status') && this.lastUserText) { @@ -329,6 +331,7 @@ export class ChatView { row.dataset.source = message.text; if (message.documentArtifact) row.dataset.documentArtifact = 'true'; if (message.interrupted) row.dataset.interrupted = 'true'; + if (message.role === 'agent') this.updateResultPresentation(row); this.messagesEl.appendChild(row); this.resizeObserver?.observe(row); @@ -402,9 +405,28 @@ export class ChatView { if (!this.liveMsg) return; this.liveMsg.dataset.interrupted = 'true'; this.liveMsg.querySelector('.cui-apply')?.remove(); + this.updateResultPresentation(this.liveMsg); this.endStream(); } + /** Result identity follows host metadata, never model prose or tool receipts. */ + private updateResultPresentation(row: HTMLElement): void { + if (!row.hasAttribute('data-document-artifact') && !row.hasAttribute('data-interrupted')) return; + let header = row.querySelector('.cui-result-header'); + if (!header) { + const icon = Span().class('cui-result-icon').attr('aria-hidden', 'true').build(); + icon.innerHTML = ICON_DRAFT; + header = Div().class('cui-result-header').children(icon, Span().class('cui-result-label').build()).build(); + row.prepend(header); + } + const label = row.hasAttribute('data-interrupted') + ? (this.labels.incomplete ?? 'Incomplete response') + : (this.labels.documentDraft ?? 'Document draft'); + header.querySelector('.cui-result-label')!.textContent = label; + row.setAttribute('role', 'article'); + row.setAttribute('aria-label', label); + } + /** Mount a host-owned review card in chronological conversation order. */ appendContent(content: HTMLElement): void { this.emptyEl.remove(); @@ -513,6 +535,8 @@ export class ChatView { } for (const button of this.messagesEl.querySelectorAll('.cui-restore')) button.textContent = labels.restore ?? 'Restore request'; + for (const header of this.messagesEl.querySelectorAll('.cui-result-header')) + this.updateResultPresentation(header.parentElement!); this.updateSendState(); if (this.emptyEl.parentElement) this.emptyTitle.textContent = labels.empty ?? ''; } diff --git a/packages/chat-ui/src/styles.ts b/packages/chat-ui/src/styles.ts index 715d4e8e..353e9e40 100644 --- a/packages/chat-ui/src/styles.ts +++ b/packages/chat-ui/src/styles.ts @@ -10,26 +10,37 @@ import { Style } from 'ranui/builder'; * with an embedded circular send button; subtle tool/error chips. */ export const CHAT_UI_CSS = ` -.cui-root { --cui-accent:var(--ran-color-primary, #171717); --cui-accent-contrast:var(--ran-color-primary-text, #fff); --cui-bg:var(--ran-color-bg, #fff); --cui-user-bg:var(--ran-color-bg-muted, #f4f4f4); --cui-text:var(--ran-color-text, #171717); --cui-muted:var(--ran-color-text-secondary, #737373); --cui-border:var(--ran-color-border, #e5e5e5); position:relative; display:flex; flex-direction:column; min-height:0; height:100%; background:var(--cui-bg); color:var(--cui-text); font-size:14px; line-height:1.65; -webkit-font-smoothing:antialiased; } -.cui-messages { flex:1 1 auto; min-height:0; overflow:auto; overflow-x:hidden; padding:24px 20px; display:flex; flex-direction:column; gap:24px; overscroll-behavior:contain; scrollbar-width:thin; } +.cui-root { --cui-accent:var(--ran-color-primary, #171717); --cui-accent-contrast:var(--ran-color-primary-text, #fff); --cui-bg:var(--ran-color-bg, #fff); --cui-user-bg:var(--ran-color-bg-muted, #f4f4f4); --cui-text:var(--ran-color-text, #171717); --cui-muted:var(--ran-color-text-secondary, #737373); --cui-border:var(--ran-color-border, #e5e5e5); --cui-surface:var(--ran-color-bg-subtle, var(--cui-user-bg)); position:relative; display:flex; flex-direction:column; min-height:0; height:100%; background:var(--cui-bg); color:var(--cui-text); font-family:var(--ran-font-family, system-ui, sans-serif); font-size:var(--ran-text-copy-2, 14px); line-height:1.7; -webkit-font-smoothing:antialiased; } +.cui-messages { flex:1 1 auto; min-height:0; overflow:auto; overflow-x:hidden; padding:var(--ran-space-6, 24px) var(--ran-space-4, 16px); display:flex; flex-direction:column; gap:var(--ran-space-6, 24px); overscroll-behavior:contain; scrollbar-width:thin; } .cui-empty { margin:auto; max-width:260px; padding:32px 8px; text-align:left; font-size:22px; font-weight:500; line-height:1.45; letter-spacing:-.035em; color:var(--cui-text); } .cui-empty-actions { margin-top:20px; font-size:12px; letter-spacing:0; font-weight:400; } .cui-empty-actions button { font:inherit; color:var(--cui-muted); border:1px solid var(--cui-border); background:transparent; padding:7px 12px; border-radius:8px; cursor:pointer; } -.cui-msg { display:flex; flex-direction:column; gap:8px; min-width:0; } +.cui-msg { display:flex; flex-direction:column; gap:var(--ran-space-2, 8px); min-width:0; } .cui-bubble { white-space:pre-wrap; overflow-wrap:anywhere; } .cui-msg-user { align-self:flex-end; max-width:88%; } .cui-msg-user .cui-bubble { background:var(--cui-user-bg); padding:10px 14px; border-radius:16px; } .cui-msg-agent { align-self:stretch; } +/* Only host-marked content receives a document surface. Ordinary answers keep + the full reading width and receipts keep their separate disclosure. */ +.cui-msg-agent[data-document-artifact] { box-sizing:border-box; padding:var(--ran-space-4, 16px); gap:var(--ran-space-3, 12px); border:1px solid var(--cui-border); border-radius:var(--ran-radius-md, 12px); background:var(--cui-surface); } +.cui-result-header { display:flex; align-items:center; min-width:0; gap:var(--ran-space-2, 8px); color:var(--cui-muted); font-size:var(--ran-text-label-3, 12px); font-weight:var(--ran-text-label-weight, 500); line-height:1.5; } +.cui-result-icon { display:inline-flex; flex:0 0 var(--ran-size-1, 16px); align-items:center; justify-content:center; } +.cui-result-icon svg { width:var(--ran-size-1, 16px); height:var(--ran-size-1, 16px); } +.cui-result-label { overflow-wrap:anywhere; } +.cui-msg-agent[data-document-artifact] .cui-message-actions { border-top:1px solid var(--cui-border); padding-top:var(--ran-space-2, 8px); } +.cui-msg-agent[data-interrupted] .cui-result-icon { opacity:.65; } .cui-msg-agent .cui-bubble { white-space:normal; } .cui-bubble p { margin:0 0 12px; } .cui-bubble p:last-child { margin-bottom:0; } -.cui-bubble h2,.cui-bubble h3,.cui-bubble h4,.cui-bubble h5,.cui-bubble h6 { font-size:1.08em; line-height:1.45; margin:20px 0 8px; font-weight:600; } +.cui-bubble h2,.cui-bubble h3,.cui-bubble h4,.cui-bubble h5,.cui-bubble h6 { font-size:inherit; line-height:1.4; margin:var(--ran-space-6, 24px) 0 var(--ran-space-2, 8px); font-weight:var(--ran-text-heading-weight, 600); text-wrap:pretty; } +.cui-bubble h2 { font-size:var(--ran-text-heading-3, 20px); letter-spacing:var(--ran-text-heading-tracking, -.03em); } +.cui-bubble h3 { font-size:var(--ran-text-heading-4, 16px); } .cui-bubble :is(h2,h3,h4,h5,h6):first-child { margin-top:0; } .cui-bubble ul,.cui-bubble ol { padding-left:22px; margin:8px 0 14px; } .cui-bubble li { padding-left:2px; margin:4px 0; } .cui-bubble blockquote { margin:12px 0; border-left:2px solid var(--cui-border); padding-left:12px; color:var(--cui-muted); } -.cui-bubble code { font-family:ui-monospace,monospace; font-size:.88em; background:var(--cui-user-bg); padding:2px 4px; border-radius:4px; } -.cui-bubble pre { margin:12px 0; background:var(--cui-user-bg); padding:12px; border-radius:10px; overflow:auto; white-space:pre; } +.cui-bubble code { font-family:var(--ran-font-mono, ui-monospace, monospace); font-size:.88em; background:var(--cui-user-bg); padding:2px 4px; border-radius:4px; } +.cui-bubble pre { margin:var(--ran-space-3, 12px) 0; border:1px solid var(--cui-border); background:var(--cui-user-bg); padding:var(--ran-space-3, 12px); border-radius:var(--ran-radius-sm, 6px); overflow:auto; white-space:pre; line-height:1.55; } .cui-bubble pre code { padding:0; background:none; } .cui-bubble > :first-child { margin-top:0; } .cui-bubble > :last-child { margin-bottom:0; } @@ -38,26 +49,28 @@ export const CHAT_UI_CSS = ` .cui-bubble li > :is(ul,ol) { margin-bottom:0; } .cui-bubble pre,.cui-table { max-width:100%; box-sizing:border-box; overscroll-behavior-x:contain; } .cui-bubble a { color:var(--cui-text); text-underline-offset:3px; } -.cui-table { overflow:auto; margin:12px 0; } +.cui-table { overflow:auto; margin:var(--ran-space-3, 12px) 0; border:1px solid var(--cui-border); border-radius:var(--ran-radius-sm, 6px); } .cui-table table { border-collapse:collapse; font-size:inherit; min-width:100%; overflow-wrap:normal; } -.cui-table th,.cui-table td { border-bottom:1px solid var(--cui-border); padding:8px; text-align:left; } -.cui-message-actions { display:flex; gap:8px; } -.cui-message-actions button { border:0; background:transparent; color:var(--cui-muted); cursor:pointer; font:inherit; font-size:11px; min-height:28px; padding:4px; border-radius:6px; } -.cui-message-actions button:hover { color:var(--cui-text); background:var(--cui-user-bg); } +.cui-table th,.cui-table td { border-bottom:1px solid var(--cui-border); padding:var(--ran-space-2, 8px) var(--ran-space-3, 12px); text-align:left; vertical-align:top; } +.cui-table th { background:var(--cui-user-bg); font-weight:var(--ran-text-label-weight, 500); font-size:var(--ran-text-label-2, 13px); } +.cui-table tr:last-child td { border-bottom:0; } +.cui-message-actions { display:flex; align-items:center; flex-wrap:wrap; gap:var(--ran-space-2, 8px); } +.cui-message-actions button { border:0; background:transparent; color:var(--cui-muted); cursor:pointer; font:inherit; font-size:var(--ran-text-label-3, 12px); min-height:var(--ran-size-7, 32px); padding:var(--ran-space-1, 4px) var(--ran-space-2, 8px); border-radius:var(--ran-radius-sm, 6px); transition:background-color var(--ran-motion-duration-fast, .15s), color var(--ran-motion-duration-fast, .15s); } +.cui-message-actions button:hover:not(:disabled) { color:var(--cui-text); background:var(--cui-user-bg); } .cui-message-actions button:disabled { opacity:.5; cursor:default; } -.cui-message-actions .cui-copy { flex:0 0 28px; width:28px; display:inline-flex; align-items:center; justify-content:center; } +.cui-message-actions .cui-copy { flex:0 0 var(--ran-size-7, 32px); width:var(--ran-size-7, 32px); padding:0; display:inline-flex; align-items:center; justify-content:center; } .cui-message-actions .cui-copy svg { flex-shrink:0; } .cui-feedback { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; border:0; } -.cui-root :is(button,pre,.cui-table):focus-visible { outline:var(--ran-focus-ring, 2px solid var(--ran-color-link, #0070f3)); outline-offset:3px; } +.cui-root :is(button,summary,pre,.cui-table):focus-visible { outline:2px solid var(--ran-color-link, #0070f3); outline-offset:3px; } .cui-streaming .cui-message-actions { display:none; } .cui-streaming .cui-bubble { white-space:pre-wrap; } -.cui-activity { color:var(--cui-muted); font-size:12px; margin:-10px 0; white-space:pre-wrap; overflow-wrap:anywhere; } -.cui-activity summary { cursor:pointer; padding:6px 0; } -.cui-activity ul { list-style:none; margin:4px 0 8px 5px; border-left:1px solid var(--cui-border); padding-left:16px; } +.cui-activity { box-sizing:border-box; color:var(--cui-muted); font-size:var(--ran-text-label-3, 12px); line-height:1.6; padding:var(--ran-space-1, 4px) 0 var(--ran-space-1, 4px) var(--ran-space-3, 12px); border-left:1px solid var(--cui-border); white-space:pre-wrap; overflow-wrap:anywhere; } +.cui-activity summary { cursor:pointer; padding:var(--ran-space-1, 4px) 0; border-radius:var(--ran-radius-sm, 6px); } +.cui-activity ul { list-style:none; margin:var(--ran-space-1, 4px) 0 0; padding-left:var(--ran-space-4, 16px); } .cui-activity li { padding:4px 0; } .cui-msg-status { color:var(--cui-muted); font-size:12px; } .cui-msg-error .cui-bubble { padding:12px; border:1px solid var(--cui-border); border-radius:10px; font-size:13px; } -.cui-status { flex:0 0 auto; display:flex; align-items:center; gap:8px; padding:0 20px 12px; color:var(--cui-muted); font-size:12px; line-height:1.5; } +.cui-status { flex:0 0 auto; display:flex; align-items:center; gap:8px; padding:0 var(--ran-space-4, 16px) var(--ran-space-3, 12px); color:var(--cui-muted); font-size:12px; line-height:1.5; } .cui-status:empty { display:none; } /* One activity cue at a time: the waiting row gives way to the live-text cursor when responding. A human review is a resting state, not loading. */ @@ -80,6 +93,7 @@ export const CHAT_UI_CSS = ` @keyframes cui-loading-turn { to { transform:rotate(360deg); } } @keyframes cui-answer-pulse { 0%,100% { opacity:.45; transform:scale(.85); } 50% { opacity:1; transform:scale(1.15); } } @media (prefers-reduced-motion:reduce) { + .cui-message-actions button { transition:none; } .cui-root[data-activity] .cui-status::before,.cui-root[data-activity] .cui-streaming .cui-bubble::after { animation:none; transform:none; opacity:1; } } `; diff --git a/packages/chat-ui/src/types.ts b/packages/chat-ui/src/types.ts index d517700d..2b58e977 100644 --- a/packages/chat-ui/src/types.ts +++ b/packages/chat-ui/src/types.ts @@ -28,6 +28,10 @@ export interface ChatViewLabels { copied?: string; copyFailed?: string; restore?: string; + /** Identifies only explicitly host-marked document content. */ + documentDraft?: string; + /** Identifies retained output from a response that did not complete. */ + incomplete?: string; waiting?: string; reading?: string; planning?: string; diff --git a/test/e2e/agent-composer-ux.spec.ts b/test/e2e/agent-composer-ux.spec.ts index 5fa1ffca..273e6717 100644 --- a/test/e2e/agent-composer-ux.spec.ts +++ b/test/e2e/agent-composer-ux.spec.ts @@ -119,6 +119,17 @@ for (const ext of ['docx', 'xlsx', 'pptx', 'pdf'] as const) { await expect(page.locator('.cui-msg-user')).toHaveCount(2); await expect(page.locator('.cui-send-stop')).toHaveCount(0); await expect(page.locator('.cui-msg-agent')).toHaveCount(2); + // Prose remains unframed; its heading has a real typographic level rather + // than the same size as body text. Draft identity comes from host metadata. + await expect(page.locator('.cui-result-header')).toHaveCount(0); + const typeHierarchy = await page + .locator('.cui-msg-agent') + .last() + .evaluate((row) => ({ + heading: Number.parseFloat(getComputedStyle(row.querySelector('h2')!).fontSize), + body: Number.parseFloat(getComputedStyle(row.querySelector('p')!).fontSize), + })); + expect(typeHierarchy.heading - typeHierarchy.body).toBeGreaterThanOrEqual(2); const resize = page.getByRole('separator', { name: 'Resize AI panel' }); const draft = 'Keep this next draft editable while I resize the sidebar. '.repeat(8); await resize.press('Home'); diff --git a/test/e2e/agent-result-hierarchy.spec.ts b/test/e2e/agent-result-hierarchy.spec.ts new file mode 100644 index 00000000..fefb7136 --- /dev/null +++ b/test/e2e/agent-result-hierarchy.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from './lib/l0'; + +test('restored drafts retain clear identity, readable narrow content and source-only keyboard copying', async ({ + page, +}, testInfo) => { + test.setTimeout(90_000); + const source = [ + '# A clearer project update', + 'The next release brings **faster document workflows** and a simpler review process.', + '- Review proposed changes before applying.\n- Keep the original document in view.', + '| Task | Owner | Status |\n| --- | --- | --- |\n| Launch note | Product team | Ready for review |', + '```txt\n' + 'release_reference_'.repeat(20) + '\n```', + ].join('\n\n'); + await page.goto('/'); + await page.evaluate(async (body) => { + localStorage.setItem('document-assistant-enabled', 'true'); + localStorage.setItem('agent-local-preset', 'Qwen3-4B-q4f16_1-MLC'); + localStorage.setItem('agent_conversation_saving_v2', 'true'); + const id = crypto.randomUUID(); + const now = Date.now(); + const request = indexedDB.open('document-ai-conversations', 2); + request.onupgradeneeded = () => { + request.result.createObjectStore('conversations'); + request.result.createObjectStore('sessions'); + }; + const db = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const tx = db.transaction(['conversations', 'sessions'], 'readwrite'); + tx.objectStore('conversations').put({ version: 2, revision: 1, activeId: id, sessionIds: [id] }, 'state'); + tx.objectStore('sessions').put( + { + id, + title: 'Project update', + createdAt: now, + updatedAt: now, + messages: [ + { role: 'user', content: 'Draft a short project update.' }, + { role: 'assistant', content: body, documentArtifact: true, copyOnly: true }, + { role: 'user', content: 'Expand the closing paragraph.' }, + { role: 'assistant', content: 'The follow-up response was interrupted', interrupted: true }, + ], + }, + id, + ); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onabort = () => reject(tx.error); + }); + db.close(); + }, source); + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + await expect(page.locator('.agent-runtime-panel')).toHaveAttribute('data-view', 'chat'); + const draft = page.locator('.cui-msg-agent[data-document-artifact]'); + await expect(draft.locator('.cui-result-label')).toHaveText('Document draft'); + await expect(page.locator('[data-interrupted] .cui-result-label')).toHaveText('Incomplete response'); + await expect(page.locator('.cui-apply')).toHaveCount(0); + await expect(draft).toHaveAttribute('data-source', source); + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + const copy = draft.locator('.cui-copy'); + await expect(copy).toBeVisible(); + await page.keyboard.press('Tab'); + await copy.focus(); + await expect(copy).toBeFocused(); + expect( + await copy.evaluate((element) => { + const style = getComputedStyle(element); + return style.outlineStyle !== 'none' && Number.parseFloat(style.outlineWidth) > 0; + }), + ).toBe(true); + await copy.press('Enter'); + await expect(copy).toHaveAccessibleName('Copied'); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(source); + const resize = page.getByRole('separator', { name: 'Resize AI panel' }); + for (const theme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme: theme, reducedMotion: 'reduce' }); + await resize.press('Home'); + await draft.scrollIntoViewIfNeeded(); + const bounds = await draft.evaluate((row) => { + const header = row.querySelector('.cui-result-header')!.getBoundingClientRect(); + const body = row.querySelector('.cui-bubble')!.getBoundingClientRect(); + const actions = row.querySelector('.cui-message-actions')!.getBoundingClientRect(); + return { header: header.x, body: body.x, actions: actions.x, width: row.scrollWidth - row.clientWidth }; + }); + expect(Math.abs(bounds.header - bounds.body)).toBeLessThanOrEqual(1); + expect(Math.abs(bounds.actions - bounds.body)).toBeLessThanOrEqual(1); + expect(bounds.width).toBeLessThan(2); + await expect + .poll(() => page.locator('.agent-runtime-panel').evaluate((el) => el.scrollWidth - el.clientWidth)) + .toBeLessThan(2); + await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath(`results-${theme}.png`) }); + } + await page.setViewportSize({ width: 360, height: 640 }); + await draft.scrollIntoViewIfNeeded(); + await expect.poll(() => draft.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThan(2); + await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath('results-narrow.png') }); + await draft.locator('pre').focus(); + await page.keyboard.press('ArrowRight'); + await expect.poll(() => draft.locator('pre').evaluate((el) => el.scrollLeft)).toBeGreaterThan(0); +}); diff --git a/test/unit/chat-view-results.test.ts b/test/unit/chat-view-results.test.ts new file mode 100644 index 00000000..c3afac78 --- /dev/null +++ b/test/unit/chat-view-results.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { ChatView } from '../../packages/chat-ui/src/chat-view'; + +let view: ChatView; +const apply = vi.fn().mockResolvedValue('retry'); +const source = '# Project update\n\n**Ready for review**, with the original formatting.'; +beforeEach(() => { + document.body.replaceChildren(); + HTMLElement.prototype.scrollTo = vi.fn(); + apply.mockClear(); + view = new ChatView({ + onSend: vi.fn(), + requireDocumentArtifact: true, + onApplyMessage: apply, + labels: { documentDraft: 'Document draft', incomplete: 'Incomplete response' }, + }); + document.body.append(view.el); +}); +afterEach(() => view.dispose()); + +it('identifies host-marked document drafts without promoting ordinary answers or tool receipts', () => { + view.append({ role: 'agent', text: 'Here is a document draft. It has been written.' }); + view.append({ role: 'tool', text: 'Sent to the editor; verification unavailable' }); + expect(view.el.querySelector('.cui-result-header')).toBeNull(); + expect(view.el.querySelector('.cui-apply')).toBeNull(); + const body = view.append({ role: 'agent', text: source, documentArtifact: true }); + const row = body.parentElement!; + expect(row.querySelector('.cui-result-label')?.textContent).toBe('Document draft'); + expect(row.getAttribute('aria-label')).toBe('Document draft'); + expect(row.getAttribute('role')).toBe('article'); + expect(row.firstElementChild?.className).toBe('cui-result-header'); + expect(body.querySelector('h2')?.textContent).toBe('Project update'); + expect(view.getLastDocumentBody()).toBe(source); + expect(row.querySelector('.cui-message-actions')?.firstElementChild?.className).toBe('cui-apply'); +}); + +it('keeps document-draft framing stable through explicit completion and copies the source alone', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + view.setRunning(true); + view.appendDelta(source, { documentArtifact: true }); + const row = view.el.querySelector('.cui-msg-agent')!; + const header = row.querySelector('.cui-result-header'); + expect(header?.textContent).toBe('Document draft'); + expect(view.getLastDocumentBody()).toBe(''); + view.endStream(); + view.setRunning(false); + expect(row.querySelector('.cui-result-header')).toBe(header); + expect(row.querySelector('.cui-result-label')?.textContent).toBe('Document draft'); + row.querySelector('.cui-copy')!.click(); + await vi.waitFor(() => expect(writeText).toHaveBeenCalledExactlyOnceWith(source)); + row.querySelector('.cui-apply')!.click(); + await vi.waitFor(() => expect(apply).toHaveBeenCalledExactlyOnceWith(source)); + expect(view.getLastDocumentBody()).toBe(source); +}); + +it('labels interrupted output immediately and preserves safe source-only copying on locale changes', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + view.setRunning(true); + view.appendDelta(source, { documentArtifact: true }); + const row = view.el.querySelector('.cui-msg-agent')!; + view.setRunning(false); + expect(row.querySelector('.cui-result-label')?.textContent).toBe('Incomplete response'); + expect(row.getAttribute('aria-label')).toBe('Incomplete response'); + expect(row.querySelector('.cui-apply')).toBeNull(); + expect(view.getLastDocumentBody()).toBe(''); + view.setLabels({ documentDraft: 'Entwurf', incomplete: 'Unvollstandige Antwort' }); + expect(row.querySelector('.cui-result-label')?.textContent).toBe('Unvollstandige Antwort'); + expect(row.getAttribute('aria-label')).toBe('Unvollstandige Antwort'); + row.querySelector('.cui-copy')!.click(); + await vi.waitFor(() => expect(writeText).toHaveBeenCalledExactlyOnceWith(source)); +}); + +it('restores truthful result identities in history without reconstructing write actions', () => { + view.appendMany([ + { role: 'agent', text: source, documentArtifact: true, copyOnly: true }, + { role: 'agent', text: 'Partial ordinary answer', interrupted: true }, + { role: 'agent', text: 'Partial document body', documentArtifact: true, interrupted: true }, + ]); + expect([...view.el.querySelectorAll('.cui-result-label')].map((node) => node.textContent)).toEqual([ + 'Document draft', + 'Incomplete response', + 'Incomplete response', + ]); + expect(view.el.querySelectorAll('.cui-copy')).toHaveLength(3); + expect(view.el.querySelector('.cui-apply')).toBeNull(); + expect(view.getLastDocumentBody()).toBe(source); +}); From d726032e152291a9a43aac244ab913c6fec0a752 Mon Sep 17 00:00:00 2001 From: chaxus Date: Wed, 14 Oct 2026 23:20:48 +0800 Subject: [PATCH 4/7] feat(assistant): refine change review and completion --- lib/agent-plugin/ui/action-preview.ts | 46 +++++++--- styles/assistant.css | 82 ++++++++++++----- test/e2e/agent-write-tools.spec.ts | 29 +++++++ test/unit/agent-action-preview.test.ts | 116 +++++++++++++++++++++++++ 4 files changed, 240 insertions(+), 33 deletions(-) diff --git a/lib/agent-plugin/ui/action-preview.ts b/lib/agent-plugin/ui/action-preview.ts index 06bd45dc..0d51966e 100644 --- a/lib/agent-plugin/ui/action-preview.ts +++ b/lib/agent-plugin/ui/action-preview.ts @@ -27,11 +27,11 @@ export type ReviewOutcome = import('../review-outcome').ReviewOutcome { + if (event.key === 'Escape' && this.secondary.open) { + event.preventDefault(); + event.stopPropagation(); + this.secondary.open = false; + this.secondarySummary.focus(); + } + }); this.record.className = 'agent-plan-record'; this.record.hidden = true; this.receiptLocate.hidden = true; @@ -87,20 +104,14 @@ export class ActionPreview { this.content, this.comparison, this.editor, - this.record, this.status, + this.record, this.receiptLocate, Div() .class('agent-plan-buttons agent-plan-primary-buttons') - .children([ - this.applyButton, - this.locateButton, - this.editButton, - this.refineButton, - this.copyButton, - this.cancelButton, - ]) + .children(this.applyButton, this.cancelButton) .build(), + this.secondary, ); this.applyButton.addEventListener('click', () => void this.apply()); this.locateButton.addEventListener('click', () => { @@ -154,6 +165,7 @@ export class ActionPreview { this.action?.review?.kind === 'text' && this.action.review.anchor ? 'agentSelectionAnchor' : 'agentPlanBefore', ); this.afterLabel.textContent = t('agentPlanAfter'); + this.secondarySummary.textContent = t('agentReviewActions'); this.applyButton.textContent = t( this.action?.plan.tool === 'add_pdf_comment' ? 'agentAddPdfComment' @@ -273,6 +285,9 @@ export class ActionPreview { this.refining = false; for (const button of [this.editButton, this.refineButton, this.saveButton]) button.removeAttribute('disabled'); this.el.dataset.state = 'pending'; + this.secondary.open = false; + this.secondary.hidden = false; + this.el.querySelector('.agent-plan-original')!.open = false; this.el.removeAttribute('aria-busy'); this.title.hidden = false; this.el.querySelector('.agent-plan-primary-buttons')!.hidden = false; @@ -325,6 +340,9 @@ export class ActionPreview { !['insert_text', 'replace_selection', 'add_slide_text', 'add_comment', 'add_pdf_comment'].includes( action.plan.tool, ); + this.secondary.hidden = [this.editButton, this.refineButton, this.copyButton, this.locateButton].every( + (button) => button.hidden, + ); this.renderComparison(action.review); this.applyButton.removeAttribute('disabled'); this.labels(); @@ -435,12 +453,14 @@ export class ActionPreview { if (this.refining || !this.pending() || !this.action?.revise || this.editButton.hidden) return; this.draft.value = String(this.action.plan.input.text ?? ''); this.editor.hidden = false; + this.secondary.hidden = true; this.comparison.hidden = true; this.el.querySelector('.agent-plan-primary-buttons')!.hidden = true; this.draft.focus(); } private discard(): void { this.editor.hidden = true; + this.secondary.hidden = this.settled; this.comparison.hidden = !this.action?.review; this.el.querySelector('.agent-plan-primary-buttons')!.hidden = this.settled; if (!this.settled) this.focusReview(); @@ -515,6 +535,8 @@ export class ActionPreview { this.applyButton.setAttribute('disabled', ''); this.el.dataset.state = 'applying'; this.el.setAttribute('aria-busy', 'true'); + this.secondary.open = false; + this.secondary.hidden = true; this.editButton.hidden = this.refineButton.hidden = true; this.copyButton.hidden = true; this.status.textContent = t('agentPlanApplying'); @@ -543,6 +565,8 @@ export class ActionPreview { private finish(state: string): void { const restore = this.el.contains(document.activeElement); this.el.dataset.state = state; + this.secondary.open = false; + this.secondary.hidden = true; this.el.removeAttribute('aria-busy'); this.el.querySelector('.agent-plan-primary-buttons')!.hidden = true; this.editor.hidden = true; diff --git a/styles/assistant.css b/styles/assistant.css index c20ef3a5..9ef33394 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -256,13 +256,12 @@ body.agent-panel-resizing #app iframe { .agent-plan-preview { display: flex; flex-direction: column; - gap: 8px; - padding: 16px 0; - margin: 0; - border: 0; - border-block-start: 1px solid var(--ran-color-border); - border-radius: 0; - background: transparent; + gap: var(--ran-space-2); + padding: var(--ran-space-4); + margin: var(--ran-space-2) 0; + border: 1px solid var(--ran-color-border); + border-radius: var(--ran-radius-md); + background: var(--ran-color-bg); color: var(--ran-color-text); max-height: none; overflow: auto; @@ -486,8 +485,16 @@ r-dropdown[role='listbox'] { height: 28px; margin-left: auto; } -.agent-plan-preview > span:first-child { - font-weight: 600; +.agent-plan-heading { + font-size: var(--ran-text-label-1); + font-weight: var(--ran-text-heading-weight); + line-height: 1.5; +} +.agent-plan-proposed-label { + margin-top: var(--ran-space-1); + font-size: var(--ran-text-label-3); + font-weight: var(--ran-text-label-weight); + color: var(--ran-color-text-secondary); } .agent-plan-content, .agent-plan-before { @@ -495,17 +502,32 @@ r-dropdown[role='listbox'] { line-height: 1.65; } .agent-plan-content { - padding: 4px 0; - background: transparent; - border-radius: 0; + padding: var(--ran-space-3); + background: var(--ran-color-bg-muted); + border-radius: var(--ran-radius-sm); } .agent-plan-original { font-size: 12px; color: var(--ran-color-text-secondary); } -.agent-plan-original summary { +.agent-plan-original summary, +.agent-plan-secondary > summary { cursor: pointer; - padding: 4px 0; + width: fit-content; + padding: var(--ran-space-1) 0; + font-size: var(--ran-text-label-3); + line-height: 1.6; + color: var(--ran-color-text-secondary); +} +.agent-plan-secondary { + border-top: 1px solid var(--ran-color-border); + padding-top: var(--ran-space-1); +} +.agent-plan-secondary[open] > .agent-plan-buttons { + margin-top: var(--ran-space-1); +} +.agent-plan-secondary > summary:hover { + color: var(--ran-color-text); } .agent-plan-original .agent-plan-before { max-height: 160px; @@ -1377,9 +1399,20 @@ r-dropdown[role='listbox'] { } .agent-plan-preview[data-state='verified'], .agent-plan-preview[data-state='sent'] { - padding: 8px 0; - border: 0; - background: transparent; + padding: var(--ran-space-3) var(--ran-space-4); + border: 1px solid var(--ran-color-border); + background: var(--ran-color-bg-subtle); +} +.agent-plan-preview[data-state='verified'] .agent-plan-status, +.agent-plan-preview[data-state='sent'] .agent-plan-status { + color: var(--ran-color-text); + font-size: var(--ran-text-label-2); + font-weight: var(--ran-text-label-weight); +} +.agent-plan-undo { + color: var(--ran-color-text-secondary); + font-size: var(--ran-text-label-3); + line-height: 1.6; } .agent-plan-content { max-height: 240px; @@ -1426,15 +1459,20 @@ r-dropdown[role='listbox'] { .agent-plan-diff del, .agent-plan-diff ins { display: block; - padding: 3px 6px; - border-inline-start: 2px solid currentColor; + padding: var(--ran-space-2) var(--ran-space-3); + margin-block: var(--ran-space-1); + border-radius: var(--ran-radius-sm); + border-inline-start: 2px solid var(--ran-color-border-hover); text-decoration: none; } .agent-plan-diff del { - background: var(--ran-color-bg-elevated); + background: var(--ran-color-bg-muted); + color: var(--ran-color-text-secondary); } .agent-plan-diff ins { - font-weight: 400; + background: var(--ran-color-bg-muted); + color: var(--ran-color-text); + font-weight: var(--ran-text-copy-weight); } .agent-plan-record summary { cursor: pointer; @@ -1476,7 +1514,7 @@ r-dropdown[role='listbox'] { } .agent-plan-preview:focus-visible, .agent-plan-preview :is(summary, textarea, r-button):focus-visible { - outline: 2px solid var(--ran-focus-ring, #1677ff); + outline: 2px solid var(--ran-color-link); outline-offset: 2px; } diff --git a/test/e2e/agent-write-tools.spec.ts b/test/e2e/agent-write-tools.spec.ts index 6348b71b..a18811ba 100644 --- a/test/e2e/agent-write-tools.spec.ts +++ b/test/e2e/agent-write-tools.spec.ts @@ -231,6 +231,30 @@ test.describe('agent document writes (real editor)', () => { await page.locator('.agent-panel-settings-toggle').click(); await send(page, 'Insert exactly this plain text at the cursor: Initial draft'); const first = page.locator('.agent-plan-preview').first(); + await expect(first.locator('.agent-plan-primary-buttons r-button')).toHaveCount(2); + await expect(first.locator('.agent-plan-edit')).toBeHidden(); + await first.screenshot({ path: testInfo.outputPath('review-light.png') }); + await page.emulateMedia({ colorScheme: 'dark' }); + await first.screenshot({ path: testInfo.outputPath('review-dark.png') }); + await page.emulateMedia({ colorScheme: 'light' }); + await page.setViewportSize({ width: 1000, height: 900 }); + await page.locator('.agent-runtime-panel').evaluate((element) => { + (element as HTMLElement).style.width = '320px'; + }); + await expect(first).toBeVisible(); + expect(await first.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); + await first.screenshot({ path: testInfo.outputPath('review-narrow.png') }); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.locator('.agent-runtime-panel').evaluate((element) => { + (element as HTMLElement).style.removeProperty('width'); + }); + await first.locator('.agent-plan-secondary > summary').click(); + await expect(first.locator('.agent-plan-edit')).toBeVisible(); + await first.locator('.agent-plan-secondary > summary').focus(); + await first.locator('.agent-plan-secondary > summary').press('Escape'); + await expect(first.locator('.agent-plan-secondary')).not.toHaveAttribute('open', ''); + await expect(first.locator('.agent-plan-secondary > summary')).toBeFocused(); + await first.locator('.agent-plan-secondary > summary').press('Enter'); await expect(first.locator('.agent-plan-edit')).toBeVisible(); await first.locator('.agent-plan-edit').click(); await expect(first.locator('.agent-plan-draft')).toBeFocused(); @@ -242,6 +266,7 @@ test.describe('agent document writes (real editor)', () => { await first.locator('.agent-plan-draft').fill('Edited draft'); await first.locator('.agent-plan-save').click(); await expect(first.locator('.agent-plan-comparison')).toContainText('Edited draft'); + await first.locator('.agent-plan-secondary > summary').click(); await first.locator('.agent-plan-refine').click(); await expect(page.locator('.agent-refinement-context')).toBeVisible(); await send(page, 'Make this suggestion more concise'); @@ -295,6 +320,9 @@ test.describe('agent document writes (real editor)', () => { expect(await readWordText(page)).not.toContain('Initial draft'); expect(await readWordText(page)).not.toContain('Edited draft'); await expect(replacement.locator('.agent-plan-primary-buttons')).toBeHidden(); + await expect(replacement.locator('.agent-plan-secondary')).toBeHidden(); + await expect(replacement).toHaveAttribute('data-state', 'verified'); + await replacement.screenshot({ path: testInfo.outputPath('review-verified.png') }); await expect(page.locator('.agent-readiness')).toHaveText(''); await replacement.locator('.agent-plan-record summary').click(); await expect(replacement.locator('.agent-plan-record')).toContainText('Final reviewed text'); @@ -471,6 +499,7 @@ test.describe('agent document writes (real editor)', () => { const before = await shapeTexts(); await send(page, 'Add a short text box to this slide'); const review = page.locator('.agent-plan-preview').last(); + await review.locator('.agent-plan-secondary > summary').click(); await expect(review.locator('.agent-plan-edit')).toBeVisible(); await expect(review.locator('.agent-plan-target')).toHaveText('Slide 1'); expect(await shapeTexts()).toEqual(before); diff --git a/test/unit/agent-action-preview.test.ts b/test/unit/agent-action-preview.test.ts index 9d03a2f4..1fa91324 100644 --- a/test/unit/agent-action-preview.test.ts +++ b/test/unit/agent-action-preview.test.ts @@ -723,3 +723,119 @@ it('keeps verified records compact with native Undo guidance and location access expect(view.el.querySelector('details.agent-plan-record')?.hasAttribute('open')).toBe(false); view.dispose(); }); + +it('keeps Apply and Cancel prominent while disclosing secondary review actions', () => { + const view = new ActionPreview(); + const action = { + target: { label: 'Word', selectedText: 'Original' }, + plan: { tool: 'insert_text', input: { text: 'Proposal' } }, + isCurrent: () => true, + cancel: vi.fn(), + apply: async () => 'verified' as const, + revise: () => action, + locate: vi.fn(), + }; + view.show(action, undefined, vi.fn()); + const primary = view.el.querySelector('.agent-plan-primary-buttons')!; + expect([...primary.querySelectorAll('r-button')].map((button) => button.className)).toEqual([ + 'agent-plan-apply', + 'agent-plan-cancel', + ]); + const secondary = view.el.querySelector('.agent-plan-secondary')!; + expect(secondary).not.toBeNull(); + expect(secondary.open).toBe(false); + expect(secondary.querySelector('summary')?.textContent).not.toBe(''); + for (const name of ['edit', 'refine', 'copy', 'locate']) + expect(secondary.querySelector(`.agent-plan-${name}`)).not.toBeNull(); + view.dispose(); +}); + +it('returns Escape focus to the secondary disclosure without cancelling the review', () => { + const view = new ActionPreview(); + document.body.append(view.el); + view.show({ + target: { label: 'Word', selectedText: 'Original' }, + plan: { tool: 'insert_text', input: { text: 'Proposal' } }, + isCurrent: () => true, + cancel: vi.fn(), + apply: async () => 'verified', + }); + const secondary = view.el.querySelector('.agent-plan-secondary')!; + expect(secondary).not.toBeNull(); + secondary.open = true; + secondary.querySelector('summary')!.focus(); + secondary.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + expect(secondary.open).toBe(false); + expect(document.activeElement).toBe(secondary.querySelector('summary')); + expect(view.pending()).toBeDefined(); + view.dispose(); +}); + +it('resets secondary and original disclosures when a preview instance shows a replacement', () => { + const view = new ActionPreview(); + const action = { + target: { label: 'Word', selectedText: 'Original' }, + plan: { tool: 'insert_text', input: { text: 'Proposal' } }, + isCurrent: () => true, + cancel: vi.fn(), + apply: async () => 'verified' as const, + }; + view.show(action); + const secondary = view.el.querySelector('.agent-plan-secondary')!; + expect(secondary).not.toBeNull(); + secondary.open = true; + view.el.querySelector('.agent-plan-original')!.open = true; + view.show({ ...action, plan: { tool: 'insert_text', input: { text: 'Replacement' } } }); + expect(secondary.open).toBe(false); + expect(secondary.hidden).toBe(false); + expect(view.el.querySelector('.agent-plan-original')!.open).toBe(false); + expect(view.el.querySelector('.agent-plan-editor')!.hidden).toBe(true); + view.dispose(); +}); + +it.each(['verified', 'sent', 'failed', 'expired'] as const)( + 'removes secondary review actions from a %s receipt and cannot revive approval', + async (outcome) => { + const view = new ActionPreview(); + const action = { + target: { label: 'Word', selectedText: 'Original' }, + plan: { tool: 'insert_text', input: { text: 'Proposal' } }, + isCurrent: () => true, + cancel: vi.fn(), + apply: vi.fn(async (): Promise<'verified' | 'sent'> => { + if (outcome === 'failed') throw new Error('Readback failed'); + return outcome === 'sent' ? 'sent' : 'verified'; + }), + }; + view.show(action); + const secondary = view.el.querySelector('.agent-plan-secondary')!; + expect(secondary).not.toBeNull(); + secondary.open = true; + if (outcome === 'expired') view.invalidate(); + else { + view.el.querySelector('.agent-plan-apply')!.click(); + await vi.waitFor(() => expect(view.el.dataset.state).toBe(outcome)); + } + expect(secondary.hidden).toBe(true); + expect(secondary.open).toBe(false); + view.setRefining(false); + expect(view.el.querySelector('.agent-plan-primary-buttons')!.hidden).toBe(true); + view.el.querySelector('.agent-plan-apply')!.click(); + expect(action.apply).toHaveBeenCalledTimes(outcome === 'expired' ? 0 : 1); + expect(view.el.querySelector('.agent-plan-undo') !== null).toBe(outcome === 'verified'); + view.dispose(); + }, +); + +it('omits an empty secondary disclosure for a destructive proposal with no secondary actions', () => { + const view = new ActionPreview(); + view.show({ + target: { label: 'Word', selectedText: '' }, + plan: { tool: 'clear_document', input: {} }, + isCurrent: () => true, + cancel: vi.fn(), + apply: async () => 'verified', + }); + expect(view.el.querySelector('.agent-plan-secondary')!.hidden).toBe(true); + view.dispose(); +}); From 57cae67532a1b04ab01005f544813ea183aac66c Mon Sep 17 00:00:00 2001 From: chaxus Date: Wed, 14 Oct 2026 23:44:53 +0800 Subject: [PATCH 5/7] fix(assistant): complete localized feedback and keyboard focus --- lib/agent-plugin/ui/panel.ts | 2 ++ styles/assistant.css | 2 +- test/e2e/agent-sidebar-layout.spec.ts | 10 ++++++++ test/unit/agent-panel.test.ts | 33 ++++++++++++++++++++++++++- 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/lib/agent-plugin/ui/panel.ts b/lib/agent-plugin/ui/panel.ts index 8f22756e..cb23cf95 100644 --- a/lib/agent-plugin/ui/panel.ts +++ b/lib/agent-plugin/ui/panel.ts @@ -569,6 +569,8 @@ export function createAgentPanel( copied: t('agentCopied'), copyFailed: t('agentCopyFailed'), restore: t('agentRestoreRequest'), + documentDraft: t('agentDocumentDraft'), + incomplete: t('agentIncompleteResponse'), waiting: t('agentWaiting'), reading: t('agentReadingContent'), planning: t('agentPreparingTask'), diff --git a/styles/assistant.css b/styles/assistant.css index 9ef33394..818d7cde 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -1221,7 +1221,7 @@ r-dropdown[role='listbox'] { margin-bottom: var(--ran-space-4); } .agent-panel :is(button, summary, r-select, r-input, r-button):focus-visible { - outline: var(--ran-focus-ring, 2px solid var(--ran-color-link)); + outline: 2px solid var(--ran-color-link, #0070f3); outline-offset: 2px; } .agent-quote-context { diff --git a/test/e2e/agent-sidebar-layout.spec.ts b/test/e2e/agent-sidebar-layout.spec.ts index 432ad7d2..0c1e5946 100644 --- a/test/e2e/agent-sidebar-layout.spec.ts +++ b/test/e2e/agent-sidebar-layout.spec.ts @@ -21,6 +21,16 @@ for (const width of [320, 1280]) { expect(Math.max(...alignment) - Math.min(...alignment)).toBeLessThanOrEqual(1); await page.locator('.agent-panel-settings-toggle').click(); await expect(page.locator('.agent-panel-settings')).toBeVisible(); + const settingsToggle = page.locator('.agent-panel-settings-toggle'); + await page.keyboard.press('Tab'); + await settingsToggle.focus(); + await expect(settingsToggle).toBeFocused(); + expect( + await settingsToggle.evaluate((button) => { + const style = getComputedStyle(button); + return style.outlineStyle !== 'none' && Number.parseFloat(style.outlineWidth) >= 2; + }), + ).toBe(true); const size = await page.locator('.agent-panel-load').evaluate((button) => button.getBoundingClientRect().height); expect(size).toBeGreaterThanOrEqual(30); expect(size).toBeLessThanOrEqual(34); diff --git a/test/unit/agent-panel.test.ts b/test/unit/agent-panel.test.ts index a80f8452..f0a48ece 100644 --- a/test/unit/agent-panel.test.ts +++ b/test/unit/agent-panel.test.ts @@ -1,4 +1,5 @@ -import { t } from '@ranuts/shared/i18n'; +import { t, getLanguage, setLanguage, LanguageCode } from '@ranuts/shared/i18n'; +import { ChatView } from '@ranuts/chat-ui'; import type { EditorApi } from '../../lib/agent-plugin/editor-bridge'; import { afterEach, expect, it, vi } from 'vitest'; import { createAgentPanel, setAgentPanelOpen, setAgentPanelEnabled } from '../../lib/agent-plugin/ui/panel'; @@ -162,6 +163,36 @@ it('labels provider and model controls in settings', () => { expect(panel.querySelector('.agent-panel-model')?.getAttribute('aria-label')).toBeTruthy(); }); +it('passes localized result identities to the chat renderer after language changes', async () => { + const original = getLanguage(); + const labels = vi.spyOn(ChatView.prototype, 'setLabels'); + try { + setLanguage(LanguageCode.EN); + createAgentPanel(); + setLanguage(LanguageCode.ZH); + await vi.waitFor(() => + expect(labels).toHaveBeenLastCalledWith( + expect.objectContaining({ + documentDraft: t('agentDocumentDraft'), + incomplete: t('agentIncompleteResponse'), + }), + ), + ); + setLanguage(LanguageCode.DE); + await vi.waitFor(() => + expect(labels).toHaveBeenLastCalledWith( + expect.objectContaining({ + documentDraft: t('agentDocumentDraft'), + incomplete: t('agentIncompleteResponse'), + }), + ), + ); + } finally { + setLanguage(original); + labels.mockRestore(); + } +}); + it.each([{ isComposing: true }, { keyCode: 229 }])( 'keeps settings and its draft open when Escape cancels IME composition (%j)', (composition) => { From d2872b11c0b404cd8f93c07902318f89026412e0 Mon Sep 17 00:00:00 2001 From: chaxus Date: Thu, 15 Oct 2026 00:07:19 +0800 Subject: [PATCH 6/7] feat(assistant): show immediate request progress --- lib/agent-plugin/ui/panel.ts | 14 +- packages/chat-ui/CLAUDE.md | 34 ++-- packages/chat-ui/README.md | 10 +- packages/chat-ui/src/chat-view.ts | 70 +++++-- packages/chat-ui/src/styles.ts | 8 +- packages/chat-ui/src/types.ts | 4 + packages/shared/src/i18n/messages/de.ts | 2 + packages/shared/src/i18n/messages/en.ts | 2 + packages/shared/src/i18n/messages/es.ts | 2 + packages/shared/src/i18n/messages/ja.ts | 2 + packages/shared/src/i18n/messages/ko.ts | 2 + packages/shared/src/i18n/messages/pt.ts | 2 + packages/shared/src/i18n/messages/zh-CN.ts | 2 + packages/shared/src/i18n/types.ts | 2 + test/e2e/agent-content-review.spec.ts | 7 + test/e2e/agent-process-feedback.spec.ts | 208 +++++++++++++++++++++ test/unit/agent-panel-loading.test.ts | 70 ++++++- test/unit/chat-view-progress.test.ts | 143 ++++++++++++++ 18 files changed, 551 insertions(+), 33 deletions(-) create mode 100644 test/e2e/agent-process-feedback.spec.ts create mode 100644 test/unit/chat-view-progress.test.ts diff --git a/lib/agent-plugin/ui/panel.ts b/lib/agent-plugin/ui/panel.ts index cb23cf95..a852bc64 100644 --- a/lib/agent-plugin/ui/panel.ts +++ b/lib/agent-plugin/ui/panel.ts @@ -572,6 +572,8 @@ export function createAgentPanel( documentDraft: t('agentDocumentDraft'), incomplete: t('agentIncompleteResponse'), waiting: t('agentWaiting'), + routing: t('agentRoutingRequest'), + elapsed: (seconds) => t('agentWaitElapsed', { seconds: new Intl.NumberFormat(getLanguage()).format(seconds) }), reading: t('agentReadingContent'), planning: t('agentPreparingTask'), reviewing: t('agentPlanReady'), @@ -1880,6 +1882,7 @@ export function createAgentPanel( let partialTaskAnswer = ''; const taskDisplay = new AssistantDisplayStream((delta) => { partialTaskAnswer += delta; + chat.setActivity('responding'); chat.appendDelta(delta); }); if (previousPreview) { @@ -1914,13 +1917,14 @@ export function createAgentPanel( const abort = new AbortController(); planning = abort; routingStarted = true; - chat.setActivity('planning'); - request = - stagedIntent ?? - (await resolveRequestIntent(routingProvider, trimmed, snapshot.context, abort.signal, { + if (stagedIntent) request = stagedIntent; + else { + chat.setActivity('routing'); + request = await resolveRequestIntent(routingProvider, trimmed, snapshot.context, abort.signal, { hasSelection: !!snapshot.text.trim() && !snapshot.truncated, pendingProposal, - })); + }); + } abort.signal.throwIfAborted(); if (generation !== controllerGeneration || conversation !== conversationRevision) throw new Error(t('agentPlanExpired')); diff --git a/packages/chat-ui/CLAUDE.md b/packages/chat-ui/CLAUDE.md index 3ab62749..86ba32f4 100644 --- a/packages/chat-ui/CLAUDE.md +++ b/packages/chat-ui/CLAUDE.md @@ -41,18 +41,18 @@ container.appendChild(chat.el); // mount the root element ## Methods -| Method | Purpose | -| ---------------------------------------------------- | ------------------------------------------------------------------------------------- | -| `append({ role, text })` | Add a finished message. `role`: `'user' \| 'agent' \| 'tool' \| 'status' \| 'error'`. | -| `appendMany(messages)` | Add finished messages in order with one final layout update, for restoring history. | -| `appendDelta(text)` | Stream into a live agent bubble (auto-created on first delta). | -| `endStream()` | Finalise the streaming bubble (drops the caret). | -| `setRunning(bool)` | Toggle Send⇄Stop; keep the next draft editable, without queuing it. | -| `setActivity('reading' / 'planning' / 'responding')` | Announce the host's actual running phase through the existing polite status. | -| `clear()` | Remove all messages, restore empty state. | -| `getInput()` / `setInput(text)` / `focus()` | Input helpers (e.g. prepend a quote). | -| `setLabels(labels)` | Re-apply labels live (e.g. language change). | -| `dispose()` | Release resize observers and copy-feedback timers when unmounting. | +| Method | Purpose | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | +| `append({ role, text })` | Add a finished message. `role`: `'user' \| 'agent' \| 'tool' \| 'status' \| 'error'`. | +| `appendMany(messages)` | Add finished messages in order with one final layout update, for restoring history. | +| `appendDelta(text)` | Stream into a live agent bubble (auto-created on first delta). | +| `endStream()` | Finalise the streaming bubble (drops the caret). | +| `setRunning(bool)` | Toggle Send⇄Stop; keep the next draft editable, without queuing it. | +| `setActivity('routing' / 'reading' / 'planning' / 'responding' / 'reviewing')` | Announce the host's actual phase through a polite status; review is a resting state. | +| `clear()` | Remove all messages, restore empty state. | +| `getInput()` / `setInput(text)` / `focus()` | Input helpers (e.g. prepend a quote). | +| `setLabels(labels)` | Re-apply labels live (e.g. language change). | +| `dispose()` | Release resize observers and copy-feedback timers when unmounting. | `chat.actionsEl` is a host-populated slot directly above the input (an IM-style compose toolbar) — append your own controls; it collapses when empty. @@ -84,6 +84,16 @@ onSend: async (text) => { - Message arrivals consume the current following state. They must not recompute it from post-reflow geometry: scroll handling owns reader attachment, while jumping to the end and clearing reset it. - Use `appendMany` for history restoration. It keeps the same message rendering and tool grouping as `append`, while deferring intermediate scroll measurements. Completed and interrupted streams publish their original source when finalized; streaming actions stay hidden. - Copy feedback uses fixed-size icons and a separate polite announcement. Clipboard failure leaves the action available to retry; feedback never replaces running activity. +- Actual waiting phases appear immediately. `labels.routing` names request + classification; use it only while the host is classifying, never for a task + that already has known intent. `labels.elapsed(seconds)` formats a visual + waiting clock measured with monotonic local time. Its ticks stay outside live + regions. It ends on the first response text, Stop/completion or disposal; + human review shows no clock or loading cue. The clock is not estimated + progress, model reasoning or a promise of faster computation. +- `clear()` clears rendered waiting feedback and its timer, without cancelling + the host operation or removing Stop. Feedback resumes only after a new + `setRunning(false)` → `setRunning(true)` transition. - Explicit `documentArtifact: true` identifies a document-draft surface. Ordinary prose and tool receipts never acquire this identity by mentioning a document. `labels.documentDraft` and `labels.incomplete` localize the result header; diff --git a/packages/chat-ui/README.md b/packages/chat-ui/README.md index 92857869..81c38b3f 100644 --- a/packages/chat-ui/README.md +++ b/packages/chat-ui/README.md @@ -43,11 +43,19 @@ Override the look via the `--cui-*` custom properties on `.cui-root`. - `appendDelta(text)` — stream text into a live agent bubble (auto-created). - `endStream()` — finalise the streaming bubble. - `setRunning(bool)` — toggle Send⇄Stop while keeping the next draft editable; never queue it. -- `clear()` — remove all messages. +- `setActivity(phase)` — display the host's actual `routing`, `reading`, `planning`, + `responding` or `reviewing` phase. Classification must be reported only when it runs. +- `clear()` — remove messages and waiting feedback; it does not stop the host's run. - `getInput()` / `setInput(text)` / `focus()` — input helpers. - `setLabels(labels)` — update labels live (e.g. language change). - `actionsEl` — host-populated slot above the input (compose toolbar); collapses when empty. +Waiting feedback appears immediately, with a locally measured clock formatted +by `labels.elapsed(seconds)` and a classification label from `labels.routing`. +Clock ticks are visual only and stay outside live regions. First response text, +Stop/completion and disposal end the clock; human review shows a resting status. +Elapsed seconds describe waiting, without estimating model progress or speed. + Hosts may mark validated document content with `documentArtifact: true` in `append(...)` or the metadata argument to `appendDelta(...)`. It receives a document-draft header; ordinary chat prose and tool receipts remain distinct. diff --git a/packages/chat-ui/src/chat-view.ts b/packages/chat-ui/src/chat-view.ts index c24c7828..01e1839f 100644 --- a/packages/chat-ui/src/chat-view.ts +++ b/packages/chat-ui/src/chat-view.ts @@ -54,10 +54,17 @@ export class ChatView { private running = false; private preparingSend = false; private submissionRevision = 0; - private activity: 'responding' | 'reading' | 'planning' | 'reviewing' = 'responding'; + private activity: 'responding' | 'routing' | 'reading' | 'planning' | 'reviewing' = 'responding'; + private respondingHasText = false; + private presentationCleared = false; + private waitStartedAt: number | null = null; + private elapsedTimer?: ReturnType; private composing = false; private lastUserText = ''; private readonly statusEl = Div().class('cui-status').attr('role', 'status').attr('aria-atomic', 'true').build(); + // The visual clock is outside live regions so its ticks do not interrupt reading. + private readonly elapsedEl = Span().class('cui-elapsed').attr('aria-hidden', 'true').build(); + private readonly progressEl = Div().class('cui-progress').children(this.statusEl, this.elapsedEl).build(); private readonly feedbackEl = Div().class('cui-feedback').attr('role', 'status').attr('aria-atomic', 'true').build(); private readonly resizeObserver?: ResizeObserver; private inputWidth = 0; @@ -167,7 +174,7 @@ export class ChatView { // slot, and the composer. const footer = Div().class('cui-footer').children(this.scrollBtn, composer).build(); - this.el = Div().class('cui-root').children(this.messagesEl, this.statusEl, footer, this.feedbackEl).build(); + this.el = Div().class('cui-root').children(this.messagesEl, this.progressEl, footer, this.feedbackEl).build(); if (typeof ResizeObserver !== 'undefined') { this.resizeObserver = new ResizeObserver((entries) => { if (this.disposed) return; @@ -373,13 +380,15 @@ export class ChatView { */ appendDelta(delta: string, metadata: Pick = {}): void { if (!delta || this.disposed) return; + const firstForPhase = !this.respondingHasText; + this.respondingHasText = true; if (!this.liveMsg) { this.liveMsg = this.append({ role: 'agent', text: '', ...metadata }).parentElement as HTMLDivElement; this.liveMsg.classList.add('cui-streaming'); this.liveNode = document.createTextNode(''); this.liveMsg.querySelector('.cui-bubble')?.replaceChildren(this.liveNode); - this.updateActivity(); } + if (firstForPhase) this.updateActivity(); this.liveText += delta; this.pendingText += delta; this.scheduleLayout(); @@ -439,41 +448,78 @@ export class ChatView { /** Toggle Send/Stop while keeping the next draft editable and never queued. */ setRunning(running: boolean): void { const restore = !running && this.running && document.activeElement === this.sendBtn; + if (running && !this.running) { + this.respondingHasText = false; + this.presentationCleared = false; + } this.running = running; if (!running) this.activity = 'responding'; this.updateActivity(); if (!running) this.interruptStream(); - this.messagesEl.setAttribute('aria-busy', String(running)); + this.messagesEl.setAttribute('aria-busy', String(running && this.activity !== 'reviewing')); this.updateSendState(); if (restore) this.input.focus({ preventScroll: true }); } /** Host-reported activity, not inferred model thoughts or estimated progress. */ - setActivity(activity: 'responding' | 'reading' | 'planning' | 'reviewing'): void { + setActivity(activity: 'responding' | 'routing' | 'reading' | 'planning' | 'reviewing'): void { + if (activity !== this.activity) this.respondingHasText = false; this.activity = activity; this.messagesEl.setAttribute('aria-busy', String(this.running && activity !== 'reviewing')); this.updateActivity(); } private updateActivity(): void { - // CSS follows the existing host-reported phase; no animation timers or - // model-specific "thinking" events are needed by this reusable view. + // Host phases describe actual work; the clock measures waiting, not inference progress. this.el.dataset.activity = this.running ? this.activity : 'idle'; - this.statusEl.textContent = - !this.running || (this.liveMsg && this.activity === 'responding') + const text = + !this.running || + this.disposed || + this.presentationCleared || + (this.respondingHasText && this.activity === 'responding') ? '' : this.activity === 'reviewing' ? (this.labels.reviewing ?? 'Review the proposed change to continue.') : this.activity === 'reading' ? (this.labels.reading ?? 'Reading content…') - : this.activity === 'planning' - ? (this.labels.planning ?? 'Preparing task…') - : (this.labels.waiting ?? 'Generating a response…'); + : this.activity === 'routing' + ? (this.labels.routing ?? 'Understanding your request…') + : this.activity === 'planning' + ? (this.labels.planning ?? 'Preparing task…') + : (this.labels.waiting ?? 'Generating a response…'); + if (this.statusEl.textContent !== text) this.statusEl.textContent = text; + this.progressEl.hidden = !text; + this.updateWaitClock(!!text && this.activity !== 'reviewing'); + } + + private updateWaitClock(waiting: boolean): void { + this.elapsedEl.hidden = !waiting; + if (!waiting) { + if (this.elapsedTimer !== undefined) clearInterval(this.elapsedTimer); + this.elapsedTimer = undefined; + this.waitStartedAt = null; + this.elapsedEl.textContent = ''; + return; + } + this.waitStartedAt ??= performance.now(); + this.updateElapsed(); + this.elapsedTimer ??= setInterval(() => this.updateElapsed(), 1000); + } + + private updateElapsed(): void { + if (this.waitStartedAt === null || this.disposed) return; + const seconds = Math.max(0, Math.floor((performance.now() - this.waitStartedAt) / 1000)); + const text = this.labels.elapsed?.(seconds) ?? `${seconds}s`; + if (this.elapsedEl.textContent !== text) this.elapsedEl.textContent = text; } /** Remove all messages and restore the empty state. */ clear(): void { this.submissionRevision++; + // Clear the rendered feedback without claiming that the host's run stopped. + this.presentationCleared = true; + this.respondingHasText = false; + this.updateWaitClock(false); if (this.layoutFrame !== undefined) cancelAnimationFrame(this.layoutFrame); this.layoutFrame = undefined; this.pendingText = ''; diff --git a/packages/chat-ui/src/styles.ts b/packages/chat-ui/src/styles.ts index 353e9e40..bfe3e717 100644 --- a/packages/chat-ui/src/styles.ts +++ b/packages/chat-ui/src/styles.ts @@ -70,11 +70,15 @@ export const CHAT_UI_CSS = ` .cui-activity li { padding:4px 0; } .cui-msg-status { color:var(--cui-muted); font-size:12px; } .cui-msg-error .cui-bubble { padding:12px; border:1px solid var(--cui-border); border-radius:10px; font-size:13px; } -.cui-status { flex:0 0 auto; display:flex; align-items:center; gap:8px; padding:0 var(--ran-space-4, 16px) var(--ran-space-3, 12px); color:var(--cui-muted); font-size:12px; line-height:1.5; } +.cui-progress { flex:0 0 auto; display:flex; align-items:center; gap:var(--ran-space-3, 12px); margin:0 var(--ran-space-4, 16px); padding:var(--ran-space-2, 8px) var(--ran-space-3, 12px); border:1px solid var(--cui-border); border-radius:var(--ran-radius-sm, 6px); background:var(--cui-surface); } +.cui-status { flex:1; min-width:0; display:flex; align-items:center; gap:var(--ran-space-2, 8px); color:var(--cui-muted); font-size:var(--ran-text-label-3, 12px); line-height:1.5; } +.cui-elapsed { flex:0 0 auto; color:var(--cui-muted); font-size:var(--ran-text-label-3, 12px); line-height:1.5; font-variant-numeric:tabular-nums; white-space:nowrap; } +.cui-progress[hidden],.cui-elapsed[hidden] { display:none; } +.cui-root[data-activity="reviewing"] .cui-progress { border-color:transparent; background:transparent; padding-inline:0; } .cui-status:empty { display:none; } /* One activity cue at a time: the waiting row gives way to the live-text cursor when responding. A human review is a resting state, not loading. */ -.cui-root:is([data-activity="responding"],[data-activity="reading"],[data-activity="planning"]) .cui-status::before { content:''; flex:0 0 auto; width:12px; height:12px; box-sizing:border-box; border:1.5px solid var(--cui-border); border-top-color:currentColor; border-right-color:currentColor; border-radius:50%; animation:cui-loading-turn 1s linear infinite; } +.cui-root:is([data-activity="responding"],[data-activity="routing"],[data-activity="reading"],[data-activity="planning"]) .cui-status::before { content:''; flex:0 0 auto; width:12px; height:12px; box-sizing:border-box; border:1.5px solid var(--cui-border); border-top-color:currentColor; border-right-color:currentColor; border-radius:50%; animation:cui-loading-turn 1s linear infinite; } .cui-footer { position:relative; flex:0 0 auto; padding:12px 16px 16px; } .cui-scroll-bottom { position:absolute; top:-40px; left:50%; transform:translateX(-50%); width:32px; height:32px; box-sizing:border-box; padding:0; display:flex; align-items:center; justify-content:center; border:1px solid var(--cui-border); border-radius:50%; background:var(--cui-bg); color:var(--cui-muted); cursor:pointer; box-shadow:0 2px 6px #00000012; } .cui-scroll-bottom[hidden] { display:none; } diff --git a/packages/chat-ui/src/types.ts b/packages/chat-ui/src/types.ts index 2b58e977..5da89771 100644 --- a/packages/chat-ui/src/types.ts +++ b/packages/chat-ui/src/types.ts @@ -33,6 +33,10 @@ export interface ChatViewLabels { /** Identifies retained output from a response that did not complete. */ incomplete?: string; waiting?: string; + /** Actual host request classification, distinct from tool planning. */ + routing?: string; + /** Format locally measured waiting seconds; visual only, never a live announcement. */ + elapsed?: (seconds: number) => string; reading?: string; planning?: string; reviewing?: string; diff --git a/packages/shared/src/i18n/messages/de.ts b/packages/shared/src/i18n/messages/de.ts index e96203f3..640ec20d 100644 --- a/packages/shared/src/i18n/messages/de.ts +++ b/packages/shared/src/i18n/messages/de.ts @@ -359,6 +359,8 @@ export const de: Partial = { agentCopied: 'Kopiert', agentCopyFailed: 'Kopieren fehlgeschlagen', agentRestoreRequest: 'Anfrage wiederherstellen', + agentRoutingRequest: 'Anfrage wird eingeordnet…', + agentWaitElapsed: '{seconds} s vergangen', agentWaiting: 'Antwort wird erstellt…', agentReadingContent: 'Aktueller Inhalt wird gelesen…', agentPreparingTask: 'Anfrage wird vorbereitet…', diff --git a/packages/shared/src/i18n/messages/en.ts b/packages/shared/src/i18n/messages/en.ts index 741aa4ac..2db22fec 100644 --- a/packages/shared/src/i18n/messages/en.ts +++ b/packages/shared/src/i18n/messages/en.ts @@ -339,6 +339,8 @@ export const en: I18nMessages = { agentCopied: 'Copied', agentCopyFailed: 'Could not copy', agentRestoreRequest: 'Restore request', + agentRoutingRequest: 'Understanding your request…', + agentWaitElapsed: '{seconds}s elapsed', agentWaiting: 'Generating a response…', agentReadingContent: 'Reading current content…', agentPreparingTask: 'Preparing your request…', diff --git a/packages/shared/src/i18n/messages/es.ts b/packages/shared/src/i18n/messages/es.ts index 3f691c2c..e8673fb0 100644 --- a/packages/shared/src/i18n/messages/es.ts +++ b/packages/shared/src/i18n/messages/es.ts @@ -348,6 +348,8 @@ export const es: Partial = { agentCopied: 'Copiado', agentCopyFailed: 'No se pudo copiar', agentRestoreRequest: 'Recuperar solicitud', + agentRoutingRequest: 'Interpretando tu solicitud…', + agentWaitElapsed: '{seconds} s transcurridos', agentWaiting: 'Generando respuesta…', agentReadingContent: 'Leyendo el contenido actual…', agentPreparingTask: 'Preparando tu solicitud…', diff --git a/packages/shared/src/i18n/messages/ja.ts b/packages/shared/src/i18n/messages/ja.ts index 29fd2723..f64ce2bb 100644 --- a/packages/shared/src/i18n/messages/ja.ts +++ b/packages/shared/src/i18n/messages/ja.ts @@ -344,6 +344,8 @@ export const ja: Partial = { agentCopied: 'コピーしました', agentCopyFailed: 'コピーできませんでした', agentRestoreRequest: 'リクエストを復元', + agentRoutingRequest: 'リクエストを確認中…', + agentWaitElapsed: '{seconds} 秒経過', agentWaiting: '回答を生成中…', agentReadingContent: '現在の内容を読み取り中…', agentPreparingTask: 'リクエストを準備中…', diff --git a/packages/shared/src/i18n/messages/ko.ts b/packages/shared/src/i18n/messages/ko.ts index b07144c0..92af9c97 100644 --- a/packages/shared/src/i18n/messages/ko.ts +++ b/packages/shared/src/i18n/messages/ko.ts @@ -336,6 +336,8 @@ export const ko: Partial = { agentCopied: '복사됨', agentCopyFailed: '복사 실패', agentRestoreRequest: '요청 복원', + agentRoutingRequest: '요청을 파악하는 중…', + agentWaitElapsed: '{seconds}초 경과', agentWaiting: '응답 생성 중…', agentReadingContent: '현재 내용을 읽는 중…', agentPreparingTask: '요청을 준비하는 중…', diff --git a/packages/shared/src/i18n/messages/pt.ts b/packages/shared/src/i18n/messages/pt.ts index f30a7315..f29518cf 100644 --- a/packages/shared/src/i18n/messages/pt.ts +++ b/packages/shared/src/i18n/messages/pt.ts @@ -344,6 +344,8 @@ export const pt: Partial = { agentCopied: 'Copiado', agentCopyFailed: 'Falha ao copiar', agentRestoreRequest: 'Restaurar pedido', + agentRoutingRequest: 'Interpretando sua solicitação…', + agentWaitElapsed: '{seconds} s decorridos', agentWaiting: 'Gerando resposta…', agentReadingContent: 'Lendo o conteúdo atual…', agentPreparingTask: 'Preparando sua solicitação…', diff --git a/packages/shared/src/i18n/messages/zh-CN.ts b/packages/shared/src/i18n/messages/zh-CN.ts index 865dc482..9be7ce85 100644 --- a/packages/shared/src/i18n/messages/zh-CN.ts +++ b/packages/shared/src/i18n/messages/zh-CN.ts @@ -325,6 +325,8 @@ export const zhCN: I18nMessages = { agentCopied: '已复制', agentCopyFailed: '复制失败', agentRestoreRequest: '恢复请求', + agentRoutingRequest: '正在理解你的请求…', + agentWaitElapsed: '已等待 {seconds} 秒', agentWaiting: '正在生成回复…', agentReadingContent: '正在读取当前内容…', agentPreparingTask: '正在准备任务…', diff --git a/packages/shared/src/i18n/types.ts b/packages/shared/src/i18n/types.ts index d1c7990e..d3e696d5 100644 --- a/packages/shared/src/i18n/types.ts +++ b/packages/shared/src/i18n/types.ts @@ -337,6 +337,8 @@ export interface I18nMessages { agentCopyFailed: string; agentRestoreRequest: string; agentWaiting: string; + agentRoutingRequest: string; + agentWaitElapsed: string; agentReadingContent: string; agentPreparingTask: string; agentScrollLatest: string; diff --git a/test/e2e/agent-content-review.spec.ts b/test/e2e/agent-content-review.spec.ts index 59881538..be336b94 100644 --- a/test/e2e/agent-content-review.spec.ts +++ b/test/e2e/agent-content-review.spec.ts @@ -91,6 +91,13 @@ test('formatted Word content retains native headings and lists with safe readbac await page.locator('.cui-input').press('Enter'); const preview = page.locator('.agent-plan-preview').last(); await expect(preview.locator('.agent-plan-diff')).toContainText('Budget'); + await expect(page.locator('.cui-root')).toHaveAttribute('data-activity', 'reviewing'); + await expect(page.locator('.cui-elapsed')).toBeHidden(); + await expect(page.locator('.cui-elapsed')).toHaveText(''); + await expect(page.getByRole('log')).toHaveAttribute('aria-busy', 'false'); + expect(await page.locator('.cui-status').evaluate((el) => getComputedStyle(el, '::before').animationName)).toBe( + 'none', + ); expect(await read()).toEqual(before); await preview.locator('.agent-plan-apply').click(); await expect.poll(() => preview.getAttribute('data-state')).not.toBe('applying'); diff --git a/test/e2e/agent-process-feedback.spec.ts b/test/e2e/agent-process-feedback.spec.ts new file mode 100644 index 00000000..1eca4ba1 --- /dev/null +++ b/test/e2e/agent-process-feedback.spec.ts @@ -0,0 +1,208 @@ +import { expect, test } from './lib/l0'; + +test('a held request shows actual classification and generation, then clears waiting on completion and Stop', async ({ + page, +}, testInfo) => { + test.setTimeout(120_000); + await page.addInitScript(() => + localStorage.setItem( + 'agent-writing-endpoint', + JSON.stringify({ + version: 1, + kind: 'loopback', + baseUrl: 'http://localhost:11434', + model: 'feedback-test', + preference: 'device-first', + localWritingConsent: false, + }), + ), + ); + let releaseRouting!: () => void; + const routingGate = new Promise((resolve) => (releaseRouting = resolve)); + let releaseAnswer!: () => void; + const answerGate = new Promise((resolve) => (releaseAnswer = resolve)); + let releaseStopped!: () => void; + const stoppedGate = new Promise((resolve) => (releaseStopped = resolve)); + let classifications = 0; + let answers = 0; + await page.route('http://localhost:11434/api/**', async (route) => { + if (route.request().method() === 'OPTIONS') { + await route.fulfill({ + status: 204, + headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type' }, + }); + return; + } + if (route.request().url().endsWith('/tags')) { + await route.fulfill({ json: { models: [{ name: 'feedback-test' }] } }); + return; + } + const routing = !!route.request().postDataJSON().format?.properties?.task; + if (routing && ++classifications === 1) await routingGate; + const answer = routing ? 0 : ++answers; + if (!routing) { + if (answer === 1) await answerGate; + if (answer === 2) await stoppedGate; + } + await route.fulfill({ + json: { + done: true, + message: { + role: 'assistant', + content: routing ? JSON.stringify({ task: 'chat', language: 'en' }) : `Useful answer ${answer}`, + }, + }, + }); + }); + try { + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.evaluate(() => window.__toggleAgentPanel?.()); + await page.locator('.agent-enable-switch').click(); + const input = page.locator('.cui-input'); + const send = page.locator('.cui-send'); + await input.fill('Explain what you can do with this document.'); + await send.click(); + await expect(page.locator('.agent-panel-endpoint-status')).toContainText('Connected'); + await expect(send).toHaveAccessibleName('Send'); + await send.click(); + await expect.poll(() => classifications).toBe(1); + const phase = page.locator('.cui-status'); + const elapsed = page.locator('.cui-elapsed'); + await expect(phase).toHaveText('Understanding your request…'); + await expect(page.locator('.cui-progress')).toBeVisible(); + await expect(elapsed).toHaveAttribute('aria-hidden', 'true'); + await expect.poll(async () => Number.parseInt((await elapsed.textContent())!, 10)).toBeGreaterThan(0); + const previousSeconds = Number.parseInt((await elapsed.textContent())!, 10); + await input.fill('Next question'); + const before = (await page.locator('.cui-composer').boundingBox())!; + await expect(input).toBeFocused(); + await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath('process-routing-light.png') }); + releaseRouting(); + await expect.poll(() => answers).toBe(1); + await expect(phase).toHaveText('Generating a response…'); + expect(Number.parseInt((await elapsed.textContent())!, 10)).toBeGreaterThanOrEqual(previousSeconds); + const after = (await page.locator('.cui-composer').boundingBox())!; + expect(Math.abs(before.y - after.y)).toBeLessThanOrEqual(1); + expect(Math.abs(before.height - after.height)).toBeLessThanOrEqual(1); + await page + .locator('.agent-runtime-panel') + .screenshot({ path: testInfo.outputPath('process-generating-light.png') }); + await page.evaluate(() => document.documentElement.setAttribute('data-ran-theme', 'dark')); + await page.getByRole('separator', { name: 'Resize AI panel' }).press('Home'); + await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath('process-generating-dark.png') }); + await page.setViewportSize({ width: 360, height: 640 }); + await expect + .poll(() => page.locator('.agent-runtime-panel').evaluate((el) => el.scrollWidth - el.clientWidth)) + .toBeLessThan(2); + await page + .locator('.agent-runtime-panel') + .screenshot({ path: testInfo.outputPath('process-generating-narrow.png') }); + releaseAnswer(); + await expect(page.locator('.cui-msg-agent .cui-bubble')).toHaveText('Useful answer 1'); + await expect(page.locator('.cui-progress')).toBeHidden(); + await expect(elapsed).toHaveText(''); + await expect(input).toHaveValue('Next question'); + await send.click(); + await expect.poll(() => answers).toBe(2); + await input.fill('One more question'); + await send.click(); + await expect(page.locator('.cui-progress')).toBeHidden(); + await expect(elapsed).toHaveText(''); + await expect(input).toHaveValue('One more question'); + await expect(send).toHaveAccessibleName('Send'); + releaseStopped(); + await send.click(); + await expect(page.getByRole('log')).toContainText('Useful answer 3'); + await expect(page.getByRole('log')).not.toContainText('Useful answer 2'); + await expect(page.locator('.cui-progress')).toBeHidden(); + await expect(page.locator('.cui-msg-error')).toHaveCount(0); + } finally { + releaseRouting(); + releaseAnswer(); + releaseStopped(); + } +}); + +test('Chinese waiting feedback remains localized and stable in a narrow real editor', async ({ page }, testInfo) => { + test.setTimeout(90_000); + await page.addInitScript(() => + localStorage.setItem( + 'agent-writing-endpoint', + JSON.stringify({ + version: 1, + kind: 'loopback', + baseUrl: 'http://localhost:11434', + model: 'feedback-test', + preference: 'device-first', + localWritingConsent: false, + }), + ), + ); + let release!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + let classifications = 0; + await page.route('http://localhost:11434/api/**', async (route) => { + if (route.request().method() === 'OPTIONS') { + await route.fulfill({ + status: 204, + headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type' }, + }); + return; + } + if (route.request().url().endsWith('/tags')) { + await route.fulfill({ json: { models: [{ name: 'feedback-test' }] } }); + return; + } + if (route.request().postDataJSON().format?.properties?.task) { + classifications++; + await gate; + } + await route.fulfill({ + json: { + done: true, + message: { role: 'assistant', content: JSON.stringify({ task: 'chat', language: 'zh-CN' }) }, + }, + }); + }); + try { + await page.goto('/editor?new=docx&locale=zh-CN'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + await page.evaluate(() => window.__toggleAgentPanel?.()); + await page.locator('.agent-enable-switch').click(); + await page.setViewportSize({ width: 360, height: 640 }); + await page.emulateMedia({ reducedMotion: 'reduce' }); + const input = page.locator('.cui-input'); + const send = page.locator('.cui-send'); + await input.fill('请解释一下你能帮我完成哪些文档任务。'); + await send.click(); + await expect(page.locator('.agent-panel-endpoint-status')).toContainText('已连接'); + await expect(send).toHaveAccessibleName('发送'); + await send.click(); + await expect.poll(() => classifications).toBe(1); + await expect(page.locator('.cui-status')).toHaveText('正在理解你的请求…'); + await input.fill('下一条问题草稿'); + const before = (await page.locator('.cui-composer').boundingBox())!; + await expect(page.locator('.cui-elapsed')).toHaveText(/已等待 [1-9]\d* 秒/); + await expect(input).toBeFocused(); + const after = (await page.locator('.cui-composer').boundingBox())!; + expect(Math.abs(before.y - after.y)).toBeLessThanOrEqual(1); + expect(Math.abs(before.height - after.height)).toBeLessThanOrEqual(1); + await expect + .poll(() => page.locator('.agent-runtime-panel').evaluate((el) => el.scrollWidth - el.clientWidth)) + .toBeLessThan(2); + expect(await page.locator('.cui-status').evaluate((el) => getComputedStyle(el, '::before').animationName)).toBe( + 'none', + ); + await page + .locator('.agent-runtime-panel') + .screenshot({ path: testInfo.outputPath('process-routing-zh-narrow.png') }); + await send.click(); + await expect(page.locator('.cui-progress')).toBeHidden(); + await expect(page.locator('.cui-elapsed')).toHaveText(''); + await expect(input).toHaveValue('下一条问题草稿'); + await expect(page.getByRole('log')).toContainText('已停止。'); + } finally { + release(); + } +}); diff --git a/test/unit/agent-panel-loading.test.ts b/test/unit/agent-panel-loading.test.ts index fdd3f484..7e691473 100644 --- a/test/unit/agent-panel-loading.test.ts +++ b/test/unit/agent-panel-loading.test.ts @@ -4,6 +4,7 @@ import { t } from '@ranuts/shared/i18n'; import { getEndpointKey } from '@ranuts/agent-core/llm/keys'; import { AgentChatController } from '../../lib/agent-plugin/ui/controller'; import { createHistoryStorage } from '../../lib/agent-plugin/ui/storage'; +import { ChatView } from '@ranuts/chat-ui'; HTMLElement.prototype.scrollTo = vi.fn(); // Runtime tests explicitly prepare, as a user must now do from settings. function createPreparedPanel() { @@ -40,6 +41,7 @@ const state = vi.hoisted(() => ({ source: '', readText: undefined as string | undefined, readAnswer: vi.fn().mockResolvedValue('120'), + directTaskAnswer: undefined as undefined | ((delta?: (text: string) => void) => Promise), editorReady: false, backend: undefined as undefined | ((backend: 'webllm' | 'wllama') => void), cpuProgress: undefined as undefined | ((value: { loaded: number; total: number }) => void), @@ -239,6 +241,10 @@ vi.mock('../../lib/agent-plugin/document-task', async (original) => { signal?: AbortSignal, delta?: (text: string) => void, ): Promise => { + if (state.directTaskAnswer) { + const text = await state.directTaskAnswer(delta); + return { text, toolCalls: [], stopReason: 'end_turn', assistant: { role: 'assistant', content: text } }; + } const results = messages.flatMap((m) => typeof m.content === 'string' ? [] : m.content.filter((b) => b.type === 'tool_result'), ); @@ -341,6 +347,7 @@ afterEach(() => { state.generate.mockReset(); state.toolPlan.mockReset(); state.readAnswer.mockReset().mockResolvedValue('120'); + state.directTaskAnswer = undefined; state.toolContext = 'slide'; state.apply.mockClear(); state.routing.mockReset(); @@ -2546,7 +2553,7 @@ it('shows an accepted request before task routing completes and never duplicates input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); try { expect(panel.querySelector('.cui-msg-user .cui-bubble')?.textContent).toBe('Hello there'); - expect(panel.querySelector('.cui-status')?.textContent).toBe(t('agentPreparingTask')); + expect(panel.querySelector('.cui-status')?.textContent).toBe(t('agentRoutingRequest')); expect(input.value).toBe(''); } finally { finish(); @@ -2556,6 +2563,67 @@ it('shows an accepted request before task routing completes and never duplicates expect(panel.querySelector('.cui-msg-agent .cui-bubble')?.textContent).toBe('answer'); }); +it('does not show classification for a trusted staged task that skips the routing model', async () => { + allowBrowserLocalWriting(); + state.ready = true; + // Expose real routing capability so this exercises the staged-intent branch, + // rather than passing because the fixture has no classification method. + state.semanticResponse = '{"task":"rewrite","language":"en"}'; + state.editorReady = true; + state.source = 'Original selected text'; + let finish!: (text: string) => void; + state.writing.mockImplementation(() => new Promise((resolve) => (finish = resolve))); + const activity = vi.spyOn(ChatView.prototype, 'setActivity'); + const panel = createPreparedPanel(); + try { + panel.querySelector('[data-task="polish"]')!.click(); + panel.querySelector('.cui-send')!.click(); + await vi.waitFor(() => expect(state.writing).toHaveBeenCalledOnce()); + expect(state.routing).not.toHaveBeenCalled(); + expect(activity).not.toHaveBeenCalledWith('routing'); + expect(panel.querySelector('.cui-status')?.textContent).toBe(t('agentWaiting')); + expect(panel.querySelector('.cui-elapsed')?.textContent).toBe(t('agentWaitElapsed', { seconds: 0 })); + } finally { + finish?.('Polished text'); + await vi.waitFor(() => expect(panel.querySelector('.cui-send-stop')).toBeNull()); + activity.mockRestore(); + } +}); + +it('ends planning feedback on visible direct task text without requiring a tool result', async () => { + state.ready = true; + state.semanticResponse = '{"task":"tools","language":"en"}'; + let emit!: (text: string) => void; + let finish!: (text: string) => void; + state.directTaskAnswer = (delta) => { + emit = delta!; + return new Promise((resolve) => (finish = resolve)); + }; + const panel = createPreparedPanel(); + const input = panel.querySelector('.cui-input')!; + input.value = 'Explain what should happen next in this presentation.'; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + try { + await vi.waitFor(() => expect(emit).toBeDefined()); + emit('Internal reasoning'); + expect(panel.querySelector('.cui-root')?.getAttribute('data-activity')).toBe('planning'); + expect(panel.querySelector('.cui-msg-agent')).toBeNull(); + expect(panel.querySelector('.cui-progress')?.hidden).toBe(false); + emit('Direct useful answer'); + await vi.waitFor(() => + expect(panel.querySelector('.cui-msg-agent .cui-bubble')?.textContent).toBe('Direct useful answer'), + ); + expect(panel.querySelector('.cui-root')?.getAttribute('data-activity')).toBe('responding'); + expect(panel.querySelector('.cui-progress')?.hidden).toBe(true); + expect(panel.querySelector('.cui-elapsed')?.textContent).toBe(''); + expect(state.toolPlan).not.toHaveBeenCalled(); + expect(state.apply).not.toHaveBeenCalled(); + } finally { + finish?.('Direct useful answer'); + await vi.waitFor(() => expect(panel.querySelector('.cui-send-stop')).toBeNull()); + } +}); + it('streams a grounded answer before completion and stores one complete reply', async () => { state.ready = true; state.readText = 'Budget is 120'; diff --git a/test/unit/chat-view-progress.test.ts b/test/unit/chat-view-progress.test.ts new file mode 100644 index 00000000..acc8cd37 --- /dev/null +++ b/test/unit/chat-view-progress.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { ChatView } from '../../packages/chat-ui/src/chat-view'; + +let view: ChatView; +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-14T00:00:00Z')); + vi.stubGlobal('requestAnimationFrame', vi.fn()); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + HTMLElement.prototype.scrollTo = vi.fn(); + document.body.replaceChildren(); + view = new ChatView({ + onSend: vi.fn(), + labels: { + routing: 'Understanding request', + reading: 'Reading content', + waiting: 'Generating answer', + elapsed: (seconds) => `Waiting ${seconds}s`, + }, + }); + document.body.append(view.el); +}); +afterEach(() => { + view.dispose(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); +const phase = () => view.el.querySelector('.cui-status')!; +const elapsed = () => view.el.querySelector('.cui-elapsed')!; +const progress = () => view.el.querySelector('.cui-progress')!; + +it('shows the real phase immediately and measures waiting across phases without live tick announcements', () => { + view.setRunning(true); + view.setActivity('routing'); + expect(phase().textContent).toBe('Understanding request'); + expect(progress().hidden).toBe(false); + expect(elapsed().textContent).toBe('Waiting 0s'); + expect(elapsed().getAttribute('aria-hidden')).toBe('true'); + expect(elapsed().closest('[aria-live], [role="status"], [role="log"]')).toBeNull(); + const changed = vi.spyOn(phase(), 'textContent', 'set'); + vi.advanceTimersByTime(2200); + expect(elapsed().textContent).toBe('Waiting 2s'); + expect(changed).not.toHaveBeenCalled(); + view.setActivity('reading'); + expect(phase().textContent).toBe('Reading content'); + expect(elapsed().textContent).toBe('Waiting 2s'); + view.setActivity('responding'); + expect(phase().textContent).toBe('Generating answer'); + expect(elapsed().textContent).toBe('Waiting 2s'); +}); + +it('replaces pre-response waiting with streamed text and does not restart waiting on stream completion', () => { + view.setRunning(true); + vi.advanceTimersByTime(3000); + view.appendDelta('First useful text'); + expect(progress().hidden).toBe(true); + expect(phase().textContent).toBe(''); + expect(elapsed().textContent).toBe(''); + expect(vi.getTimerCount()).toBe(0); + view.endStream(); + expect(view.getLastAnswer()).toBe('First useful text'); + expect(progress().hidden).toBe(true); + expect(vi.getTimerCount()).toBe(0); + view.setRunning(false); +}); + +it('keeps human review resting without a timer and excludes review time from a later wait', () => { + view.setRunning(true); + vi.advanceTimersByTime(3000); + view.setActivity('reviewing'); + expect(phase().textContent).toBe('Review the proposed change to continue.'); + expect(elapsed().hidden).toBe(true); + expect(elapsed().textContent).toBe(''); + expect(vi.getTimerCount()).toBe(0); + view.setRunning(true); + expect(view.el.querySelector('.cui-messages')?.getAttribute('aria-busy')).toBe('false'); + vi.advanceTimersByTime(90_000); + view.setActivity('responding'); + expect(elapsed().textContent).toBe('Waiting 0s'); +}); + +it('clears elapsed state on Stop and starts the next explicit turn from zero without touching the draft', () => { + view.setRunning(true); + view.setInput('Next question'); + vi.advanceTimersByTime(7000); + view.setRunning(false); + expect(progress().hidden).toBe(true); + expect(elapsed().textContent).toBe(''); + expect(vi.getTimerCount()).toBe(0); + expect(view.getInput()).toBe('Next question'); + view.setRunning(true); + expect(elapsed().textContent).toBe('Waiting 0s'); + view.dispose(); + expect(vi.getTimerCount()).toBe(0); + vi.advanceTimersByTime(7000); + expect(elapsed().textContent).toBe(''); +}); + +it('preserves measured time on repeated running updates and live label changes', () => { + view.setRunning(true); + view.setActivity('routing'); + vi.advanceTimersByTime(4000); + view.setRunning(true); + expect(elapsed().textContent).toBe('Waiting 4s'); + view.setLabels({ routing: 'Checking request', elapsed: (seconds) => `${seconds} seconds elapsed` }); + expect(phase().textContent).toBe('Checking request'); + expect(elapsed().textContent).toBe('4 seconds elapsed'); + expect(vi.getTimerCount()).toBe(1); +}); + +it('measures elapsed waiting independently of system clock changes', () => { + view.setRunning(true); + vi.advanceTimersByTime(2000); + vi.setSystemTime(new Date('2026-10-15T00:00:00Z')); + vi.advanceTimersByTime(1000); + expect(elapsed().textContent).toBe('Waiting 3s'); +}); + +it('clears running presentation without stopping the backend and restarts feedback only for a new turn', () => { + view.setRunning(true); + view.setActivity('routing'); + view.setInput('Keep this next draft'); + vi.advanceTimersByTime(2000); + view.clear(); + expect(vi.getTimerCount()).toBe(0); + expect(progress().hidden).toBe(true); + expect(phase().textContent).toBe(''); + expect(elapsed().textContent).toBe(''); + expect(view.getInput()).toBe('Keep this next draft'); + expect(view.el.querySelector('.cui-send-stop')?.disabled).toBe(false); + expect(view.el.querySelector('.cui-messages')?.getAttribute('aria-busy')).toBe('true'); + view.setRunning(true); + view.setActivity('reading'); + view.setLabels({ elapsed: (seconds) => `${seconds}s` }); + vi.advanceTimersByTime(3000); + expect(vi.getTimerCount()).toBe(0); + expect(progress().hidden).toBe(true); + view.setRunning(false); + view.setRunning(true); + expect(progress().hidden).toBe(false); + expect(elapsed().textContent).toBe('0s'); + expect(vi.getTimerCount()).toBe(1); +}); From 2b3689e6620e6310c14b664ce932bfe0ed3a2bec Mon Sep 17 00:00:00 2001 From: chaxus Date: Thu, 15 Oct 2026 00:26:03 +0800 Subject: [PATCH 7/7] docs(assistant): record experience review and acceptance --- CHANGELOG.md | 12 ++ .../2026-10-14-assistant-workspace.md | 105 ++++++++++++++++++ .../plans/2026-10-14-assistant-workspace.md | 62 +++++++---- .../2026-10-14-assistant-workspace-design.md | 11 ++ 4 files changed, 168 insertions(+), 22 deletions(-) create mode 100644 docs/explorations/2026-10-14-assistant-workspace.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 206da67f..d4cab60e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,18 @@ notes. Entries describe what users experience, not internal refactors. ### Added +- **Start with a task for the document in front of you.** Assistant suggestions + follow the editor and selection. Choose a task to prepare an editable request, + then send. If the document scope changes, your draft is kept for you to confirm + again. +- **Read drafts and review changes more clearly.** Document drafts and incomplete + responses have distinct labels. Change reviews emphasize Apply and Cancel, + with editing, refinement, copying and location in More actions. + +- **See what the assistant is doing while you wait.** Request understanding, + generation and review show their actual stage. A compact elapsed-time hint + clears when content arrives or you stop, while your next draft stays editable. + - **Find earlier assistant conversations.** Search titles and messages locally, browse recent chats by date and see the current conversation at a glance. Compact row actions confirm which conversation will be deleted; history diff --git a/docs/explorations/2026-10-14-assistant-workspace.md b/docs/explorations/2026-10-14-assistant-workspace.md new file mode 100644 index 00000000..b4fe8ffa --- /dev/null +++ b/docs/explorations/2026-10-14-assistant-workspace.md @@ -0,0 +1,105 @@ +# Contextual assistant workspace + +## Product direction + +The assistant should help complete the current document task. A generic welcome, +undifferentiated replies and a row of equally prominent review buttons made the +next step harder to identify. This iteration improves task discovery, reading +and the existing review decision together. + +Contextual tasks stage an editable request. Explicit Send remains the execution +boundary, and Apply remains the document-change boundary. A host-selected task +can carry its known intent, avoiding a separate classification request when its +text, session and original editor scope still match. Arbitrary typed requests +continue through normal routing. + +Result identity follows host metadata: ordinary answers, document drafts and +interrupted output retain their separate meaning. Presentation labels never +become part of copied or applied source content. Review gives Apply and Cancel +priority, with Edit, Refine, Copy and Locate in an accessible disclosure. + +## Architecture and boundaries + +The panel owns submission, preparation, consent and controller/session state. +Task entry owns transient staging provenance; existing bounded context capture +and SDK event subscriptions remain authoritative. ChatView remains independent +of the editor. ActionPreview retains target validity, confirmation and truthful +sent/verified receipts. + +No provider default, persistent history format or editor mutation contract is +replaced. Local inference continues to use existing WebGPU support. Visual +feedback uses CSS/SVG and real application state; no simulated reasoning or +decorative GPU renderer is introduced. + +## Verification + +Specification and code-quality reviews approved each functional chunk. The +integration checks passed 5,448 unit tests in 187 files, `pnpm run lint:ts` +(including workspace and application TypeScript), and `pnpm run build`. + +Reverse regressions established that the tests detect missing task staging, +extra classification on trusted tasks, duplicate context capture, incorrect +Chinese writing intent, missing artifact identity and invisible keyboard focus. +Review tests fail against the earlier primary/secondary action layout. Progress +tests detect a wall-clock jump, a timer restarting after renderer-only clear, +and direct task answers retaining the planning phase. + +Browser acceptance covers the actual OnlyOffice editor with controlled provider +and speech events. It checks shared leading edges, compact controls, light and +dark themes, 320/432/520-pixel sidebars, narrow Chinese feedback, reduced motion, +keyboard focus, draft preservation, Stop/retry, review and native Undo. Progress +phase changes move the composer by at most one pixel; dictation checks stable +composer and message geometry. The clock is outside live regions so a screen +reader receives phase changes without per-second announcements. + +The initial combined run passed 34 cases; four failed while saving screenshots +because the disk was full. After scoped cleanup of ignored generated outputs, +all four reruns passed. The final combined Chromium run passed all 40 cases +with one worker, including Stop/resume, history, dictation, writing, context +events, layout and new process feedback. The repository format check passed; +35 final screenshots were preserved and inspected across the changed surfaces. + +Physical microphone behavior, manual screen-reader operation, high browser zoom +and model quality or first-token latency on the user's hardware are not verified +by these controlled checks. No measured model speedup is claimed. + +## Browser-local latency follow-up + +Source inspection establishes that ordinary non-native requests first await +`resolveRequestIntent` and then start the answer or document task. Trusted staged +tasks avoid that first inference. The ordinary chat controller already forwards +streamed text; its local preflight supplies no native tools, so the runtime's +tool-prose deferral does not explain this path's first-content delay. + +`captureRequestContext` attaches readable current-document data, capped at 8,000 +characters, even for ordinary questions. Writing transformations await a complete +JSON body and validation before publishing the review. These are concrete places +to measure classification time, input processing and first visible output. This +inspection does not establish the user's actual model, GPU/CPU backend, prompt +token count or latency. + +The requested process feedback identifies actual host phases and elapsed waiting; +it does not claim faster model computation or invent a reasoning transcript. +Prompt compression, selective context and validated streamed writing remain +separate performance interventions requiring quality and scope regressions. + +## Overall experience assessment + +The next action is clearer at each boundary: choose a document task, edit and +send its request, read the answer or draft, review the change, then apply and +undo if needed. Task selection does not execute automatically. Interrupted +content cannot acquire completed-draft authority. Existing history and voice +controls remain compact and preserve the editing surface while their state +changes. + +The largest remaining opportunity is time to useful content for ordinary +browser-local questions. Measure intent classification, document input +processing and answer generation separately before changing defaults. A scoped +read-only path could avoid repeated inference, but it must explicitly disable +mutation tools and preserve target, consent and stream validity checks; simply +routing document tasks as ordinary chat would lose those guarantees. + +A smaller recovery opportunity is an expired staged task after changing model +or document scope. The current behavior safely preserves the draft and requires +confirmation. A later explicit recovery action could retain that draft and make +reselection easier without silently retargeting or sending it. diff --git a/docs/superpowers/plans/2026-10-14-assistant-workspace.md b/docs/superpowers/plans/2026-10-14-assistant-workspace.md index b7e2786b..ff02f410 100644 --- a/docs/superpowers/plans/2026-10-14-assistant-workspace.md +++ b/docs/superpowers/plans/2026-10-14-assistant-workspace.md @@ -16,57 +16,75 @@ Files: new `lib/agent-plugin/ui/task-entry.ts`; integration in `panel.ts` and `panel-context.ts`; task-entry styles in `styles/assistant.css`; locale tables and `i18n/types.ts`; unit tests and `test/e2e/agent-workspace.spec.ts`. -- [ ] Write failing tests for format/selection suggestions, no automatic sends, +- [x] Write failing tests for format/selection suggestions, no automatic sends, draft preservation, expired scope and edited-request fallback. -- [ ] Run focused Vitest; verify behavioral failures before implementation. -- [ ] Build the contextual task entry using existing bounded context reads. +- [x] Run focused Vitest; verify behavioral failures before implementation. +- [x] Build the contextual task entry using existing bounded context reads. Stage `{ text, intent, target, session }` only on a user task click; consume after explicit Send and fresh scope checks. Preserve preparation/consent. -- [ ] Integrate known intent into routing without altering arbitrary typed +- [x] Integrate known intent into routing without altering arbitrary typed requests, native tools, literal commands, cancellation or refinement. -- [ ] Verify a ready staged writing task makes zero classification calls and +- [x] Verify a ready staged writing task makes zero classification calls and one writing call. Add real Word flow through native change and Undo. -- [ ] Commit as `feat(assistant): add contextual tasks and direct request routing`. +- [x] Commit as `feat(assistant): add contextual tasks and direct request routing`. ## Chunk 2: Conversation and result hierarchy Files: `packages/chat-ui/src/{chat-view,styles,types}.ts`; existing chat-view unit suites; relevant browser result/streaming tests. -- [ ] Write failing result tests: artifact identity, interrupted/restored +- [x] Write failing result tests: artifact identity, interrupted/restored semantics, original source for copy/apply, accessible actual activity. -- [ ] Run red tests, then improve typography, document-draft framing, compact +- [x] Run red tests, then improve typography, document-draft framing, compact actions and truthful streaming/activity states through optional labels. -- [ ] Preserve editor independence and all existing public method behavior. +- [x] Preserve editor independence and all existing public method behavior. Coordinate any new localized labels with the task-entry owner. -- [ ] Verify long Markdown/table/code, keyboard focus, draft editing, Stop and +- [x] Verify long Markdown/table/code, keyboard focus, draft editing, Stop and reader scroll position. Honor reduced motion and narrow layouts. -- [ ] Commit as `feat(chat-ui): clarify drafts and conversation results`. +- [x] Commit as `feat(chat-ui): clarify drafts and conversation results`. ## Chunk 3: Review and completion Files: `lib/agent-plugin/ui/action-preview.ts`, review styles only in `styles/assistant.css`, action-preview units and content-review E2E. -- [ ] Add failing tests for clear primary/secondary action hierarchy and +- [x] Add failing tests for clear primary/secondary action hierarchy and accessible secondary controls; retain all existing review behaviors. -- [ ] Improve target/comparison composition and action grouping using ranui. +- [x] Improve target/comparison composition and action grouping using ranui. Use actual review data and actual execution outcomes. Preserve expiration, cancellation, refinement, current-target checks and draft editing. -- [ ] Verify Apply/Cancel/Edit/Refine/Copy/Locate, selection invalidation, +- [x] Verify Apply/Cancel/Edit/Refine/Copy/Locate, selection invalidation, sent versus verified outcomes, error states and restored focus. -- [ ] Run native Word review/application/Undo E2E and screenshot validation. -- [ ] Commit as `feat(assistant): refine change review and completion`. +- [x] Run native Word review/application/Undo E2E and screenshot validation. +- [x] Commit as `feat(assistant): refine change review and completion`. -## Chunk 4: Integration and delivery +## Chunk 4: Immediate process feedback -- [ ] Spec compliance review, then code-quality review for each functional unit. +The user requested visible process while browser-local inference is waiting. +ChatView owns presentation and elapsed-time cleanup; the host supplies the +actual phase and translated labels. Existing provider and history contracts stay +unchanged. A classification phase is only shown when classification runs. + +- [x] Add failing tests for immediate phase feedback, measured elapsed waiting, + first streamed content, review resting state and Stop/disposal cleanup. +- [x] Add an explicit routing phase and localized labels. Keep elapsed text out + of repeated live announcements and preserve stable composer geometry. +- [x] Validate a held endpoint request before and after its first content delta, + Stop/retry and focus. Do not claim real-device model latency improvement. +- [x] Commit as `feat(assistant): show immediate request progress`. + +## Chunk 5: Integration and delivery + +- [x] Spec compliance review, then code-quality review for each functional unit. Fix verified findings and check final consumers/state owners. -- [ ] Integrate functional commits; keep root and subagent checkouts isolated. -- [ ] Inspect final light/dark/narrow screenshots and measure shared alignment +- [x] Integrate functional commits; keep root and subagent checkouts isolated. +- [x] Inspect final light/dark/narrow screenshots and measure shared alignment and stable composer geometry; record unverified acceptance surfaces. -- [ ] Update changelog and concise exploration record. Run reverse regressions. -- [ ] Run full unit suite, format, lint/typecheck and production build, then +- [x] Update changelog and concise exploration record. Run reverse regressions. +- [x] Run full unit suite, format, lint/typecheck and production build, then selected real-editor E2E including Stop, history and dictation regressions. - [ ] Fetch latest main, verify clean checkout and ahead/behind, push the feature branch, create/attach one new PR, and inspect checks for the pushed SHA. + +This checklist is committed before publication. The PR records the final branch +revision and subsequent CI status. diff --git a/docs/superpowers/specs/2026-10-14-assistant-workspace-design.md b/docs/superpowers/specs/2026-10-14-assistant-workspace-design.md index d59a5d16..f312d05e 100644 --- a/docs/superpowers/specs/2026-10-14-assistant-workspace-design.md +++ b/docs/superpowers/specs/2026-10-14-assistant-workspace-design.md @@ -23,6 +23,17 @@ Current code facts: No claim is made about actual model latency or answer quality without measurement. +### Follow-up: immediate process feedback + +The user reports long waits before browser-local answers and requests visible +process feedback. Show actual host phases immediately: request classification, +reading content and generating an answer. Keep human review a resting state. +Elapsed waiting time is measured locally, does not announce every tick to screen +readers, and ends on completion, Stop, failure, view disposal or a new turn. +Streaming text still appears as it arrives. Do not expose simulated reasoning, +invent percentages or claim that process visibility speeds up model computation. +Keep provider settings, inference and history contracts unchanged. + ## Experience 1. Open the assistant to see the current document scope and a compact set of