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
2 changes: 1 addition & 1 deletion apps/desktop/e2e-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
},
"workhub-layout.spec.ts": {
"tests": 3,
"electron": "WorkHub model configuration and attachment sending cross the Host boundary; the Project New task action must dismiss the native dock owned by a separate WebContentsView; the default titlebar toggle controls the sibling WorkHub WebContentsView through Main IPC, and switching to edge mode restores the native-renderer toggle; the native Workbar menu must coexist with the live sibling WebContentsView without hiding its compositor; a delayed sidebar-preview capture keeps the sibling view live until its decoded backdrop can take over; the compact native window grows only with the inline composer wheel and keeps its bottom anchor; opening context details routes to Main’s Workbar without resizing or docking the floating conversation, while the thinking sheet stays within the compact bounds, and pointer dragging scrolls the no-drag wheel instead of moving the frameless window; native floating preserves macOS Dock visibility; a held-open Host Turn verifies immediate prompt visibility, durable transcript handoff and persistence after renderer reload"
"electron": "WorkHub model configuration and attachment sending cross the Host boundary; the Project New task action must dismiss the native dock owned by a separate WebContentsView; the default titlebar toggle controls the sibling WorkHub WebContentsView through Main IPC, and switching to edge mode restores the native-renderer toggle; the native Workbar menu must coexist with the live sibling WebContentsView without hiding its compositor; a delayed sidebar-preview capture keeps the sibling view live until its decoded backdrop can take over; model selection in the compact sheet must reach the Host without changing the native floating window bounds or bottom anchor; opening context details routes to Main’s Workbar without resizing or docking the floating conversation, while the thinking sheet stays within the compact bounds; native floating preserves macOS Dock visibility; a held-open Host Turn verifies immediate prompt visibility, durable transcript handoff and persistence after renderer reload"
},
"workhub-reconstruction.spec.ts": {
"tests": 1,
Expand Down
93 changes: 27 additions & 66 deletions apps/desktop/e2e/workhub-layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@
import { FAKE_HOLD_OPEN_PROMPT } from '@maka/runtime/test-only/fake-backend';
import { awaitSendReady, COMPOSER_INPUT, expect, test, getWorkHubPage } from './fixtures';

// The compact native window must contain the inline wheel and retain its
// bottom anchor; native dragging must scroll the no-drag
// wheel instead of moving the frameless window. A browser has neither boundary.
// Model changes cross the Host boundary; picker sheets must preserve the
// compact native window's bounds and bottom anchor.
test('WorkHub uses its coordination model and shared attachment composer', async ({ sessionLocalWindow: { page, app } }, testInfo) => {
await page.evaluate(async () => {
const { connections } = await window.maka.connections.getSnapshot();
Expand Down Expand Up @@ -324,77 +323,40 @@ test('WorkHub uses its coordination model and shared attachment composer', async
await workhub.screenshot({ animations: 'disabled', path: testInfo.outputPath('floating-composer-controls.png') });
const compactHeight = await workhub.evaluate(() => innerHeight);
const floatingBounds = () => app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().find((window) => window.getTitle() === 'WorkHub')!.getContentBounds());
const beforeModelPicker = await floatingBounds();
await model.click();
const wheel = workhub.getByRole('listbox');
await expect(wheel).toBeVisible();
await expect(wheel.getByRole('option')).toHaveCount(3);
const modelChoices = await workhub.evaluate(async (id) => (await window.maka.connections.getSnapshot(id)).chatModelChoices, sessionId);
await expect(wheel.getByRole('option').locator('.maka-model-wheel-label')).toHaveText(modelChoices.map((choice) => choice.label));
const modelSheet = workhub.getByRole('dialog');
await expect(modelSheet).toBeVisible();
await expect(workhub.locator('.workHubHistory')).toBeHidden();
await expect.poll(() => workhub.evaluate(() => innerHeight)).toBeGreaterThan(compactHeight);
await expect.poll(floatingBottom).toBe(anchoredBottom);
await expect.poll(() => workhub.evaluate(() => innerHeight)).toBeLessThanOrEqual(compactHeight + 132);
const expectWheelInsideWindow = () => expect.poll(() => wheel.evaluate((element) => {
await expect.poll(floatingBounds).toEqual(beforeModelPicker);
await expect.poll(() => modelSheet.evaluate((element) => {
const rect = element.getBoundingClientRect();
return rect.top >= 0 && rect.left >= 0 && rect.bottom <= innerHeight && rect.right <= innerWidth;
})).toBe(true);
await expectWheelInsideWindow();
await workhub.screenshot({ path: testInfo.outputPath('floating-composer-wheel.png') });
const expectSnappedSelection = async (index: number) => {
const choice = modelChoices[index]!;
const option = wheel.getByRole('option').nth(index);
await expect(option).toHaveAttribute('data-active', 'true');
await expect(option).toHaveAttribute('aria-selected', 'true');
await expect(wheel).toHaveAttribute('aria-busy', 'false');
return choice;
};
const initialIndex = await wheel.getByRole('option').evaluateAll((options) => options.findIndex((option) => option.getAttribute('aria-selected') === 'true'));
const scrollDirection = initialIndex < modelChoices.length - 1 ? 1 : -1;
await wheel.hover();
await workhub.mouse.wheel(0, scrollDirection * 30);
await expectSnappedSelection(initialIndex + scrollDirection);
await wheel.press('Escape');
await model.click();
await expect(wheel.getByRole('option')).toHaveCount(3);
await expect.poll(() => workhub.evaluate(() => innerHeight === Math.ceil(document.querySelector('.workHubComposerSurface')!.getBoundingClientRect().height))).toBe(true);
const dragInitialTop = await wheel.evaluate((element) => element.scrollTop);
const beforeDrag = await floatingBounds();
const dragDistance = dragInitialTop > 0 ? 32 : -32;
const wheelBounds = (await wheel.boundingBox())!;
const dragX = wheelBounds.x + wheelBounds.width / 2;
const dragY = wheelBounds.y + wheelBounds.height / 2;
await workhub.mouse.move(dragX, dragY);
await workhub.mouse.down();
await workhub.mouse.move(dragX, dragY + dragDistance, { steps: 8 });
await expect.poll(() => wheel.evaluate((element) => element.scrollTop)).toBe(dragInitialTop - dragDistance);
await workhub.mouse.up();
// A model label may resize; dragging the no-drag wheel must not move its window.
await expect.poll(floatingBounds).toEqual(beforeDrag);
await expect(wheel).toBeVisible();
const draggedIndex = Math.round((dragInitialTop - dragDistance + 44) / 44) % modelChoices.length;
await expectSnappedSelection(draggedIndex);
await wheel.press('ArrowDown');
await expectSnappedSelection((draggedIndex + 1) % modelChoices.length);
await wheel.press('Escape');
await expect(wheel).toHaveCount(0);
await expect(model).toBeFocused();
await model.click();
const options = wheel.getByRole('option');
const chooseLast = await options.first().getAttribute('aria-selected') === 'true';
await wheel.press(chooseLast ? 'End' : 'Home');
const selectedChoice = await expectSnappedSelection(chooseLast ? modelChoices.length - 1 : 0);
await expectWheelInsideWindow();
await wheel.press('Enter');
await expect(wheel).toHaveCount(0);
await expect(model).toBeFocused();
await model.click();
await expect(wheel.getByRole('option', { selected: true })).toContainText(selectedChoice.label);
await wheel.press('Escape');
const selectedChoice = await workhub.evaluate(async (id) => {
const session = await window.maka.workHub.getSession(id);
const { chatModelChoices } = await window.maka.connections.getSnapshot(id);
return chatModelChoices.find((choice) => choice.model !== session.model)!;
}, sessionId);
await modelSheet.getByRole('option', { name: selectedChoice.label, exact: true }).click();
await expect.poll(async () => workhub.evaluate(async (id) => {
const session = await window.maka.workHub.getSession(id);
return { model: session.model, connectionId: session.llmConnectionId };
}, sessionId)).toEqual({ model: selectedChoice.model, connectionId: selectedChoice.connectionId });
await expect(modelSheet).toHaveCount(0);
await expect.poll(() => workhub.evaluate(() => innerHeight)).toBe(compactHeight);
await expect(editor).toHaveText('Keep this draft while folding the conversation.');
const longDraft = Array.from({ length: 30 }, (_, index) => `第 ${index + 1} 行:长输入应当只在编辑区内滚动。`).join('\n');
await editor.click();
await expect(editor).toBeFocused();
await editor.fill(longDraft);
await expect.poll(() => editor.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
await expect.poll(() => editor.evaluate((element) => ({
text: element.textContent,
overflow: element.scrollHeight > element.clientHeight,
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
}))).toMatchObject({ text: longDraft, overflow: true });
await expect.poll(() => editor.evaluate((element) => element.clientHeight)).toBeLessThanOrEqual(132);
await expect(workhub.locator('[data-chat-scroll-container]')).toHaveCSS('overflow-y', 'hidden');
await expect.poll(floatingBottom).toBe(anchoredBottom);
Expand Down Expand Up @@ -445,8 +407,7 @@ test('WorkHub uses its coordination model and shared attachment composer', async
await expect.poll(floatingBottom).toBe(anchoredBottom);
await expect.poll(() => workhub.locator('[data-chat-scroll-container]').evaluate((element) => element.scrollTop)).toBe(scrollTop);
await expect(editor).toHaveText('Keep this draft while folding the conversation.');
// Expanded again, the wheel gives way to the composer's popover options menu.
await expect(model).toHaveAttribute('aria-haspopup', 'menu');
await expect(model).toHaveAttribute('aria-haspopup', 'listbox');
});


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,17 +202,11 @@ test('recovery CTA opens the production Composer model picker', async () => {
});
await act(() => flow.action.dispatchEvent(new flow.window.Event('click', { bubbles: true })));
assert.equal(
flow.document.querySelector('.maka-composer-options-trigger')?.getAttribute('aria-expanded'),
flow.document.querySelector('.maka-model-switcher-trigger button')?.getAttribute('aria-expanded'),
'true',
'the recovery action opens the composer model menu',
);
// Astryx has no controlled open for DropdownMenuSubMenu, so the model list is
// one step further: its row in the open menu.
const modelRow = [...flow.document.querySelectorAll<HTMLElement>('[role="menuitem"][aria-haspopup="menu"]')]
.find((row) => row.textContent?.includes('Model'));
assert.ok(modelRow, 'the open menu offers the model list');
await act(() => modelRow.dispatchEvent(new flow.window.Event('click', { bubbles: true })));
const options = [...flow.document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')];
const options = [...flow.document.querySelectorAll<HTMLElement>('[role="option"]')];
assert.equal(options.length, 1, 'only the available exact account-and-model choice is offered');
assert.equal(options[0]?.textContent?.includes(CHOICE.label), true);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -374,8 +374,7 @@ export function WorkHubRoot() {
activeModelConnectionId={newWorkNativeModel?.llmConnectionId}
activeModelConnectionSlug={newWorkNativeModel?.llmConnectionSlug}
modelChoices={controller.choices}
pickerPresentation={showConversation ? 'popover' : 'wheel'}
modelSelectionPurpose="session"
pickerPresentation={showConversation ? 'popover' : 'bottom-sheet'}
pickersReadOnly={Boolean(controller.activeQuestion || controller.activeForm || controller.configuringModel)}
maxInputRows={progress && !editingProgress ? 1 : showConversation ? undefined : 6}
onModelChange={controller.changeModel}
Expand Down
3 changes: 0 additions & 3 deletions apps/desktop/src/renderer/maka-tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -616,9 +616,6 @@
away. Where a box IS an Astryx component the fix is still its `size` prop
and not a height here — `.maka-sidebar-update-button` and the three
composer selectors were moved that way rather than pinned.
`.maka-composer-model-chip` is NOT one of those: that class sits on
`ModelChipStatic`'s inert `<span>`, never on the Button it renders when
clickable, so it pins like any other chip.

A consequence worth stating, because it limits what this convergence
bought: a unitless leading still inherits as a ratio. An element that
Expand Down
76 changes: 0 additions & 76 deletions apps/desktop/src/renderer/styles/composer.css
Original file line number Diff line number Diff line change
Expand Up @@ -394,40 +394,6 @@
max-width: min(320px, 92vw);
}

/* Model chip (static fallback) still owns its own quiet geometry. */
.maka-composer-model-chip {
font: var(--maka-text-label);
/* #1879: was a bare 26px. It is NOT an Astryx component — `ModelChipStatic`
puts this class only on its inert `<span>` branch; the `onOpenSettings`
branch renders a `Button size="sm"` that carries no class at all
(chat-model-switcher.tsx). So nothing sizes this box but this rule, and
unpinning it measured 22px (the label role's 20px line box plus two
hairlines) against the 28px Selector beside it. `--h-control-md` resolves
to `--size-element-sm`, which is the token that Selector's own `size="sm"`
reads — so the two agree by deriving from one token rather than by two
product rules agreeing on a literal. */
height: var(--h-control-md);
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
padding: 0 var(--space-2);
border: var(--border-width-hairline) solid transparent;
border-radius: var(--radius-pill);
background: transparent;
color: var(--muted-foreground);
white-space: nowrap;
max-width: min(180px, 28vw);
transition:
background-color var(--duration-base) var(--ease-out-strong),
color var(--duration-base) var(--ease-out-strong);
}
.maka-composer-model-chip:hover {
background: var(--state-hover-bg);
color: var(--foreground);
border-color: transparent;
}

/* Mode ON marks sit at the tail of the footer's left controls (#1897) —
session state, not context staged for the next send, so they no longer live
in the drawer.
Expand Down Expand Up @@ -474,33 +440,6 @@
justify-content: flex-start;
text-align: left;
}
.maka-composer-left-controls .maka-composer-options-trigger {
/* local: one trigger now carries model, context and effort, so it gets the
model + thinking pair's former combined share instead of the model cap. */
max-width: min(280px, 40vw);
}
.maka-composer-options-label {
display: flex;
align-items: baseline;
gap: var(--space-1-5);
min-width: 0;
}
.maka-composer-options-details {
flex: 0 0 auto;
color: var(--muted-foreground);
white-space: nowrap;
}
.maka-composer-options-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-6);
/* local: keeps a short row name and its value apart in a narrow flyout. */
min-width: 9rem;
}
.maka-composer-options-value {
color: var(--muted-foreground);
}
.maka-composer-model-label {
display: block;
min-width: 0;
Expand Down Expand Up @@ -541,21 +480,6 @@
max-width: 100%;
flex: 0 1 auto;
}
.maka-composer-model-chip-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.maka-composer-model-status {
width: 6px;
height: 6px;
border-radius: var(--radius-pill);
background: var(--destructive);
flex: 0 0 auto;
}

/* The active transcript and docked card meet at one boundary. Keeping the
outer composer's default top padding here exposes an extra strip of panel
background after the transcript viewport has already ended. */
Expand Down
Loading