diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 9bcc05da3bf..32b7729f637 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -722,8 +722,14 @@ function StickyHumanMessageContainer({ children }: { children: ReactNode }) { // edit composer render the same bubble surface (rounded glass card); // they only differ in border weight, cursor, and padding-right (the // read-only view reserves room for the restore icon). +// +// no-drag: sticky bubbles park at --sticky-human-top (~4px), sliding under the +// titlebar's [-webkit-app-region:drag] strips (app-shell.tsx). Electron resolves +// drag regions at the compositor level — z-index and pointer-events don't help — +// so without the carve-out, clicking a stuck bubble drags the window instead of +// opening the edit composer. const USER_BUBBLE_BASE_CLASS = - 'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left' + 'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]' const USER_ACTION_ICON_BUTTON_CLASS = 'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70' diff --git a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx index 6f3e7edd340..391510f71bf 100644 --- a/apps/desktop/src/components/assistant-ui/tool-fallback.tsx +++ b/apps/desktop/src/components/assistant-ui/tool-fallback.tsx @@ -13,9 +13,9 @@ import { DisclosureRow } from '@/components/chat/disclosure-row' import { PreviewAttachment } from '@/components/chat/preview-attachment' import { ZoomableImage } from '@/components/chat/zoomable-image' import { BrailleSpinner } from '@/components/ui/braille-spinner' -import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' import { FadeText } from '@/components/ui/fade-text' +import { ToolIcon } from '@/components/ui/tool-icon' import { useI18n } from '@/i18n' import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link' import { AlertCircle, CheckCircle2 } from '@/lib/icons' @@ -136,7 +136,7 @@ function ToolGlyph({ copy, icon, status }: { copy: ToolStatusCopy; icon?: string const node = status ? ( statusGlyph(status, copy) ) : icon ? ( - + ) : null return node ? {node} : null diff --git a/apps/desktop/src/components/assistant-ui/user-message-edit.test.tsx b/apps/desktop/src/components/assistant-ui/user-message-edit.test.tsx new file mode 100644 index 00000000000..ee915cf7429 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/user-message-edit.test.tsx @@ -0,0 +1,141 @@ +import { ExportedMessageRepository } from '@assistant-ui/core/internal' +// Clicking a user bubble must open the inline edit composer — through the +// app's incremental external-store runtime (which reimplements capability +// resolution, incl. `edit: onEdit !== undefined`) and the stock runtime. +// +// Note: this covers the React/runtime wiring only. The Electron-level failure +// mode (titlebar -webkit-app-region:drag swallowing clicks on *stuck* sticky +// bubbles) is not reproducible in jsdom — see USER_BUBBLE_BASE_CLASS's no-drag +// carve-out in thread.tsx. +import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' + +import { Thread } from './thread' + +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)) + +Element.prototype.scrollTo = function scrollTo() {} + +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 +} + +// Mirrors chat/index.tsx: incremental runtime + messageRepository + onEdit. +function IncrementalHarness({ onEdit }: { onEdit: () => Promise }) { + const repository = ExportedMessageRepository.fromArray([userMessage(), assistantMessage()]) + + const runtime = useIncrementalExternalStoreRuntime({ + messageRepository: repository, + isRunning: false, + setMessages: () => {}, + onNew: async () => {}, + onEdit, + onCancel: async () => {}, + onReload: async () => {} + }) + + return ( + + + + ) +} + +// Control: stock external store runtime. +function StockHarness({ onEdit }: { onEdit: () => Promise }) { + const runtime = useExternalStoreRuntime({ + messages: [userMessage(), assistantMessage()], + isRunning: false, + onNew: async () => {}, + onEdit + }) + + return ( + + + + ) +} + +describe('click-to-edit user message', () => { + it('opens the edit composer with the incremental runtime', async () => { + const { container } = render( {}} />) + + const bubble = await screen.findByRole('button', { name: 'Edit message' }) + + fireEvent.click(bubble) + + await waitFor(() => { + expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy() + }) + }) + + it('opens the edit composer with the stock runtime', async () => { + const { container } = render( {}} />) + + const bubble = await screen.findByRole('button', { name: 'Edit message' }) + + fireEvent.click(bubble) + + await waitFor(() => { + expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy() + }) + }) +}) diff --git a/apps/desktop/src/components/ui/tool-icon.tsx b/apps/desktop/src/components/ui/tool-icon.tsx new file mode 100644 index 00000000000..11119855ea0 --- /dev/null +++ b/apps/desktop/src/components/ui/tool-icon.tsx @@ -0,0 +1,65 @@ +import type * as React from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { cn } from '@/lib/utils' + +// Solid (filled) glyphs for in-thread tool rows. Codicons are an outline icon +// *font*, so an outline glyph has no separate fillable region — a filled look +// can't be derived from it (stroke-thickening just bolds the outline). To get +// the Cursor-style filled tool icons we render dedicated solid SVG paths, +// keyed by the same names used in `TOOL_META` (tool-fallback-model.ts). +// +// Paths are Phosphor Icons (MIT) "fill" weight, 256×256 viewBox. Inlining the +// path data mirrors the existing precedent in `directive-text.tsx`. +const TOOL_ICON_PATHS: Record = { + diff: 'M118.18,213.08c-.11.14-.24.27-.36.4l-.16.18-.17.15a4.83,4.83,0,0,1-.42.37,3.92,3.92,0,0,1-.32.25l-.3.22-.38.23a2.91,2.91,0,0,1-.3.17l-.37.19-.34.15-.36.13a2.84,2.84,0,0,1-.38.13l-.36.1c-.14,0-.26.07-.4.09l-.42.07-.35.05a7,7,0,0,1-.79,0H64a8,8,0,0,1,0-16H92.69L55,162.34a23.85,23.85,0,0,1-7-17V95a32,32,0,1,1,16,0v50.38A8,8,0,0,0,66.34,151L104,188.69V160a8,8,0,0,1,16,0v48a7,7,0,0,1,0,.8c0,.11,0,.21,0,.32s0,.3-.07.46a2.83,2.83,0,0,1-.09.37c0,.13-.06.26-.1.39s-.08.23-.12.35l-.14.39-.15.31c-.06.13-.12.27-.19.4s-.11.18-.16.28l-.24.39-.21.28ZM208,161V110.63a23.85,23.85,0,0,0-7-17L163.31,56H192a8,8,0,0,0,0-16H143.82l-.6,0c-.14,0-.28,0-.41.06l-.37,0-.43.11-.33.08-.4.14-.34.13-.35.16-.36.18a3.14,3.14,0,0,0-.31.18c-.12.07-.25.14-.36.22a3.55,3.55,0,0,0-.31.23,3.81,3.81,0,0,0-.32.24c-.15.12-.28.24-.42.37l-.17.15-.16.18c-.12.13-.25.26-.36.4l-.26.35-.21.28-.24.39c-.05.1-.11.19-.16.28s-.13.27-.19.4l-.15.31-.14.39c0,.12-.09.23-.12.35s-.07.26-.1.39a2.83,2.83,0,0,0-.09.37c0,.16,0,.31-.07.46s0,.21-.05.32a7,7,0,0,0,0,.8V96a8,8,0,0,0,16,0V67.31L189.66,105a8,8,0,0,1,2.34,5.66V161a32,32,0,1,0,16,0Z', + edit: 'M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM192,108.68,147.31,64l24-24L216,84.68Z', + eye: 'M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,168a40,40,0,1,1,40-40A40,40,0,0,1,128,168Z', + file: 'M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM152,88V44l44,44Z', + 'file-media': + 'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM156,88a12,12,0,1,1-12,12A12,12,0,0,1,156,88Zm60,112H40V160.69l46.34-46.35a8,8,0,0,1,11.32,0h0L165,181.66a8,8,0,0,0,11.32-11.32l-17.66-17.65L173,138.34a8,8,0,0,1,11.31,0L216,170.07V200Z', + files: + 'M213.66,66.34l-40-40A8,8,0,0,0,168,24H88A16,16,0,0,0,72,40V56H56A16,16,0,0,0,40,72V216a16,16,0,0,0,16,16H168a16,16,0,0,0,16-16V200h16a16,16,0,0,0,16-16V72A8,8,0,0,0,213.66,66.34ZM136,192H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm0-32H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm64,24H184V104a8,8,0,0,0-2.34-5.66l-40-40A8,8,0,0,0,136,56H88V40h76.69L200,75.31Z', + globe: + 'M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm78.36,64H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM216,128a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM128,43a115.27,115.27,0,0,1,26,45H102A115.11,115.11,0,0,1,128,43ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48Zm50.35,61.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z', + question: + 'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,168a12,12,0,1,1,12-12A12,12,0,0,1,128,192Zm8-48.72V144a8,8,0,0,1-16,0v-8a8,8,0,0,1,8-8c13.23,0,24-9,24-20s-10.77-20-24-20-24,9-24,20v4a8,8,0,0,1-16,0v-4c0-19.85,17.94-36,40-36s40,16.15,40,36C168,125.38,154.24,139.93,136,143.28Z', + search: + 'M168,112a56,56,0,1,1-56-56A56,56,0,0,1,168,112Zm61.66,117.66a8,8,0,0,1-11.32,0l-50.06-50.07a88,88,0,1,1,11.32-11.31l50.06,50.06A8,8,0,0,1,229.66,229.66ZM112,184a72,72,0,1,0-72-72A72.08,72.08,0,0,0,112,184Z', + terminal: + 'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm-91,94.25-40,32a8,8,0,1,1-10-12.5L107.19,128,75,102.25a8,8,0,1,1,10-12.5l40,32a8,8,0,0,1,0,12.5ZM176,168H136a8,8,0,0,1,0-16h40a8,8,0,0,1,0,16Z', + tools: + 'M232,96a72,72,0,0,1-100.94,66L79,222.22c-.12.14-.26.29-.39.42a32,32,0,0,1-45.26-45.26c.14-.13.28-.27.43-.39L94,124.94a72.07,72.07,0,0,1,83.54-98.78,8,8,0,0,1,3.93,13.19L144,80l5.66,26.35L176,112l40.65-37.52a8,8,0,0,1,13.19,3.93A72.6,72.6,0,0,1,232,96Z', + watch: + 'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm56,112H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48a8,8,0,0,1,0,16Z' +} + +export interface ToolIconProps { + className?: string + name: string + size?: number | string +} + +/** Filled tool glyph. Falls back to the outline codicon font for any name not + * covered by the solid set so new tools still render an icon. */ +export function ToolIcon({ className, name, size = '0.875rem' }: ToolIconProps) { + const path = TOOL_ICON_PATHS[name] + + if (!path) { + return + } + + const dimension: React.CSSProperties = { height: size, width: size } + + return ( + + ) +}