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
13 changes: 13 additions & 0 deletions docs/superpowers/plans/2026-10-16-ai-workspace-craft.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,16 @@ Standalone reference budgets: 20 MiB compressed input, 2,000 ZIP entries, 32 MiB
| Safe task Undo | 7 | Exact owned history-tail tests; later edit disables Undo with explanation |
| Google connection readiness | 8 | Unconfigured state and setup guide; real OAuth remains externally pending |
| Minimal visual hierarchy | 2, 8 | Screenshots of narrow/light/dark/state matrix; single primary action, progressive details |

## Chunk 6: Live-preview composer proportions

The latest owner feedback continues the approved compact design. PR #274 merged during the walkthrough, so the density correction is delivered as a small follow-up branch based on the updated main. The transplanted source tree is identical to the locally verified tree.

- [x] Walk through the deployed preview: enable/open, draft/navigation, optional connector failure, structured artifact import/edit/save version; inspect desktop and narrow composition.
- [x] Add browser geometry assertions for matching 32-pixel desktop send/stop/source/voice lanes, single focus border and no overflow. Exercise a docked narrow panel and small windows; retain coarse-pointer 44-pixel targets. Run before styling to demonstrate the regression.
- [x] In `styles/assistant.css`, consolidate host composer geometry: 12-pixel token inset, 32-pixel execution controls, consistent toolbar labels/lanes, no narrow-panel enlargement, single neutral focused boundary. At widths up to 360 pixels use 4-pixel toolbar gaps and public select inset variables so a busy state never reduces the language label to an empty chevron. Keep existing coarse-pointer override and keyboard affordances.
- [x] Run composer, queue, result hierarchy, sidebar/settings, source/artifact and error recovery browser suites, lint/types/format; inspect real screenshots. Prepare the follow-up delivery; retain the remote preview/CI/review gates below.

Local evidence at code commit `6e22dad`: the initial 12 browser scenarios passed, followed by 10 narrow-language/voice scenarios and a final 35-scenario integration run across ten UI suites. Full lint/types, format/1,511 immutable archive checks and production build passed. Both meaningful regressions were observed before their CSS fixes: desktop controls enlarged to 40 pixels, and a busy 320-pixel language label had zero available text width. Spec and quality reviews approved the final changes. Root inspected actual light/dark/short/busy screenshots.

Required remote gates: push the acceptance record, verify the new deployed preview, wait for all checks on the latest head and review the final increment. Record the authoritative result in the follow-up PR, preserving the distinction between direct preview navigation/artifact checks and controlled model/voice responses.
10 changes: 10 additions & 0 deletions docs/superpowers/specs/2026-10-16-ai-workspace-craft-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,13 @@ Adopt existing queue/review/provider/store owners and small explicit contracts.
Every listed outcome has focused red-green tests and browser verification in light/dark/narrow states. Preserve keyboard/IME, clipboard content, reference budgets, document freshness and explicit consent. Every error preserves the user's draft and offers an honest recovery. Inspect actual screenshots, not only computed CSS. Full coverage/type/lint/format/build and latest PR checks must pass; repeat overall review after CI. Report live OAuth as unverified until real deployment configuration exists, with actionable setup documentation.

Implementation budgets and the per-outcome acceptance map are normative in the companion plan’s “Binding limits and acceptance map” section. They bound structured storage, standalone extraction and capability-based fallbacks before implementation.

## Live-preview density correction

The owner requested another live walkthrough of PR #274 and identified the composer send button as visually oversized. Actual preview inspection at desktop sidebar and full-width narrow layouts confirmed that the host's narrow-container rule enlarges the execution controls from 36 to 40 pixels while adjacent microphone controls stay at 32 pixels. The focused composer also combines its border with an extra primary-colored shadow.

Continue the already approved compact direction: use the existing 32-pixel control token for desktop send, stop, source and voice toolbar lanes at all panel widths, keeping the existing 44-pixel coarse-pointer send/stop targets. At panel widths up to 360 pixels, reduce toolbar gaps and source horizontal padding to the existing 4-pixel token; use the select component's public padding/item/arrow variables to keep its selected language readable when Stop is present. Preserve normal-weight labels, dropdown ellipsis for longer languages and accessible full values. Use a single visible neutral focus border with no additional shadow, a 12-pixel content inset and the existing 8-pixel toolbar gap. Keep icons and copy readable, all names, keyboard focus, draft/IME behavior and request ownership intact. This is host styling only; shared ChatView contracts and runtime behavior do not change.

