diff --git a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx index 5093658af88c..14cb89d3ddcd 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/coding-row.tsx @@ -3,17 +3,16 @@ import { memo, useEffect, useRef, useState } from 'react' import { WorktreeDialog } from '@/app/chat/sidebar/projects/worktree-dialog' import { StatusRow } from '@/components/chat/status-row' +import { + type ActionItemSpec, + ActionsContextMenu, + ActionsMenu, + type MenuKit, + renderActionItem +} from '@/components/ui/actions-menu' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { DiffCount } from '@/components/ui/diff-count' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' import type { HermesGitBranch } from '@/global' import { useI18n } from '@/i18n' import { $repoStatus, $repoWorktrees } from '@/store/coding-status' @@ -153,34 +152,87 @@ export const CodingStatusRow = memo(function CodingStatusRow({ // they're the only change (otherwise +/- tells the story). const untrackedOnly = !hasLineDelta && status.untracked > 0 + // The branch actions, rendered identically by the kebab dropdown and the + // row's right-click menu so the two never drift. `onBranchOff` gates the + // whole menu (omitted = remote backend), matching the kebab. + const renderBranchItems = (kit: MenuKit) => { + const branchItems: ActionItemSpec[] = branchTargets.map(target => ({ + key: target.base ?? '__head__', + label: {target.label}, + onSelect: () => startBranch(target.base) + })) + + const worktreeItems: ActionItemSpec[] = otherWorktrees.map(worktree => ({ + key: worktree.path, + label: {worktree.branch}, + onSelect: () => onOpenWorktree?.(worktree.path) + })) + + return ( + <> + {s.newBranch} + {branchItems.map(item => renderActionItem(kit, item))} + {switchTarget && + renderActionItem(kit, { + key: '__switch__', + label: {s.switchTo(switchTarget)}, + onSelect: () => void switchToBranch(switchTarget) + })} + + {s.worktrees} + {worktreeItems.map(item => renderActionItem(kit, item))} + {/* Create a fresh worktree off the current HEAD (the generic "spin up a + worktree here", mirroring the sidebar's + button). */} + {renderActionItem(kit, { + key: '__start__', + label: {p.startWork}, + onSelect: () => startBranch(undefined) + })} + {onConvertBranch && + renderActionItem(kit, { + key: '__convert__', + label: {p.convertBranch}, + onSelect: () => startBranch(undefined) + })} + + ) + } + return ( <> - } - onActivate={onOpen} - > -
- - {branchLabel} - + + } + onActivate={onOpen} + > +
+ + {branchLabel} + - {/* Branch actions kebab — same pattern as the session/worktree rows. - ALWAYS laid out; only its opacity flips on hover/focus/open, so - revealing it never reflows the row (no layout shift). pointer-events - follow opacity so the invisible trigger isn't clickable at rest. */} - {onBranchOff && ( - - + {/* Branch actions kebab — same pattern as the session/worktree rows. + ALWAYS laid out; only its opacity flips on hover/focus/open, so + revealing it never reflows the row (no layout shift). pointer-events + follow opacity so the invisible trigger isn't clickable at rest. */} + {onBranchOff && ( + - - {/* The row sits at the bottom of the screen (above the composer), - so the menu opens upward. */} - - {s.newBranch} - {branchTargets.map(target => ( - startBranch(target.base)}> - {target.label} - - ))} + + )} +
- {switchTarget && ( - void switchToBranch(switchTarget)}> - {s.switchTo(switchTarget)} - - )} - - - {s.worktrees} - {otherWorktrees.map(worktree => ( - onOpenWorktree?.(worktree.path)}> - {worktree.branch} - - ))} - {/* Create a fresh worktree off the current HEAD (the generic - "spin up a worktree here", mirroring the sidebar's + button). */} - startBranch(undefined)}> - {p.startWork} - - {/* Create a fresh worktree off the current HEAD (the generic - "spin up a worktree here", mirroring the sidebar's + button). */} - {onConvertBranch && ( - startBranch(undefined)}> - {p.convertBranch} - - )} - - + {(status.ahead > 0 || status.behind > 0) && ( + + {status.ahead > 0 && ( + + + {status.ahead} + + )} + {status.behind > 0 && ( + + + {status.behind} + + )} + )} -
- {(status.ahead > 0 || status.behind > 0) && ( - - {status.ahead > 0 && ( - - - {status.ahead} - - )} - {status.behind > 0 && ( - - - {status.behind} - - )} - - )} - - {hasLineDelta ? ( - - ) : untrackedOnly ? ( - - {s.changed(status.untracked)} - - ) : null} -
+ {hasLineDelta ? ( + + ) : untrackedOnly ? ( + + {s.changed(status.untracked)} + + ) : null} + + {resolvedRepoPath && onOpenWorktree && ( ({ // ProjectMenu (the kebab) has its own dedicated test file — stub it here so // this file only exercises overview-row's own Tip usage (the disclosure -// toggle) plus the WorkspaceAddButton wiring. +// toggle) plus the WorkspaceAddButton wiring. ProjectContextMenu (the row's +// right-click wrapper) is stubbed as a pass-through so the row still renders. vi.mock('./project-menu', () => ({ + ProjectContextMenu: ({ children }: { children: ReactNode }) => children, ProjectMenu: () => null })) diff --git a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx index a37541ad337e..0653b01ae400 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/overview-row.tsx @@ -22,7 +22,7 @@ import { } from '../chrome' import { latestProjectSessions, PROJECT_PREVIEW_COUNT, useWorkspaceNodeOpen } from './model' -import { ProjectMenu } from './project-menu' +import { ProjectContextMenu, ProjectMenu } from './project-menu' import type { SidebarProjectTree } from './workspace-groups' import { WorkspaceAddButton } from './workspace-header' @@ -112,50 +112,61 @@ export function ProjectOverviewRow({ {projectIcon(project)} ) + const shell = ( + + {/* Home has no folder to start a chat in — the sidebar's own "New + session" is that button — and no record to rename or delete. */} + {onNewSession && !project.isNoProject && ( + onNewSession(project.path)} /> + )} + {!project.isNoProject && } + + } + className={cn('group/workspace', dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')} + ref={rowRef} + > + + {lead} + onEnter?.(project.id)} + > + {project.label} + + {preview.length > 0 ? ( + + + + ) : ( + + )} + + + ) + return (
- - {/* Home has no folder to start a chat in — the sidebar's own "New - session" is that button — and no record to rename or delete. */} - {onNewSession && !project.isNoProject && ( - onNewSession(project.path)} /> - )} - {!project.isNoProject && } - - } - className={cn('group/workspace', dragging && 'cursor-grabbing bg-(--ui-sidebar-surface-background)')} - ref={rowRef} - > - - {lead} - onEnter?.(project.id)} - > - {project.label} - - {preview.length > 0 ? ( - - - - ) : ( - - )} - - + {/* Home has no per-project actions, so it gets no right-click menu. */} + {project.isNoProject ? ( + shell + ) : ( + + {shell} + + )} {open && preview.length > 0 && {renderRows?.(preview)}}
) diff --git a/apps/desktop/src/app/chat/sidebar/projects/project-appearance.tsx b/apps/desktop/src/app/chat/sidebar/projects/project-appearance.tsx new file mode 100644 index 000000000000..f6ef82745cd6 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/projects/project-appearance.tsx @@ -0,0 +1,83 @@ +import { Codicon } from '@/components/ui/codicon' +import { ColorSwatches } from '@/components/ui/color-swatches' +import { Tip } from '@/components/ui/tooltip' +import { PROFILE_SWATCHES } from '@/lib/profile-color' +import { cn } from '@/lib/utils' + +// Curated codicons for a project glyph (tinted by the chosen color). Shared by +// the kebab's Appearance popover and the right-click menu's Appearance submenu +// so both offer the same picker. +export const PROJECT_ICONS = [ + 'folder-library', + 'repo', + 'rocket', + 'beaker', + 'flame', + 'star-full', + 'heart', + 'zap', + 'target', + 'lightbulb', + 'tools', + 'device-desktop', + 'device-mobile', + 'terminal', + 'dashboard', + 'globe', + 'broadcast', + 'cloud', + 'database', + 'package', + 'book', + 'organization', + 'bug', + 'shield', + 'key', + 'gift', + 'telescope', + 'home' +] + +interface ProjectAppearancePickerProps { + color: null | string + icon: null | string + noColorLabel: string + onColor: (color: null | string) => void + onIcon: (icon: null | string) => void +} + +/** Color swatches + icon grid for a project's appearance — one component so the + * kebab popover and the right-click submenu render an identical picker. */ +export function ProjectAppearancePicker({ color, icon, noColorLabel, onColor, onIcon }: ProjectAppearancePickerProps) { + return ( + <> + + {/* Same 6 columns + gap as the swatch grid so the picker keeps the + profile picker's width (icons flex to fill, not fixed-width). */} +
+ {PROJECT_ICONS.map(name => ( + + + + ))} +
+ + ) +} diff --git a/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx b/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx index 6eaeaab12a4e..f601007c7e38 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/project-menu.tsx @@ -2,8 +2,14 @@ import { useStore } from '@nanostores/react' import type * as React from 'react' import { useState } from 'react' +import { + type ActionItemSpec, + ActionsContextMenu, + DROPDOWN_KIT, + type MenuKit, + renderActionItem +} from '@/components/ui/actions-menu' import { Codicon } from '@/components/ui/codicon' -import { ColorSwatches } from '@/components/ui/color-swatches' import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { DropdownMenu, @@ -15,7 +21,6 @@ import { import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' -import { PROFILE_SWATCHES } from '@/lib/profile-color' import { cn } from '@/lib/utils' import { $panesFlipped, dismissAutoProject } from '@/store/layout' import { @@ -28,45 +33,114 @@ import { setProjectAppearance } from '@/store/projects' +import { ProjectAppearancePicker } from './project-appearance' import type { SidebarProjectTree } from './workspace-groups' -// Curated codicons for the project glyph (tinted by the chosen color). -const ICONS = [ - 'folder-library', - 'repo', - 'rocket', - 'beaker', - 'flame', - 'star-full', - 'heart', - 'zap', - 'target', - 'lightbulb', - 'tools', - 'device-desktop', - 'device-mobile', - 'terminal', - 'dashboard', - 'globe', - 'broadcast', - 'cloud', - 'database', - 'package', - 'book', - 'organization', - 'bug', - 'shield', - 'key', - 'gift', - 'telescope', - 'home' -] +// Shared per-project state + handlers, so the kebab dropdown and the row's +// right-click menu drive the exact same actions. Modeled on git GUIs (GitHub +// Desktop / GitKraken): reveal in the file manager, copy path, and "Remove from +// sidebar" (never deletes files — auto projects are dismissed, explicit ones +// drop their entry). Explicit projects additionally get rename / add folder / +// set active. +function useProjectActions({ + project, + isActive, + scoped, + onExitScope +}: { + project: SidebarProjectTree + isActive: boolean + scoped: boolean + onExitScope?: () => void +}) { + const { t } = useI18n() + const p = t.sidebar.projects + const target = { id: project.id, name: project.label } + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false) -// Per-project actions, modeled on git GUIs (GitHub Desktop / GitKraken): reveal -// in the file manager, copy path, and "Remove from sidebar" (never deletes files -// — auto projects are dismissed, explicit ones drop their entry). Explicit -// projects additionally get rename / add folder / set active. Hidden until the -// row is hovered (group/workspace), matching the + affordance. + const removeAuto = () => { + dismissAutoProject(project.id) + + if (scoped) { + onExitScope?.() + } + } + + const confirmDelete = async () => { + await deleteProject(project.id) + + if (scoped) { + onExitScope?.() + } + } + + // Rename / add folder / set active — explicit projects only (auto ones lack a + // materialized record). Appearance is handled per-surface (popover vs submenu) + // by the caller since its picker chrome differs. + const identityItems: ActionItemSpec[] = project.isAuto + ? [] + : [ + { icon: 'edit', key: 'rename', label: p.menuRename, onSelect: () => openProjectRename(target) }, + { + icon: 'new-folder', + key: 'add-folder', + label: p.menuAddFolder, + onSelect: () => openProjectAddFolder(target) + }, + { + disabled: isActive, + icon: 'target', + key: 'set-active', + label: p.menuSetActive, + onSelect: () => void setActiveProject(project.id) + } + ] + + const pathItems: ActionItemSpec[] = [ + { + disabled: !project.path, + icon: 'folder-opened', + key: 'reveal', + label: p.reveal, + onSelect: () => void revealPath(project.path) + }, + { + disabled: !project.path, + icon: 'copy', + key: 'copy', + label: p.copyPath, + onSelect: () => void copyPath(project.path) + } + ] + + const dangerItem: ActionItemSpec = project.isAuto + ? { icon: 'trash', key: 'remove', label: p.removeFromSidebar, onSelect: removeAuto, variant: 'destructive' } + : { + icon: 'trash', + key: 'delete', + label: `${p.menuDelete}…`, + onSelect: () => setConfirmDeleteOpen(true), + variant: 'destructive' + } + + const confirmDialog = ( + setConfirmDeleteOpen(false)} + onConfirm={confirmDelete} + open={confirmDeleteOpen} + title={`${p.menuDelete} "${project.label}"?`} + /> + ) + + return { confirmDialog, dangerItem, identityItems, pathItems } +} + +// Per-project actions. The kebab keeps its row-anchored Appearance popover; the +// right-click menu (ProjectContextMenu) renders the same actions with Appearance +// as a submenu. Hidden until the row is hovered, matching the + affordance. export function ProjectMenu({ project, isActive, @@ -88,28 +162,17 @@ export function ProjectMenu({ }) { const { t } = useI18n() const p = t.sidebar.projects - const target = { id: project.id, name: project.label } - const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false) const [appearanceOpen, setAppearanceOpen] = useState(false) // Open toward the content area: right when the sidebar is on the left, left // when the panes are flipped (sidebar on the right). const panesFlipped = useStore($panesFlipped) - const removeAuto = () => { - dismissAutoProject(project.id) - - if (scoped) { - onExitScope?.() - } - } - - const confirmDelete = async () => { - await deleteProject(project.id) - - if (scoped) { - onExitScope?.() - } - } + const { confirmDialog, dangerItem, identityItems, pathItems } = useProjectActions({ + isActive, + onExitScope, + project, + scoped + }) // Appearance writes route through the adopt-aware helper: an auto project is // materialized on its first change (its id then changes), so close the picker @@ -193,42 +256,15 @@ export function ProjectMenu({ ) ) : ( <> - openProjectRename(target)}> - - {p.menuRename} - + {identityItems.slice(0, 1).map(item => renderActionItem(DROPDOWN_KIT, item))} {appearanceItem} - openProjectAddFolder(target)}> - - {p.menuAddFolder} - - void setActiveProject(project.id)}> - - {p.menuSetActive} - + {identityItems.slice(1).map(item => renderActionItem(DROPDOWN_KIT, item))} )} - void revealPath(project.path)}> - - {p.reveal} - - void copyPath(project.path)}> - - {p.copyPath} - + {pathItems.map(item => renderActionItem(DROPDOWN_KIT, item))} - {project.isAuto ? ( - - - {p.removeFromSidebar} - - ) : ( - setConfirmDeleteOpen(true)} variant="destructive"> - - {`${p.menuDelete}…`} - - )} + {renderActionItem(DROPDOWN_KIT, dangerItem)} - void applyAppearance({ color })} - swatches={PROFILE_SWATCHES} - value={project.color ?? null} + void applyAppearance({ color })} + onIcon={icon => void applyAppearance({ icon })} /> - {/* Same 6 columns + gap as the swatch grid so the popover keeps the - profile picker's width (icons flex to fill, not fixed-width). */} -
- {ICONS.map(name => ( - - - - ))} -
- setConfirmDeleteOpen(false)} - onConfirm={confirmDelete} - open={confirmDeleteOpen} - title={`${p.menuDelete} "${project.label}"?`} - /> + {confirmDialog} ) } + +interface ProjectContextMenuProps { + project: SidebarProjectTree + isActive: boolean + scoped?: boolean + onExitScope?: () => void + children: React.ReactNode +} + +// Wrap a project row so right-clicking it opens the same actions as its kebab. +// The kebab's row-anchored Appearance popover can't nest in a context menu, so +// here Appearance is a submenu with the same swatch + icon picker. +export function ProjectContextMenu({ + project, + isActive, + scoped = false, + onExitScope, + children +}: ProjectContextMenuProps) { + const { t } = useI18n() + const p = t.sidebar.projects + + const { confirmDialog, dangerItem, identityItems, pathItems } = useProjectActions({ + isActive, + onExitScope, + project, + scoped + }) + + const canTheme = !project.isAuto || Boolean(project.path) + + const applyAppearance = (patch: { color?: null | string; icon?: null | string }) => { + void setProjectAppearance(project, patch) + } + + const items = (kit: MenuKit) => ( + <> + {identityItems.map(item => renderActionItem(kit, item))} + {canTheme && ( + + + + {p.menuAppearance} + + + applyAppearance({ color })} + onIcon={icon => applyAppearance({ icon })} + /> + + + )} + {(identityItems.length > 0 || canTheme) && } + {pathItems.map(item => renderActionItem(kit, item))} + + {renderActionItem(kit, dangerItem)} + + ) + + return ( + <> + + {children} + + {confirmDialog} + + ) +} diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx index 21a89f03b618..dd38e84a2b9c 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-group.tsx @@ -14,7 +14,13 @@ import { SidebarLoadMoreRow } from '../load-more-row' import { SIDEBAR_GROUP_PAGE, useWorkspaceNodeOpen } from './model' import type { SidebarSessionGroup } from './workspace-groups' -import { WorkspaceAddButton, WorkspaceHeader, WorkspaceMenu, WorkspaceShowMoreButton } from './workspace-header' +import { + WorkspaceAddButton, + WorkspaceContextMenu, + WorkspaceHeader, + WorkspaceMenu, + WorkspaceShowMoreButton +} from './workspace-header' interface SidebarWorkspaceGroupProps { group: SidebarSessionGroup @@ -104,29 +110,31 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov return ( - - {(onNewSession || isProfileGroup) && ( - void handleNewSession()} - /> - )} - {onRemove && } - - ) - } - icon={leadingIcon} - label={group.label} - onToggle={toggleOpen} - open={open} - title={group.path ?? undefined} - /> + + + {(onNewSession || isProfileGroup) && ( + void handleNewSession()} + /> + )} + {onRemove && } + + ) + } + icon={leadingIcon} + label={group.label} + onToggle={toggleOpen} + open={open} + title={group.path ?? undefined} + /> + {open && ( <> {visibleSessions.length === 0 ? ( diff --git a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx index 1cfb12ea5eed..b2665abd4dc8 100644 --- a/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx +++ b/apps/desktop/src/app/chat/sidebar/projects/workspace-header.tsx @@ -1,15 +1,9 @@ import type * as React from 'react' import { useState } from 'react' +import { ActionsContextMenu, ActionsMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu' import { Codicon } from '@/components/ui/codicon' import { DisclosureCaret } from '@/components/ui/disclosure-caret' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' @@ -83,40 +77,77 @@ export function WorkspaceShowMoreButton({ // Per-worktree actions (linked worktree lanes only), mirroring the session row // and ProjectMenu kebab: reveal in the file manager, copy path, and remove the // worktree (runs a real `git worktree remove` via the caller's confirm dialog). -export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemove: () => void }) { +// Shared by the kebab dropdown and the header's right-click menu so both match. +function useWorkspaceItems({ path, onRemove }: { path: null | string; onRemove: () => void }) { const { t } = useI18n() const p = t.sidebar.projects + return (kit: MenuKit) => ( + <> + {renderActionItem(kit, { + disabled: !path, + icon: 'folder-opened', + key: 'reveal', + label: p.reveal, + onSelect: () => void revealPath(path) + })} + {renderActionItem(kit, { + disabled: !path, + icon: 'copy', + key: 'copy', + label: p.copyPath, + onSelect: () => void copyPath(path) + })} + + {renderActionItem(kit, { + icon: 'trash', + key: 'remove', + label: `${p.removeWorktree}…`, + onSelect: onRemove, + variant: 'destructive' + })} + + ) +} + +export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemove: () => void }) { + const { t } = useI18n() + const p = t.sidebar.projects + const items = useWorkspaceItems({ onRemove, path }) + return ( - - - - - - - - void revealPath(path)}> - - {p.reveal} - - void copyPath(path)}> - - {p.copyPath} - - - - - {`${p.removeWorktree}…`} - - - + + + + ) +} + +// Wrap a worktree lane's header so right-clicking it opens the same actions as +// its kebab. `disabled` renders children bare (a lane with no removable path). +export function WorkspaceContextMenu({ + path, + onRemove, + children +}: { + path: null | string + onRemove?: () => void + children: React.ReactNode +}) { + const { t } = useI18n() + const p = t.sidebar.projects + const items = useWorkspaceItems({ onRemove: onRemove ?? (() => {}), path }) + + return ( + + {children} + ) } @@ -156,7 +187,9 @@ export function WorkspaceHeader({ label, onToggle, open, - title + title, + ref, + ...rest }: { action?: React.ReactNode emphasis?: boolean @@ -166,13 +199,15 @@ export function WorkspaceHeader({ open: boolean /** Hover tooltip — the lane's full on-disk path (worktree / repo root). */ title?: string -}) { +} & React.ComponentProps<'div'>) { return (
+ +
)} - - {isSet && revealed !== null && ( -
- {revealed || '---'} -
- )} - - {editing && ( -
- setValue(e.target.value)} - placeholder={envVar.prompt || envVar.key} - type={envVar.default ? 'text' : 'password'} - value={value} - /> - - -
- )} - + ) } diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index 5168a0d84317..12ff50eb2227 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -555,7 +555,17 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess } setSubmitting(true) - aui.composer().send() + + // `aui.composer().send()` throws "Composer is not available" when the edit + // composer core has been torn down (e.g. a blur-driven cancel raced the + // click). Reset `submitting` on failure so the arrow can't wedge on `true` + // and leave revert as the only way out (#49903 is the same unguarded-core + // hazard on the main composer). + try { + aui.composer().send() + } catch { + setSubmitting(false) + } } const handleEditBlur = useCallback( @@ -590,7 +600,15 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess } closeTrigger() - aui.composer().cancel() + + // Swallow the unbound-core throw: if the composer core was already torn + // down (a send/cancel raced this timer), cancel() throws "Composer is + // not available" as an uncaught renderer error. Nothing to cancel then. + try { + aui.composer().cancel() + } catch { + // Composer core already gone — the edit is closing anyway. + } }, 80) }, [aui, closeTrigger, submitting, syncDraftFromEditor] @@ -786,6 +804,13 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess submitEdit(editor) } }} + // Keep focus in the editor on click: macOS doesn't focus a button + // on mousedown, so without this the arrow-click blurs the editor, + // the blur timer cancels the edit (tearing down the composer + // core), and the click's send() then throws against a dead core — + // the edit silently never sends. The restore button guards the + // same way. + onPointerDown={event => event.preventDefault()} title={copy.sendEdited} type="button" > diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message-edit-gesture.test.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message-edit-gesture.test.tsx new file mode 100644 index 000000000000..26a0a76681c5 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/user-message-edit-gesture.test.tsx @@ -0,0 +1,151 @@ +import { type AppendMessage, AssistantRuntimeProvider, ExportedMessageRepository, type ThreadMessage } from '@assistant-ui/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' + +import { Thread } from '.' + +const createdAt = new Date('2026-05-01T00:00:00.000Z') + +class TestResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +vi.stubGlobal('ResizeObserver', TestResizeObserver) +vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0) +) +vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) +vi.stubGlobal('CSS', { escape: (str: string) => str }) + +Element.prototype.scrollTo = function scrollTo() {} + +afterEach(() => { + cleanup() +}) + +function stubOffsetDimension( + prop: 'offsetHeight' | 'offsetWidth', + clientProp: 'clientHeight' | 'clientWidth', + fallback: number +) { + const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop) + + Object.defineProperty(HTMLElement.prototype, prop, { + configurable: true, + get() { + return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback + } + }) +} + +stubOffsetDimension('offsetWidth', 'clientWidth', 800) +stubOffsetDimension('offsetHeight', 'clientHeight', 600) + +function userMessage(): ThreadMessage { + return { + id: 'user-1', + role: 'user', + content: [{ type: 'text', text: 'edit me please' }], + attachments: [], + createdAt, + metadata: { custom: {} } + } as ThreadMessage +} + +function assistantMessage(): ThreadMessage { + return { + id: 'assistant-1', + role: 'assistant', + content: [{ type: 'text', text: 'done' }], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} } + } as ThreadMessage +} + +function Harness({ onEdit }: { onEdit: (message: AppendMessage) => Promise }) { + const repository = ExportedMessageRepository.fromArray([userMessage(), assistantMessage()]) + + const runtime = useIncrementalExternalStoreRuntime({ + messageRepository: repository, + isRunning: false, + setMessages: () => {}, + onNew: async () => {}, + onEdit, + onCancel: async () => {}, + onReload: async () => {} + }) + + return ( + + + + ) +} + +// Regression for the desktop "editing a message, clicking the arrow does +// nothing — I have to click revert" report. +// +// On macOS a