Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ notes. Entries describe what users experience, not internal refactors.

### Fixed

- Stopping a browser-local assistant response no longer prevents the next question
from being answered.
- Cancelling a save keeps the unsaved-changes warning and recovery status intact.
- Exporting another format, such as PDF, preserves the original document file
and its ordinary save target, including when the export is cancelled or fails.
Expand All @@ -18,6 +20,11 @@ notes. Entries describe what users experience, not internal refactors.

### Added

- **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
saving and data tools stay available in a collapsed section.

- **Keep document structure when writing an assistant draft.** Word headings,
lists and emphasis remain formatted; long slide content continues on fitted
pages without dropping text. Completed changes keep location and Undo guidance.
Expand Down Expand Up @@ -73,6 +80,8 @@ notes. Entries describe what users experience, not internal refactors.

### Changed

- Voice controls stay in place while recording or finishing. Explanations appear
without moving the draft, and cancelling preserves the text typed before dictation.
- **Configured assistants reopen in chat.** Enable a downloaded model or reconnect
your service without losing your draft. Preparation never sends the draft.
Speech language choices open directly, and model settings use one consistent
Expand Down
80 changes: 80 additions & 0 deletions docs/explorations/2026-10-14-assistant-conversation-browser.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Assistant conversation browsing

## Task and direction

The conversation view used a native select followed by a large fieldset of
storage and deletion buttons. Picking an earlier chat was visually secondary
to data administration. The sidebar now gives its main space to finding and
reopening conversations.

The design uses a restrained list with one shared text spine, local search,
recent date groups, timestamps and a textual current-conversation marker.
Deletion is a compact trailing action, available without hover. Storage tools
are a native disclosure below the list. The existing header continues to own
new-conversation creation and navigation.

References checked:

