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;
/* figma 75:8208: 12 between hero chrome / workspace row / card. */
gap: 12px;
/* Foot inside the centered box floats the stack a bit above true center. */
padding-bottom: 32px;
width: min(776px, calc(100% - 48px));
z-index: 1;
}

View File

@@ -36,27 +36,42 @@ export function ConversationRoot({
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(() => {
if (pendingWorkspaceId !== undefined
&& sessionWorkspace?.workspaceId === pendingWorkspaceId) {
if (pendingWorkspaceId === undefined) return
if (sessionWorkspace?.workspaceId === pendingWorkspaceId
|| (workspaces.phase === 'ready' && pendingWorkspace === undefined)) {
setPendingWorkspaceId(undefined)
}
}, [pendingWorkspaceId, sessionWorkspace?.workspaceId])
}, [pendingWorkspaceId, sessionWorkspace?.workspaceId, workspaces.phase, pendingWorkspace])
const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))
const zone: InputZone | undefined =
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 = (
<div className={css.heroWorkspaceRow}>
<WorkspaceChip
buttonRef={pickerAnchor}
label={
pendingWorkspace?.title
?? (sessionId === undefined
? workspaceLabel('')
: sessionWorkspace?.title ?? workspaceLabel(cwd ?? ''))
}
label={chipTitle}
menuOpen={pickerOpen}
onClick={() => { setPickerOpen(open => !open) }}
/>

View File

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

View File

@@ -79,13 +79,19 @@
/* Brand group (figma I133:7632): the full wordmark rides the text ink
(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 {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: center;
overflow: hidden;
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
}
.iconButton {

View File

@@ -61,10 +61,17 @@ export function SidebarRoot({
style={wide ? { width: collapsed ? lastWideWidth.current : width } : undefined}
>
<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 && (
<span className={clsx(css.brand, css.wide)}>
<button
type="button"
className={clsx(css.brand, css.wide)}
aria-label="New session"
onClick={() => { startSession() }}
>
<BrandWordmark />
</span>
</button>
)}
{/* Rail resting state is the whale mark; hovering swaps in the panel
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', () => {
it('routes New Session and the column toggle', () => {
it('routes New Session (capsule + wordmark) and the column toggle', () => {
const b = mountShell()
fireEvent.click(screen.getByRole('button', { name: 'New session' }))
expect(b.startSession).toHaveBeenCalledWith()
// Expanded, both the wordmark and the capsule start a session.
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' }))
expect(b.toggleSidebar).toHaveBeenCalledOnce()
})