fix(web-ui): workspace chip placeholder, logo new-session shortcut, hero foot padding

- The hero workspace chip is a selector: no-live-selection states (cold
  start, workspace deleted from the sidebar after the list is ready) now
  render a "Choose workspace" placeholder (closed-folder icon) instead of
  resurrecting the deleted folder name via the session cwd; the cwd-derived
  name still bridges the initial list load. Stale pending picks clear when
  their workspace leaves a ready list.
- The expanded sidebar wordmark starts a new session (visuals unchanged,
  pointer cursor only); the collapsed rail logo keeps its expand toggle.
- The centered hero composer stack gains a 32px foot for visual balance.
This commit is contained in:
Yif
2026-07-28 04:56:14 +08:00
parent 1f79b78045
commit ae76ed2874
6 changed files with 61 additions and 26 deletions

View File

@@ -142,6 +142,8 @@
align-self: center; align-self: center;
/* figma 75:8208: 12 between hero chrome / workspace row / card. */ /* figma 75:8208: 12 between hero chrome / workspace row / card. */
gap: 12px; gap: 12px;
/* Foot inside the centered box floats the stack a bit above true center. */
padding-bottom: 32px;
width: min(776px, calc(100% - 48px)); width: min(776px, calc(100% - 48px));
z-index: 1; z-index: 1;
} }

View File

@@ -36,27 +36,42 @@ export function ConversationRoot({
workspace => workspace.workspaceId === pendingWorkspaceId, workspace => workspace.workspaceId === pendingWorkspaceId,
) )
// Clear the pending pick once the session lands in it, or when the picked
// workspace disappears from a ready list (deleted from the sidebar).
useEffect(() => { useEffect(() => {
if (pendingWorkspaceId !== undefined if (pendingWorkspaceId === undefined) return
&& sessionWorkspace?.workspaceId === pendingWorkspaceId) { if (sessionWorkspace?.workspaceId === pendingWorkspaceId
|| (workspaces.phase === 'ready' && pendingWorkspace === undefined)) {
setPendingWorkspaceId(undefined) setPendingWorkspaceId(undefined)
} }
}, [pendingWorkspaceId, sessionWorkspace?.workspaceId]) }, [pendingWorkspaceId, sessionWorkspace?.workspaceId, workspaces.phase, pendingWorkspace])
const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading')) const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))
const zone: InputZone | undefined = const zone: InputZone | undefined =
session === undefined || inputState === undefined ? undefined : { session, input: inputState } session === undefined || inputState === undefined ? undefined : { session, input: inputState }
// Flow optimization — worth a close PR review for code/boundary issues.
// The chip is a selector; label resolution walks the flow top-down:
// 1. a just-picked workspace (pending) → its title;
// 2. cold start, no session yet → placeholder ("Choose workspace");
// 3. the blank session's workspace is in the list → its title;
// 4. list still loading → cwd folder name bridges so the title does not
// flash on refresh (empty cwd → placeholder);
// 5. list ready but no owning workspace (deleted from the sidebar) →
// placeholder, never the deleted folder's name via cwd.
const chipTitle = pendingWorkspace?.title
?? (sessionId === undefined
? undefined
: sessionWorkspace?.title
?? (workspaces.phase === 'ready' || cwd === undefined || cwd === ''
? undefined
: workspaceLabel(cwd)))
const heroWorkspaceRow = ( const heroWorkspaceRow = (
<div className={css.heroWorkspaceRow}> <div className={css.heroWorkspaceRow}>
<WorkspaceChip <WorkspaceChip
buttonRef={pickerAnchor} buttonRef={pickerAnchor}
label={ label={chipTitle}
pendingWorkspace?.title
?? (sessionId === undefined
? workspaceLabel('')
: sessionWorkspace?.title ?? workspaceLabel(cwd ?? ''))
}
menuOpen={pickerOpen} menuOpen={pickerOpen}
onClick={() => { setPickerOpen(open => !open) }} onClick={() => { setPickerOpen(open => !open) }}
/> />

View File

@@ -7,20 +7,18 @@
import { useId } from 'react' import { useId } from 'react'
import type { ReactNode, RefObject } from 'react' import type { ReactNode, RefObject } from 'react'
import { import {
FishLogo, IconChevronDownOutline14, IconFolderOpen16, FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16,
} from '@deepseek-ai/dsh-client-ui-primitives' } from '@deepseek-ai/dsh-client-ui-primitives'
import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client' import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client'
import css from './HeroShell.module.css' import css from './HeroShell.module.css'
/** /**
* Basename label for the workspace chip / menu rows (the shared derivation); * Basename label for the workspace chip (the shared derivation);
* empty → the design's "New Workspace" placeholder copy; separator-only * separator-only paths echo the raw cwd.
* paths echo the raw cwd. * @param cwd - workspace directory path (non-empty).
* @param cwd - workspace directory path ('' for none).
* @returns chip label. * @returns chip label.
*/ */
export function workspaceLabel(cwd: string): string { export function workspaceLabel(cwd: string): string {
if (cwd === '') return 'New Workspace'
const base = workspaceTitleOf(cwd) const base = workspaceTitleOf(cwd)
return base !== '' ? base : cwd return base !== '' ? base : cwd
} }
@@ -28,15 +26,17 @@ export function workspaceLabel(cwd: string): string {
/** /**
* The workspace chip (folder + label + chevron), always interactive: before * The workspace chip (folder + label + chevron), always interactive: before
* the first message the workspace stays switchable — picking another one * the first message the workspace stays switchable — picking another one
* moves the New Session flow to that workspace's blank session. * moves the New Session flow to that workspace's blank session. Without a
* @param props.label - chip label (see {@link workspaceLabel}). * label the chip renders its placeholder state: closed folder + the
* "Choose workspace" call to action.
* @param props.label - chip label (see {@link workspaceLabel}); omitted → placeholder.
* @param props.menuOpen - menu expansion echo. * @param props.menuOpen - menu expansion echo.
* @param props.onClick - menu toggle. * @param props.onClick - menu toggle.
* @returns the chip button element. * @returns the chip button element.
*/ */
export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: { export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
buttonRef?: RefObject<HTMLButtonElement> buttonRef?: RefObject<HTMLButtonElement>
label: string label?: string | undefined
menuOpen?: boolean menuOpen?: boolean
onClick?: () => void onClick?: () => void
}) { }) {
@@ -50,8 +50,10 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
aria-expanded={menuOpen} aria-expanded={menuOpen}
onClick={onClick} onClick={onClick}
> >
<IconFolderOpen16 className={css.folder} size={16} /> {label === undefined
<span className={css.workspaceLabel}>{label}</span> ? <IconFolderClose16 className={css.folder} size={16} />
: <IconFolderOpen16 className={css.folder} size={16} />}
<span className={css.workspaceLabel}>{label ?? 'Choose workspace'}</span>
<IconChevronDownOutline14 className={css.chevron} size={12} /> <IconChevronDownOutline14 className={css.chevron} size={12} />
</button> </button>
) )

