Skip to content
Open
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
15 changes: 15 additions & 0 deletions apps/desktop/src/renderer/styles/composer.css
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@
max-width: var(--maka-reading-measure);
}

.maka-composer-editor {
--maka-editor-padding-block: var(--space-1);
}

/* An empty scrolling contenteditable can lose its line box when shown under
an inert session-switching parent (#5264). Astryx's outer minimum does not
protect the caret inside it. Keep one line plus the actual block padding,
including compact WorkHub's override, without fixing the multiline height.
Match only the editor, not nested contenteditable=false reference tokens;
keep the minimum while the editor itself is disabled, too. */
.maka-composer-editor > [contenteditable] {
padding-block: var(--maka-editor-padding-block);
min-height: calc(1lh + 2 * var(--maka-editor-padding-block));
}

.maka-return-workhub {
--_button-radius: var(--radius-chat);
display: flex;
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/renderer/styles/workhub.css
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,8 @@
.workHubLive[data-conversation-expanded='false'] .maka-chat-layout { overflow: hidden; scrollbar-width: none; }
.workHubLive[data-conversation-expanded='false'] .maka-chat-layout > div:last-child > div:has(> div > .workHubComposerSurface) { padding: 0; }
.workHubLive[data-conversation-expanded='false'] .maka-composer { --shadow-low: none; --shadow-med: none; }
.workHubLive[data-conversation-expanded='false'] .maka-composer-editor { padding-right: 24px; }
.workHubLive[data-conversation-expanded='false'] .maka-composer-editor [contenteditable='true'] { padding-block: 6px; overscroll-behavior-y: contain; scrollbar-gutter: stable; scrollbar-width: thin; scroll-padding-block: 6px; }
.workHubLive[data-conversation-expanded='false'] .maka-composer-editor { --maka-editor-padding-block: var(--space-1-5); padding-right: 24px; }
.workHubLive[data-conversation-expanded='false'] .maka-composer-editor > [contenteditable] { overscroll-behavior-y: contain; scrollbar-gutter: stable; scrollbar-width: thin; scroll-padding-block: var(--maka-editor-padding-block); }
.workHubLive[data-conversation-expanded='false'] .workHubComposerSurface { position: relative; -webkit-app-region: drag; }
.workHubLive[data-conversation-expanded='false'] .workHubComposerSurface :is(button, input, textarea, [contenteditable='true'], [role='listbox'], [role='option'], a) { -webkit-app-region: no-drag; }
.workHubExpandButton { position: absolute; top: 6px; right: 8px; z-index: 2; color: var(--muted-foreground); -webkit-app-region: no-drag; }
Expand Down
96 changes: 94 additions & 2 deletions apps/desktop/stories/app-shell.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,9 @@ function ComposedShell(props: {
session?: (Omit<Partial<SessionSummary>, 'id'> & { streaming?: boolean }) | null;
chat?: Partial<ChatViewProps>;
composer?: Partial<ComposerProps>;
/** The mainColumn interaction gate and ChatSurfaceLayout visibility in app-shell.tsx. */
switchingSession?: boolean;
chatHidden?: boolean;
detailChildren?: ReactNode;
motionEnabled?: boolean;
/**
Expand Down Expand Up @@ -484,8 +487,9 @@ function ComposedShell(props: {
// the chat column (app-shell.tsx). `.mainColumn` owns composer
// padding, so a story without it measures its own box.
(<div className="maka-detail-with-artifacts">
<div className="mainColumn">
<div className="mainColumn" inert={props.switchingSession || undefined}>
<ChatSurfaceLayout
hidden={props.chatHidden}
composer={
<Composer
{...baseComposerProps}
Expand Down Expand Up @@ -1225,8 +1229,96 @@ export const WaitingForPermission: Story = {
// half and it is not the same screen — see NewChatComposer below — so this
// story is the one where the composer still binds to a session.
export const EmptyHome: Story = {
render: () => <ComposedShell chat={{ messages: [] }} />,
render: () => <EmptyComposerLifecycle />,
play: async ({ canvasElement }) => {
const editor = canvasElement.querySelector<HTMLElement>('.maka-composer-editor > [contenteditable]')!;
const assertLineBox = () => {
const style = getComputedStyle(editor);
const required = Number.parseFloat(style.lineHeight)
+ Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom);
// The editable itself must contain a line, not just the placeholder or
// outer wrapper. Before #5264 this becomes 8px: padding with no line box.
expect(editor.clientHeight).toBeGreaterThanOrEqual(required);
expect(canvasElement.querySelector('.maka-composer-editor > [contenteditable]')).toBe(editor);
};
const transition = async (next: Partial<EmptyComposerState>) => {
expect(setEmptyComposerState).toBeDefined();
setEmptyComposerState!(next);
// Render each hide/inert boundary; collapsing these into one React
// commit would skip the browser layout reconstruction being tested.
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
};
await waitFor(assertLineBox);

// Reduced WorkHub/sidebar lifecycle: the frame returns while session
// switching still makes its parent inert. Keep the same empty editor DOM.
for (const draftKey of ['session:caret-b', 'session:caret-a']) {
await transition({ switchingSession: true });
await transition({ chatHidden: true });
expect(editor.getClientRects()).toHaveLength(0);
await transition({ chatHidden: false, draftKey });
expect(editor.closest('[inert]')).not.toBeNull();
assertLineBox();
await transition({ switchingSession: false });
expect(editor.closest('[inert]')).toBeNull();
assertLineBox();
}

await userEvent.click(editor);
await userEvent.keyboard('one{Shift>}{Enter}{/Shift}two{Shift>}{Enter}{/Shift}three');
const multilineHeight = editor.clientHeight;
expect(multilineHeight).toBeGreaterThan(2 * Number.parseFloat(getComputedStyle(editor).lineHeight));
await transition({ draftKey: 'session:caret-b' });
await waitFor(() => expect(editor.textContent).toBe(''));
assertLineBox();
await transition({ draftKey: 'session:caret-a' });
await waitFor(() => expect(editor).toHaveTextContent('three'));
expect(editor.clientHeight).toBe(multilineHeight);
await userEvent.clear(editor);
await waitFor(() => expect(editor.textContent).toBe(''));
assertLineBox();
await transition({ disabled: true });
expect(editor).toHaveAttribute('contenteditable', 'false');
assertLineBox();
await transition({ disabled: false });
assertLineBox();

// A minimum must not turn into a fixed height or defeat the existing cap.
await userEvent.click(editor);
for (let line = 0; line < 12; line += 1) {
await userEvent.keyboard(`${line === 0 ? '' : '{Shift>}{Enter}{/Shift}'}line`);
}
expect(editor.clientHeight).toBe(Number.parseFloat(getComputedStyle(editor).maxHeight));
expect(editor.scrollHeight).toBeGreaterThan(editor.clientHeight);
await userEvent.clear(editor);
await userEvent.type(editor, 'send and clear');
await userEvent.keyboard('{Enter}');
await waitFor(() => expect(emptyComposerSend).toHaveBeenCalledWith('send and clear', undefined));
await waitFor(() => expect(editor.textContent).toBe(''));
assertLineBox();
},
};

type EmptyComposerState = {
switchingSession: boolean;
chatHidden: boolean;
draftKey: string;
disabled: boolean;
};
let setEmptyComposerState: ((next: Partial<EmptyComposerState>) => void) | undefined;
const emptyComposerSend = fn();
function EmptyComposerLifecycle() {
const [state, setState] = useState<EmptyComposerState>({
switchingSession: false, chatHidden: false, draftKey: 'session:caret-a', disabled: false,
});
useEffect(() => {
emptyComposerSend.mockClear();
setEmptyComposerState = (next) => setState((current) => ({ ...current, ...next }));
return () => { setEmptyComposerState = undefined; };
}, []);
return <ComposedShell chat={{ messages: [] }} switchingSession={state.switchingSession}
chatHidden={state.chatHidden} composer={{ draftKey: state.draftKey, disabled: state.disabled, onSend: emptyComposerSend }} />;
}

// Real path: 新任务 → no session exists yet. The composer swaps
// ChatModelSwitcher for NewChatModelPicker and drops the thinking selector,
Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/stories/workhub.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,20 @@ export const ProgressModelPicker: Story = {
const canvas = within(canvasElement);
await waitFor(() => expect(canvasElement.querySelector('.workHubLive')).toHaveAttribute('data-progress', 'true'));
const editor = canvasElement.querySelector('[contenteditable="true"]') as HTMLElement;
const assertCompactLineBox = () => {
expect(editor.closest('.workHubLive')).toHaveAttribute('data-conversation-expanded', 'false');
const style = getComputedStyle(editor);
expect(style.paddingTop).toBe('6px');
expect(style.paddingBottom).toBe('6px');
// Compact WorkHub has larger padding and initially requests maxRows=1.
// Even then, its scrolling editor must leave a full line for the caret.
expect(editor.clientHeight).toBeGreaterThanOrEqual(
Number.parseFloat(style.lineHeight) + Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom),
);
};
await waitFor(assertCompactLineBox);
await userEvent.click(editor);
assertCompactLineBox();
await userEvent.type(editor, 'Keep this draft readable while choosing a model.');
const trigger = await canvas.findByRole('button', { name: /切换当前任务模型/ });
await userEvent.click(trigger);
Expand All @@ -247,6 +260,14 @@ export const ProgressModelPicker: Story = {
pixels.fillRect(0, 0, 1, 1);
expect(pixels.getImageData(0, 0, 1, 1).data[3]).toBe(255);
expect(editor).toHaveTextContent('Keep this draft readable while choosing a model.');
await userEvent.keyboard('{Escape}');
await userEvent.clear(editor);
await waitFor(() => expect(editor.textContent).toBe(''));
assertCompactLineBox();
// Restore this story's named final state: a draft with the model wheel open.
await userEvent.type(editor, 'Keep this draft readable while choosing a model.');
await userEvent.click(trigger);
await waitFor(() => expect(canvas.getByRole('listbox')).toHaveFocus());
},
};
export const ComposerRetainsFailedAttachment: Story = {
Expand Down