diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 515147795b..24518aa030 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2568,9 +2568,8 @@ function AppShellContent({ titlebarProjectName ? { name: titlebarProjectName, - ...(activeProjectCapabilities.viewClientPath - ? { onOpenFolder: openProjectFolder } - : {}), + path: projectInfo?.projectPath, + onOpenFolder: activeProjectCapabilities.viewClientPath ? openProjectFolder : undefined, } : undefined } diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index db342b090c..fe3e1b6278 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -889,15 +889,8 @@ var(--space-2) + var(--maka-titlebar-overlay-right-width) ); --maka-titlebar-gap: var(--space-2); - /* What the strip keeps for itself. This window has no OS title bar, so the - strip IS the drag handle, and the session breadcrumb is a grid item that - grows to its content: with a maximal name it took the whole middle column - and left an 8px band between it and the actions cluster (measured, 1000px - window: draggable width across the strip fell from ~730px to 216px, almost - all of it left of the breadcrumb). A name is worth reading, but not at the - price of being unable to move the window — so the trail truncates a little - sooner and this much stays draggable. */ - --maka-titlebar-drag-reserve: 25%; + /* Keep a drag target beside the title without reserving a quarter of wide windows. */ + --maka-titlebar-drag-reserve: min(25%, var(--space-12)); /* session list geometry */ diff --git a/apps/desktop/src/renderer/styles/shell-layout.css b/apps/desktop/src/renderer/styles/shell-layout.css index ff67677784..ef1368dc9f 100644 --- a/apps/desktop/src/renderer/styles/shell-layout.css +++ b/apps/desktop/src/renderer/styles/shell-layout.css @@ -271,10 +271,9 @@ OS reports (macOS: traffic lights on the left; Windows: caption buttons on the right; Linux: neither, so the design floors stand). */ padding-left: var(--maka-titlebar-gutter-left); - padding-right: calc( - var(--maka-titlebar-gutter-right) + var(--maka-titlebar-workbar-reserve, 0px) - ); + padding-right: calc(var(--maka-titlebar-gutter-right) + var(--maka-titlebar-workbar-reserve, 0px)); -webkit-app-region: drag; + transition: padding-right var(--duration-large) var(--ease-out-strong); /* Three columns, not a flex row, because the middle one has to line up with something OUTSIDE this strip. The strip spans the whole window while the @@ -330,6 +329,10 @@ ); } +.appFrame:has(.maka-workbar-resize-handle[data-resizing]) .maka-window-titlebar { + transition: none; +} + /* Drag regions are hit-tested from element rects, and the top layer is invisible to that pass: a `showModal()` dialog paints over the titlebar, but its controls inside the titlebar rect still reach the OS as window drags. */ @@ -359,37 +362,13 @@ dialog:modal { color: var(--muted-foreground); } -/* The session's identity: left-aligned at the content column's left edge. - - It sits in the titlebar's second grid column, which opens exactly where the - sidebar column ends, so the breadcrumb reads as the heading of the - conversation plate beneath it. Laid out in flow instead, it anchored to the - icon rail and landed mid-sidebar — straddling the seam, aligned to neither - column. Collapsing the sidebar narrows column 1 and the breadcrumb follows it - left — flush with the plate while the sidebar is wide enough to be the - binding measure, and held off the icon rail by the strip's own gap once the - icons are wider than the collapsed column. Both readings are one rule: the - column's floor is the sidebar, its ceiling the icons. - - `padding-left` keeps the text off the seam itself. It matches the inset the - sidebar's own rows carry, so the two columns' text starts read as a pair - rather than one label welded to the divider. - - Both plates below reserve the full `--h-titlebar` (`.mainColumn` via - `--maka-plate-titlebar-clearance`, `.maka-session-panel` directly), so this - strip is empty for its whole width and the label collides with nothing. - - `min-width: 0` + `justify-self: start`: the column is `minmax(0, 1fr)`, so on - a narrow window this is what gives way, truncating its own segments instead - of squeezing the two action clusters that flank it. `no-drag` covers only - this box — the strip stays draggable on both sides of the text, exactly as it - already is around the icon clusters. */ +/* The title hugs its controls; the remaining track stays draggable. */ .maka-titlebar-identity { grid-column: 2; justify-self: start; min-width: 0; - /* Never the whole column: what is left of it is the strip's drag band. */ max-width: calc(100% - var(--maka-titlebar-drag-reserve)); + box-sizing: border-box; display: flex; padding-left: var(--space-3); -webkit-app-region: no-drag; @@ -399,109 +378,40 @@ dialog:modal { .maka-titlebar-identity__action { flex: 0 0 auto; + display: inline-flex; } -/* Every box between the strip's column and the text has to agree to shrink, or - none of them do. `min-width: 0` on the outer box alone clamped only that box: - the nav, the crumb buttons and the segments kept `min-width: auto`, so with a - long name the text ran on past the clamp — measured 2408px inside a 1320px - window — painting over the workspace actions and, worse, spilling the visible - tail into the strip's DRAG region, where a click on the session name reaches - the OS as a window drag. The ellipsis never engaged, because nothing upstream - ever handed these elements a smaller size to ellipsize into. */ -.maka-titlebar-identity__breadcrumbs { - min-width: 0; - overflow: hidden; -} - -.maka-titlebar-identity__breadcrumbs > ol > li > button { - min-width: 0; - overflow: hidden; -} - -.maka-titlebar-identity__breadcrumbs > ol { - min-width: 0; - flex-wrap: nowrap; - overflow: hidden; -} - -.maka-titlebar-identity__breadcrumbs > ol > li { +.maka-titlebar-identity__name { min-width: 0; overflow: hidden; + padding-inline: var(--space-1); } -.maka-titlebar-identity__segment { - display: block; - min-width: 0; +.maka-titlebar-identity__segment--session { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-weight: var(--font-weight-medium); + color: var(--foreground); } -/* Astryx styles every non-current crumb as a link, and its hover is an - underline. In this app an underline means "this leaves for the open web" - (`.maka-web-search-preview a` is its only other use); these two crumbs open a - folder and start a rename. Both are in-app actions, so they take the hover - the rest of the app's controls take — the `--state-hover-bg` wash — and the - underline is declined. - - Keyed on the button rather than the segment span so the wash covers the whole - hit target. What clears the StyleX atom is LAYER ORDER, not specificity: - product CSS imports into `components`, which cascade-layers.css declares - last, and Astryx's atoms carry `:not(#\#):not(#\#)` — on specificity alone - the atom wins. */ -.maka-titlebar-identity__breadcrumbs button { - border-radius: var(--radius-control); - padding-inline: var(--space-1); - transition: background-color var(--duration-quick) var(--ease-out-strong); -} - -.maka-titlebar-identity__breadcrumbs button:hover { - text-decoration: none; - background: var(--state-hover-bg); -} - -.maka-titlebar-identity__breadcrumbs button:focus-visible { - /* Inset, not outline: the breadcrumb item ancestor clips 3px of an - outward ring (measured by the T5 geometry probe) — a ring that cannot - fully paint is worse than none. Same idiom as the workbar launcher. */ - outline: none; - box-shadow: inset 0 0 0 var(--focus-ring-width) var(--focus-ring); +.maka-titlebar-menu__project { + padding: var(--space-1) var(--space-2); + color: var(--muted-foreground); + font-size: var(--text-label-size); + line-height: var(--text-label-leading); + white-space: pre-wrap; + overflow-wrap: anywhere; } -/* The session name carries the weight of the pair: it is what the window is - showing, and the project is the context it sits in. Astryx gives every - non-current crumb the same secondary ink, and this one has to be non-current - to stay clickable (see the `isCurrent={false}` note in the component), so the - emphasis is restored here rather than inherited. - - The target is the sidebar's own selected row — same size, same weight, same - ink. One session should not have two different weights depending on which - surface is naming it, and the titlebar has no business being the lighter of - the two. */ -.maka-titlebar-identity__segment--session { +.maka-titlebar-menu__project-name { font-weight: var(--font-weight-medium); color: var(--foreground); } -/* The rename field stands in for the session crumb ALONE — it renders inside - that crumb, so the project crumb and the separator keep their places — and it - matches the label it replaces exactly in size, weight and ink, because the - name must not shift when it becomes editable. - - `field-sizing: content` is what keeps the row still: the field opens at the - width of the text it is editing and grows with what is typed. A fixed width - made starting a rename shove everything beside it, which is the same jump - from the other direction. `min-width` keeps an empty field clickable; the - `max-width` is the truncation ceiling the label itself has. - - The field claims no box of its own beyond that: no border, and a focus line - drawn with `box-shadow`, which paints outside layout. The bottom border it - used to carry was 1px the crumb did not have, so the trail lost a pixel of - height the moment the field appeared. */ .maka-titlebar-identity__rename-input { min-width: 2rem; - max-width: min(20rem, 100%); + max-width: 100%; background: transparent; border: 0; box-shadow: none; @@ -515,18 +425,12 @@ dialog:modal { background: transparent; border: 0; outline: 0; - box-shadow: 0 1px 0 oklch(from var(--focus-ring) l c h / 0.4); + box-shadow: 0 1px 0 var(--focus-ring); field-sizing: content; padding-block: 0; - /* The crumb button's own inline padding, so the first glyph does not step - 4px left the moment the label becomes a field. */ padding-inline: var(--space-1); min-width: 2rem; - max-width: min(20rem, 100%); -} - -.maka-titlebar-identity__rename-input input:focus { - box-shadow: 0 1px 0 var(--focus-ring); + max-width: 100%; } .mainColumn { diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 0afc9d087a..af3075be8b 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -303,6 +303,7 @@ function ShellFrame(props: { style={ { minHeight: 640, + '--maka-session-workbar-width': `${props.workbarWidth ?? 480}px`, height: props.height, /* Same publication point as production, for the same reason as `data-sidebar-state` above: the titlebar's first grid track is a @@ -313,9 +314,6 @@ function ShellFrame(props: { left to the CSS rule, exactly as in the app. `SessionListPanel`'s own default width. */ ...(props.sidebarCollapsed ? null : { '--maka-sidenav-width': '260px' }), - ...(props.workbarWidth === undefined - ? null - : { '--maka-session-workbar-width': `${props.workbarWidth}px` }), } as CSSProperties } > @@ -360,9 +358,9 @@ function ComposedShell(props: { /** Drives the footer's update action; `undefined` is the silent phase. */ updateReminder?: SessionListPanelProps['updateReminder']; workbarCollapsed?: boolean; - onToggleWorkbar?: () => void; workbarWidth?: number; - titlebarAction?: ComponentProps['action']; + onShare?: () => void; + onToggleWorkbar?: () => void; }) { const [collapsed, setCollapsed] = useState(props.sidebarCollapsed ?? false); const [viewMode, setViewMode] = useState(props.initialViewMode ?? 'conversation'); @@ -424,14 +422,14 @@ function ComposedShell(props: { {active && ( { const name = deriveTitlebarProjectName({ projectName: catalogProjects.find((item) => item.id === active.projectId)?.name, projectPath: active.cwd, }); - return name ? { name, onOpenFolder: noop } : undefined; + return name ? { name, path: active.cwd, onOpenFolder: noop } : undefined; })()} /> )} @@ -1501,18 +1499,86 @@ export const SessionContextLayerPaused: Story = { }, }; -// The titlebar states the session's identity in every session view, so the -// stories above already show its ordinary state. These two cover what they -// cannot: a session with no directory to name, and a name long enough to reach -// the action cluster. - -// Real path: a session started before any project was picked. The breadcrumb -// collapses to the session name — a leading empty crumb would read as a project -// whose name failed to load. export const TitlebarIdentityWithoutProject: Story = { render: () => , }; +export const TitlebarProjectFeedbackNarrow: Story = { + render: () => ( + +
+ +
+
+ ), + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const original = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + const writeText = fn().mockRejectedValueOnce(new Error('Clipboard unavailable')).mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + try { + await userEvent.click(page.getByRole('button', { name: '项目信息' })); + const menu = await page.findByRole('menu', { name: '项目信息' }); + const name = menu.querySelector('.maka-titlebar-menu__project-name')!; + const path = name.nextElementSibling!; + expect(name.getBoundingClientRect().height).toBeGreaterThan(Number.parseFloat(getComputedStyle(name).lineHeight)); + expect(getComputedStyle(name).fontSize).toBe('14px'); + expect(getComputedStyle(path).fontSize).toBe('14px'); + expect(getComputedStyle(name).fontWeight).toBe('500'); + expect(getComputedStyle(name).color).not.toBe(getComputedStyle(path).color); + await userEvent.click(within(menu).getByRole('menuitem', { name: '复制路径' })); + await waitFor(() => expect(within(menu).getByRole('menuitem', { name: '复制失败' })).toBeVisible()); + await userEvent.click(within(menu).getByRole('menuitem', { name: '复制失败' })); + await waitFor(() => expect(within(menu).getByRole('menuitem', { name: '已复制' })).toBeVisible()); + expect(writeText).toHaveBeenCalledTimes(2); + expect(writeText).toHaveBeenLastCalledWith('/workspace/maka-agent'); + await userEvent.keyboard('{Escape}'); + expect(document.activeElement).toBe(page.getByRole('button', { name: '项目信息' })); + } finally { + if (original) Object.defineProperty(navigator, 'clipboard', original); + else Reflect.deleteProperty(navigator, 'clipboard'); + } + }, +}; + +export const TitlebarParentReturn: Story = { + render: function ParentReturn() { + const names = ['发布新版网站', '检查登录功能', '复现登录失败']; + const [level, setLevel] = useState(2); + return ( + +
+ 0 ? { name: names[level - 1]!, onOpen: () => setLevel(level - 1) } : undefined} + /> +
+
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.queryByRole('button', { name: '项目信息' })).toBeNull(); + await userEvent.click(canvas.getByRole('button', { name: '复现登录失败 任务操作' })); + const page = within(canvasElement.ownerDocument.body); + expect(await page.findByRole('menuitem', { name: '复制路径' })).toBeVisible(); + await userEvent.keyboard('{Escape}'); + await userEvent.click(canvas.getByRole('button', { name: '返回父任务「检查登录功能」' })); + expect(canvas.getByRole('button', { name: '检查登录功能 — 重命名任务' })).toBeVisible(); + await userEvent.click(canvas.getByRole('button', { name: '返回父任务「发布新版网站」' })); + expect(canvas.getByRole('button', { name: '发布新版网站 — 重命名任务' })).toBeVisible(); + expect(canvas.queryByRole('button', { name: /返回父任务/ })).toBeNull(); + expect(canvas.getByRole('button', { name: '项目信息' })).toBeVisible(); + }, +}; + // Real path: a long auto-generated session name, sidebar collapsed so the // identity sits closest to the conversation column. It must truncate itself // rather than push the workbar toggle off the strip. @@ -3044,33 +3110,24 @@ const workbarLayoutWithOneFace: WorkbarLayoutState = reduceWorkbarLayout( { type: 'open', placement: 'right', tab: { id: 'workbar:files', kind: 'files' } }, ); -function WorkbarInShell(props: { - sessionName?: string; - titlebarAction?: ComponentProps['action']; - workbarWidth?: number; -}) { +function WorkbarInShell(props: { longTitle?: boolean; onShare?: () => void; workbarWidth?: number } = {}) { const [layout, dispatch] = useReducer(reduceWorkbarLayout, workbarLayoutWithOneFace); + const workbarWidth = props.workbarWidth ?? layout.rightWidth; const rightCollapsed = isSessionWorkbarCollapsed(layout); const collapseRight = (collapsed: boolean) => dispatch({ type: 'collapse', placement: 'right', collapsed }); - const workbarWidth = props.workbarWidth ?? layout.rightWidth; return ( collapseRight(!rightCollapsed)} workbarWidth={workbarWidth} + session={props.longTitle ? { name: '主对话标题与右侧工作栏的宽度和信息层级验证 Long conversation title' } : undefined} + onShare={props.onShare} + onToggleWorkbar={() => collapseRight(!rightCollapsed)} detailChildren={ -
+
, + play: async ({ canvasElement }) => { + const frame = canvasElement.querySelector('.appFrame')!; + const title = canvasElement.querySelector('.maka-titlebar-identity')!; + const workbar = canvasElement.querySelector('.maka-session-workbar[data-placement="right"]')!; + const menuButton = title.querySelector('[aria-label$="任务操作"]')!; + const bounds = () => { + const box = title.getBoundingClientRect(); + const boundary = window.innerWidth > 990 + ? workbar.getBoundingClientRect().left + : frame.getBoundingClientRect().right; + expect(box.right).toBeLessThanOrEqual(boundary); + const action = menuButton.getBoundingClientRect(); + expect(action.width).toBeGreaterThanOrEqual(24); + expect(action.right).toBeLessThanOrEqual(boundary); + expect(document.elementFromPoint(action.x + action.width / 2, action.y + action.height / 2)?.closest('button')).toBe(menuButton); + }; + // Read the rendered columns at several controller widths, including the resize limits. + for (const width of [340, 600, 480]) { + frame.style.setProperty('--maka-session-workbar-width', `${width}px`); + if (window.innerWidth > 990) { + await waitFor(() => expect(workbar.getBoundingClientRect().width).toBe(width)); + } + await waitFor(bounds); + } + menuButton.focus(); + expect(document.activeElement).toBe(menuButton); + expect(getComputedStyle(title).getPropertyValue('-webkit-app-region')).toBe('no-drag'); + expect(getComputedStyle(canvasElement.querySelector('.maka-window-titlebar')!).getPropertyValue('-webkit-app-region')).toBe('drag'); + const rename = title.querySelector('.maka-titlebar-identity__segment--session')!.closest('button')!; + await userEvent.click(rename); + const input = title.querySelector('input')!; + expect(document.activeElement).toBe(input); + await userEvent.keyboard('{Escape}'); + expect(document.activeElement).toBe(title.querySelector('.maka-titlebar-identity__segment--session')!.closest('button')); + await userEvent.click(menuButton); + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole('menuitem', { name: '重命名' })); + await waitFor(() => expect(document.activeElement).toBe(title.querySelector('input'))); + await userEvent.keyboard('{Escape}'); + await userEvent.click(within(title).getByRole('button', { name: '项目信息' })); + await waitFor(() => expect(page.getByRole('menuitem', { name: '打开项目文件夹' })).toBeVisible()); + await waitFor(() => expect(page.getByRole('menuitem', { name: '复制路径' })).toBeVisible()); + expect(within(page.getByRole('menu', { name: '项目信息' })).queryByRole('menuitem', { name: '重命名' })).toBeNull(); + await userEvent.keyboard('{Escape}'); + titlebarShare.mockClear(); + await userEvent.click(menuButton); + await userEvent.click(await page.findByRole('menuitem', { name: '分享任务' })); + expect(titlebarShare).toHaveBeenCalledTimes(1); + await waitFor(() => expect(document.activeElement).toBe(menuButton)); + }, +}; + // Real path: 收起一个开着面的工作栏 → 从标题栏再展开. The face is opened by // dispatching the app's own `open` action rather than by clicking through the // launcher, so the story starts where the app does without re-testing the @@ -3250,47 +3363,14 @@ export const WorkbarCollapseKeepsOneToggleInPlace: Story = { }, }; -const longWorkbarTitle = - 'Investigate why the completed plan session title overlaps the token usage dashboard'; - -const wideWorkbarShare = fn(); - -export const WorkbarKeepsTitlebarClear: Story = { - render: () => ( - - ), - play: async ({ canvasElement }) => { - wideWorkbarShare.mockClear(); - const canvas = within(canvasElement); - const identity = canvasElement.querySelector( - '[data-maka-contract="titlebar-identity"]', - ); - const workbar = canvasElement.querySelector( - '.maka-session-workbar[data-placement="right"]:not([data-collapsed])', - ); - if (!identity || !workbar) throw new Error('the titlebar or right workbar is missing'); - - await waitFor(() => expect(workbar.getBoundingClientRect().width).toBeCloseTo(600, 0)); - expect(identity.getBoundingClientRect().right).toBeLessThanOrEqual( - workbar.getBoundingClientRect().left, - ); - - await userEvent.click(canvas.getByRole('button', { name: '分享此任务' })); - expect(wideWorkbarShare).toHaveBeenCalledOnce(); - }, -}; const narrowWorkbarShare = fn(); export const NarrowWorkbarClearsTitlebarReserve: Story = { render: () => ( ), @@ -3326,7 +3406,7 @@ export const NarrowWorkbarClearsTitlebarReserve: Story = { ); }); - const share = canvas.getByRole('button', { name: '分享此任务' }); + const share = identity.querySelector('[aria-label$="任务操作"]')!; await waitFor(() => expect(share.getBoundingClientRect().left).toBeGreaterThanOrEqual( titlebar.getBoundingClientRect().left, @@ -3341,6 +3421,7 @@ export const NarrowWorkbarClearsTitlebarReserve: Story = { ); await userEvent.click(share); + await userEvent.click(await within(canvasElement.ownerDocument.body).findByRole('menuitem', { name: '分享任务' })); expect(narrowWorkbarShare).toHaveBeenCalledOnce(); }, }; diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index ca5ab66316..4d02b3de02 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -273,7 +273,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/skill-inspector.tsx` | shell-chrome-or-panel | Button, Divider, HStack, Heading, MetadataList, MetadataListItem, StackItem, StatusDot, Switch, Text, VStack | aligned — uses Astryx (Button, Divider, HStack, Heading, MetadataList, MetadataListItem, StackItem, StatusDot) | aligned | | `packages/ui/src/skills-panel.tsx` | module-hub | Button, DropdownMenu, DropdownMenuItem, EmptyState, IconButton, List, ListItem, SegmentedControl, SegmentedControlItem, Selector, StatusDot, Text, TextInput, Toolbar | aligned — uses Astryx (Button, DropdownMenu, DropdownMenuItem, EmptyState, IconButton, List, ListItem, SegmentedControl) | aligned | | `packages/ui/src/styles.css` | ui-composition | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | -| `packages/ui/src/titlebar-session-identity.tsx` | shell-chrome-or-panel | BreadcrumbItem, Breadcrumbs, Icon, IconButton, Tooltip | aligned — uses Astryx (BreadcrumbItem, Breadcrumbs, Icon, IconButton, Tooltip) | aligned | +| `packages/ui/src/titlebar-session-identity.tsx` | shell-chrome-or-panel | Button, DropdownMenu, DropdownMenuItem, IconButton | aligned — uses Astryx (Button, DropdownMenu, DropdownMenuItem, IconButton) | aligned | | `packages/ui/src/toast.tsx` | ui-composition | AlertDialog, Button, HStack, LayerProvider, Text, VStack | aligned — uses Astryx (AlertDialog, Button, HStack, LayerProvider, Text, VStack) | aligned | | `packages/ui/src/tool-activity.tsx` | ui-composition | Banner, Button, ChatToolCalls, List, ListItem, StatusDot, Text, VisuallyHidden | aligned — uses Astryx (Banner, Button, ChatToolCalls, List, ListItem, StatusDot, Text, VisuallyHidden) | aligned | | `packages/ui/src/tool-activity/diff-code-preview.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 9fe705f1ff..485d988766 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -392,13 +392,10 @@ export interface ConversationCopy { sessionContextAriaLabel: string; sessionLineageAriaLabel: string; titlebarIdentityAriaLabel: string; - openProjectFolder: (name: string) => string; - /** Action phrase appended to the titlebar project crumb's accessible name. */ openProjectFolderAction: string; - /** Tooltip / accessible name for the parent crumb when a linked child is open. */ + projectInfo: string; + copyProjectPath: string; openParentSession: (name: string) => string; - /** Action phrase appended to the titlebar parent crumb's accessible name. */ - openParentSessionAction: string; sessionContextMore: (count: number) => string; revisionVersionsAriaLabel: string; revisionVersion: (current: number, total: number) => string; @@ -613,8 +610,8 @@ const CONVERSATION_COPY = { goalPausedAriaLabel: '自主目标已暂停', pauseGoalAriaLabel: (iteration, max) => `暂停自主执行目标(已进行 ${iteration}/${max} 轮)`, resumeGoalAriaLabel: (iteration, max) => `恢复自主执行目标(已进行 ${iteration}/${max} 轮)`, pauseGoal: (condition, iteration, max, status) => `暂停自主执行目标:「${condition}」(第 ${iteration}/${max} 轮,${status})。暂停后立即停止自动续行,不再消耗令牌;可随时恢复。`, resumeGoal: (condition, iteration, max) => `恢复自主执行目标:「${condition}」(第 ${iteration}/${max} 轮)。恢复后立即继续自动续行。`, goalElapsed: (elapsedMs) => formatGoalElapsedUnits(elapsedMs, { second: ' 秒', minute: ' 分钟', hour: ' 小时', day: ' 天' }), goalTokens: (spent, budget) => `${formatCompactTokenCount(spent)} / ${formatCompactTokenCount(budget)}`, loadFailed: '任务载入失败', loading: '载入中…', retryLoad: '重试载入', quoteSelection: '引用', askInSidePanel: '在侧栏追问', noMessages: '暂无消息', branchBeforeInterrupt: '从中断前分支', sessionContextAriaLabel: '任务上下文', sessionLineageAriaLabel: '任务来源', sessionContextMore: (count) => `更多任务上下文(${count})`, - titlebarIdentityAriaLabel: '当前任务', openProjectFolder: (name) => `在文件管理器中打开「${name}」`, openProjectFolderAction: '打开项目文件夹', - openParentSession: (name) => `返回父任务「${name}」`, openParentSessionAction: '打开父任务', + titlebarIdentityAriaLabel: '当前任务', openProjectFolderAction: '打开项目文件夹', projectInfo: '项目信息', copyProjectPath: '复制路径', + openParentSession: (name) => `返回父任务「${name}」`, revisionVersionsAriaLabel: '任务版本', revisionVersion: (current, total) => `版本 ${current} / ${total}`, previousRevision: '查看上一版本', nextRevision: '查看下一版本', }, sessions: { @@ -772,8 +769,8 @@ const CONVERSATION_COPY = { goalPausedAriaLabel: '自主目標已暫停', pauseGoalAriaLabel: (iteration, max) => `暫停自主執行目標(已進行 ${iteration}/${max} 輪)`, resumeGoalAriaLabel: (iteration, max) => `恢復自主執行目標(已進行 ${iteration}/${max} 輪)`, pauseGoal: (condition, iteration, max, status) => `暫停自主執行目標:「${condition}」(第 ${iteration}/${max} 輪,${status})。暫停後立即停止自動續行,不再消耗權杖;可隨時恢復。`, resumeGoal: (condition, iteration, max) => `恢復自主執行目標:「${condition}」(第 ${iteration}/${max} 輪)。恢復後立即繼續自動續行。`, goalElapsed: (elapsedMs) => formatGoalElapsedUnits(elapsedMs, { second: ' 秒', minute: ' 分鐘', hour: ' 小時', day: ' 天' }), goalTokens: (spent, budget) => `${formatCompactTokenCount(spent)} / ${formatCompactTokenCount(budget)}`, loadFailed: '任務載入失敗', loading: '載入中…', retryLoad: '重試載入', quoteSelection: '引用', askInSidePanel: '在側欄追問', noMessages: '暫無訊息', branchBeforeInterrupt: '從中斷前分支', sessionContextAriaLabel: '任務上下文', sessionLineageAriaLabel: '任務來源', sessionContextMore: (count) => `更多工上下文(${count})`, - titlebarIdentityAriaLabel: '目前任務', openProjectFolder: (name) => `在檔案管理器中開啟「${name}」`, openProjectFolderAction: '開啟專案資料夾', - openParentSession: (name) => `返回父任務「${name}」`, openParentSessionAction: '開啟父任務', + titlebarIdentityAriaLabel: '目前任務', openProjectFolderAction: '開啟專案資料夾', projectInfo: '專案資訊', copyProjectPath: '複製路徑', + openParentSession: (name) => `返回父任務「${name}」`, revisionVersionsAriaLabel: '任務版本', revisionVersion: (current, total) => `版本 ${current} / ${total}`, previousRevision: '檢視上一版本', nextRevision: '檢視下一版本', }, sessions: { @@ -957,8 +954,8 @@ const CONVERSATION_COPY = { goalPausedAriaLabel: 'Autonomous goal paused', pauseGoalAriaLabel: (iteration, max) => `Pause autonomous goal after ${iteration}/${max} iterations`, resumeGoalAriaLabel: (iteration, max) => `Resume autonomous goal after ${iteration}/${max} iterations`, pauseGoal: (condition, iteration, max, status) => `Pause autonomous goal: “${condition}” (iteration ${iteration}/${max}, ${status}). Pausing stops autonomous continuation immediately — no more tokens burn; resume any time.`, resumeGoal: (condition, iteration, max) => `Resume autonomous goal: “${condition}” (iteration ${iteration}/${max}). Resuming continues autonomous iteration immediately.`, goalElapsed: (elapsedMs) => formatGoalElapsedUnits(elapsedMs, { second: 's', minute: 'm', hour: 'h', day: 'd' }), goalTokens: (spent, budget) => `${formatCompactTokenCount(spent)} / ${formatCompactTokenCount(budget)}`, loadFailed: 'Task failed to load', loading: 'Loading…', retryLoad: 'Retry', quoteSelection: 'Quote', askInSidePanel: 'Ask in side panel', noMessages: 'No messages yet', branchBeforeInterrupt: 'Branched before interruption', sessionContextAriaLabel: 'Task context', sessionLineageAriaLabel: 'Task origin', sessionContextMore: (count) => `More task context (${count})`, - titlebarIdentityAriaLabel: 'Current task', openProjectFolder: (name) => `Open “${name}” in the file manager`, openProjectFolderAction: 'Open project folder', - openParentSession: (name) => `Return to parent task “${name}”`, openParentSessionAction: 'Open parent task', + titlebarIdentityAriaLabel: 'Current task', openProjectFolderAction: 'Open project folder', projectInfo: 'Project information', copyProjectPath: 'Copy path', + openParentSession: (name) => `Return to parent task “${name}”`, revisionVersionsAriaLabel: 'Task versions', revisionVersion: (current, total) => `Version ${current} of ${total}`, previousRevision: 'View previous version', nextRevision: 'View next version', }, sessions: { diff --git a/packages/ui/src/icons.tsx b/packages/ui/src/icons.tsx index 31c4b9e326..4a1abe2b65 100644 --- a/packages/ui/src/icons.tsx +++ b/packages/ui/src/icons.tsx @@ -94,6 +94,7 @@ export { FileText, FileType, Flag, + Folder, FolderOpen, FolderGit2, GitBranch, diff --git a/packages/ui/src/titlebar-session-identity.tsx b/packages/ui/src/titlebar-session-identity.tsx index 6615a3c81b..a0920d2200 100644 --- a/packages/ui/src/titlebar-session-identity.tsx +++ b/packages/ui/src/titlebar-session-identity.tsx @@ -18,102 +18,40 @@ */ import { useEffect, useRef, useState } from 'react'; -import { BreadcrumbItem, Breadcrumbs } from '@astryxdesign/core/Breadcrumbs'; -import { Icon } from '@astryxdesign/core/Icon'; +import { Button } from '@astryxdesign/core/Button'; +import { DropdownMenu, DropdownMenuItem } from '@astryxdesign/core/DropdownMenu'; import { IconButton } from '@astryxdesign/core/IconButton'; -import { Tooltip } from '@astryxdesign/core/Tooltip'; -import { Share2 } from './icons.js'; +import { ArrowLeft, Folder, MoreHorizontal } from './icons.js'; import { getConversationCopy } from './conversation-copy.js'; import { InlineRenameInput } from './inline-rename-input.js'; +import { useClipboardCopyFeedback } from './clipboard-feedback.js'; import { useUiLocale } from './locale-context.js'; -/** - * Which project a session belongs to, as the titlebar needs to say it. - * - * `name` is the project record's name when the session is bound to one, and - * the working directory's own folder name when it only has a cwd — the - * titlebar answers "where am I", and a directory answers that whether or not - * the user ever registered it as a project. - */ export interface TitlebarProject { name: string; + path?: string; onOpenFolder?(): void; } -/** - * What to call the session's directory in the titlebar. - * - * A registered project's own name wins. Failing that, a session still has a - * cwd, and its folder name answers "where am I" perfectly well — the titlebar - * would otherwise fall silent for exactly the sessions started outside the - * project catalog. Undefined only when there is no directory at all, which - * collapses the breadcrumb to the session name alone. - * - * Splits on both separators: the path comes from the host OS, so a Windows - * session yields backslashes. Trailing separators are dropped first, so - * `/a/b/` names `b` rather than nothing, and a bare root names nothing rather - * than an empty segment. - */ -export function deriveTitlebarProjectName(options: { - projectName?: string; - projectPath?: string; -}): string | undefined { - if (options.projectName) return options.projectName; - const path = options.projectPath?.replace(/[/\\]+$/, ''); - if (!path) return undefined; - return path.split(/[/\\]/).pop() || undefined; -} - -/** - * Parent session trail segment for a linked subagent open in the main column. - * Clicking returns to the parent; the child is never listed in the sidebar. - */ export interface TitlebarParentSession { name: string; onOpen(): void; } -/** - * The session's identity in the window titlebar: `project › session name`, - * or `project › parent › child` when a linked subagent is open. - * - * Before this, an open session showed neither. The name lived only in the - * sidebar list — gone the moment the sidebar was collapsed — and the project - * lived only in the composer's WorkspacePicker, which renders only while NO - * session owns it, so it disappeared at the very moment the session gained a - * directory to be in. - * - * A breadcrumb rather than two labels: the session genuinely hangs off the - * project, it is the trail `SessionContextLayer` already speaks for session - * lineage, and it degrades to a single item when there is no project. - * - * The interactive segments carve `no-drag` rectangles out of the titlebar's - * drag surface, the same way the left rail and the workspace actions do. Each - * covers only its own text, so the strip stays draggable around them. - */ export function TitlebarSessionIdentity(props: { sessionName: string; onRenameSession(name: string): void; project?: TitlebarProject; - /** Immediate linked parent when viewing a child subagent session. */ parentSession?: TitlebarParentSession; readOnly?: boolean; action?: { readonly label: string; onClick(): void }; }) { const copy = getConversationCopy(useUiLocale()); + const clipboard = useClipboardCopyFeedback(undefined, { redact: false }); const [renaming, setRenaming] = useState(false); - const trailRef = useRef(null); + const nameRef = useRef(null); const handBackFocusRef = useRef(false); - /** - * Where focus goes when the edit ends. - * - * The crumb the user pressed to start the rename is not hidden while the - * field is up — it is unmounted — so when the edit ends a NEW button takes - * its place and focus has nowhere to fall back to but the document body, - * which puts the next Tab at the top of the window. Only a keyboard exit - * hands it back: a click-away already moved focus somewhere the user picked. - */ function endRename(handBackFocus: boolean) { handBackFocusRef.current = handBackFocus; setRenaming(false); @@ -122,123 +60,112 @@ export function TitlebarSessionIdentity(props: { useEffect(() => { if (renaming || !handBackFocusRef.current) return; handBackFocusRef.current = false; - trailRef.current - ?.querySelector('.maka-titlebar-identity__segment--session') - ?.closest('button') - ?.focus(); + nameRef.current?.focus(); }, [renaming]); + const path = props.project?.path; + const copyPhase = path ? clipboard.phaseFor(path) : null; + const copyLabel = copyPhase === 'pending' ? copy.messages.copying + : copyPhase === 'failed' ? copy.messages.copyFailed + : copyPhase === 'copied' ? copy.messages.copied : copy.chat.copyProjectPath; + const projectContent = props.project ? ( +
+
+
{props.project.name}
+ {path && path !== props.project.name ?
{path}
: null} +
+ {props.project.onOpenFolder ? ( + + ) : null} + {path ? ( + { void clipboard.copy(path, path); }} + /> + ) : null} +
+ ) : null; + return ( -
- {/* `default`, not `supporting`. Astryx documents supporting as the variant - for dense UIs "where the breadcrumb should be subtle", which this is - the opposite of: it is the window's statement of which session is - open. On supporting it rendered 12px/400/secondary — smaller, lighter - and greyer than the SAME session's row in the sidebar (14px/500/ - primary), so the highest-level label in the window was the weakest. */} - } - > - {/* The action is named inside the button, not on the
  • : BreadcrumbItem - spreads unknown props onto the list item, so an `aria-label` there - would leave the control itself still announced as the bare - "示例项目, button" — a name that says where it points but not that - pressing it does anything. The hidden phrase joins the visible text - in the name computation; `title` stays for the mouse. */} - {props.project ? ( - - - {props.project.name} - - {props.project.onOpenFolder ? ( - - {copy.chat.openProjectFolderAction} - - ) : null} - - ) : null} - {props.parentSession ? ( - - - {props.parentSession.name} - - - {copy.chat.openParentSessionAction} - - - ) : null} - {/* The rename happens INSIDE this crumb, not in place of the whole - trail. Swapping the trail wholesale unmounted the project crumb and - the separator, so starting a rename made the two things beside the - field disappear and the row re-lay out around a fixed-width input. - The edit is to one segment; only that segment should change. */} - {renaming ? ( - - { - endRename(via === 'keyboard'); - // An empty field is an abandoned edit, not a request for a - // session with no name — the sidebar's rename reads it the - // same way. - if (name && name !== props.sessionName) props.onRenameSession(name); - }} - onCancel={() => endRename(true)} - /> - - ) : props.readOnly ? ( - - - {props.sessionName} - - - ) : ( - /* `isCurrent={false}`, not the default: a current crumb renders as a - plain and DROPS onClick, so the rename - affordance would be dead and keyboard-unreachable. Passing false - also opts out of the auto-last-item detection — which would not - take the button away, but would mark an ACTION as the current - page. What is left is a link-styled + )} + {!props.readOnly || props.action || (props.parentSession && props.project) ? ( + + , isIconOnly: true, variant: 'ghost', size: 'sm' }} + hasChevron={false} + alignment="end" + > + {!props.readOnly ? setRenaming(true)} /> : null} + {props.action ? : null} + {props.parentSession ? projectContent : null} + + ) : null} + {copyPhase === 'failed' || copyPhase === 'copied' ? copyLabel : null}
  • ); } + +// The menu names a registered project, falling back to the session directory. +export function deriveTitlebarProjectName(options: { + projectName?: string; + projectPath?: string; +}): string | undefined { + if (options.projectName) return options.projectName; + const path = options.projectPath?.replace(/[/\\]+$/, ''); + if (!path) return undefined; + return path.split(/[/\\]/).pop() || undefined; +}