- [ChatGPT search](https://help.openai.com/en/articles/10056348-finding-your-chats-projects-and-files-in-chatgpt)
documents matching conversation titles and messages and reopening a result.
- [Claude conversation management](https://support.claude.com/en/articles/8230524-delete-or-rename-a-conversation)
documents row-scoped management and a second explicit deletion action.

This borrows the interaction hierarchy and retains this project's browser-local
storage model. It does not add account synchronization, archiving or another
persistence format.

## Ownership and safeguards

`history-list.ts` renders the existing conversation store and emits select or
request-delete events. `history-controls.ts` continues to own opt-in, export,
legacy import, restore and deletion confirmation. The panel owns switching,
controller invalidation and draft preservation. Search reads local message
content only; it performs no network requests.

A row deletion names its target before confirmation, including a non-current
conversation. Cancel returns focus to that row. After deleting the focused row,
focus returns to search. Escape cancels the topmost deletion decision first;
a later Escape returns to chat. Pending deletion cannot be cancelled after its
storage operation has started. Existing storage failure and revision-conflict
handling remain in place. A storage failure automatically opens the data
disclosure so its error remains visible even when a row deletion closes its
confirmation. Chinese and Korean confirmation copy names this conversation,
not the current conversation.

## Verification

- Focused Vitest coverage includes the list, data controls, panel, model loading,
writing replies, sessions and all locale tables.
- Regression proof: the old layout failed assertions for the searchable list
and collapsed management disclosure. Temporarily disabling query filtering
made the title/message search test fail (two results instead of one).
- Collapsed-disclosure row deletion tests reproduce both revision conflicts
and storage failures; they failed before error reporting opened the disclosure.
Focus returns to search when the deleted trigger has been removed.
- The row-target confirmation test failed without the target's title. Escape
cancellation failed before the local confirmation key handler was added.
- The real-editor Playwright path restores 400 messages, searches message text,
verifies an empty result, switches both conversations, cancels and confirms
deletion of the inactive conversation, preserves the draft and verifies focus.
- Geometry assertions check that the sidebar title, history heading and list
title share the same leading edge, and that a 360px viewport has no horizontal
overflow. Light, dark and narrow-view screenshots were inspected.
- Lint and workspace/application TypeScript validation pass.

## Design and accessibility review

Task, hierarchy and scope: checked against the rendered editor panel and the
select/delete flows. Geometry, tokens and typography: checked with layout
assertions and screenshots; existing ranui tokens provide theme, sizes, spacing
and color. Keyboard, focus, accessible names, empty state and destructive
feedback: covered by the component and editor tests. The current marker is text
as well as a row fill. Pointer targets remain 32px and metadata uses 12px type.

Storage busy, failure and opt-in states are covered by existing controls tests.
Seven locale tables remain complete; every language was not separately visually
reviewed. Screen-reader output and high browser zoom were not manually audited.
Motion is not introduced. Offline behavior uses the existing local store and
has no new transport. GPUI-native window, action and component APIs do not apply
to this browser UI; the repository's ranui and web interaction conventions take
precedence.
47 changes: 47 additions & 0 deletions docs/explorations/2026-10-14-assistant-stop-resume.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Asking again after stopping a local assistant

## Scope

Repair the WebLLM cancellation-to-next-request path. Preserve conversation
storage, explicit Send, the model queue, schema constraints, tool exchanges and
usage reporting. History and speech presentation are separate changes.

## Cause and decision

The installed WebLLM 0.2.85 engine keeps `interruptSignal` after
`interruptGenerate()`. Its non-streaming `chatCompletion()` checks that flag
before calling `_generate()`, which would normally clear it. Consequently,
stopping an answer or routing request makes subsequent non-streaming JSON
routing return empty content. The panel reports a generic request failure.

Use the SDK streaming entry for every WebLLM inference, including callers that
only need a complete `chat()` or `generateJSON()` result. That entry clears the
interrupt flag before generation. The provider accumulates the response and
keeps consuming the interrupted stream tail to release the worker model lock;
it does not expose internal JSON deltas to chat callers or replay a cancelled
request. This uses public SDK APIs rather than changing its private flag.

Hermes tool streams supply `index` without `id`, whereas the non-streaming SDK
supplies numeric string IDs. The WebLLM adapter fills missing IDs from the index
and keeps explicit IDs across later argument fragments. Generic OpenAI stream
conversion is unchanged.

## Evidence

- `webllm-stop-resume.test.ts` runs the installed SDK's real `chatCompletion`,
`asyncGenerate` and `interruptGenerate`. Only the GPU pipeline boundary is
synthetic. Stopping answer and routing generation is followed by two complete
JSON-routing/chat turns.
- Reverse verification: restoring the original provider makes both cases fail
with `Unexpected end of JSON input`; restoring the fix passes both.
- SDK-shaped dual-tool regression fails before ID adaptation (`['', '']`
instead of `['0', '1']`) and passes afterwards. Explicit IDs remain intact.
- `agent-stop-resume.spec.ts` runs the real Word and PowerPoint editor panels
with controlled loopback responses. Four cases stop during routing or an
answer, preserve the draft, then receive two fresh replies without reload.
Cancelled late content does not appear and no error message is shown.

These checks validate SDK lifecycle and application interaction separately.
They do not measure physical WebGPU inference, GPU memory loss, actual browser
worker latency or microphone transcription quality. The CPU provider's existing
runtime retirement and explicit reload policy is unchanged.
59 changes: 59 additions & 0 deletions docs/explorations/2026-10-14-speech-toolbar-stability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Speech toolbar stability

## Observed failure

The first microphone click changed the real DOCX composer's height from 134px
(idle) to 180.375px (listening). The privacy paragraph was inserted into the
composer flow, status padding changed between states, and cancellation was
added and removed from the toolbar. The conversation pane consequently shrank,
which explains the large apparent flash. Rendering also replaced the microphone
and cancellation SVG nodes on every state update.

## Change

Keep microphone, language, live state, cancellation and privacy in one compact
compose toolbar. Reserve the status and cancellation lanes in idle state, retain
SVG identity, and limit animation to the actual permission/finalization spinner.
Listening has a steady selected stop button. Privacy and full error details use the existing ranui `r-popover` with its
body-level portal, so opening them does not resize the draft. Passive disclosures
close after three seconds; pointer hover or keyboard focus pauses this timer.
Escape dismisses the disclosure before cancelling dictation. Recording and
finalization status remain visible, and errors remain inline after their popover
closes. The info control can reopen the full current error.

The interaction follows the compact voice button and processing-state treatment
in [AI Elements Speech Input](https://elements.ai-sdk.dev/components/speech-input).
The application retains its editable interim transcript, recognition-language
selector, cancellation policy and existing ranui tokens.

## Verification

- Red-before-green unit regression: status lived outside the toolbar and icons
were remounted. It now preserves toolbar containment and both SVG nodes.
- Red-before-green real DOCX regression: composer height differed by 46.375px
when listening. Starting, listening and finishing now preserve its bounds.
- Real DOCX, XLSX, PPTX and PDF editor E2E: unchanged composer/message-pane
geometry, Stop followed by Send waits for the final transcript, a single
request is sent, cancellation restores a 12-line draft, and disclosure
keyboard opening/dismissal preserves geometry and returns focus.
- DOCX at the minimum 320px panel width: no toolbar overflow or state movement;
reduced-motion preference disables spinner animation. A further failing
assertion caught a truncated listening label at this width: active status now
uses the reserved language lane, and the disabled selector returns on idle.
The full listening label fits without moving microphone or trailing controls.
Persistent error feedback uses the same lane so it does not collapse into a
one-letter fragment.
- Speech unit suite: 25 passing; focused formatting/lint and TypeScript checking
passed. Existing bounded two-second finalization, late-event isolation,
manual-edit preservation and recognition-error tests remain covered.
- Real portal hover testing caught a stacking mismatch: the default dropdown
layer was behind the assistant panel. The speech popover now shares the
existing application select-portal layer; no vendor code was modified.
- Real browser privacy tests cover passive expiry, keyboard-focus retention,
portalled-content hover retention, Escape and outside-click dismissal. Error
text remains accessible and can be reopened after passive popover expiry.

Browser speech events and the AI endpoint are controlled in E2E. Editor frames,
composer components and browser layout are real. Physical microphone recognition,
external browser speech-service latency and operating-system permission prompts
were not verified by these tests.
31 changes: 25 additions & 6 deletions lib/agent-plugin/ui/history-controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface HistoryControlsOptions {
store: ReturnType<typeof createConversationStore>;
onRestore(): void;
onBeforeRestore?(): void;
onFocusAfterDeletion?(): void;
}

/** Local-only download. The export contains conversation data, never provider settings. */
Expand All @@ -21,9 +22,11 @@ export function downloadConversations(json: string): void {

export function createHistoryControls(options: HistoryControlsOptions) {
const { store } = options;
const el = document.createElement('fieldset');
const el = document.createElement('div');
el.className = 'agent-history-options';
const legend = document.createElement('legend');
const details = document.createElement('details');
details.className = 'agent-history-management';
const legend = document.createElement('summary');
const label = document.createElement('label');
const toggle = document.createElement('input');
toggle.type = 'checkbox';
Expand Down Expand Up @@ -54,7 +57,8 @@ export function createHistoryControls(options: HistoryControlsOptions) {
const confirm = button('agent-history-confirm');
const cancel = button('agent-history-cancel');
confirmation.append(question, confirm, cancel);
el.append(legend, label, status, actions, confirmation);
details.append(legend, label, status, actions);
el.append(details, confirmation);
let busy = 0;
let requestedSaving: boolean | undefined;
let failure: keyof I18nMessages | undefined;
Expand All @@ -67,18 +71,22 @@ export function createHistoryControls(options: HistoryControlsOptions) {
for (const item of actions.querySelectorAll('button')) item.disabled = busy > 0;
confirm.disabled = busy > 0;
cancel.disabled = busy > 0;
legend.textContent = t('agentConversations');
legend.textContent = t('agentHistoryDataControls');
saveText.textContent = t('agentSaveHistory');
restore.textContent = t('agentRestoreHistory');
legacy.textContent = t('agentImportHistory');
exported.textContent = t('agentExportHistory');
remove.textContent = t('agentDeleteConversation');
clear.textContent = t('agentDeleteHistory');
const target = store.list().find((item) => item.id === deletion);
question.textContent = t(deletion === 'all' ? 'agentDeleteHistoryConfirm' : 'agentDeleteConversationConfirm');
if (target) question.textContent += ` ${target.title || t('agentNewConversation')}`;
confirm.textContent = t('agentHistoryConfirmDelete');
cancel.textContent = t('agentPlanCancel');
};
const showError = (error: unknown) => {
// Row actions live outside the disclosure, so failures must reveal its status.
details.open = true;
requestedSaving = undefined;
failure =
typeof error === 'object' && error !== null && 'name' in error && error.name === 'ConversationConflictError'
Expand Down Expand Up @@ -150,6 +158,7 @@ export function createHistoryControls(options: HistoryControlsOptions) {
confirmation.hidden = false;
sync();
cancel.focus();
confirmation.scrollIntoView?.({ block: 'nearest' });
};
remove.addEventListener('click', () => askDeletion(store.activeId, remove));
clear.addEventListener('click', () => askDeletion('all', clear));
Expand All @@ -159,6 +168,11 @@ export function createHistoryControls(options: HistoryControlsOptions) {
deletionTrigger?.focus();
deletionTrigger = undefined;
});
confirmation.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || event.isComposing) return;
event.stopPropagation();
if (!busy) cancel.click();
});
confirm.addEventListener('click', () => {
const target = deletion;
if (!target) return;
Expand All @@ -184,8 +198,10 @@ export function createHistoryControls(options: HistoryControlsOptions) {
finishDeletion();
}).then(() => {
if (restoreFocus && (document.activeElement === document.body || confirmation.contains(document.activeElement))) {
if (confirmation.hidden) trigger?.focus();
else confirm.focus();
if (confirmation.hidden) {
if (trigger?.isConnected) trigger.focus();
else options.onFocusAfterDeletion?.();
} else confirm.focus();
}
if (confirmation.hidden) deletionTrigger = undefined;
});
Expand All @@ -195,6 +211,9 @@ export function createHistoryControls(options: HistoryControlsOptions) {
el,
sync,
showError,
requestDelete: (id: string, trigger: HTMLButtonElement) => {
if (!busy && store.list().some((item) => item.id === id)) askDeletion(id, trigger);
},
start: async () => {
if (localStorageGetItem(SAVE_PREFERENCE) === 'true') {
requestedSaving = true;
Expand Down
Loading
Loading