Skip to content

Commit 38bab22

Browse files
fix(app-shell): a chat pane too narrow for the preview split previews the way a phone does (#27467)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8NKwKGQBkpf1QUFK6NsWFT5)_ Fixes #27420 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:助手聊天面板放不下「聊天 + 预览」并排(两者最小宽度合计 780px)时,改走手机端已有的预览方式:聊天占满宽度,输入框上方给「预览应用」按钮,预览以整块浮层打开,关闭回到聊天。780px 及以上保持原来的并排分栏和拖拽分隔条。 - 为什么改:1024px 窗口下应用内助手的草稿预览被挤出面板,380px 的 iframe 只露出 136px,客户看不到改动。 - 风险与回滚:只改 `AiChatPage.tsx` 的分栏判定(新增 `isTooNarrowForSplit` 与 `tooNarrow`,原 `isMobile` 的五处开关改读 `phonePreview`);宽度足够时行为不变。回滚 = revert 本 PR。 - 先看:`AiChatPage.tsx` 的 `useResizableChatPane` 新增的 `tooNarrow` 测量与 `phonePreview`;再看新测试 `AiChatPage.narrowSplit-27420.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/console/ai/__tests__/AiChatPage.narrowSplit-27420.test.tsx`,预期 6 passed。 - 需要你做的:无,席位落地。 ## What changes `ui/packages/app-shell/src/console/ai/AiChatPage.tsx` only, as the seat ruled on the card (reuse the phone path): - **`isTooNarrowForSplit(containerWidth)`**, pure and exported beside `clampChatPaneWidth`: true below `CHAT_PANE_MIN_WIDTH + CHAT_PREVIEW_MIN_WIDTH` (780 px); an unmeasured container (0) is not narrow, the same convention `clampChatPaneWidth` uses. - **`useResizableChatPane` returns `tooNarrow`.** It is read from the pane's own root (the existing `containerRef`, present in both presentations), in a layout effect with its own `ResizeObserver` that runs whether or not the split is open, so a narrow pane never paints the split first. The existing re-clamp observer is unchanged and still runs only while the split is open. - **`phonePreview = isMobile || split.tooNarrow`** replaces `isMobile` at the five switch points: the beside-chat split (`desktopSplit`), the pill row (`mobilePreviewAvailable`), the two Preview-tap auto-opens, and the full-surface overlay (`ai-chat-mobile-canvas`). No new widget, no change to the dock's width or the app page beside it. `ui/packages/app-shell/src/console/ai/__tests__/clampChatPaneWidth.test.ts` gains the threshold cases; `AiChatPage.narrowSplit-27420.test.tsx` is new. One patch changeset (`@object-ui/app-shell`, `@object-ui/console`). ## Why #27420: in the console dock at a 1024 px window the dock is maximized to 504 px while a preview is open, the chat column keeps its 360 px floor and is `shrink-0`, and the Live Canvas is laid out past the dock's edge. ## Acceptance notes - **Measured in real Chromium** (`/opt/pw-browsers/chromium`, 768 px tall), on the real `ChatDockPanel` with the real `useChatDockState` (so the canvas auto-maximize is real), the real `ChatDockConversation`, `ChatPane`, `ChatbotEnhanced` and `LiveCanvas`, served by the console's own Vite config with its Tailwind sheet. Fixtures: the app page beside the rail is a `min-w-0 flex-1` box; the chat hooks are inert and a hidden handle calls `onPreviewDraftApp` as a draft card's Preview does (the stand-ins of `AiChatPage.previewPillInFlow-13068.test.tsx`); the agent catalog, metadata, adapter and auth hooks are stubbed. The harness was scratch and is not committed. "Before" is this branch with the switch ablated back to `isMobile` alone. | Window | Dock | Before: presentation · preview iframe x-range · visible | After: presentation · preview iframe x-range · visible | |---|---|---|---| | 1024 | 504 | split · 888–1221 · **136 px** | overlay · 522–1024 · 502 px (whole) | | 1280 | 760 | split · 888–1280 · 392 px (preview below its 420 px minimum) | overlay · 522–1280 · 758 px (whole) | | 1440 | 920 | split · 888–1440 · 552 px | split · 888–1440 · 552 px (unchanged) | The 1024 reading reproduces the card's figures (iframe from x 888, 136 px visible). `document.scrollWidth` equals the window width in every row. - **The dock keeps the split only from a 1300 px window.** The maximized dock is the window minus the 520 px the page beside it keeps (`maximizedDockWidth`), so the 780 px threshold is a 1300 px window. At 1280 px the dock now takes the phone path where it used to show a squeezed (not clipped) split. That follows the ruled threshold; recorded here so the seat sees it. - **Other hosts of the same pane follow the same rule.** The full-page `/ai` chat keeps its split whenever its pane is at least 780 px (pinned at 780 px in the new test); the Studio copilot's narrow slot, which also mounts `ChatPane`, now previews through the overlay instead of overflowing. - **No flip-flop.** The width is read from the pane root, whose width the presentation does not change (the overlay is `absolute inset-0` inside it, the split's children are inside it too). The new test replays resizes across the threshold and repeats each width (779, 780) to show a held width never switches back. - An auto-drafted preview (an artifact landing, not a Preview tap) in a narrow pane offers the pill without covering the chat, as on a phone. ## Verification Head `605e6e5c` (branch merged with `origin/main` `bbb49c9f`); every reading below is from that tree. - New pins: `AiChatPage.narrowSplit-27420.test.tsx` 6/6 and `clampChatPaneWidth.test.ts` 8/8 passed. - Ablation (`scripts/ablation-replace.mjs`, anchor `const phonePreview = isMobile || split.tooNarrow;` replaced by `const phonePreview = isMobile;`, landed 1 to 0): the new suite went red, 3 failed (both 760 px cases and the resize case), 3 passed (the 780 px and unmeasured cases, which do not depend on it). Restored: blob equals `HEAD` and `git diff HEAD` is empty. - `pnpm --dir ui exec vitest run packages/app-shell/src/console/ai/ packages/app-shell/src/layout/`: 165 files, 1024 tests passed. - `pnpm --filter @object-ui/app-shell run type-check`: exit 0. `pnpm --dir ui run check:new-line-citations`: 0 new. `pnpm check:changeset-fixed`: exit 0. <details><summary>Derived gates: 35 of 35 run, all exit 0</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 35 commands; each was run and its exit recorded before any pipe; `--ran` reconciles 35 derived, 35 run, 0 not measured. Among them `pnpm --dir ui exec vitest run scripts/`: 169 files passed, 2 skipped. Tier: `dispatch-gates --tier` reports no path-derived mandate. Changed lines 378 against the 3000 threshold. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (left to CI) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbXTbdoAxiCMJjYAtze)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fbaacfe commit 38bab22

4 files changed

Lines changed: 367 additions & 11 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(console): the assistant's draft preview is no longer cut off in a narrow chat pane
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
The chat and the draft preview open side by side, and each keeps a minimum width (360 px for the chat, 420 px for the preview). In a pane narrower than the two together, such as the in-app assistant dock at a 1024 px window, the chat kept its minimum and pushed the preview past the pane's edge, so only a sliver of it showed.
12+
13+
Below 780 px the pane now previews the way it already does on a phone: the chat keeps the full width, a Preview button above the composer offers the preview, and opening it shows the preview over the whole pane, with its close button returning to the chat. At 780 px and wider nothing changes: the preview opens beside the chat with the draggable divider. The width is the pane's own, so the full-page assistant at a desktop width keeps its split.
14+
15+
What to do: nothing.

‎ui/packages/app-shell/src/console/ai/AiChatPage.tsx‎

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* turns are appended to `ai_messages` automatically.
1414
*/
1515

16-
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
16+
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
1717
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
1818
import { useAuth } from '@object-ui/auth';
1919
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
@@ -578,6 +578,17 @@ export function clampChatPaneWidth(
578578
return Math.min(Math.max(desired, opts.min), upper);
579579
}
580580

581+
/**
582+
* True when a container cannot hold the chat and the preview side by side at
583+
* their minimums (`CHAT_PANE_MIN_WIDTH + CHAT_PREVIEW_MIN_WIDTH`). The pane
584+
* then previews the way a phone does (#2481) instead of pushing the preview
585+
* past the container's edge, as the console dock at a 1024 px window did.
586+
* Pure + exported for tests. `containerWidth <= 0` (unmeasured) is not narrow.
587+
*/
588+
export function isTooNarrowForSplit(containerWidth: number): boolean {
589+
return containerWidth > 0 && containerWidth < CHAT_PANE_MIN_WIDTH + CHAT_PREVIEW_MIN_WIDTH;
590+
}
591+
581592
interface ResizableChatPane {
582593
/** Current chat-column width in px (clamped). */
583594
width: number;
@@ -591,6 +602,8 @@ interface ResizableChatPane {
591602
onHandleKeyDown: (e: React.KeyboardEvent) => void;
592603
/** Reset to the default width (double-click the divider). */
593604
reset: () => void;
605+
/** The container is too narrow for the split (`isTooNarrowForSplit`), measured whether or not it is open. */
606+
tooNarrow: boolean;
594607
}
595608

596609
/**
@@ -641,6 +654,23 @@ export function useResizableChatPane(active: boolean): ResizableChatPane {
641654
return () => ro.disconnect();
642655
}, [active, clampToContainer]);
643656

657+
// Whether the split fits at all. Read from the same container in both
658+
// presentations and observed even while the split is closed, so the answer
659+
// never depends on which presentation is showing: the container's width does
660+
// not change when the pane switches, so there is no threshold to oscillate on.
661+
// A layout effect, so a narrow container never paints the split first.
662+
const [tooNarrow, setTooNarrow] = useState(false);
663+
useLayoutEffect(() => {
664+
const el = containerRef.current;
665+
if (!el) return;
666+
const read = () => setTooNarrow(isTooNarrowForSplit(el.clientWidth));
667+
read();
668+
if (typeof ResizeObserver === 'undefined') return;
669+
const ro = new ResizeObserver(read);
670+
ro.observe(el);
671+
return () => ro.disconnect();
672+
}, []);
673+
644674
const onHandlePointerDown = useCallback(
645675
(e: React.PointerEvent) => {
646676
e.preventDefault();
@@ -682,7 +712,7 @@ export function useResizableChatPane(active: boolean): ResizableChatPane {
682712
persist(next);
683713
}, [clampToContainer, persist]);
684714

685-
return { width, dragging, containerRef, onHandlePointerDown, onHandleKeyDown, reset };
715+
return { width, dragging, containerRef, onHandlePointerDown, onHandleKeyDown, reset, tooNarrow };
686716
}
687717

688718
export type AiChatShortcut = 'toggle-list' | 'new-chat';
@@ -1771,6 +1801,10 @@ export function ChatPane({
17711801
}, [canvasOpen, onCanvasOpenChange]);
17721802
// Draggable chat ↔ preview split (active only while the preview is open).
17731803
const split = useResizableChatPane(canvasOpen);
1804+
// A pane too narrow for the split (the console dock at a laptop width, the
1805+
// Studio copilot's slot) previews the way a phone does: full-width chat, the
1806+
// pill, the full-surface overlay. Every phone switch below reads this.
1807+
const phonePreview = isMobile || split.tooNarrow;
17741808
const handleDraftArtifacts = useCallback((artifacts: Array<{ type: string; name: string }>, appSegment?: string) => {
17751809
const app = artifacts.find((a) => a.type === 'app');
17761810
// Route the preview on the app's package id (ADR-0048), not its name.
@@ -2461,9 +2495,10 @@ export function ChatPane({
24612495
</div>
24622496
);
24632497

2464-
// The beside-chat split is DESKTOP-only: on a phone the chat keeps full width
2465-
// and the canvas becomes a full-screen overlay instead (see #2481 below).
2466-
const desktopSplit = canvasApp !== null && !isMobile;
2498+
// The beside-chat split needs room for both panes: on a phone, or in a pane
2499+
// narrower than that (`phonePreview`), the chat keeps full width and the
2500+
// canvas becomes a full-surface overlay instead (see #2481 below).
2501+
const desktopSplit = canvasApp !== null && !phonePreview;
24672502

24682503
// objectui#5694 — the standing unpublished-changes affordance: shown while
24692504
// the bound package has pending drafts, so the publish entry point survives
@@ -2487,7 +2522,7 @@ export function ChatPane({
24872522
// remounts the bar (its count would re-read and blink). That column exists
24882523
// only then: without a preview the slot holds the bar alone, as before, and
24892524
// stays empty, taking no space, while the bar renders nothing.
2490-
const mobilePreviewAvailable = canvasApp !== null && isMobile;
2525+
const mobilePreviewAvailable = canvasApp !== null && phonePreview;
24912526
const promptHeaderRows = mobilePreviewAvailable ? (
24922527
<div className="flex w-full flex-col items-center gap-2">
24932528
{!mobileCanvasOpen ? (
@@ -2851,7 +2886,7 @@ export function ChatPane({
28512886
setCanvasApp({ name: appName, segment: opts?.appSegment, materialized: opts?.materialized === true });
28522887
// #2481 — a Preview tap is an explicit request to SEE it, so on a
28532888
// phone open the full-screen overlay (desktop shows the split pane).
2854-
if (isMobile) setMobileCanvasOpen(true);
2889+
if (phonePreview) setMobileCanvasOpen(true);
28552890
}}
28562891
// objectui#12968 (objectstack#24324 ruling A) — any other draft's
28572892
// Preview: the Live Canvas opens the affected surface (a page, the
@@ -2865,7 +2900,7 @@ export function ChatPane({
28652900
materialized: false,
28662901
path: draftPreviewPath(items, appSegment),
28672902
});
2868-
if (isMobile) setMobileCanvasOpen(true);
2903+
if (phonePreview) setMobileCanvasOpen(true);
28692904
}}
28702905
// ADR-0045: build materialized → canvas leaves the draft overlay for
28712906
// the real (unlisted) app; the reload shows live seed rows.
@@ -2922,12 +2957,12 @@ export function ChatPane({
29222957
</>
29232958
) : null}
29242959
{/* #2481 — MOBILE preview. The beside-chat split has no room on a phone,
2925-
so the canvas is opt-in and full-screen: the pill above the composer
2960+
nor in a pane too narrow for it (`phonePreview`), so the canvas is opt-in and full-screen: the pill above the composer
29262961
(`promptHeaderRows`) offers it (build streams uninterrupted in the
29272962
chat), and tapping it — or a Preview button on a draft card — takes
29282963
over the screen. Closing the overlay returns to the chat with the
29292964
preview still one tap away. */}
2930-
{canvasApp && isMobile && mobileCanvasOpen ? (
2965+
{canvasApp && phonePreview && mobileCanvasOpen ? (
29312966
<div
29322967
className="absolute inset-0 z-30 flex flex-col bg-background"
29332968
data-testid="ai-chat-mobile-canvas"

0 commit comments

Comments
 (0)