Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
043beae
feat(console): clipboard helper with insecure-origin fallback
andersonleal Jul 22, 2026
96de97c
fix(console): function-call pane copy works on insecure origins
andersonleal Jul 22, 2026
6e13339
feat(console): editor deep-link registry with persisted preference
andersonleal Jul 22, 2026
251d86a
feat(console): copy button on user/assistant chat messages
andersonleal Jul 22, 2026
90f067b
feat(console): open-in-editor split button (cursor/vscode/zed + copy …
andersonleal Jul 22, 2026
5e06524
feat(console): open-in-editor on update-file result headers
andersonleal Jul 22, 2026
5d8c3e5
feat(console): open-in-editor on create-file and move results
andersonleal Jul 22, 2026
48b6f8f
style(console): wrap copy button ternary to satisfy biome formatter
andersonleal Jul 22, 2026
074f6ba
feat(console): always open the editor menu on file-change cards
andersonleal Jul 22, 2026
5790a3a
feat(console): include function calls in assistant message copy
andersonleal Jul 22, 2026
cf94cf8
feat(console): copy button for the function id on call-card headers
andersonleal Jul 22, 2026
a6017d0
style(console): pointer cursor on copy and open-in-editor icon buttons
andersonleal Jul 22, 2026
c74e8f9
fix(console): percent-encode URL delimiters in editor file links
andersonleal Jul 22, 2026
fbd0087
fix(console): show the copy button on tool-only assistant turns
andersonleal Jul 22, 2026
7ef76c2
fix(console): attribute leading function-call runs to the turn's assi…
andersonleal Jul 22, 2026
44b75f7
style(console): ease color together with opacity on copy buttons
andersonleal Jul 22, 2026
50d3b31
refactor(database)!: register batch writes only as executeBatch
andersonleal Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions console/web/src/components/chat/CopyMessageButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
onClick={copy}
className={cn(
'shrink-0 cursor-pointer text-ink-ghost hover:text-ink transition-colors',
className,
)}
aria-label={copied ? 'copied' : label}
title={copied ? 'copied' : label}
>
{copied ? (
<Check size={12} aria-hidden />
) : (
<Copy size={12} aria-hidden />
)}
</button>
)
}
37 changes: 32 additions & 5 deletions console/web/src/components/chat/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -34,6 +35,9 @@ interface MessageProps {
) => Promise<void>
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({
Expand All @@ -43,6 +47,7 @@ export function Message({
onResolveFilesystemAccess,
onManageFilesystemAccess,
workingDir,
copyText,
}: MessageProps) {
switch (message.role) {
case 'user':
Expand All @@ -56,7 +61,7 @@ export function Message({
<UserMessage message={message} />
)
case 'assistant':
return <AssistantMessage message={message} />
return <AssistantMessage message={message} copyText={copyText} />
case 'thought':
return <ThoughtMessage message={message} />
case 'function-call': {
Expand Down Expand Up @@ -266,8 +271,14 @@ function SpawnTaskMessage({ message }: { message: UserMessageType }) {

function UserMessage({ message }: { message: UserMessageType }) {
return (
<article className="flex flex-col items-end gap-2">
<header className="font-mono text-[11px] uppercase tracking-[0.06em] text-ink-ghost">
<article className="group flex flex-col items-end gap-2">
<header className="font-mono text-[11px] uppercase tracking-[0.06em] text-ink-ghost flex items-center gap-2">
{message.content ? (
<CopyMessageButton
text={message.content}
className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-[opacity,color]"
/>
) : null}
<Prompt symbol="$">you</Prompt>
</header>
<div
Expand All @@ -289,10 +300,20 @@ function UserMessage({ message }: { message: UserMessageType }) {
)
}

function AssistantMessage({ message }: { message: AssistantMessageType }) {
function AssistantMessage({
message,
copyText,
}: {
message: AssistantMessageType
copyText?: string | (() => 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 (
<article className="flex flex-col gap-2">
<article className="group flex flex-col gap-2">
<header className="font-mono text-[11px] uppercase tracking-[0.06em] text-ink-ghost flex items-center gap-2 flex-wrap">
<Prompt symbol=">">assistant</Prompt>
{message.model ? (
Expand All @@ -302,6 +323,12 @@ function AssistantMessage({ message }: { message: AssistantMessageType }) {
<span className="text-ink-ghost">· {message.mode}</span>
) : null}
{message.memory ? <MemoryChip memory={message.memory} /> : null}
{copySource !== undefined && !message.streaming ? (
<CopyMessageButton
text={copySource}
className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-[opacity,color]"
/>
) : null}
</header>
<div className="pr-1">
{message.content ? (
Expand Down
53 changes: 38 additions & 15 deletions console/web/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -101,6 +105,10 @@ export function MessageList({
const lastPendingIdRef = useRef<string | null>(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.
Expand Down Expand Up @@ -167,29 +175,44 @@ export function MessageList({
return (
<div ref={containerRef} className={cn('flex-1 overflow-y-auto', listPad)}>
<div className="mx-auto max-w-[760px] flex flex-col gap-y-8">
{items.map((item) =>
item.kind === 'message' ? (
{items.map((item) => {
if (item.kind === 'fcall-group') {
return (
<FunctionCallGroup
key={item.key}
messages={item.messages}
onResolveApproval={onResolveApproval}
onAlwaysAllow={onAlwaysAllow}
onResolveFilesystemAccess={onResolveFilesystemAccess}
onManageFilesystemAccess={onManageFilesystemAccess}
workingDir={workingDir}
/>
)
}
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 (
<Message
key={item.key}
message={item.message}
onResolveApproval={onResolveApproval}
onAlwaysAllow={onAlwaysAllow}
onResolveFilesystemAccess={onResolveFilesystemAccess}
onManageFilesystemAccess={onManageFilesystemAccess}
workingDir={workingDir}
/>
) : (
<FunctionCallGroup
key={item.key}
messages={item.messages}
message={m}
copyText={copyText}
onResolveApproval={onResolveApproval}
onAlwaysAllow={onAlwaysAllow}
onResolveFilesystemAccess={onResolveFilesystemAccess}
onManageFilesystemAccess={onManageFilesystemAccess}
workingDir={workingDir}
/>
),
)}
)
})}
{isThinking ? (
<div className="font-mono text-[13px] italic thinking-shimmer text-ink-faint">
{thinkingDetail ?? 'thinking…'}
Expand Down
2 changes: 2 additions & 0 deletions console/web/src/components/chat/coder/CreateFileView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
TooltipTrigger,
} from '@/components/ui/Tooltip'
import { CoderNewFilePreview, CoderOverwritePreview } from './CoderDiff'
import { OpenInEditorButton } from './OpenInEditorButton'
import {
createFileRequestSchema,
createFileResponseSchema,
Expand Down Expand Up @@ -99,6 +100,7 @@ export function CreateFileView({
<span className="font-mono text-[12px] text-ink">
{file.path}
</span>
<OpenInEditorButton path={result.path} />
</div>
{file.overwrite ? (
<CoderOverwritePreview
Expand Down
4 changes: 4 additions & 0 deletions console/web/src/components/chat/coder/MoveView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/Tooltip'
import { OpenInEditorButton } from './OpenInEditorButton'
import {
moveFileRequestSchema,
moveFileResponseSchema,
Expand Down Expand Up @@ -85,6 +86,9 @@ export function MoveView({ input, output, running, preview }: MoveViewProps) {
<span>{from}</span>
<span className="text-ink-ghost">→</span>
<span>{to}</span>
{!pending && result?.success ? (
<OpenInEditorButton path={result.to} />
) : null}
{spec.overwrite ? (
<Chip label="overwrite" className="border-warn text-warn">
true
Expand Down
75 changes: 75 additions & 0 deletions console/web/src/components/chat/coder/OpenInEditorButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center shrink-0 cursor-pointer text-ink-ghost hover:text-ink transition-colors"
aria-label="open in editor"
title="open in editor"
>
<SquareArrowOutUpRight size={12} aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{EDITORS.map((e) => (
<DropdownMenuItem key={e.id} onSelect={() => openWith(e.id)}>
open in {e.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(event) => {
// Keep the menu open so the "copied" flip is visible.
event.preventDefault()
copyPath()
}}
>
{copied ? 'copied' : 'copy path'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
11 changes: 11 additions & 0 deletions console/web/src/components/chat/coder/UpdateFileView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
TooltipTrigger,
} from '@/components/ui/Tooltip'
import { cn } from '@/lib/utils'
import { OpenInEditorButton } from './OpenInEditorButton'
import {
formatUpdateOp,
type OpEcho,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -496,6 +506,7 @@ function FileEchoSection({
<div className="bg-paper-2 border-b border-rule-2 px-3 py-1.5 flex flex-wrap items-center gap-1.5">
{/* Canonical absolute path from the result, not the request. */}
<span className="font-mono text-[12px] text-ink">{result.path}</span>
<OpenInEditorButton path={result.path} line={firstEchoLine(result)} />
<Chip label="ops">{file.ops.length}</Chip>
<Chip label="applied">{result.applied}</Chip>
<Chip label="lines">{result.new_line_count}</Chip>
Expand Down
Loading
Loading