Przeglądaj źródła

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.
Yif 1 miesiąc temu
rodzic
commit
ae76ed2874

+ 2 - 0
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css

@@ -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;
 }

+ 24 - 9
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx

@@ -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) }}
       />

+ 13 - 11
packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx

@@ -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>
   )

+ 7 - 1
packages/client/ui-sidebar/src/client/SidebarRoot.module.css

@@ -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 {

+ 9 - 2
packages/client/ui-sidebar/src/client/SidebarRoot.tsx

@@ -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). */}

+ 6 - 3
packages/client/ui-sidebar/tests/sidebar-root.spec.tsx

@@ -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()
   })