feat(web): redesign top bar — lift Share/Present, zoom dropdown, focus toggle (#1048)

* feat(web): redesign top bar — lift Share/Present, add zoom dropdown, move focus toggle

- AppChromeHeader: add #app-chrome-file-actions portal anchor so file viewers
  can render their primary actions (Present/Share) up in the project chrome
  instead of cramming a second toolbar row.
- HtmlFileViewer / LiveArtifactViewer: portal Present + Share into the top
  bar via createPortal; Share gets a real chrome-action-primary button.
- HtmlFileViewer: replace the 100% reset button with a zoom dropdown
  (50/75/100/125/150/200) with click-outside + Esc handling.
- HtmlFileViewer: move Preview/Source tabs next to Reload (left side, view
  modes); move Tweaks to the right cluster next to Inspect/Edit.
- HtmlFileViewer: showPresent no longer requires deck — any HTML artifact
  with loaded source can be presented (prototype/slide/regular HTML).
- LiveArtifactViewer: add Present (in-tab/fullscreen/new-tab) with iframe
  ref + previewBodyRef wrapper; in-tab present hides chrome and overlays an
  exit button (Esc also exits).
- ChatPane: add chevron-left collapse icon in chat header (onCollapse prop)
  so users can hide the chat from where it lives.
- FileWorkspace: focus toggle is now icon-only and only renders when chat is
  collapsed, sitting on the LEFT of the workspace tabs row as a chevron-right
  expand button — direction matches where chat re-emerges from.
- index.css: add chrome-action-primary/secondary, zoom-menu, present-exit-btn,
  app-chrome-file-actions styling, plus a narrow-width media query that
  collapses secondary action labels.

* fix(web): tests — fall back to inline render when chrome portal anchor missing

The Share/Present primary actions render via createPortal into
#app-chrome-file-actions, which only exists when AppChromeHeader has
mounted. Vitest renders FileViewer / LiveArtifactViewer in isolation,
so the portal anchor was absent and the buttons disappeared from the
test DOM, breaking 7 share-menu tests.

- HtmlFileViewer / LiveArtifactViewer: when chromeActionsHost is null,
  render the present/share JSX inline instead of returning null. UX is
  identical in production (host is always present); tests now find the
  buttons without needing a portal-aware harness.
- FileWorkspace tests: rewrite the "focus toggle in tab bar" assertions
  to reflect the new design — the toggle lives in ChatPane while the
  chat is open, and FileWorkspace only renders an icon-only expand
  button on the LEFT of the tab bar once chat is collapsed.
This commit is contained in:
Eli 2026-05-09 15:26:22 +08:00 committed by GitHub
parent c16e1179fe
commit 1bf7836471
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 381 additions and 73 deletions

View file

@ -9,6 +9,8 @@ interface Props {
backLabel?: string;
}
export const APP_CHROME_FILE_ACTIONS_ID = 'app-chrome-file-actions';
export function AppChromeHeader({ actions, children, onBack, backLabel }: Props) {
const t = useT();
const resolvedBackLabel = backLabel ?? t('project.backToProjects');
@ -18,7 +20,6 @@ export function AppChromeHeader({ actions, children, onBack, backLabel }: Props)
<div className="app-chrome-traffic-space" aria-hidden />
<div className="app-chrome-brand" aria-label={t('app.brand')}>
<span className="app-chrome-mark" aria-hidden>
{/* decorative, parent has aria-label */}
<img src="/app-icon.svg" alt="" className="brand-mark-img" draggable={false} />
</span>
<span className="app-chrome-name">{t('app.brand')}</span>
@ -36,6 +37,7 @@ export function AppChromeHeader({ actions, children, onBack, backLabel }: Props)
) : null}
{children ? <div className="app-chrome-content">{children}</div> : null}
<div className="app-chrome-drag" aria-hidden />
<div id={APP_CHROME_FILE_ACTIONS_ID} className="app-chrome-file-actions" />
{actions ? <div className="app-chrome-actions">{actions}</div> : null}
</header>
);

View file