Alternatives considered: retaining 36/40 pixels leaves the toolbar mismatch; shrinking only the arrow leaves the heavy button silhouette. A shared desktop control size addresses the cause without adding menus or hiding actions.

Acceptance: real browser bounds at 320/360-pixel windows and a docked 320-pixel panel must show matching execution controls and no overflow. Coarse-pointer targets remain 44 pixels. Inspect light/dark, empty/one-line/multiline, busy/queued, focus and disabled states. Live preview checks can cover navigation, drafts, optional-connector feedback and artifact versions without credentials; generated responses and queues use controlled test providers, with that distinction stated explicitly.
48 changes: 30 additions & 18 deletions styles/assistant.css
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,8 @@ body.agent-panel-resizing #app iframe {
max-width: min(88%, 64ch);
}
.agent-panel .cui-composer:focus-within {
border-color: var(--ran-color-primary);
box-shadow: 0 0 0 1px var(--ran-color-primary);
border-color: var(--ran-color-text-secondary);
box-shadow: none;
}
.agent-panel .cui-message-actions .cui-apply {
min-height: var(--ran-size-7);
Expand Down Expand Up @@ -626,9 +626,7 @@ r-dropdown[role='listbox'] {
@media (max-width: 480px) {
.agent-panel-header button,
.agent-panel-quote,
.agent-panel-clear,
.agent-panel .cui-send,
.agent-panel .cui-stop {
.agent-panel-clear {
width: 44px;
height: 44px;
flex-shrink: 0;
Expand Down Expand Up @@ -968,7 +966,7 @@ r-dropdown[role='listbox'] {
border-color: var(--ran-color-border);
background: var(--ran-color-bg-elevated);
border-radius: var(--ran-radius-md);
padding: 13px;
padding: var(--ran-space-3);
}
.agent-panel .cui-msg-user .cui-bubble {
padding: 12px 16px;
Expand All @@ -978,17 +976,8 @@ r-dropdown[role='listbox'] {
.agent-panel .cui-send,
.agent-panel .cui-stop {
border-radius: var(--ran-radius-sm);
width: var(--ran-size-8, 36px);
height: var(--ran-size-8, 36px);
}
/* Both execution controls keep the same geometry at narrow panel widths,
including a docked panel in a wide editor window. */
@container agent-panel (max-width: 480px) {
.agent-panel .cui-send,
.agent-panel .cui-stop {
width: 40px;
height: 40px;
}
width: var(--ran-size-7);
height: var(--ran-size-7);
}
@media (pointer: coarse) {
.agent-panel .cui-send,
Expand Down Expand Up @@ -1399,7 +1388,7 @@ r-dropdown[role='listbox'] {
--ran-select-hover-border: 1px solid transparent;
--ran-select-height: 32px;
--ran-select-min-height: 32px;
--ran-select-font-size: var(--ran-text-label-2);
--ran-select-font-size: var(--ran-text-label-3);
--ran-select-color: var(--ran-color-text-secondary);
--ran-select-item-hover-color: var(--ran-color-text);
--ran-select-search-focus-color: var(--ran-color-text);
Expand Down Expand Up @@ -2158,6 +2147,11 @@ r-dropdown[role='listbox'] {
cursor: pointer;
text-align: start;
}
.agent-panel .cui-composer .agent-sources-toggle {
min-height: var(--ran-size-7);
padding: var(--ran-space-1) var(--ran-space-2);
font-size: var(--ran-text-label-3);
}
.agent-workspace-view button:hover,
.agent-artifacts button:hover,
.agent-sources-toggle:hover,
Expand Down Expand Up @@ -2251,6 +2245,24 @@ r-dropdown[role='listbox'] {
min-width: 0;
max-width: 96px;
}
/* A narrow toolbar keeps a real language label when Stop joins Send.
Use the select's public spacing hooks rather than losing its text lane. */
@container agent-panel (max-width: 360px) {
.agent-panel .cui-composer-bar,
.agent-panel .cui-actions {
gap: var(--ran-space-1);
}
.agent-panel .cui-composer .agent-sources-toggle {
padding-inline: var(--ran-space-1);
}
.agent-speech-language {
--ran-select-padding: 0 var(--ran-space-1);
--ran-select-item-left: 0;
--ran-select-item-right: var(--ran-size-1);
--ran-select-icon-inset-inline-end: 0;
--ran-select-font-weight: var(--ran-text-copy-weight);
}
}
.agent-sources-view [hidden] {
display: none;
}
Expand Down
91 changes: 89 additions & 2 deletions test/e2e/agent-composer-ux.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,31 +213,118 @@ for (const ext of ['docx', 'xlsx', 'pptx', 'pdf'] as const) {
});
}

for (const width of [360, 1280]) {
for (const width of [320, 360, 1280]) {
test(`composer stays compact and grows with multiline drafts at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
await page.addInitScript(() => {
localStorage.setItem('document-assistant-enabled', 'true');
localStorage.setItem('agent-local-preset', 'Qwen3-4B-q4f16_1-MLC');
localStorage.setItem(
'agent-writing-endpoint',
JSON.stringify({
version: 1,
kind: 'loopback',
baseUrl: 'http://localhost:11434',
model: 'density-test',
preference: 'device-first',
localWritingConsent: false,
}),
);
});
await page.route('http://localhost:11434/api/**', (route) =>
route.fulfill({ json: { models: [{ name: 'density-test' }] } }),
);
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();
const input = page.locator('.cui-input');
const composer = page.locator('.cui-composer');
await expect(input).toBeVisible();
const send = page.locator('.cui-send');
await input.fill('A draft before reconnecting');
await expect(send).toHaveAccessibleName('Reconnect');
// Preparation stays a readable text action rather than a clipped icon square.
expect((await send.boundingBox())!.width).toBeGreaterThan(32);
expect(await send.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThan(2);
await send.click();
await expect(send).toHaveAccessibleName('Send');
await expect(input).toHaveValue('A draft before reconnecting');
await input.fill('');
await expect(send).toBeDisabled();
const controls = [send, page.locator('.agent-sources-toggle'), page.locator('.agent-speech-mic')];
const bounds = await Promise.all(controls.map((control) => control.boundingBox()));
for (const box of bounds) expect(box!.height).toBe(32);
expect(bounds[0]!.width).toBe(32);
const centers = bounds.map((box) => box!.y + box!.height / 2);
expect(Math.max(...centers) - Math.min(...centers)).toBeLessThanOrEqual(1);
await expect(page.locator('.agent-sources-toggle')).toHaveCSS('font-size', '12px');
await expect(page.locator('.agent-speech-language')).toHaveCSS('--ran-select-font-size', '12px');
await expect(composer).toHaveCSS('padding', '12px');
if (width === 1280) expect((await page.locator('.agent-runtime-panel').boundingBox())!.width).toBe(432);
const empty = (await composer.boundingBox())!.height;
await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath(`composer-empty-${width}.png`) });
expect(empty).toBeLessThan(115);
await input.focus();
const focus = await composer.evaluate((el) => {
const style = getComputedStyle(el);
return {
border: style.borderTopWidth,
color: style.borderTopColor,
shadow: style.boxShadow,
};
});
expect(focus.border).toBe('1px');
expect(focus.shadow).toBe('none');
// Resolve the semantic token through a temporary inherited color, including dark mode.
const neutral = await composer.evaluate((el) => {
const probe = document.createElement('span');
probe.style.color = 'var(--ran-color-text-secondary)';
el.append(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return color;
});
expect(focus.color).toBe(neutral);
await input.fill(Array.from({ length: 12 }, (_, i) => `第 ${i + 1} 行:保持原文的事实和数字。`).join('\n'));
expect((await input.boundingBox())!.height).toBeGreaterThan(80);
expect((await input.boundingBox())!.height).toBeLessThanOrEqual(160);
expect((await composer.boundingBox())!.height).toBeGreaterThan(empty + 40);
await page
.locator('.agent-runtime-panel')
.screenshot({ path: testInfo.outputPath(`composer-multiline-${width}.png`) });
await input.fill('Short draft');
await expect(send).toBeEnabled();
await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath(`composer-short-${width}.png`) });
await composer.screenshot({ path: testInfo.outputPath(`composer-only-${width}.png`) });
await page.evaluate(() => document.documentElement.setAttribute('data-ran-theme', 'dark'));
await expect(composer).toHaveCSS('box-shadow', 'none');
await page.locator('.agent-sources-toggle').focus();
expect(await page.locator('.agent-sources-toggle').evaluate((el) => getComputedStyle(el).outlineWidth)).toBe('2px');
await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath(`composer-dark-${width}.png`) });
await expect.poll(async () => (await composer.boundingBox())!.height).toBeLessThanOrEqual(empty + 1);
await input.fill('');
await expect.poll(async () => (await composer.boundingBox())!.height).toBeLessThanOrEqual(empty + 1);
expect(await composer.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThan(2);
await page
.locator('.agent-runtime-panel')
.screenshot({ path: testInfo.outputPath(`composer-compact-${width}.png`) });
});
}

test.describe('coarse pointer composer', () => {
test.use({ hasTouch: true });
test('retains a 44-pixel execution target on touch hardware', async ({ page }) => {
await page.setViewportSize({ width: 360, height: 900 });
await page.addInitScript(() => {
localStorage.setItem('document-assistant-enabled', 'true');
localStorage.setItem('agent-local-preset', 'Qwen3-4B-q4f16_1-MLC');
});
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();
expect(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(true);
const send = page.locator('.cui-send');
await expect(send).toBeVisible();
expect((await send.boundingBox())!.width).toBe(44);
expect((await send.boundingBox())!.height).toBe(44);
});
});
40 changes: 40 additions & 0 deletions test/e2e/agent-request-queue.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,44 @@ for (const ext of ['docx', 'pptx'] as const) {
const stop = page.locator('.cui-send-stop');
const sendSize = await send.boundingBox();
const stopSize = await stop.boundingBox();
expect(sendSize?.width).toBe(32);
expect(sendSize?.height).toBe(32);
expect(sendSize?.width).toBe(stopSize?.width);
expect(sendSize?.height).toBe(stopSize?.height);
expect(sendSize!.y).toBe(stopSize!.y);
const toolbarBounds = await page.locator('.agent-speech-mic').boundingBox();
expect(toolbarBounds!.height).toBe(32);
expect(toolbarBounds!.y).toBe(sendSize!.y);
expect(await page.locator('.cui-composer').evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThan(2);
const language = page.locator('.agent-speech-language');
const languageMetrics = () =>
language.evaluate((el) => {
const root = el.shadowRoot ?? (el as HTMLElement & { _shadowDom: ShadowRoot })._shadowDom;
const label = root.querySelector('[part="selection-item"]')!;
const range = document.createRange();
range.selectNodeContents(label);
return {
width: label.getBoundingClientRect().width,
textWidth: range.getBoundingClientRect().width,
text: label.textContent,
scroll: label.scrollWidth,
client: label.clientWidth,
};
});
const english = await languageMetrics();
expect(english.text).toBe('English');
expect(english.width).toBeGreaterThanOrEqual(english.textWidth);
// Longer locale names may truncate; the label must retain a readable lane.
await language.evaluate((el) => {
(el as HTMLElement & { value: string }).value = 'pt-BR';
});
const longer = await languageMetrics();
expect(longer.text).toBe('Português');
expect(longer.width).toBeGreaterThanOrEqual(32);
expect(longer.scroll).toBeGreaterThan(longer.client);
await language.evaluate((el) => {
(el as HTMLElement & { value: string }).value = 'en-US';
});
await expect(page.locator('.agent-panel-title')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
await expect(page.locator('.cui-progress')).toHaveCSS('border-top-width', '0px');
await expect(page.locator('.cui-progress')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
Expand All @@ -90,6 +126,9 @@ for (const ext of ['docx', 'pptx'] as const) {
await page
.locator('.agent-runtime-panel')
.screenshot({ path: testInfo.outputPath(`queue-${ext}-collapsed.png`) });
await page
.locator('.cui-footer')
.screenshot({ path: testInfo.outputPath(`queue-${ext}-320-composer-light.png`) });
await page.locator('.agent-queue-toggle').click();
await page.locator('.agent-queue-more').first().click();
await page
Expand All @@ -101,6 +140,7 @@ for (const ext of ['docx', 'pptx'] as const) {
.poll(() => page.locator('.cui-composer').evaluate((el) => el.scrollWidth - el.clientWidth))
.toBeLessThan(2);
await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath(`queue-${ext}-320-dark.png`) });
await page.locator('.cui-footer').screenshot({ path: testInfo.outputPath(`queue-${ext}-320-composer-dark.png`) });
if (ext === 'docx') {
await page.evaluate(() => {
const win = window.__ooFrames.readyEditor() as unknown as {
Expand Down
Loading