open-swe/ui/src/components/agents/AgentThreadView.tsx
Ramon Nogueira 534f402f48
feat(open-swe): copy-plan-as-markdown button + fix premature "ready" banner (#1603)
- Add a "Copy markdown" button to the plan header that copies the whole plan.
  Cross-browser: async Clipboard API in secure contexts, hidden-textarea +
  execCommand fallback for older Safari/Firefox and non-HTTPS origins.
- Fix the conversation banner: it showed "A plan is ready for your review" for
  every non-approved/cancelled status, including "planning" — so it claimed the
  plan was ready the instant plan mode began (the agent shares the link early
  to follow along), then the plan page correctly said it was still being
  written. Now: planning → "writing a plan", revising → "revising", ready →
  "ready for your review".
2026-06-24 15:07:25 -04:00

162 lines
6.7 KiB
TypeScript

import { useMemo, useState } from "react"
import { Link } from "@tanstack/react-router"
import { useStreamContext as useAgentThreadStream } from "@langchain/react"
import { Map as MapIcon } from "lucide-react"
import type { AgentThread, Message } from "@/lib/agents/types"
import type { ModelSelection } from "@/lib/agents/provider/useModelOptions"
import {
AgentGitPanel,
PANEL_MIN_CHAT_WIDTH,
} from "@/components/agents/AgentGitPanel"
import { AgentPromptBar } from "@/components/agents/AgentPromptBar"
import { Messages } from "@/components/agents/messages"
import { streamMessagesToUi } from "@/lib/agents/streamMessagesToUi"
import { useSubmitAgentMessage } from "@/lib/agents/provider/useSubmitAgentMessage"
import { useModelOptions } from "@/lib/agents/provider/useModelOptions"
import { useIsMobile } from "@/lib/useIsMobile"
interface AgentThreadViewProps {
thread: AgentThread
}
// The stream lives at the `/agents` layout (one persistent provider that
// survives the home → thread navigation), so this view only consumes it.
export function AgentThreadView({ thread }: AgentThreadViewProps) {
const sendMessage = useSubmitAgentMessage(thread.id)
const stream = useAgentThreadStream()
const isMobile = useIsMobile()
const { models, defaultSelection } = useModelOptions()
const threadSelection = useMemo<ModelSelection | null>(() => {
if (!thread.model || !thread.effort) return null
const supported = models.some(
(m) => m.id === thread.model && m.efforts.includes(thread.effort ?? "")
)
if (!supported) return null
return { modelId: thread.model, effort: thread.effort }
}, [models, thread.model, thread.effort])
const [selection, setSelection] = useState<ModelSelection | null>(null)
const activeSelection = selection ?? threadSelection ?? defaultSelection
const [planMode, setPlanMode] = useState<boolean | null>(null)
const activePlanMode = planMode ?? thread.planMode ?? false
const baseMessages = useMemo<Array<Message>>(() => {
const live = streamMessagesToUi(
stream.messages,
stream.toolCalls,
stream.subagents
)
if (live.length > 0) return live
// Optimistic transcript seeded by `AgentsHome` on thread creation (the
// only case where a fetched thread carries messages — `getThread` returns
// none). Bridges the brief gap before the SDK's optimistic `submit` echo
// lands in `stream.messages`.
if (thread.messages.length > 0) return thread.messages
return live
}, [stream.messages, stream.toolCalls, stream.subagents, thread.messages])
const hasMessages = baseMessages.length > 0
const isStreaming = thread.status === "running" || stream.isLoading
const isThinking = stream.isLoading
const settingUpSandbox = isThinking && baseMessages.length === 0
// The transcript hydrates from the SDK (`GET …/state` → `stream.messages`).
// Show a loading state during that one-time fetch instead of the empty state.
const isHydrating = stream.isThreadLoading && !hasMessages
return (
<div className="flex min-w-0 flex-1">
<div
className="flex min-w-0 flex-1 flex-col"
style={isMobile ? undefined : { minWidth: PANEL_MIN_CHAT_WIDTH }}
>
{thread.planStatus &&
thread.planStatus !== "approved" &&
thread.planStatus !== "cancelled" && (
<Link
to="/agents/$threadId/plan"
params={{ threadId: thread.id }}
data-testid="review-plan-link"
className="flex items-center justify-between gap-2 border-b border-[var(--ui-border)] bg-[var(--ui-panel)] px-4 py-2 text-xs text-[var(--ui-text)] hover:bg-[var(--ui-panel-2)]"
>
<span className="flex items-center gap-2">
<MapIcon className="size-3.5 text-[var(--ui-accent)]" />
{thread.planStatus === "ready"
? "A plan is ready for your review."
: thread.planStatus === "revising"
? "The agent is revising the plan."
: "The agent is writing a plan."}
</span>
<span className="font-medium text-[var(--ui-accent)]">
Review plan →
</span>
</Link>
)}
{hasMessages ? (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<Messages
messages={baseMessages}
isStreaming={isStreaming}
streamIsLoading={stream.isLoading}
isThinking={isThinking}
settingUpSandbox={settingUpSandbox}
contentWidthClass="max-w-3xl"
/>
<div className="shrink-0 px-4 pb-4">
<div className="mx-auto w-full min-w-0 max-w-3xl">
<AgentPromptBar
placeholder="Add a follow up"
compact
busy={isStreaming}
onSubmit={(content, images) =>
sendMessage.mutateAsync({
content,
images,
model_id: activeSelection?.modelId ?? null,
effort: activeSelection?.effort ?? null,
plan_mode: activePlanMode,
})
}
models={models}
selection={activeSelection}
onSelectionChange={setSelection}
planMode={activePlanMode}
onPlanModeChange={setPlanMode}
/>
</div>
</div>
</div>
) : isHydrating ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
<p className="text-xs text-[var(--ui-text-dim)]">Loading conversation…</p>
</div>
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
<p className="text-xs text-[var(--ui-text-dim)]">
This thread has no messages yet.
</p>
<div className="w-full max-w-3xl">
<AgentPromptBar
placeholder="Send the first message"
compact
busy={isStreaming}
onSubmit={(content, images) =>
sendMessage.mutateAsync({
content,
images,
model_id: activeSelection?.modelId ?? null,
effort: activeSelection?.effort ?? null,
})
}
models={models}
selection={activeSelection}
onSelectionChange={setSelection}
/>
</div>
</div>
)}
</div>
<AgentGitPanel thread={thread} messages={baseMessages} />
</div>
)
}