View File

@@ -79,13 +79,19 @@
/* Brand group (figma I133:7632): the full wordmark rides the text ink /* Brand group (figma I133:7632): the full wordmark rides the text ink
(figma-flows ruling: main-screen instance is black; blue is brand (figma-flows ruling: main-screen instance is black; blue is brand
emphasis only). */ emphasis only). A button only in behavior (New Session shortcut): the
pointer cursor is the sole affordance — no hover chrome on the mark. */
.brand { .brand {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
overflow: hidden; overflow: hidden;
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
} }
.iconButton { .iconButton {

View File

@@ -61,10 +61,17 @@ export function SidebarRoot({
style={wide ? { width: collapsed ? lastWideWidth.current : width } : undefined} style={wide ? { width: collapsed ? lastWideWidth.current : width } : undefined}
> >
<div className={css.logoRow}> <div className={css.logoRow}>
{/* Expanded, the wordmark doubles as a New Session shortcut; the
collapsed rail's logo is the expand toggle below instead. */}
{wide && ( {wide && (
<span className={clsx(css.brand, css.wide)}> <button
type="button"
className={clsx(css.brand, css.wide)}
aria-label="New session"
onClick={() => { startSession() }}
>
<BrandWordmark /> <BrandWordmark />
</span> </button>
)} )}
{/* Rail resting state is the whale mark; hovering swaps in the panel {/* Rail resting state is the whale mark; hovering swaps in the panel
icon (the expand affordance, figma sidebar-hover flow). */} icon (the expand affordance, figma sidebar-hover flow). */}

View File

@@ -54,10 +54,13 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
} }
describe('SidebarRoot shell', () => { describe('SidebarRoot shell', () => {
it('routes New Session and the column toggle', () => { it('routes New Session (capsule + wordmark) and the column toggle', () => {
const b = mountShell() const b = mountShell()
fireEvent.click(screen.getByRole('button', { name: 'New session' })) // Expanded, both the wordmark and the capsule start a session.
expect(b.startSession).toHaveBeenCalledWith() const starters = screen.getAllByRole('button', { name: 'New session' })
expect(starters).toHaveLength(2)
for (const button of starters) fireEvent.click(button)
expect(b.startSession).toHaveBeenCalledTimes(2)
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' })) fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
expect(b.toggleSidebar).toHaveBeenCalledOnce() expect(b.toggleSidebar).toHaveBeenCalledOnce()
}) })