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