You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/11 10:27:41 UTC
(maka) branch main updated: fix(desktop): restore WorkHub return and align prompt rails (#5177)
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new a57b4e866c fix(desktop): restore WorkHub return and align prompt rails (#5177)
a57b4e866c is described below
commit a57b4e866cb74fad1d0801d5ff3ff2cafaec99c6
Author: ChengBo Zhang <66...@users.noreply.github.com>
AuthorDate: Fri Sep 11 18:27:36 2026 +0800
fix(desktop): restore WorkHub return and align prompt rails (#5177)
* fix(desktop): restore WorkHub return and align prompt rails
Generated-by: Codex
* fix(desktop): place WorkHub return above composer
Generated-by: Codex
* fix(desktop): keep WorkHub return within shell architecture budget
Move return rendering into the WorkHub feature and share Session selection checks so the legacy shell does not grow. Validate with the same --base architecture check as CI.
Generated-by: Codex
* fix(desktop): use Astryx for WorkHub return control
Replace the raw return button with Astryx Button, retain composer geometry, and refresh the surface inventory. Stabilize native startup resizing in the regression.
Generated-by: Codex
* fix(desktop): address WorkHub rail review findings
Use one rail host and gutter authority, preserve the public empty-state behavior, compare rail edges directly, and expose WorkHub return in shared Sessions. Recapture comparable screenshots and retain baseline failure evidence.
Generated-by: Codex
* test(desktop): wait for derived effects before archiving history
Regenerate visibility only proves root Turn completion. Retry the specific live-derived-effect retirement rejection while naming or recap settles; propagate all other errors and keep a bounded timeout.
Generated-by: Codex
* fix(desktop): finish WorkHub review cleanup
Restore named imports, use the rail host consistently, and recapture comparable Session images. Keep one-off baseline logs in the PR discussion rather than the source tree.
Generated-by: Codex
---
apps/desktop/e2e-budget.json | 4 +
apps/desktop/e2e/new-task-reload.spec.ts | 20 +++-
apps/desktop/e2e/workhub-return-rail.spec.ts | 121 +++++++++++++++++++++
apps/desktop/renderer-architecture.json | 2 +-
apps/desktop/src/renderer/app-shell.tsx | 25 +++--
.../desktop/src/renderer/features/workhub/index.ts | 1 +
.../renderer/features/workhub/ui/return-button.tsx | 34 ++++++
apps/desktop/src/renderer/locales/shell-copy.ts | 7 +-
apps/desktop/src/renderer/maka-tokens.css | 1 +
apps/desktop/src/renderer/styles/chat-header.css | 2 +-
apps/desktop/src/renderer/styles/composer.css | 9 ++
apps/desktop/src/renderer/styles/prompt-rail.css | 34 ++----
.../src/renderer/styles/workbar/side-chat.css | 4 +-
apps/desktop/src/renderer/styles/workhub.css | 7 +-
docs/astryx-surface-file-inventory.md | 3 +-
docs/astryx-surface-file-inventory.paths | 1 +
.../pr/workhub-return-rail/after-session.png | Bin 0 -> 81011 bytes
.../pr/workhub-return-rail/after-workhub-720.png | Bin 0 -> 68970 bytes
.../pr/workhub-return-rail/after-workhub.png | Bin 0 -> 83901 bytes
.../pr/workhub-return-rail/before-session.png | Bin 0 -> 78621 bytes
.../pr/workhub-return-rail/before-workhub.png | Bin 0 -> 84035 bytes
.../__tests__/chat-view-empty-compaction.test.tsx | 11 ++
.../ui/src/__tests__/prompt-anchor-rail.test.ts | 23 ----
.../prompt-rail-observer-identity.test.tsx | 26 ++++-
packages/ui/src/chat-surface-layout.tsx | 20 +++-
packages/ui/src/chat-view.tsx | 5 +-
packages/ui/src/prompt-anchor-rail.tsx | 16 ++-
27 files changed, 290 insertions(+), 86 deletions(-)
diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json
index 8bd366b228..ef8b2d1bcf 100644
--- a/apps/desktop/e2e-budget.json
+++ b/apps/desktop/e2e-budget.json
@@ -77,6 +77,10 @@
"tests": 1,
"electron": "the same WebContentsView and unsent draft survive reparenting between native main and floating windows, shared appearance changes and the explicit return-from-picture-in-picture button"
},
+ "workhub-return-rail.spec.ts": {
+ "tests": 2,
+ "electron": "WorkHub lives in a separate native WebContentsView: compare its scrollport with the main Session after Host-backed sends, then open that Session and return through the shell while retaining the same WorkHub draft"
+ },
"zh-tw-locale.spec.ts": {
"tests": 1,
"electron": "the locale is a persisted Host setting and only takes effect through a renderer reload"
diff --git a/apps/desktop/e2e/new-task-reload.spec.ts b/apps/desktop/e2e/new-task-reload.spec.ts
index a6c54cac76..b3af053dd9 100644
--- a/apps/desktop/e2e/new-task-reload.spec.ts
+++ b/apps/desktop/e2e/new-task-reload.spec.ts
@@ -34,10 +34,22 @@ test('archived-only history boots into a usable new task', async ({ window: page
// Prove bootstrap can restore this history before archiving it.
await page.reload();
await expect(reply).toBeVisible();
- await page.evaluate(async () => {
- const sessions = await window.maka.sessions.list();
- for (const session of sessions) await window.maka.sessions.archive(session.id);
- });
+ const sessionIds = await page.evaluate(async () =>
+ (await window.maka.sessions.list()).map((session) => session.id));
+ for (const sessionId of sessionIds) {
+ // Turn completion does not join background naming/recap effects. Archive
+ // is the authoritative readiness check; retry only its specific busy case.
+ await expect.poll(() => page.evaluate(async (id) => {
+ try {
+ await window.maka.sessions.archive(id);
+ return 'archived';
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ if (message.includes(`Session ${id} has a live derived effect`)) return message;
+ throw error;
+ }
+ }, sessionId), { timeout: 20_000 }).toBe('archived');
+ }
await expect.poll(async () =>
page.evaluate(async () => (await window.maka.sessions.list()).map(({ isArchived }) => isArchived)),
).toEqual([true]);
diff --git a/apps/desktop/e2e/workhub-return-rail.spec.ts b/apps/desktop/e2e/workhub-return-rail.spec.ts
new file mode 100644
index 0000000000..9c372ee8e9
--- /dev/null
+++ b/apps/desktop/e2e/workhub-return-rail.spec.ts
@@ -0,0 +1,121 @@
+/*
+ * 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 type { Page } from '@playwright/test';
+import { awaitSendReady, COMPOSER_INPUT, expect, test, getWorkHubPage } from './fixtures';
+
+async function sendPrompts(page: Page, prefix: string) {
+ for (let index = 1; index <= 3; index++) {
+ const text = `${prefix} ${index}`;
+ await page.locator(COMPOSER_INPUT).fill(text);
+ await awaitSendReady(page);
+ await page.locator(COMPOSER_INPUT).press('Enter');
+ await expect(page.getByText(`Fake backend received: ${text}`, { exact: true })).toBeVisible();
+ await expect(page.getByRole('button', { name: '停止', exact: true })).toHaveCount(0);
+ }
+ await expect(page.locator('.maka-prompt-rail [data-prompt-turn-id]')).toHaveCount(3);
+ await expect(page.locator('.maka-prompt-rail')).toBeVisible();
+}
+
+async function railGeometry(page: Page) {
+ return page.locator('.maka-prompt-rail').evaluate((rail) => {
+ const scroller = document.querySelector('[data-chat-scroll-container]:not([hidden])')!;
+ const edge = scroller.getBoundingClientRect();
+ const ticks = rail.getBoundingClientRect();
+ const composer = document.querySelector('.maka-composer')!.getBoundingClientRect();
+ return { rightInset: edge.right - ticks.right, top: ticks.top - edge.top,
+ bottom: composer.top - ticks.bottom, width: ticks.width };
+ });
+}
+
+test('WorkHub prompt rail uses the scrollport edge and the shared reading width', async ({ sessionLocalWindow: { page, app } }, testInfo) => {
+ const mainWindow = await app.browserWindow(page);
+ const contentWidth = await mainWindow.evaluate((window) => {
+ window.unmaximize();
+ window.setBounds({ width: 1600, height: 900 });
+ return window.getContentSize()[0];
+ });
+ // Startup can restore saved bounds after the first resize request.
+ await expect.poll(async () => {
+ await mainWindow.evaluate((window) => window.setBounds({ width: 1600, height: 900 }));
+ return page.evaluate(() => innerWidth);
+ }).toBe(contentWidth);
+ await sendPrompts(page, 'Session navigation');
+ const sessionRail = await railGeometry(page);
+ const ordinary = await page.locator('.maka-turn').first().evaluate((element) => element.getBoundingClientRect().width);
+ await page.evaluate(() => window.maka.settings.updateClient({ workHub: { enabled: true } }));
+ const workhub = await getWorkHubPage(app);
+ await workhub.setViewportSize({ width: 1600, height: 800 });
+ await sendPrompts(workhub, 'WorkHub navigation');
+ await workhub.screenshot({ path: testInfo.outputPath('workhub-wide.png'), scale: 'css' });
+ const geometry = await railGeometry(workhub);
+ expect(geometry.width).toBeGreaterThan(0);
+ expect(geometry.rightInset).toBeGreaterThanOrEqual(10);
+ expect(geometry.rightInset).toBeLessThanOrEqual(32);
+ expect(Math.abs(geometry.rightInset - sessionRail.rightInset)).toBeLessThanOrEqual(1);
+ // Both surfaces apply their shared transcript gutters exactly once.
+ const hubWidth = await workhub.locator('.maka-turn').first().evaluate((element) => element.getBoundingClientRect().width);
+ expect(Math.abs(hubWidth - ordinary)).toBeLessThanOrEqual(2);
+ for (const width of [1000, 720]) {
+ await workhub.setViewportSize({ width, height: 720 });
+ await expect.poll(async () => (await railGeometry(workhub)).bottom).toBeGreaterThanOrEqual(0);
+ const narrow = await railGeometry(workhub);
+ expect(narrow.rightInset).toBeGreaterThanOrEqual(10);
+ expect(narrow.rightInset).toBeLessThanOrEqual(32);
+ expect(narrow.top).toBeGreaterThanOrEqual(0);
+ expect(await workhub.locator('.workhub-body').evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
+ await workhub.screenshot({ path: testInfo.outputPath(`workhub-${width}.png`), scale: 'css' });
+ }
+});
+
+test('Session keeps a return to WorkHub control when the sidebar is collapsed', async ({ sessionLocalWindow: { page, app } }, testInfo) => {
+ await page.setViewportSize({ width: 1600, height: 900 });
+ await sendPrompts(page, 'Return navigation');
+ await page.evaluate(() => window.maka.settings.updateClient({ workHub: { enabled: true } }));
+ const workhub = await getWorkHubPage(app);
+ await workhub.locator(COMPOSER_INPUT).fill('Keep my WorkHub draft');
+ await workhub.locator('.workhub-navigation-item').first().click();
+ await expect(page.locator('.workHubDock')).toBeHidden();
+ const collapse = page.getByRole('button', { name: '收起侧边栏', exact: true });
+ if (await collapse.isVisible()) await collapse.click();
+ await page.screenshot({ path: testInfo.outputPath('session-return.png'), scale: 'css' });
+ const back = page.getByRole('button', { name: '返回 WorkHub', exact: true });
+ await expect(back).toBeVisible();
+ const alignment = await back.evaluate((button) => {
+ const composer = document.querySelector('.maka-composer-astryx')!;
+ const body = Array.from(composer.children).find((child) => child.querySelector('[contenteditable]'))!;
+ const a = button.getBoundingClientRect();
+ const b = composer.getBoundingClientRect();
+ return {
+ left: Math.abs(a.left - b.left), right: Math.abs(a.right - b.right),
+ above: a.bottom <= b.top,
+ radius: getComputedStyle(button).borderRadius,
+ composerRadius: getComputedStyle(body).borderRadius,
+ textAlign: getComputedStyle(button).textAlign,
+ };
+ });
+ expect(alignment.left).toBeLessThanOrEqual(1);
+ expect(alignment.right).toBeLessThanOrEqual(1);
+ expect(alignment.above).toBe(true);
+ expect(alignment.radius).toBe(alignment.composerRadius);
+ expect(alignment.textAlign).toBe('center');
+ await back.click();
+ await expect(page.locator('.workHubDock')).toBeVisible();
+ await expect(workhub.locator(COMPOSER_INPUT)).toHaveText('Keep my WorkHub draft');
+});
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index cc39f3b958..9808914dc4 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -872,7 +872,7 @@
"react": 1
},
"importSpecifiers": 108,
- "nonTriviaTokens": 13697
+ "nonTriviaTokens": 13696
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 2,
diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx
index 5e8182eb33..b9be86eff0 100644
--- a/apps/desktop/src/renderer/app-shell.tsx
+++ b/apps/desktop/src/renderer/app-shell.tsx
@@ -17,7 +17,7 @@
* under the License.
*/
-import { WorkHubDock, WorkHubControlOverlay, WorkHubMainNavigation } from './features/workhub';
+import { WorkHubControlOverlay, WorkHubDock, WorkHubMainNavigation, WorkHubReturnButton } from './features/workhub';
import {
useCallback,
useEffect,
@@ -427,6 +427,7 @@ function AppShellContent({
const [petCompletionNonce, setPetCompletionNonce] = useState(0);
const [navigationState, setNavigationState] = useState(() => readNavigationState());
const navSelection = navigationState.selection;
+ const sessionsSelected = navSelection.section === 'sessions';
const setNavSelection = useCallback<Dispatch<SetStateAction<NavSelection>>>((nextSelection) => {
setNavigationState((current) => selectNavigation(
current,
@@ -1367,7 +1368,7 @@ function AppShellContent({
[toastApi],
);
const workbarAvailable =
- navSelection.section === 'sessions' && !workHubActive && Boolean(activeId);
+ sessionsSelected && !workHubActive && Boolean(activeId);
const workbar = useWorkbarController({
available: workbarAvailable,
layoutSessionId: activeId,
@@ -2213,7 +2214,7 @@ function AppShellContent({
activeId,
);
const homeSurfaceActive =
- navSelection.section === 'sessions' &&
+ sessionsSelected &&
messages.length === 0 &&
!hasLiveTurnContent &&
!activeMessageLoadError;
@@ -2378,7 +2379,7 @@ function AppShellContent({
summary loads, and the name this replaced (the context layer's) was
showing through that window. Hung on the real record alone, 新任务
was named nowhere for the length of it. */}
- {navSelection.section === 'sessions' && !workHubActive && activeSessionForView && (
+ {sessionsSelected && !workHubActive && activeSessionForView && (
<TitlebarSessionIdentity
/* Keyed by session: the open rename is local state and the field is
uncontrolled, so a switch that left the instance mounted would
@@ -2483,7 +2484,7 @@ function AppShellContent({
<div className="mainColumn" data-home-surface={homeSurfaceActive ? 'true' : undefined}>
<ModuleHub.ModuleHubHost />
<WorkHubMainNavigation onOpenWorkHub={openWorkHub} onOpenSession={(sessionId) => { closeSettings(); openSession(sessionId); }} />
- <WorkHubDock enabled={workHubEnabled} visible={workHubActive && navSelection.section === 'sessions' && !shellObscured} />
+ <WorkHubDock enabled={workHubEnabled} visible={workHubActive && sessionsSelected && !shellObscured} />
<ChatSurfaceLayout
// ChatView positions this transcript: switching conversations,
// following the tail and the moves the reader asks for are one
@@ -2496,7 +2497,7 @@ function AppShellContent({
onReturnToTail={activeTranscriptRange?.hasNewer
? () => transcriptReadingCommands.current?.loadHistory('latest')
: undefined}
- hidden={workHubActive || navSelection.section !== 'sessions'}
+ hidden={workHubActive || !sessionsSelected}
composer={
<>
{ownerActiveId ? (
@@ -2506,7 +2507,7 @@ function AppShellContent({
onOpenSession={openSessionInChat}
/>
) : null}
- {navSelection.section === 'sessions' &&
+ {sessionsSelected &&
ownerActiveId &&
activeSessionForView &&
!isLinkedSubagentSession(activeSessionForView) ? (
@@ -2517,7 +2518,11 @@ function AppShellContent({
onOpenSession={openSessionInChat}
/>
) : null}
- {!sharedSessionActive && navSelection.section === 'sessions' ? <PlanExecutionPanel planMode={planMode} /> : null}
+ {!sharedSessionActive && sessionsSelected ? <PlanExecutionPanel planMode={planMode} /> : null}
+ <WorkHubReturnButton
+ visible={workHubEnabled && Boolean(activeId) && !onboardingComposerHidden}
+ onReturn={openWorkHub}
+ />
{sharedSessionActive && activeId ? (
<SessionCollaboration.SessionTurnRequestComposer
sessionId={activeId}
@@ -2528,7 +2533,7 @@ function AppShellContent({
<ChatComposerRegion
workspacePicker={workspacePicker}
composerRef={composerRef}
- active={navSelection.section === 'sessions'}
+ active={sessionsSelected}
onboardingComposerHidden={
onboardingComposerHidden
}
@@ -2677,7 +2682,7 @@ function AppShellContent({
</>
}
>
- {navSelection.section === 'sessions' ? (
+ {sessionsSelected ? (
<SessionCollaboration.SessionGuestTurnActionBoundary
sessionId={sharedSessionActive ? activeId : undefined}
deriveTurnPresentation={deriveTurnPresentation}
diff --git a/apps/desktop/src/renderer/features/workhub/index.ts b/apps/desktop/src/renderer/features/workhub/index.ts
index dd8ff7de9c..c668aa256b 100644
--- a/apps/desktop/src/renderer/features/workhub/index.ts
+++ b/apps/desktop/src/renderer/features/workhub/index.ts
@@ -33,3 +33,4 @@ export { WorkHubSurfaceSwitch } from './ui/workhub-surface-switch.js';
export { WorkHubControlOverlay } from './ui/control-overlay.js';
export { WorkHubMainNavigation } from './ui/main-navigation.js';
export { startWorkHubCoordinationLifecycle, type WorkHubCoordinationHostChange } from './controller/coordination-lifecycle.js';
+export { WorkHubReturnButton } from './ui/return-button.js';
diff --git a/apps/desktop/src/renderer/features/workhub/ui/return-button.tsx b/apps/desktop/src/renderer/features/workhub/ui/return-button.tsx
new file mode 100644
index 0000000000..cb779b4053
--- /dev/null
+++ b/apps/desktop/src/renderer/features/workhub/ui/return-button.tsx
@@ -0,0 +1,34 @@
+/*
+ * 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 { Button } from '@astryxdesign/core/Button';
+import { useUiLocale } from '@maka/ui';
+import { getShellCopy } from '../../../locales/shell-copy.js';
+
+export function WorkHubReturnButton({ visible, onReturn }: { visible: boolean; onReturn(): void }) {
+ const locale = useUiLocale();
+ return visible ? (
+ <Button
+ className="maka-return-workhub"
+ label={getShellCopy(locale).navigation.backToWorkHub}
+ variant="secondary"
+ onClick={onReturn}
+ />
+ ) : null;
+}
diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts
index 2f81a1ad94..c2a36dd949 100644
--- a/apps/desktop/src/renderer/locales/shell-copy.ts
+++ b/apps/desktop/src/renderer/locales/shell-copy.ts
@@ -152,6 +152,7 @@ const STATIC_COMMAND_KEYWORDS: Record<StaticCommandId, readonly string[]> = {
type ShellCopy = {
navigation: {
settings: string;
+ backToWorkHub: string;
};
actions: {
retry: string;
@@ -755,7 +756,7 @@ const EN_SETTINGS_SECTIONS: Record<SettingsSection, string> = {
const SHELL_COPY_BY_LOCALE = {
'zh-CN': {
- navigation: { settings: '设置' },
+ navigation: { settings: '设置', backToWorkHub: '返回 WorkHub' },
actions: { retry: '重试' },
paths: {
workspace: '工作区文件夹',
@@ -1256,7 +1257,7 @@ const SHELL_COPY_BY_LOCALE = {
},
},
'zh-TW': {
- navigation: { settings: '設定' },
+ navigation: { settings: '設定', backToWorkHub: '返回 WorkHub' },
actions: { retry: '重試' },
paths: {
workspace: '工作區資料夾',
@@ -1757,7 +1758,7 @@ const SHELL_COPY_BY_LOCALE = {
},
},
en: {
- navigation: { settings: 'Settings' },
+ navigation: { settings: 'Settings', backToWorkHub: 'Back to WorkHub' },
actions: { retry: 'Retry' },
paths: {
workspace: 'workspace',
diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css
index fe3e1b6278..8c30aaa5db 100644
--- a/apps/desktop/src/renderer/maka-tokens.css
+++ b/apps/desktop/src/renderer/maka-tokens.css
@@ -944,6 +944,7 @@
the answer wider than the composer under it. Two measures for one column
is what put a second right edge inside a turn; there is one now. */
--maka-reading-measure: 800px;
+ --maka-transcript-gutter: clamp(var(--space-3), 4vw, var(--space-10));
/* === settings ==========================================================
One right-edge cap for a settings row's end slot — the read-only value
diff --git a/apps/desktop/src/renderer/styles/chat-header.css b/apps/desktop/src/renderer/styles/chat-header.css
index 369ef5e7f1..2a1402e19c 100644
--- a/apps/desktop/src/renderer/styles/chat-header.css
+++ b/apps/desktop/src/renderer/styles/chat-header.css
@@ -189,7 +189,7 @@
position: relative;
width: 100%;
box-sizing: border-box;
- padding-inline: clamp(var(--space-3), 4vw, var(--space-10));
+ padding-inline: var(--maka-transcript-gutter);
}
.maka-chat-layout {
diff --git a/apps/desktop/src/renderer/styles/composer.css b/apps/desktop/src/renderer/styles/composer.css
index 2f70498211..6eb49c157b 100644
--- a/apps/desktop/src/renderer/styles/composer.css
+++ b/apps/desktop/src/renderer/styles/composer.css
@@ -40,6 +40,15 @@
max-width: var(--maka-reading-measure);
}
+.maka-return-workhub {
+ --_button-radius: var(--radius-chat);
+ display: flex;
+ width: min(var(--maka-reading-measure), calc(100% - 2 * var(--space-6)));
+ min-height: var(--space-10);
+ margin: 0 auto;
+ text-align: center;
+}
+
/* Reaching the transcript tail fades the scroll-to-bottom control while the
composer below it stays still. Both otherwise share the dock's filtered
compositing surface, so Chromium may re-raster the thin context-gauge paths
diff --git a/apps/desktop/src/renderer/styles/prompt-rail.css b/apps/desktop/src/renderer/styles/prompt-rail.css
index 25fc0453d4..cdd3b34ca6 100644
--- a/apps/desktop/src/renderer/styles/prompt-rail.css
+++ b/apps/desktop/src/renderer/styles/prompt-rail.css
@@ -25,37 +25,17 @@
The hover preview is Astryx's HoverCard; only the two lines inside it are
styled here. */
-/* Astryx's ChatLayout is the scroll container and the transcript — chat shell
- and all — renders inside it, so `position: absolute` resolves against a box
- as tall as the whole conversation: the rail used to be laid out across
- ~32000px and scroll away with the content instead of staying on screen.
-
- This zero-height sticky anchor is what pins it. It costs no flow space and
- holds the scrollport's top edge at every scroll position — the same sticky
- mechanism Astryx uses for the composer dock. It only works from the anchor's
- own static position onward, so ChatView renders it as the first child of
- `.maka-chat-shell`.
-
- `top: 0` rather than the centreline, deliberately: a sticky offset is
- clamped by its containing block, and this one's is the chat shell, which
- ends where the transcript does — above the scrollport's bottom edge, since
- the dock's own box follows it in flow. An anchor parked mid-scrollport
- therefore gets dragged upward as the reader reaches the end of a
- conversation, taking the rail off the top of the scrollport with it (CI
- caught -62px at a 500px window). Pinned to the top edge the clamp cannot
- engage while any transcript is on screen, and the rail does its own
- centring from the measured band below.
-
- `--maka-prompt-rail-scrollport` / `--maka-prompt-rail-dock` /
- `--maka-prompt-rail-height` are measured in prompt-anchor-rail.tsx; see the
- rail's own rule for what they buy. */
-.maka-prompt-rail-anchor {
+/* ChatLayout scrolls the entire transcript. A zero-height sticky host gives
+ the absolute rail a viewport anchor instead of the full transcript height.
+ Keep top: 0: a centreline offset is clamped by messageArea's bottom edge
+ above the in-flow dock and can pull the rail offscreen at the tail.
+ prompt-anchor-rail.tsx measures scrollport/dock; it identifies the dock via
+ root.lastElementChild, so Astryx's child order is part of this contract. */
+.maka-prompt-rail-host {
position: sticky;
top: 0;
height: 0;
z-index: var(--z-panel-action);
- /* The anchor spans the full chat column; only the rail inside it is a
- target, or it would swallow clicks across the transcript. */
pointer-events: none;
}
diff --git a/apps/desktop/src/renderer/styles/workbar/side-chat.css b/apps/desktop/src/renderer/styles/workbar/side-chat.css
index ec52fc52c7..8d73e8754a 100644
--- a/apps/desktop/src/renderer/styles/workbar/side-chat.css
+++ b/apps/desktop/src/renderer/styles/workbar/side-chat.css
@@ -42,9 +42,9 @@
min-height: 0;
}
-/* The anchor, not just the rail inside it: hiding the sticky box outright
+/* The host, not just the rail inside it: hiding the sticky box outright
keeps a zero-height sticky element out of the narrow panel's flow. */
-.maka-quote-companion .maka-prompt-rail-anchor {
+.maka-quote-companion .maka-prompt-rail-host {
display: none;
}
diff --git a/apps/desktop/src/renderer/styles/workhub.css b/apps/desktop/src/renderer/styles/workhub.css
index 19e4c331d7..991bb26399 100644
--- a/apps/desktop/src/renderer/styles/workhub.css
+++ b/apps/desktop/src/renderer/styles/workhub.css
@@ -28,7 +28,9 @@
display: grid;
width: 100%;
min-width: 0;
- grid-template-columns: minmax(0, 220px) var(--maka-reading-measure) minmax(0, 220px);
+ /* Reserve the outer transcript gutters and balanced message-list padding
+ outside the shared reading measure; --spacing-4 is Astryx balanced list padding (16px). */
+ grid-template-columns: minmax(0, 220px) minmax(0, calc(var(--maka-reading-measure) + 2 * (var(--maka-transcript-gutter) + var(--spacing-4)))) minmax(0, 220px);
gap: var(--space-6, 24px);
justify-content: center;
}
@@ -247,6 +249,7 @@
}
.workHubLive[data-placement='floating'] .workHubLiveWelcome { min-height: 280px; }
.workHubLive[data-conversation-expanded='false'] { padding-top: 0; }
+ .workHubLive[data-conversation-expanded='false'] .maka-prompt-rail-host { visibility: hidden; }
.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; }
@@ -260,7 +263,7 @@ html:has(.workHubLive), body:has(.workHubLive), #root:has(.workHubLive) { backgr
@media (max-width: 1240px) {
.workhub-body { display: flex; flex-direction: column; gap: var(--space-3); }
- .workhub-conversation-shell { width: min(var(--maka-reading-measure), 100%); margin-inline: auto; }
+ .workhub-conversation-shell { width: 100%; }
.workhub-anchor-rail { position: static; width: min(var(--maka-reading-measure), 100%); max-height: 180px; margin-inline: auto; padding: var(--space-3); }
.workhub-anchors { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(180px, 1fr); overflow-x: auto; }
.workhub-filters { flex-wrap: nowrap; overflow-x: auto; }
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index af7c8b7898..4d87e72bfa 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports).
Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
-**Totals:** 267 files — blocker 0, reimplementation 0, polish 2, aligned 265.
+**Totals:** 268 files — blocker 0, reimplementation 0, polish 2, aligned 266.
## Exclusions (explicit)
@@ -102,6 +102,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/features/workhub/ui/control-overlay.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/workhub/ui/cursor.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/workhub/ui/main-navigation.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `apps/desktop/src/renderer/features/workhub/ui/return-button.tsx` | other | Button | aligned — uses Astryx (Button) | aligned |
| `apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx` | other | Button | aligned — uses Astryx (Button) | aligned |
| `apps/desktop/src/renderer/features/workhub/ui/workhub-dock.tsx` | other | Button | aligned — uses Astryx (Button) | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index 822d7b76e4..39c8aea7cb 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -73,6 +73,7 @@ apps/desktop/src/renderer/features/workbar/ui/workbar-toggle.tsx
apps/desktop/src/renderer/features/workhub/ui/control-overlay.tsx
apps/desktop/src/renderer/features/workhub/ui/cursor.tsx
apps/desktop/src/renderer/features/workhub/ui/main-navigation.tsx
+apps/desktop/src/renderer/features/workhub/ui/return-button.tsx
apps/desktop/src/renderer/features/workhub/ui/workhub-composer.tsx
apps/desktop/src/renderer/features/workhub/ui/workhub-conversation.tsx
apps/desktop/src/renderer/features/workhub/ui/workhub-dock.tsx
diff --git a/docs/images/pr/workhub-return-rail/after-session.png b/docs/images/pr/workhub-return-rail/after-session.png
new file mode 100644
index 0000000000..5b3f2694d9
Binary files /dev/null and b/docs/images/pr/workhub-return-rail/after-session.png differ
diff --git a/docs/images/pr/workhub-return-rail/after-workhub-720.png b/docs/images/pr/workhub-return-rail/after-workhub-720.png
new file mode 100644
index 0000000000..6d4918b1b7
Binary files /dev/null and b/docs/images/pr/workhub-return-rail/after-workhub-720.png differ
diff --git a/docs/images/pr/workhub-return-rail/after-workhub.png b/docs/images/pr/workhub-return-rail/after-workhub.png
new file mode 100644
index 0000000000..9f19349263
Binary files /dev/null and b/docs/images/pr/workhub-return-rail/after-workhub.png differ
diff --git a/docs/images/pr/workhub-return-rail/before-session.png b/docs/images/pr/workhub-return-rail/before-session.png
new file mode 100644
index 0000000000..45061a2406
Binary files /dev/null and b/docs/images/pr/workhub-return-rail/before-session.png differ
diff --git a/docs/images/pr/workhub-return-rail/before-workhub.png b/docs/images/pr/workhub-return-rail/before-workhub.png
new file mode 100644
index 0000000000..db96290faa
Binary files /dev/null and b/docs/images/pr/workhub-return-rail/before-workhub.png differ
diff --git a/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx b/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx
index 43d6561d18..f01c5cf6e7 100644
--- a/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx
+++ b/packages/ui/src/__tests__/chat-view-empty-compaction.test.tsx
@@ -141,3 +141,14 @@ test('the pending Turn clock ticks from send time and hands over without a dupli
await render({ liveTurn: undefined, runningStatus: false, transientMessages: [] });
assert.equal(container.querySelectorAll('.maka-turn-processing').length, 0);
});
+
+
+test('ChatSurfaceLayout preserves the public emptyState for absent children', () => {
+ for (const children of [null, undefined, false, []]) {
+ const markup = renderToStaticMarkup(
+ <ChatSurfaceLayout composer={null} emptyState={<p>Empty layout</p>} children={children} />,
+ );
+ assert.match(markup, /Empty layout/);
+ assert.doesNotMatch(markup, /maka-prompt-rail-host/);
+ }
+});
diff --git a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts
index 26eac1c61d..f7e2bc25f9 100644
--- a/packages/ui/src/__tests__/prompt-anchor-rail.test.ts
+++ b/packages/ui/src/__tests__/prompt-anchor-rail.test.ts
@@ -19,14 +19,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import { createElement } from 'react';
-import { renderToStaticMarkup } from 'react-dom/server';
-import { LocaleProvider } from '../locale-context.js';
import {
holdJumpDestination,
mergePromptAnchorRailTurns,
observeActivePromptRailVisibility,
- PromptAnchorRail,
selectPromptRailActiveTurn,
selectPromptRailTickForMountedTurn,
type PromptRailFrameScheduler,
@@ -481,25 +477,6 @@ test('updates landmark content when its body enters a later resident range', ()
assert.deepEqual(intermediate.map((turn) => turn.reply), ['', 'Answer 2']);
});
-test('keeps unloaded landmarks visually uniform and actionable', () => {
- const markup = renderToStaticMarkup(createElement(LocaleProvider, {
- locale: 'en',
- children: createElement(PromptAnchorRail, {
- turns: [
- { turnId: 'turn-1', label: 'Prompt 1', sequence: 0 },
- { turnId: 'turn-2', label: 'Prompt 2', sequence: 2 },
- { turnId: 'turn-3', label: 'Prompt 3', sequence: 4 },
- ],
- scrollRef: { current: null },
- }),
- }));
-
- assert.match(markup, /data-prompt-turn-id="turn-2"/);
- assert.doesNotMatch(markup, /data-resident/);
- assert.match(markup, /aria-label="Jump to prompt: Prompt 2"/);
- assert.doesNotMatch(markup, /Not currently loaded/);
- assert.doesNotMatch(markup, /aria-disabled="true"/);
-});
function box(top: number, bottom: number): DOMRect {
return { top, bottom } as DOMRect;
diff --git a/packages/ui/src/__tests__/prompt-rail-observer-identity.test.tsx b/packages/ui/src/__tests__/prompt-rail-observer-identity.test.tsx
index 7680f0d5f0..4cd89529a2 100644
--- a/packages/ui/src/__tests__/prompt-rail-observer-identity.test.tsx
+++ b/packages/ui/src/__tests__/prompt-rail-observer-identity.test.tsx
@@ -47,7 +47,7 @@ import { AstryxLocaleProvider } from '../astryx-i18n.js';
import { ChatSurfaceLayout } from '../chat-surface-layout.js';
import { ChatView } from '../chat-view.js';
import { LocaleProvider } from '../locale-context.js';
-import { READING_BAND_TOP_PERCENT } from '../prompt-anchor-rail.js';
+import { PromptAnchorRail, READING_BAND_TOP_PERCENT } from '../prompt-anchor-rail.js';
const originalGlobals = {
CSS: globalThis.CSS,
@@ -238,3 +238,27 @@ test('the rail observes its reading band, not the whole scrollport', async () =>
// positive threshold is what distinguishes real overlap from that.
assert.deepEqual(init.threshold, [0, 0.000_001]);
});
+
+
+test('portals unloaded landmarks into the layout host and keeps them actionable', async () => {
+ const { mount } = harness();
+ const root = createRoot(mount);
+ mountedRoot = root;
+ const rail = createElement(PromptAnchorRail, {
+ turns: [
+ { turnId: 'turn-1', label: 'Prompt 1', sequence: 0 },
+ { turnId: 'turn-2', label: 'Prompt 2', sequence: 2 },
+ { turnId: 'turn-3', label: 'Prompt 3', sequence: 4 },
+ ],
+ scrollRef: { current: null },
+ });
+ await act(() => root.render(createElement(LocaleProvider, { locale: 'en', children: rail })));
+ assert.equal(mount.querySelector('.maka-prompt-rail'), null, 'no inline rail before a host exists');
+ await act(() => root.render(createElement(LocaleProvider, {
+ locale: 'en', children: createElement(ChatSurfaceLayout, { composer: null, children: rail }),
+ })));
+ assert.equal(mount.querySelectorAll('.maka-prompt-rail-host .maka-prompt-rail').length, 1);
+ assert.match(mount.innerHTML, /data-prompt-turn-id="turn-2"/);
+ assert.doesNotMatch(mount.innerHTML, /data-resident|Not currently loaded|aria-disabled="true"/);
+ assert.match(mount.innerHTML, /aria-label="Jump to prompt: Prompt 2"/);
+});
diff --git a/packages/ui/src/chat-surface-layout.tsx b/packages/ui/src/chat-surface-layout.tsx
index 184e47c8c3..321845e652 100644
--- a/packages/ui/src/chat-surface-layout.tsx
+++ b/packages/ui/src/chat-surface-layout.tsx
@@ -17,7 +17,7 @@
* under the License.
*/
-import { useMemo, type ComponentProps } from 'react';
+import { useMemo, useState, type ComponentProps } from 'react';
import { ChatLayout } from '@astryxdesign/core/Chat';
import { AstryxLocaleProvider } from './astryx-i18n.js';
import {
@@ -25,6 +25,7 @@ import {
TranscriptScrollButton,
} from './transcript-scroll-authority.js';
import { cn } from './utils.js';
+import { PromptAnchorRailHostContext } from './prompt-anchor-rail.js';
/**
* Stock ChatLayoutProps, minus `autoScroll`. That prop is the patch-package
@@ -57,11 +58,13 @@ export type ChatSurfaceLayoutProps = Omit<ComponentProps<typeof ChatLayout>, 'au
*/
export function ChatSurfaceLayout({
className,
+ children,
density = 'balanced',
scrollToBottomLabel,
onReturnToTail,
...props
}: ChatSurfaceLayoutProps) {
+ const [railHost, setRailHost] = useState<HTMLDivElement | null>(null);
const astryxOverrides = useMemo(
() =>
scrollToBottomLabel
@@ -71,6 +74,10 @@ export function ChatSurfaceLayout({
: undefined,
[scrollToBottomLabel],
);
+ // Mirror Astryx ChatLayout's hasVisibleContent check to preserve the public
+ // emptyState prop: a host fragment would otherwise count as visible content.
+ const hasContent = children != null && children !== false
+ && !(Array.isArray(children) && children.length === 0);
const layout = (
<ChatLayout
{...props}
@@ -82,7 +89,12 @@ export function ChatSurfaceLayout({
density={density}
className={cn('maka-chat-layout', className)}
data-chat-scroll-container="true"
- />
+ >
+ {hasContent ? <>
+ <div className="maka-prompt-rail-host" ref={setRailHost} />
+ {children}
+ </> : children}
+ </ChatLayout>
);
const localized = astryxOverrides ? (
<AstryxLocaleProvider overrides={astryxOverrides}>{layout}</AstryxLocaleProvider>
@@ -93,5 +105,7 @@ export function ChatSurfaceLayout({
// and costs one object, and providing it always is what lets everything
// below treat it as present instead of carrying a second, unreachable
// behaviour for its absence.
- return <TranscriptScrollAuthorityProvider>{localized}</TranscriptScrollAuthorityProvider>;
+ return <TranscriptScrollAuthorityProvider>
+ <PromptAnchorRailHostContext value={railHost}>{localized}</PromptAnchorRailHostContext>
+ </TranscriptScrollAuthorityProvider>;
}
diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx
index a764d921b3..e278ca3ff7 100644
--- a/packages/ui/src/chat-view.tsx
+++ b/packages/ui/src/chat-view.tsx
@@ -806,10 +806,7 @@ export function ChatView(props: {
/>
)}
<div className="maka-chat-shell">
- {/* First child on purpose: the rail pins itself with a sticky anchor,
- and a sticky box only takes an offset from its own static position
- onward. Rendered after the transcript it would stay parked at the
- bottom of the conversation until the reader scrolled there. */}
+ {/* ChatSurfaceLayout hosts the rail outside bounded transcript columns. */}
<PromptAnchorRail
turns={promptRailTurns}
onHighlightTurn={props.onPromptRailHighlight ? (turn) => props.onPromptRailHighlight?.(turn?.turnId) : undefined}
diff --git a/packages/ui/src/prompt-anchor-rail.tsx b/packages/ui/src/prompt-anchor-rail.tsx
index b397ad069c..e50da9f534 100644
--- a/packages/ui/src/prompt-anchor-rail.tsx
+++ b/packages/ui/src/prompt-anchor-rail.tsx
@@ -18,6 +18,8 @@
*/
import {
+ createContext,
+ useContext,
memo,
useCallback,
useEffect,
@@ -27,6 +29,7 @@ import {
type CSSProperties,
type RefObject,
} from 'react';
+import { createPortal } from 'react-dom';
import { Button } from '@astryxdesign/core/Button';
import { HoverCard } from '@astryxdesign/core/HoverCard';
import { useUiLocale } from './locale-context.js';
@@ -380,8 +383,12 @@ export function selectPromptRailTickForMountedTurn(input: {
return selected?.turnId ?? fallbackRailTurnId;
}
+/** The scroll layout owns the rail's full-width sticky anchor. */
+export const PromptAnchorRailHostContext = createContext<HTMLElement | null>(null);
+
/** Right-edge rail: bounded prompt landmarks that scroll to `[data-turn-id]`. */
export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRef, onNavigateFallback, onNavigateStart, onHighlightTurn }: PromptAnchorRailProps): React.ReactElement | null {
+ const host = useContext(PromptAnchorRailHostContext);
const copy = getConversationCopy(useUiLocale()).sessions;
const [activeSelection, setActiveSelection] = useState<{
turnId: string;
@@ -479,7 +486,7 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe
activeVisibilityFrame.current = 0;
}
};
- }, [activeRailTurnId]);
+ }, [activeRailTurnId, host]);
useEffect(() => {
const root = scrollRef.current;
@@ -674,7 +681,7 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe
const rail = railRef.current;
if (!rail) return;
return observeActivePromptRailVisibility(rail);
- }, [orderedTurnIds]);
+ }, [orderedTurnIds, host]);
// A click owns the highlight until the destination settles, so the scroll it
// started cannot walk the active tick through every prompt on the way. Keyed
@@ -730,9 +737,9 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe
}
// A rail is only useful once there are a few prompts to jump between.
- if (railTurns.length < 3) return null;
+ if (railTurns.length < 3 || !host) return null;
- return (
+ const rail = (
<div
className="maka-prompt-rail-anchor"
style={
@@ -803,4 +810,5 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe
</nav>
</div>
);
+ return createPortal(rail, host);
});
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org