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