Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2723998
feat(desktop): focus file and browser previews with shared composer
testikun Sep 18, 2026
ce243bf
fix(desktop): use Astryx buttons in focused preview
testikun Sep 18, 2026
1d269fa
Merge upstream main into desktop-focused-preview
testikun Sep 18, 2026
eb560c4
refactor(ui): share WorkHub progress card with focused previews
testikun Sep 18, 2026
fba8327
feat(desktop): focus previews by dragging the conversation divider
testikun Sep 18, 2026
8f6c037
fix(desktop): guide preview focus gestures and restore keyboard flow
testikun Sep 18, 2026
b883c85
fix(desktop): retain streamed preview text after stopping a turn
testikun Sep 18, 2026
3d2933b
docs(desktop): document the native focused preview interaction journey
testikun Sep 18, 2026
9eb2fff
test(desktop): distinguish layout declarations from palette reads
testikun Sep 18, 2026
0701925
Merge upstream main into focused preview
testikun Sep 20, 2026
3339064
fix(desktop): address focused preview interaction review
testikun Sep 20, 2026
3bfb7cd
test(desktop): close the WorkHub menu on its owning window
testikun Sep 20, 2026
0fc0491
fix(desktop): restore preview scroll after the focused layout settles
testikun Sep 20, 2026
1eefc8c
fix(desktop): address focused preview follow-up review
testikun Sep 21, 2026
9937a20
Merge upstream main into focused preview
testikun Sep 21, 2026
8f4babc
docs(desktop): refresh Astryx surface inventory
testikun Sep 21, 2026
55774c9
test(desktop): submit disabled skill draft explicitly
testikun Sep 21, 2026
0a4c147
test(desktop): assert native menu closes semantically
testikun Sep 21, 2026
bce5e6e
test(desktop): submit skill drafts through form
testikun Sep 21, 2026
91f8992
Revert "test(desktop): submit skill drafts through form"
testikun Sep 21, 2026
3f7561a
Merge upstream main into desktop focused preview
testikun Sep 21, 2026
81ae472
Merge current main into desktop focused preview
testikun Sep 22, 2026
4d41b10
test(desktop): isolate floating WorkHub draft input
testikun Sep 22, 2026
9fd2ae1
fix(ui): dispatch composer escape in its document
testikun Sep 22, 2026
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
10 changes: 8 additions & 2 deletions apps/desktop/e2e/workhub-layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,12 @@ test('WorkHub uses its coordination model and shared attachment composer', async
// auto-dismissed (Linux closes popups after window resizes) must not be
// closed again — closePopup on a dead popup crashes the main process.
if ((await addPanel.getAttribute('aria-expanded')) === 'true') {
await app.evaluate(() => (globalThis as unknown as { workbarMenu: Electron.Menu }).workbarMenu.closePopup());
// Close on the same owner passed to popup(). The no-window overload takes
// Electron's close-all MenuRunner path on Linux, even for this single menu.
await mainWindow.evaluate((window) =>
(globalThis as unknown as { workbarMenu: Electron.Menu }).workbarMenu.closePopup(window));
}
await expect(addPanel).toHaveAttribute('aria-expanded', 'false');
await expect(addPanel).not.toHaveAttribute('aria-expanded', 'true');
await workhub.getByRole('button', { name: '收起任务工作栏', exact: true }).click();
await expect(page.locator('.maka-session-workbar[data-placement="right"]')).toBeHidden();
const anchors = workhub.locator('.workhub-anchors');
Expand Down Expand Up @@ -196,6 +199,9 @@ test('WorkHub uses its coordination model and shared attachment composer', async
expect(await app.evaluate(({ app }) => process.platform !== 'darwin' || app.dock!.isVisible())).toBe(true);
const editor = workhub.locator('.maka-composer-editor [contenteditable="true"]');
await editor.fill('Keep this draft while folding the conversation.');
// The floating native window is foregrounded on local runs. Release its
// editor so host keyboard input cannot mutate the draft under assertion.
await editor.blur();
const expandedHeight = await workhub.evaluate(() => window.innerHeight);
const floatingBottom = () => app.evaluate(({ BrowserWindow }) => {
const bounds = BrowserWindow.getAllWindows().find((window) => window.getTitle() === 'WorkHub')!.getBounds();
Expand Down
32 changes: 28 additions & 4 deletions apps/desktop/src/main/__tests__/ink-ladder-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ const SOURCE_EXTENSIONS = ['.css', '.ts', '.tsx'];

const RETIRED_INK = ['--foreground-secondary', '--foreground-dimmed'];

// These reads intentionally consume layout declarations, never colours.
// Keep this exact: an unknown property must still fail the palette guard.
const LAYOUT_DECLARATION_READS = new Set([
'--maka-session-workbar-width', // Restore the inline width declaration after a drag.
'--maka-focused-composer-space', // Assert the measured pixel clearance in the layout story.
]);

function unapprovedCustomPropertyReads(source: string): string[] {
return [...withoutComments(source).matchAll(/getPropertyValue\(\s*['"`](--[a-z0-9-]+)/g)]
.map((match) => match[1]!)
.filter((name) => !LAYOUT_DECLARATION_READS.has(name));
}

async function sourceFilesUnder(root: string): Promise<string[]> {
const entries = await readdir(root, { withFileTypes: true });
const found: string[] = [];
Expand Down Expand Up @@ -205,16 +218,27 @@ describe('mode expression', () => {
const offenders: string[] = [];
for (const file of files) {
if (!file.endsWith('.ts') && !file.endsWith('.tsx')) continue;
const source = withoutComments(await readFile(file, 'utf8'));
for (const match of source.matchAll(/getPropertyValue\(\s*['"`](--[a-z0-9-]+)/g)) {
offenders.push(`${relative(REPO_ROOT, file)} → ${match[1]}`);
const source = await readFile(file, 'utf8');
for (const name of unapprovedCustomPropertyReads(source)) {
offenders.push(`${relative(REPO_ROOT, file)} → ${name}`);
}
}

assert.deepEqual(
offenders,
[],
'a custom property reads back as its declaration, not as a resolved value',
'a palette custom property reads back as its declaration, not as a resolved colour',
);
});

it('allows the explicit layout reads without admitting palette or unknown properties', () => {
assert.deepEqual(unapprovedCustomPropertyReads(`
frame.style.getPropertyValue('--maka-session-workbar-width');
getComputedStyle(frame).getPropertyValue('--maka-focused-composer-space');
getComputedStyle(frame).getPropertyValue('--foreground');
getComputedStyle(frame).getPropertyValue("--background");
getComputedStyle(frame).getPropertyValue('--maka-brand');
frame.style.getPropertyValue('--maka-unknown-width');
`), ['--foreground', '--background', '--maka-brand', '--maka-unknown-width']);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { WorkbarHostModel } from '../ui/workbar-host.js';

type PreviewKind = 'files' | 'browser';

/** Presentation-only state; the selected tab and session remain authoritative. */
export function useFocusedPreview(input: {
host: WorkbarHostModel;
}) {
const [request, setRequest] = useState<{ sessionId: string; kind: PreviewKind } | null>(null);
const [minimized, setMinimized] = useState(false);
const [composerTarget, setComposerTarget] = useState<HTMLElement | null>(null);
const [overlayHeight, setOverlayHeight] = useState(0);
const surfaceRef = useRef<HTMLDivElement>(null);
const resizeRef = useRef<{
frame: HTMLElement; kind: PreviewKind; sessionId: string;
startWidth: number; width: number; previousWidth: string;
} | null>(null);
const pointerResizePendingRef = useRef(false);
const rightPanel = input.host.panelsState.right;
const activeRightTab = rightPanel.tabs.find((tab) => tab.id === rightPanel.activeTabId);
const focusedPreview = composerTarget && request && request.sessionId === input.host.activeId &&
activeRightTab?.kind === request.kind && !rightPanel.launcherOpen &&
!input.host.rightCollapsed && !input.host.hidden && input.host.workspace !== 'workhub'
? request.kind : null;

useEffect(() => {
if (request && !focusedPreview) {
setRequest(null);
setMinimized(false);
}
}, [request, focusedPreview]);

useLayoutEffect(() => {
const frame = surfaceRef.current?.closest('.maka-detail-with-artifacts');
setComposerTarget(frame?.querySelector<HTMLElement>(':scope > .mainColumn > .maka-chat-layout [data-maka-composer-dock]') ?? null);
}, [input.host.activeId]);

useLayoutEffect(() => {
const frame = surfaceRef.current?.closest<HTMLElement>('.maka-detail-with-artifacts');
if (!focusedPreview || !frame || !composerTarget) return;
frame.dataset.previewFocused = focusedPreview;
return () => {
delete frame.dataset.previewFocused;
delete frame.dataset.previewDockMinimized;
frame.style.removeProperty('--maka-focused-dock-height');
frame.style.removeProperty('--maka-focused-composer-space');
};
}, [focusedPreview, composerTarget]);

useLayoutEffect(() => {
const frame = surfaceRef.current?.closest<HTMLElement>('.maka-detail-with-artifacts');
if (!focusedPreview || !frame || !composerTarget) return;
if (minimized) frame.dataset.previewDockMinimized = 'true';
else delete frame.dataset.previewDockMinimized;
const measure = () => {
const dockHeight = Math.ceil(composerTarget.getBoundingClientRect().height);
frame.style.setProperty('--maka-focused-dock-height', `${dockHeight}px`);
frame.style.setProperty('--maka-focused-composer-space', `${dockHeight + overlayHeight + 32}px`);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(composerTarget);
return () => observer.disconnect();
}, [focusedPreview, composerTarget, overlayHeight, minimized]);

function toggle(kind: PreviewKind) {
if (!input.host.activeId || !composerTarget) return;
setMinimized(false);
const sessionId = input.host.activeId;
setRequest((current) => current?.sessionId === sessionId && current.kind === kind
? null : { sessionId, kind });
}

useEffect(() => {
if (!focusedPreview || !composerTarget) return;
const exit = (event: Event) => {
event.preventDefault();
setRequest(null);
setMinimized(false);
};
composerTarget.addEventListener('maka-composer-escape', exit);
return () => composerTarget.removeEventListener('maka-composer-escape', exit);
}, [focusedPreview, composerTarget]);

function finishResize() {
const drag = resizeRef.current;
if (!drag) return null;
resizeRef.current = null;
if (drag.previousWidth) drag.frame.style.setProperty('--maka-session-workbar-width', drag.previousWidth);
else drag.frame.style.removeProperty('--maka-session-workbar-width');
delete drag.frame.dataset.previewResizing;
delete drag.frame.dataset.previewCollapseReady;
return drag;
}

// A cancelled gesture, tab switch or unmount must not leave a temporary width.
useEffect(() => () => { finishResize(); }, [input.host.activeId, activeRightTab?.id, input.host.hidden, input.host.rightCollapsed]);

const rightResizable = {
...input.host.rightResizable,
_onResizeStart: () => {
input.host.rightResizable._onResizeStart();
const pointerResize = pointerResizePendingRef.current;
pointerResizePendingRef.current = false;
if (!pointerResize) return;
const frame = surfaceRef.current?.closest<HTMLElement>('.maka-detail-with-artifacts');
const kind = activeRightTab?.kind;
if (!frame || !composerTarget || !input.host.activeId || focusedPreview || input.host.workspace === 'workhub' ||
rightPanel.launcherOpen || input.host.hidden || input.host.rightCollapsed ||
(kind !== 'browser' && kind !== 'files') ||
(kind === 'files' && !frame.querySelector('.maka-artifact-preview-screen'))) return;
const panel = frame.querySelector<HTMLElement>('.maka-session-workbar[data-placement="right"]');
if (!panel || panel.getBoundingClientRect().width >= frame.getBoundingClientRect().width) return;
const width = panel.getBoundingClientRect().width;
resizeRef.current = { frame, kind, sessionId: input.host.activeId, startWidth: width, width,
previousWidth: frame.style.getPropertyValue('--maka-session-workbar-width') };
frame.dataset.previewResizing = 'true';
},
_onResizeMove: (delta: number) => {
const drag = resizeRef.current;
if (!drag) { input.host.rightResizable._onResizeMove(delta); return; }
// Follow the divider beyond the ordinary panel cap. Only a completed
// gesture with less than 240px of conversation left enters focus mode.
drag.width = Math.max(input.host.rightResizable._minSizePx,
Math.min(drag.frame.clientWidth - 120, drag.startWidth + delta));
drag.frame.style.setProperty('--maka-session-workbar-width', `${drag.width}px`);
if (drag.width > drag.startWidth && drag.frame.clientWidth - drag.width <= 240) {
drag.frame.dataset.previewCollapseReady = 'true';
} else delete drag.frame.dataset.previewCollapseReady;
},
_onResizeEnd: () => {
const pending = resizeRef.current;
const focus = pending?.frame.dataset.previewCollapseReady === 'true';
const drag = finishResize();
if (drag && focus) {
setMinimized(false);
setRequest({ sessionId: drag.sessionId, kind: drag.kind });
requestAnimationFrame(() => composerTarget?.querySelector<HTMLElement>('[contenteditable="true"], textarea')?.focus());
} else if (drag) input.host.rightResizable._onResizeMove(drag.width - drag.startWidth);
input.host.rightResizable._onResizeEnd();
},
_onResizeCancel: () => {
finishResize();
input.host.rightResizable._onResizeCancel?.();
},
};

return {
focusedPreview, minimized, activeRightTab, composerTarget, surfaceRef, setOverlayHeight, toggle, rightResizable,
markPointerResize: () => {
pointerResizePendingRef.current = true;
queueMicrotask(() => { pointerResizePendingRef.current = false; });
},
minimize: () => {
setMinimized(true);
requestAnimationFrame(() => composerTarget?.querySelector<HTMLElement>('.maka-progress-card-primary')?.focus());
},
restore: () => {
setMinimized(false);
requestAnimationFrame(() => composerTarget?.querySelector<HTMLElement>('.maka-composer [contenteditable="true"]')?.focus());
},
clear: () => { setRequest(null); setMinimized(false); },
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export interface UseWorkbarControllerInput {
toastApi: ToastApi;
composerRef?: { current: Pick<ComposerHandle, 'focus' | 'setDraft'> | null };
openNewTaskSurface?(): number;
openSessionInChat?(sessionId: string): void;
openSessionInChat?(sessionId: string, turnId?: string): void;
resolveWorkBoardTarget?(item: WorkBoardItem):
| { ok: true; target: { profileId: string; hostId: string; projectId: string } }
| { ok: false; message: string };
Expand Down Expand Up @@ -982,6 +982,7 @@ export function useWorkbarController(
},
onActivityStateChange: sideConversations.setActive,
sourceSession: input.activeSession,
onOpenConversation: input.openSessionInChat,
modelChoices: input.modelChoices,
onStartWorkBoardTask: startWorkBoardTask,
resolveWorkBoardStartTask: input.resolveWorkBoardTarget,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/features/workbar/stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,5 @@
*/

export { WorkbarSurface } from './ui/workbar-surface.js';
export type { WorkbarHostModel } from './ui/workbar-host.js';
export { useWorkbarLayoutState } from './controller/use-workbar-layout-state.js';
Loading