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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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). */}
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user