@ -98,6 +98,7 @@ interface Props {
projectMetadata?: ProjectMetadata;
onProjectMetadataChange?: (metadata: ProjectMetadata) => void;
researchAvailable?: boolean;
onCollapse?: () => void;
}
type Tab = 'chat' | 'comments';
@ -136,6 +137,7 @@ export function ChatPane({
projectMetadata,
onProjectMetadataChange,
researchAvailable,
onCollapse,
}: Props) {
const t = useT();
const logRef = useRef<HTMLDivElement | null>(null);
@ -415,6 +417,18 @@ export function ChatPane({
>
<Icon name="plus" size={16} />
</button>
{onCollapse ? (
<button
type="button"
className="icon-only"
data-testid="chat-collapse"
title={t('workspace.focusMode')}
aria-label={t('workspace.focusMode')}
onClick={onCollapse}
>
<Icon name="chevron-left" size={15} />
</button>
) : null}
</div>
</div>
{tab === 'chat' ? (

View file

@ -1,4 +1,6 @@
import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from 'react';
import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { APP_CHROME_FILE_ACTIONS_ID } from './AppChromeHeader';
import { MarkdownRenderer, artifactRendererRegistry } from '../artifacts/renderer-registry';
import { renderMarkdownToSafeHtml } from '../artifacts/markdown';
import { useT } from '../i18n';
@ -389,6 +391,34 @@ export function LiveArtifactViewer({
const [refreshSuccess, setRefreshSuccess] = useState<string | null>(null);
const [refreshEvents, setRefreshEvents] = useState<LiveArtifactRefreshEvent[]>([]);
const [refreshHistory, setRefreshHistory] = useState<LiveArtifactRefreshLogEntry[]>([]);
const [presentMenuOpen, setPresentMenuOpen] = useState(false);
const [inTabPresent, setInTabPresent] = useState(false);
const previewBodyRef = useRef<HTMLDivElement | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const presentWrapRef = useRef<HTMLDivElement | null>(null);
const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null);
useEffect(() => {
if (typeof document === 'undefined') return;
setChromeActionsHost(document.getElementById(APP_CHROME_FILE_ACTIONS_ID));
}, []);
useEffect(() => {
if (!presentMenuOpen) return;
const onPointer = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (!target) return;
if (target.closest('.present-wrap')) return;
setPresentMenuOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setPresentMenuOpen(false);
};
document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [presentMenuOpen]);
useEffect(() => {
setRefreshError(null);
@ -545,8 +575,78 @@ export function LiveArtifactViewer({
const currentRefreshStatus = detail?.refreshStatus ?? liveArtifact.refreshStatus;
const isRunning = refreshing || currentRefreshStatus === 'running';
const presentInThisTab = () => {
setPresentMenuOpen(false);
setMode('preview');
setInTabPresent(true);
};
const presentFullscreen = () => {
setPresentMenuOpen(false);
setMode('preview');
const target = previewBodyRef.current ?? iframeRef.current;
if (target?.requestFullscreen) {
void target.requestFullscreen().catch(() => {});
}
};
const presentNewTab = () => {
setPresentMenuOpen(false);
if (typeof window === 'undefined') return;
window.open(liveArtifactPreviewUrl(projectId, liveArtifact.artifactId), '_blank', 'noopener,noreferrer');
};
useEffect(() => {
if (!inTabPresent) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setInTabPresent(false);
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [inTabPresent]);
return (
<div className="viewer html-viewer live-artifact-viewer">
<div className={`viewer html-viewer live-artifact-viewer${inTabPresent ? ' is-tab-present' : ''}`}>
{((node: ReactNode) => (
chromeActionsHost ? createPortal(node, chromeActionsHost) : node
))(
<div className="present-wrap chrome-present-wrap" ref={presentWrapRef}>
<button
className="chrome-action chrome-action-secondary present-trigger"
aria-haspopup="menu"
aria-expanded={presentMenuOpen}
onClick={() => setPresentMenuOpen((v) => !v)}
>
<Icon name="present" size={13} />
<span>{t('fileViewer.present')}</span>
<Icon name="chevron-down" size={11} />
</button>
{presentMenuOpen ? (
<div className="present-menu" role="menu">
<button role="menuitem" onClick={presentInThisTab}>
<span className="present-icon"><Icon name="eye" size={13} /></span>{' '}
{t('fileViewer.presentInTab')}
</button>
<button role="menuitem" onClick={presentFullscreen}>
<span className="present-icon"><Icon name="play" size={13} /></span>{' '}
{t('fileViewer.presentFullscreen')}
</button>
<button role="menuitem" onClick={presentNewTab}>
<span className="present-icon"><Icon name="share" size={13} /></span>{' '}
{t('fileViewer.presentNewTab')}
</button>
</div>
) : null}
</div>
)}
{inTabPresent ? (
<button
type="button"
className="present-exit-btn"
onClick={() => setInTabPresent(false)}
title={t('common.exitFullscreen')}
aria-label={t('common.exitFullscreen')}
>
<Icon name="close" size={14} />
</button>
) : null}
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<button
@ -667,20 +767,23 @@ export function LiveArtifactViewer({
/>
) : null}
{mode === 'preview' ? (
<div
style={{
width: `${100 / previewScale}%`,
height: `${100 / previewScale}%`,
transform: `scale(${previewScale})`,
transformOrigin: '0 0',
}}
>
<iframe
data-testid="live-artifact-preview-frame"
title={liveArtifact.title}
sandbox="allow-scripts allow-popups"
src={previewUrl}
/>
<div ref={previewBodyRef} className="live-artifact-preview-frame-host">
<div
style={{
width: `${100 / previewScale}%`,
height: `${100 / previewScale}%`,
transform: `scale(${previewScale})`,
transformOrigin: '0 0',
}}
>
<iframe
ref={iframeRef}
data-testid="live-artifact-preview-frame"
title={liveArtifact.title}
sandbox="allow-scripts allow-popups"
src={previewUrl}
/>
</div>
</div>
) : loading ? (
<div className="viewer-empty">{t('fileViewer.loading')}</div>
@ -2849,6 +2952,8 @@ function HtmlViewer({
const [source, setSource] = useState<string | null>(liveHtml ?? null);
const [inlinedSource, setInlinedSource] = useState<string | null>(null);
const [zoom, setZoom] = useState(100);
const [zoomMenuOpen, setZoomMenuOpen] = useState(false);
const zoomMenuRef = useRef<HTMLDivElement | null>(null);
const [presentMenuOpen, setPresentMenuOpen] = useState(false);
const [shareMenuOpen, setShareMenuOpen] = useState(false);
// Template save UX. We surface a transient "Saved" pill in the share
@ -3062,6 +3167,11 @@ function HtmlViewer({
const previewBodyRef = useRef<HTMLDivElement | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const shareRef = useRef<HTMLDivElement | null>(null);
const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null);
useEffect(() => {
if (typeof document === 'undefined') return;
setChromeActionsHost(document.getElementById(APP_CHROME_FILE_ACTIONS_ID));
}, []);
useEffect(() => {
liveCommentTargetsRef.current = liveCommentTargets;
@ -3749,6 +3859,23 @@ function HtmlViewer({
};
}, [presentMenuOpen]);
useEffect(() => {
if (!zoomMenuOpen) return;
const onDocClick = (e: MouseEvent) => {
if (!zoomMenuRef.current) return;
if (!zoomMenuRef.current.contains(e.target as Node)) setZoomMenuOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setZoomMenuOpen(false);
};
document.addEventListener('mousedown', onDocClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('keydown', onKey);
};
}, [zoomMenuOpen]);
useEffect(() => {
if (!shareMenuOpen) return;
const onDocClick = (e: MouseEvent) => {
@ -4062,7 +4189,7 @@ function HtmlViewer({
}
}
const showPresent = effectiveDeck && source !== null;
const showPresent = source !== null;
const canShare = source !== null;
const exportTitle = file.name.replace(/\.html?$/i, '') || file.name;
const canPptx = canShare && Boolean(onExportAsPptx) && !streaming;
@ -4173,6 +4300,20 @@ function HtmlViewer({
>
<Icon name="reload" size={14} />
</button>
<div className="viewer-tabs">
<button
className={`viewer-tab ${mode === 'preview' ? 'active' : ''}`}
onClick={() => setMode('preview')}
>
{t('fileViewer.preview')}
</button>
<button
className={`viewer-tab ${mode === 'source' ? 'active' : ''}`}
onClick={() => setMode('source')}
>
{t('fileViewer.source')}
</button>
</div>
{effectiveDeck ? (
<span
className="deck-nav"
@ -4209,6 +4350,8 @@ function HtmlViewer({
</button>
</span>
) : null}
</div>
<div className="viewer-toolbar-actions">
<button
type="button"
className={`viewer-toggle${boardMode ? ' active' : ''}`}
@ -4230,23 +4373,6 @@ function HtmlViewer({
<span>{t('fileViewer.tweaks')}</span>
<span className="switch" aria-hidden />
</button>
</div>
<div className="viewer-toolbar-actions">
<div className="viewer-tabs">
<button
className={`viewer-tab ${mode === 'preview' ? 'active' : ''}`}
onClick={() => setMode('preview')}
>
{t('fileViewer.preview')}
</button>
<button
className={`viewer-tab ${mode === 'source' ? 'active' : ''}`}
onClick={() => setMode('source')}
>
{t('fileViewer.source')}
</button>
</div>
<span className="viewer-divider" aria-hidden />
{boardMode ? (
<>
<button
@ -4327,15 +4453,40 @@ function HtmlViewer({
>
<Icon name="minus" size={14} />
</button>
<button
type="button"
className="viewer-action"
onClick={() => setZoom(100)}
title={t('fileViewer.resetZoom')}
style={{ minWidth: 60 }}
>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{zoom}%</span>
</button>
<div className="zoom-menu" ref={zoomMenuRef}>
<button
type="button"
className="viewer-action zoom-trigger"
aria-haspopup="menu"
aria-expanded={zoomMenuOpen}
onClick={() => setZoomMenuOpen((v) => !v)}
style={{ minWidth: 64 }}
>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{zoom}%</span>
<Icon name="chevron-down" size={11} />
</button>
{zoomMenuOpen ? (
<div className="zoom-menu-popover" role="menu">
{[50, 75, 100, 125, 150, 200].map((level) => (
<button
key={level}
type="button"
className={`zoom-menu-item${zoom === level ? ' active' : ''}`}
role="menuitem"
onClick={() => {
setZoom(level);
setZoomMenuOpen(false);
}}
>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{level}%</span>
{zoom === level ? (
<Icon name="check" size={13} />
) : null}
</button>
))}
</div>
) : null}
</div>
<button
type="button"
className="icon-only"
@ -4345,11 +4496,15 @@ function HtmlViewer({
>
<Icon name="plus" size={14} />
</button>
<span className="viewer-divider" aria-hidden />
</div>
</div>
{((filePrimaryActions: ReactNode) => (
chromeActionsHost ? createPortal(filePrimaryActions, chromeActionsHost) : filePrimaryActions
))(<>
{showPresent ? (
<div className="present-wrap">
<div className="present-wrap chrome-present-wrap">
<button
className="viewer-action present-trigger"
className="chrome-action chrome-action-secondary present-trigger"
aria-haspopup="menu"
aria-expanded={presentMenuOpen}
onClick={() => setPresentMenuOpen((v) => !v)}
@ -4377,13 +4532,14 @@ function HtmlViewer({
</div>
) : null}
{canShare ? (
<div className="share-menu" ref={shareRef}>
<div className="share-menu chrome-share-menu" ref={shareRef}>
<button
className="viewer-action primary"
className="chrome-action chrome-action-primary"
aria-haspopup="menu"
aria-expanded={shareMenuOpen}
onClick={() => setShareMenuOpen((v) => !v)}
>
<Icon name="share" size={13} />
<span>{t('fileViewer.shareLabel')}</span>
<Icon name="chevron-down" size={11} />
</button>
@ -4535,8 +4691,7 @@ function HtmlViewer({
) : null}
</div>
) : null}
</div>
</div>
</>)}
<div className="viewer-body" ref={previewBodyRef}>
{source === null ? (
<div className="viewer-empty">{t('fileViewer.loading')}</div>

View file

@ -514,6 +514,19 @@ export function FileWorkspace({
return (
<div className="workspace" data-testid="file-workspace">
<div className="ws-tabs-shell">
{onFocusModeChange && focusMode ? (
<button
type="button"
className="icon-only ws-focus-expand"
data-testid="workspace-focus-toggle"
aria-pressed={focusMode}
title={t('workspace.showChat')}
aria-label={t('workspace.showChat')}
onClick={() => onFocusModeChange(false)}
>
<Icon name="chevron-right" size={15} />
</button>
) : null}
<div
ref={tabsBarRef}
className="ws-tabs-bar"
@ -604,21 +617,6 @@ export function FileWorkspace({
);
})}
</div>
{onFocusModeChange ? (
<div className="ws-tabs-actions">
<button
type="button"
className="ws-focus-toggle"
data-testid="workspace-focus-toggle"
aria-pressed={focusMode}
title={focusMode ? t('workspace.showChat') : t('workspace.focusMode')}
onClick={() => onFocusModeChange(!focusMode)}
>
<Icon name={focusMode ? 'comment' : 'zoom-in'} size={13} />
<span>{focusMode ? t('workspace.showChat') : t('workspace.focusMode')}</span>
</button>
</div>
) : null}
</div>
<div className="ws-body">
{/* Keep the failure banner visible across tab switches so the

View file

@ -1869,6 +1869,7 @@ export function ProjectView({
onProjectMetadataChange={(metadata) => {
onProjectChange({ ...project, metadata });
}}
onCollapse={() => setWorkspaceFocused(true)}
/>
) : (
<div className="pane" data-testid="chat-pane-loading">

View file

@ -355,6 +355,141 @@ code {
gap: 6px;
flex-shrink: 0;
}
.app-chrome-file-actions {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.app-chrome-file-actions:not(:empty) + .app-chrome-actions {
margin-left: 4px;
padding-left: 8px;
border-left: 1px solid var(--border);
}
.chrome-action {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 12px;
border-radius: 7px;
font-size: 12.5px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
border: 1px solid transparent;
background: transparent;
color: var(--text);
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
}
.chrome-action:hover:not(:disabled) {
background: var(--bg-subtle);
}
.chrome-action-secondary {
border-color: var(--border);
background: var(--bg);
color: var(--text);
}
.chrome-action-secondary:hover:not(:disabled) {
background: var(--bg-subtle);
border-color: var(--text-muted);
}
.chrome-action-primary {
background: var(--text-strong);
border-color: var(--text-strong);
color: var(--bg);
}
.chrome-action-primary:hover:not(:disabled) {
background: var(--text);
border-color: var(--text);
color: var(--bg);
}
.chrome-share-menu .share-menu-popover,
.chrome-present-wrap .present-menu {
top: calc(100% + 6px);
right: 0;
}
.zoom-menu { position: relative; display: inline-block; }
.zoom-menu .zoom-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
}
.zoom-menu-popover {
position: absolute;
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
padding: 4px;
min-width: 110px;
z-index: 40;
display: flex;
flex-direction: column;
gap: 1px;
}
.zoom-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text);
font-size: 12.5px;
cursor: pointer;
text-align: left;
}
.zoom-menu-item:hover { background: var(--bg-subtle); }
.zoom-menu-item.active { color: var(--accent-strong); font-weight: 600; }
@media (max-width: 880px) {
.chrome-action-secondary span { display: none; }
.chrome-action-secondary { padding: 0 10px; }
}
@media (max-width: 720px) {
.app-chrome-content { display: none; }
}
.viewer.is-tab-present .viewer-toolbar,
.viewer.is-tab-present .live-artifact-refresh-notice {
display: none;
}
.viewer.is-tab-present .viewer-body {
inset: 0;
}
.viewer .present-exit-btn {
position: absolute;
top: 12px;
right: 12px;
z-index: 50;
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--bg);
border: 1px solid var(--border);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: var(--shadow-sm);
color: var(--text-muted);
}
.viewer .present-exit-btn:hover {
background: var(--bg-subtle);
color: var(--text);
}
.live-artifact-preview-frame-host {
width: 100%;
height: 100%;
background: var(--bg);
}
.live-artifact-preview-frame-host:fullscreen {
background: var(--bg);
}
.app-project-title {
display: flex;
flex-direction: column;

View file

@ -113,7 +113,7 @@ describe('FileWorkspace upload input', () => {
expect(markup).not.toContain('accept=');
});
it('keeps focus mode controls in the workspace tab bar', () => {
it('hides the workspace focus control while the chat pane is open', () => {
const markup = renderToStaticMarkup(
<FileWorkspace
projectId="project-1"
@ -128,11 +128,12 @@ describe('FileWorkspace upload input', () => {
/>,
);
expect(markup).toContain('data-testid="workspace-focus-toggle"');
expect(markup).toContain('Focus workspace');
// While chat is visible the collapse trigger lives in ChatPane.
// FileWorkspace only renders an expand control once chat is hidden.
expect(markup).not.toContain('data-testid="workspace-focus-toggle"');
});
it('keeps the focus mode action outside the horizontally scrollable tablist', () => {
it('renders the expand control on the LEFT of the tab bar while focused', () => {
const markup = renderToStaticMarkup(
<FileWorkspace
projectId="project-1"
@ -142,15 +143,17 @@ describe('FileWorkspace upload input', () => {
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
focusMode={false}
focusMode
onFocusModeChange={vi.fn()}
/>,
);
expect(markup).toContain('class="ws-tabs-shell"');
expect(markup).toContain('class="ws-tabs-actions"');
expect(markup).toContain('data-testid="workspace-focus-toggle"');
// The expand control sits before the tabs bar (left side) so its
// direction matches where the chat pane re-emerges from.
expect(markup).toMatch(
/<div class="ws-tabs-bar" role="tablist"[^>]*>[\s\S]*?<\/div><div class="ws-tabs-actions">/,
/<div class="ws-tabs-shell">\s*<button[^>]*data-testid="workspace-focus-toggle"[\s\S]*?<\/button>\s*<div class="ws-tabs-bar"/,
);
});