diff --git a/console/web/src/components/chat/CopyMessageButton.tsx b/console/web/src/components/chat/CopyMessageButton.tsx new file mode 100644 index 000000000..48e6bb467 --- /dev/null +++ b/console/web/src/components/chat/CopyMessageButton.tsx @@ -0,0 +1,51 @@ +import { Check, Copy } from 'lucide-react' +import { useState } from 'react' +import { copyTextToClipboard } from '@/lib/clipboard' +import { cn } from '@/lib/utils' + +/** + * Copy affordance for a chat message — the PaneShell copy idiom (12px + * Copy→Check flip on success, nothing on failure) lifted to a message + * header. Visibility is the parent's job (hover/focus opacity classes); + * this component only copies and flips. + */ +export function CopyMessageButton({ + text, + label = 'copy message', + className, +}: { + /** A thunk defers building large payloads (e.g. serialized function calls) + until the click, keeping streaming re-renders free. */ + text: string | (() => string) + /** Accessible name while idle (aria-label/title); flips to "copied". */ + label?: string + className?: string +}) { + const [copied, setCopied] = useState(false) + const copy = () => { + const value = typeof text === 'function' ? text() : text + void copyTextToClipboard(value).then((ok) => { + if (!ok) return + setCopied(true) + window.setTimeout(() => setCopied(false), 1200) + }) + } + return ( + + ) +} diff --git a/console/web/src/components/chat/Message.tsx b/console/web/src/components/chat/Message.tsx index bdf822b13..0a4f7020c 100644 --- a/console/web/src/components/chat/Message.tsx +++ b/console/web/src/components/chat/Message.tsx @@ -12,6 +12,7 @@ import type { UserMessage as UserMessageType, } from '@/types/chat' import { AttachmentChip } from './AttachmentChip' +import { CopyMessageButton } from './CopyMessageButton' import { MemoryChip } from './MemoryChip' import { ThoughtMessage } from './ThoughtMessage' @@ -34,6 +35,9 @@ interface MessageProps { ) => Promise onManageFilesystemAccess?: () => void workingDir?: string | null + /** Copy payload for an assistant turn (prose + its function calls). Lazy so + the string is built on click, not on every streaming re-render. */ + copyText?: string | (() => string) } export function Message({ @@ -43,6 +47,7 @@ export function Message({ onResolveFilesystemAccess, onManageFilesystemAccess, workingDir, + copyText, }: MessageProps) { switch (message.role) { case 'user': @@ -56,7 +61,7 @@ export function Message({ ) case 'assistant': - return + return case 'thought': return case 'function-call': { @@ -266,8 +271,14 @@ function SpawnTaskMessage({ message }: { message: UserMessageType }) { function UserMessage({ message }: { message: UserMessageType }) { return ( -
-
+
+
+ {message.content ? ( + + ) : null} you
string) +}) { const showCaret = !!message.streaming + // A tool-only turn has no prose but still carries a copy payload (its + // function calls) via copyText; direct renders without a list-provided + // payload fall back to the prose gate. + const copySource = copyText ?? (message.content || undefined) return ( -
+
assistant {message.model ? ( @@ -302,6 +323,12 @@ function AssistantMessage({ message }: { message: AssistantMessageType }) { · {message.mode} ) : null} {message.memory ? : null} + {copySource !== undefined && !message.streaming ? ( + + ) : null}
{message.content ? ( diff --git a/console/web/src/components/chat/MessageList.tsx b/console/web/src/components/chat/MessageList.tsx index 4a203eba1..a4d609823 100644 --- a/console/web/src/components/chat/MessageList.tsx +++ b/console/web/src/components/chat/MessageList.tsx @@ -1,6 +1,10 @@ import { useEffect, useMemo, useRef } from 'react' import type { FilesystemAccessAction } from '@/components/permissions/FilesystemAccessPrompt' import { useConversationsCtxOptional } from '@/lib/conversations-context' +import { + assistantCopyText, + functionCallsByAssistant, +} from '@/lib/function-call-copy' import { cn } from '@/lib/utils' import type { FunctionCallMessage as FunctionCallMessageType, @@ -101,6 +105,10 @@ export function MessageList({ const lastPendingIdRef = useRef(null) const items = useMemo(() => groupConsecutiveFcalls(messages), [messages]) + const fcallsByAssistant = useMemo( + () => functionCallsByAssistant(messages), + [messages], + ) // Read optionally so isolated renders (Storybook) still work without the // ConversationsProvider; the empty state falls back to `ready` there. @@ -167,29 +175,44 @@ export function MessageList({ return (
- {items.map((item) => - item.kind === 'message' ? ( + {items.map((item) => { + if (item.kind === 'fcall-group') { + return ( + + ) + } + const m = item.message + // Assistant turns copy their prose plus the calls that follow them; + // the thunk defers building that string until the copy click. Left + // undefined when the turn has nothing to copy (no prose, no calls) + // so the header shows no copy affordance. + const calls = + m.role === 'assistant' ? fcallsByAssistant.get(m.id) : undefined + const copyText = + m.role === 'assistant' && (m.content || calls?.length) + ? () => assistantCopyText(m.content, calls ?? []) + : undefined + return ( - ) : ( - - ), - )} + ) + })} {isThinking ? (
{thinkingDetail ?? 'thinking…'} diff --git a/console/web/src/components/chat/coder/CreateFileView.tsx b/console/web/src/components/chat/coder/CreateFileView.tsx index f9868915f..e19fbfc8e 100644 --- a/console/web/src/components/chat/coder/CreateFileView.tsx +++ b/console/web/src/components/chat/coder/CreateFileView.tsx @@ -16,6 +16,7 @@ import { TooltipTrigger, } from '@/components/ui/Tooltip' import { CoderNewFilePreview, CoderOverwritePreview } from './CoderDiff' +import { OpenInEditorButton } from './OpenInEditorButton' import { createFileRequestSchema, createFileResponseSchema, @@ -99,6 +100,7 @@ export function CreateFileView({ {file.path} +
{file.overwrite ? ( {from} {to} + {!pending && result?.success ? ( + + ) : null} {spec.overwrite ? ( true diff --git a/console/web/src/components/chat/coder/OpenInEditorButton.tsx b/console/web/src/components/chat/coder/OpenInEditorButton.tsx new file mode 100644 index 000000000..90deef580 --- /dev/null +++ b/console/web/src/components/chat/coder/OpenInEditorButton.tsx @@ -0,0 +1,75 @@ +import { SquareArrowOutUpRight } from 'lucide-react' +import { useState } from 'react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/DropdownMenu' +import { copyTextToClipboard } from '@/lib/clipboard' +import { EDITORS, type EditorId, editorById } from '@/lib/editor-links' + +/** + * "open in editor" affordance for coder file-change rows: one button that + * always opens a menu of editors (cursor / vs code / zed) — each entry + * launches that editor via its URL scheme — plus "copy path", the fallback + * when the browser isn't on the machine that has the files. No editor is + * privileged; the menu is shown every time so the choice stays explicit. + * Renders nothing for non-absolute paths (result resolution failed — + * `coder` results are jail-resolved absolute on success). + */ +export function OpenInEditorButton({ + path, + line, +}: { + path: string + line?: number +}) { + const [copied, setCopied] = useState(false) + if (!path.startsWith('/')) return null + + const openWith = (id: EditorId) => { + window.location.href = editorById(id).buildUrl(path, line) + } + + const copyPath = () => { + void copyTextToClipboard(path).then((ok) => { + if (!ok) return + setCopied(true) + window.setTimeout(() => setCopied(false), 1200) + }) + } + + return ( + + + + + + {EDITORS.map((e) => ( + openWith(e.id)}> + open in {e.label} + + ))} + + { + // Keep the menu open so the "copied" flip is visible. + event.preventDefault() + copyPath() + }} + > + {copied ? 'copied' : 'copy path'} + + + + ) +} diff --git a/console/web/src/components/chat/coder/UpdateFileView.tsx b/console/web/src/components/chat/coder/UpdateFileView.tsx index 3b9c1df0b..a67101847 100644 --- a/console/web/src/components/chat/coder/UpdateFileView.tsx +++ b/console/web/src/components/chat/coder/UpdateFileView.tsx @@ -29,6 +29,7 @@ import { TooltipTrigger, } from '@/components/ui/Tooltip' import { cn } from '@/lib/utils' +import { OpenInEditorButton } from './OpenInEditorButton' import { formatUpdateOp, type OpEcho, @@ -97,6 +98,15 @@ export function contentLineCount(content: string): number { return parts[parts.length - 1] === '' ? parts.length - 1 : parts.length } +/** + * The open-in-editor line anchor for an update-file result: the first + * echoed line in wire order, or undefined when the file has no echoes + * (a failure, or an echo-less success). + */ +export function firstEchoLine(result: UpdateFileResult): number | undefined { + return result.echoes[0]?.from_line +} + /** Mirror of `update_file.rs::ECHO_CONTEXT` — context lines above/below a line op's region; load-bearing for `postRegionFirst` reconstruction. */ const ECHO_CONTEXT = 2 @@ -496,6 +506,7 @@ function FileEchoSection({
{/* Canonical absolute path from the result, not the request. */} {result.path} + {file.ops.length} {result.applied} {result.new_line_count} diff --git a/console/web/src/components/chat/coder/__tests__/UpdateFileView.test.ts b/console/web/src/components/chat/coder/__tests__/UpdateFileView.test.ts index d62888628..3a40927f7 100644 --- a/console/web/src/components/chat/coder/__tests__/UpdateFileView.test.ts +++ b/console/web/src/components/chat/coder/__tests__/UpdateFileView.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' import type { OpEcho, UpdateOp } from '../parsers' -import { contentLineCount, echoRows, groupEchoesByOp } from '../UpdateFileView' +import { + contentLineCount, + echoRows, + firstEchoLine, + groupEchoesByOp, +} from '../UpdateFileView' function lineEcho(overrides: Partial = {}): OpEcho { return { @@ -517,3 +522,29 @@ describe('echoRows — multi-op anchor reconstruction (post-apply coords)', () = expect(added).toEqual(['1']) // known off-by-shift; ideal would be ['TWO'] }) }) + +describe('firstEchoLine (open-in-editor anchor)', () => { + const base = { + path: '/w/a.ts', + success: true, + applied: 1, + new_line_count: 10, + echoes_truncated: false, + } + + it('returns the first echo from_line (wire order, first op group)', () => { + expect( + firstEchoLine({ + ...base, + echoes: [ + lineEcho({ op_index: 0, from_line: 7 }), + lineEcho({ op_index: 1, from_line: 30 }), + ], + }), + ).toBe(7) + }) + + it('returns undefined when there are no echoes (failed or echo-less file)', () => { + expect(firstEchoLine({ ...base, echoes: [] })).toBeUndefined() + }) +}) diff --git a/console/web/src/components/function-call/FunctionCallCard.tsx b/console/web/src/components/function-call/FunctionCallCard.tsx index 8812a6a82..468869a03 100644 --- a/console/web/src/components/function-call/FunctionCallCard.tsx +++ b/console/web/src/components/function-call/FunctionCallCard.tsx @@ -4,17 +4,18 @@ import { BrowserFunctionIdLabel, BrowserToolView, } from '@/components/chat/browser' +import { CopyMessageButton } from '@/components/chat/CopyMessageButton' import { CoderFunctionIdLabel, CoderToolView } from '@/components/chat/coder' import { DirectoryFunctionIdLabel, DirectoryToolView, } from '@/components/chat/directory' import { EngineFunctionIdLabel, EngineToolView } from '@/components/chat/engine' +import { FpFunctionIdLabel, FpToolView } from '@/components/chat/fp' import { HarnessFunctionIdLabel, HarnessToolView, } from '@/components/chat/harness' -import { FpFunctionIdLabel, FpToolView } from '@/components/chat/fp' import { RouterFunctionIdLabel, RouterToolView } from '@/components/chat/router' import { SandboxFunctionIdLabel, @@ -40,6 +41,7 @@ import { import { Button } from '@/components/ui/Button' import { StatusDot } from '@/components/ui/StatusDot' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/Tabs' +import { copyTextToClipboard } from '@/lib/clipboard' import { JsonHighlight } from '@/lib/syntax' import { cn } from '@/lib/utils' import type { FunctionCallMessage as FunctionCallMessageType } from '@/types/chat' @@ -380,74 +382,94 @@ export function FunctionCallCard({ )} data-message-id={message.id} > - + + ▸ + + +
{open ? (
@@ -595,8 +617,8 @@ function PaneShell({ const [copied, setCopied] = useState(false) const copy = () => { - if (typeof navigator === 'undefined' || !navigator.clipboard) return - void navigator.clipboard.writeText(copyText).then(() => { + void copyTextToClipboard(copyText).then((ok) => { + if (!ok) return setCopied(true) window.setTimeout(() => setCopied(false), 1200) }) @@ -620,7 +642,7 @@ function PaneShell({