- Preview isn't available for{" "}
+ Preview is not available for{" "}
{attachment.filename}
{" "}
@@ -500,13 +506,24 @@ function groupEntries(entries: RenderEntry[]): EntryGroup[] {
}
function ChatInner({
+ revertDisabled = false,
+ createSession,
+ composerContext,
agentName,
agentNames,
chatPreferences,
- draftId,
+ initialMessage,
+ draftModel,
+ coding = false,
+ draftMode,
+ onDraftModeChange,
+ onDraftModelChange,
+ onDraftChange,
+ projectName,
firstName,
greetingIndex,
promptMobile = false,
+ navigationPending = false,
sessionId,
workspaceId,
workspacePath,
@@ -514,10 +531,12 @@ function ChatInner({
onSessionCreated,
}: ChatProps) {
const queryClient = useQueryClient()
- const preferenceKey = ["chatSessionPreference", workspaceId] as const
const agentReadiness = useAgentReadiness(agentName, workspaceId)
const composerRef = useRef(null)
const { data: authSession } = authClient.useSession()
+ const draftKey = ["chatDraft", workspaceId, agentName, authSession?.user.id, sessionId]
+ const draftMessage = queryClient.getQueryData(draftKey) ?? initialMessage
+ const preferenceKey = ["chatSessionPreference", workspaceId, authSession?.user.id] as const
const rememberAgent = useMutation({
mutationFn: async ({
next,
@@ -545,14 +564,13 @@ function ChatInner({
scope: { id: `chat-preferences:${workspaceId}` },
})
const {
- applyOptimisticSession,
+ updateSession,
blocked,
hasEarlierMessages,
isLoadingEarlier,
loadError,
isBusy,
isPending,
- localMessages,
loadEarlier,
messages,
partsByMessage,
@@ -566,7 +584,46 @@ function ChatInner({
streamError,
textByPart,
todos,
- } = useOpencodeChat(agentName, workspaceId, sessionId, draftId)
+ } = useOpencodeChat(agentName, workspaceId, sessionId)
+
+ const questionTool = questionRequest?.tool
+ const planApproval =
+ coding &&
+ questionRequest?.sessionID === session?.id &&
+ questionTool &&
+ partsByMessage[questionTool.messageID]?.some(
+ (part) =>
+ part.type === "tool" && part.callID === questionTool.callID && part.tool === "plan_exit"
+ )
+
+ // Approval creates a synthetic Build user message without updating
+ // session.agent. Raw user history owns the mode, including after reconnect.
+ const lastUser = messages.findLast(
+ (message) =>
+ message.role === "user" && (!session?.revert || message.id < session.revert.messageID)
+ )
+ // A revert can precede the loaded page. Fetch its preceding user turn
+ // before choosing a mode rather than falling back to stale session metadata.
+ const modeHistoryPending =
+ coding && session?.revert !== undefined && !lastUser && hasEarlierMessages
+ useEffect(() => {
+ if (modeHistoryPending && !isLoadingEarlier && !loadError) void loadEarlier()
+ }, [modeHistoryPending, isLoadingEarlier, loadError, loadEarlier])
+ const [modeSelection, setModeSelection] = useState<{
+ mode: string
+ messageID?: string
+ revertID?: string
+ }>()
+ const modeSelectionCurrent =
+ modeSelection?.messageID === lastUser?.id &&
+ modeSelection?.revertID === session?.revert?.messageID
+ if (modeSelection && !modeSelectionCurrent) setModeSelection(undefined)
+ const mode =
+ (modeSelectionCurrent ? modeSelection?.mode : undefined) ??
+ lastUser?.agent ??
+ (session?.revert ? undefined : session?.agent) ??
+ (!sessionId ? draftMode : undefined) ??
+ "build"
useEffect(() => {
const id = `chat:${agentName}:${sessionId ?? "new"}:history-error`
@@ -623,7 +680,9 @@ function ChatInner({
}, [agentName, sessionId, sessionStatus])
const directory = session?.directory
- const [model, setModel] = useState("")
+ const [model, setModel] = useState(
+ draftModel ? `${draftModel.providerID}:${draftModel.modelID}` : ""
+ )
const [modelSelectorOpen, setModelSelectorOpen] = useState(false)
const [reasoningLevel, setReasoningLevel] = useState(DEFAULT_REASONING_LEVEL)
const {
@@ -673,7 +732,7 @@ function ChatInner({
}
return {
- agent: agentsResult.data.find((item) => item.name === agentName),
+ agents: agentsResult.data,
chefs: [...new Set(models.map((item) => item.chef))],
config: configResult.data,
models,
@@ -684,11 +743,15 @@ function ChatInner({
})
)
const catalog = modelCatalog.data
+ const catalogAgent = catalog?.agents.find((item) => item.name === agentName)
+ const modes = catalog?.agents.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
+ const modeAvailable = modes?.some((agent) => agent.name === mode)
+ const nextMode = mode === "plan" ? "build" : "plan"
const models = useMemo(() => catalog?.models ?? [], [catalog?.models])
const chefs = useMemo(() => catalog?.chefs ?? [], [catalog?.chefs])
const sessionModel = session?.model
- const agentModel = catalog?.agent?.model
+ const agentModel = catalogAgent?.model
const selectedModel = (() => {
const explicitModel = model ? models.find((item) => item.id === model) : undefined
if (explicitModel) return explicitModel
@@ -749,10 +812,10 @@ function ChatInner({
} else if (
agentModel?.providerID === selectedModel.providerID &&
agentModel.modelID === selectedModel.modelID &&
- catalog?.agent?.variant &&
- variants.has(catalog.agent.variant)
+ catalogAgent?.variant &&
+ variants.has(catalogAgent.variant)
) {
- fallbackReasoningLevel = catalog.agent.variant
+ fallbackReasoningLevel = catalogAgent.variant
} else {
const storedVariant = getVariant({
modelID: selectedModel.modelID,
@@ -779,15 +842,60 @@ function ChatInner({
? messages.filter((message) => message.id < revertMessageID)
: messages
const contextUsage = getAssistantUsage(contextMessages, models)
- const { abortMessage, canSubmit, isStopping, sendMessage, sendState } = useOpencodeSend(
+ const {
+ abortMessage,
+ canStop,
+ canSubmit,
+ hasSession,
+ isStopping,
+ sendMessage,
+ sendState,
+ pending: pendingMessages,
+ queue,
+ queueError,
+ updateInput,
+ } = useOpencodeSend(
agentName,
workspaceId,
sessionId,
- draftId,
- directory,
- isBusy || isPending || blocked || agentReadiness.isGettingReady,
- onSessionCreated
+ (id) => {
+ // The session route remounts the composer after checkout creation.
+ const nextDraftKey = [...draftKey.slice(0, -1), id]
+ queryClient.setQueryData(nextDraftKey, composerRef.current?.getMessage())
+ onSessionCreated?.(id)
+ },
+ createSession
+ )
+ const activeSteers = queue.filter(
+ (item) =>
+ item.delivery === "steer" &&
+ !item.error &&
+ (item.state === "queued" || item.state === "sending")
)
+ const pendingInputs = [
+ ...pendingMessages
+ .filter(({ input }) => input.delivery === "steer")
+ .map(({ id, input, status }) => ({
+ id,
+ text: input.text,
+ files: input.files,
+ status,
+ author: authSession?.user.name,
+ })),
+ ...activeSteers
+ .filter(
+ (item) =>
+ !pendingMessages.some((pending) => pending.id === item.id) &&
+ !messages.some((message) => message.id === item.message_id)
+ )
+ .map((item) => ({
+ id: item.id,
+ text: item.content.text,
+ files: item.content.attachments,
+ status: isBusy ? "Waiting for the current step..." : "Starting agent...",
+ author: item.author.name,
+ })),
+ ]
useEffect(() => {
if (models.length === 0 || !modelStorageReady) return
@@ -798,13 +906,19 @@ function ChatInner({
})
}, [clearInvalid, modelStorageReady, models])
- const { isPending: isQuestionPending, mutateAsync: submitQuestionAnswer } = useMutation({
+ const {
+ isPending: isQuestionPending,
+ mutate: submitQuestionAnswer,
+ mutateAsync: answerQuestion,
+ } = useMutation({
mutationFn: async (answers: QuestionAnswer[]) => {
if (!questionRequest) {
throw new Error("No question request is active")
}
+ if (!directory) throw new Error("Wait for the session to finish loading")
const client = await createAgentOpencodeClient(agentName, workspaceId)
const result = await client.question.reply({
+ directory,
answers,
requestID: questionRequest.id,
})
@@ -824,8 +938,10 @@ function ChatInner({
if (!questionRequest) {
throw new Error("No question request is active")
}
+ if (!directory) throw new Error("Wait for the session to finish loading")
const client = await createAgentOpencodeClient(agentName, workspaceId)
const result = await client.question.reject({
+ directory,
requestID: questionRequest.id,
})
if (result.error || result.data !== true) {
@@ -844,8 +960,10 @@ function ChatInner({
if (!permissionRequest) {
throw new Error("No permission request is active")
}
+ if (!directory) throw new Error("Wait for the session to finish loading")
const client = await createAgentOpencodeClient(agentName, workspaceId)
const result = await client.permission.reply({
+ directory,
requestID: permissionRequest.id,
reply,
})
@@ -860,8 +978,7 @@ function ChatInner({
},
})
- // Fold the echoed session into the live store for an instant update; the
- // matching session.updated stream event reconciles it (see applyOptimisticSession).
+ // Publish the response immediately; the matching stream event may arrive later.
const applyRevert = useCallback(
async (messageID?: string) => {
if (!sessionId || isStopping) return
@@ -872,9 +989,9 @@ function ChatInner({
if (result.error || !result.data) {
throw new Error(opencodeErrorMessage(result.error, "Failed to update session"))
}
- applyOptimisticSession(result.data)
+ await updateSession(result.data)
},
- [agentName, applyOptimisticSession, directory, isStopping, sessionId, workspaceId]
+ [agentName, updateSession, directory, isStopping, sessionId, workspaceId]
)
// A resendable composer draft (non-synthetic text + file attachments) for a
@@ -936,41 +1053,108 @@ function ChatInner({
// replacing the selected turn.
const revertPending = isReverting || restoreMutation.isPending
- const handleSubmit = useCallback(
- async (message: PromptInputMessage) => {
- if (agentReadiness.isGettingReady) return
- if (message.text.trim().length === 0 && message.files.length === 0) {
- toast.error("Message cannot be empty")
- return
- }
- await sendMessage({
- files: message.files,
- model: selectedModel,
- sessionID: sessionId,
- text: message.text,
- variant: selectedReasoningVariant,
- })
- if (!selectedModel) return
- pushRecent({
- modelID: selectedModel.modelID,
- providerID: selectedModel.providerID,
+ const [restoredInput, setRestoredInput] = useState()
+ const restoreInput = (item: ChatInput) => {
+ const current = composerRef.current?.getMessage()
+ if (current?.text || current?.files.length) {
+ toast.error("Send or clear your current draft before restoring this message")
+ return
+ }
+ const model = models.find(
+ (entry) =>
+ entry.modelID === item.content.model.modelID &&
+ entry.providerID === item.content.model.providerID
+ )
+ if (model) {
+ setModel(model.id)
+ setVariant(item.content.model, item.content.variant)
+ setReasoningLevel(item.content.variant ?? DEFAULT_REASONING_LEVEL)
+ }
+ if (coding && item.content.agent)
+ setModeSelection({
+ mode: item.content.agent,
+ messageID: lastUser?.id,
+ revertID: session?.revert?.messageID,
})
- },
- [
- agentReadiness.isGettingReady,
- pushRecent,
- selectedModel,
- selectedReasoningVariant,
- sendMessage,
- sessionId,
- ]
- )
+ composerRef.current?.setMessage({
+ text: item.content.text,
+ files: item.content.attachments.map((file) => ({
+ ...file,
+ type: "file",
+ source: "workspace",
+ })),
+ })
+ setRestoredInput(item)
+ }
+ const handleStop = async () => {
+ const result = await abortMessage()
+ const recovered = result.items.find(
+ (item) => item.state === "recovered" && item.author.id === authSession?.user.id
+ )
+ if (
+ recovered &&
+ !composerRef.current?.getMessage().text &&
+ !composerRef.current?.getMessage().files.length
+ )
+ restoreInput(recovered)
+ }
+ const handleSubmit = async (
+ message: PromptInputMessage,
+ delivery: "steer" | "queue" = "steer"
+ ) => {
+ if (
+ agentReadiness.isGettingReady ||
+ isStopping ||
+ revertPending ||
+ (blocked && delivery === "steer")
+ )
+ throw new Error("Chat is not ready to send")
+ if (coding && (isPending || modeHistoryPending || !modeAvailable)) {
+ const error = new Error(
+ isPending || modeHistoryPending
+ ? "Chat is still loading"
+ : "The selected chat mode is unavailable"
+ )
+ toast.error(error.message)
+ throw error
+ }
+ if (message.text.trim().length === 0 && message.files.length === 0) {
+ toast.error("Message cannot be empty")
+ return
+ }
+ await sendMessage({
+ requestID: message.requestID,
+ delivery,
+ agent: coding ? mode : undefined,
+ files: message.files,
+ model: selectedModel,
+ sessionID: sessionId,
+ text: message.text,
+ variant: selectedReasoningVariant,
+ })
+ if (restoredInput) {
+ setRestoredInput(undefined)
+ try {
+ await updateInput({ item: restoredInput, action: "remove" })
+ } catch {
+ toast.error("Message sent. The recovered copy could not be removed.")
+ }
+ }
+ if (!selectedModel) return
+ pushRecent({
+ modelID: selectedModel.modelID,
+ providerID: selectedModel.providerID,
+ })
+ }
- const handleModelSelect = useCallback((modelId: string) => {
+ const handleModelSelect = (modelId: string) => {
+ const selected = models.find((item) => item.id === modelId)
+ if (!selected) return
setModel(modelId)
+ onDraftModelChange?.({ modelID: selected.modelID, providerID: selected.providerID })
setReasoningLevel(DEFAULT_REASONING_LEVEL)
setModelSelectorOpen(false)
- }, [])
+ }
const handleReasoningLevelChange = useCallback(
(value: string) => {
@@ -996,21 +1180,12 @@ function ChatInner({
projectTimeline({
isBusy,
isRetrying: sessionStatus?.type === "retry",
- localMessages,
messages,
partsByMessage,
revertMessageID: session?.revert?.messageID,
textByPart,
}),
- [
- isBusy,
- localMessages,
- messages,
- partsByMessage,
- session?.revert?.messageID,
- sessionStatus?.type,
- textByPart,
- ]
+ [isBusy, messages, partsByMessage, session?.revert?.messageID, sessionStatus?.type, textByPart]
)
const actorUserIDs = useMemo(
() =>
@@ -1043,15 +1218,50 @@ function ChatInner({
() => new Map(actorProfilesQuery.data?.profiles.map((profile) => [profile.id, profile]) ?? []),
[actorProfilesQuery.data]
)
- const timelineIdentity = useMemo(
- () => ({ actorProfiles, user: authSession?.user }),
- [actorProfiles, authSession?.user]
- )
- const inputDisabled = blocked || isBusy || isStopping || agentReadiness.isGettingReady
- const showStarter = !sessionId && !isPending && rows.length === 0
- const showHistorySkeleton = isPending && rows.length === 0 && !showStarter
+ const inputDisabled =
+ agentReadiness.isGettingReady || navigationPending || (!sessionId && sendState === "submitted")
+ const showStop = isBusy || isStopping || queue.some((item) => item.state !== "recovered")
+ const submitDisabled =
+ inputDisabled ||
+ isStopping ||
+ revertPending ||
+ !selectedModel ||
+ !canSubmit ||
+ (coding && (isPending || !modeAvailable || modeHistoryPending))
+ const modeDisabled =
+ inputDisabled ||
+ isPending ||
+ modeHistoryPending ||
+ revertPending ||
+ sendState === "submitted" ||
+ !modes?.some((agent) => agent.name === nextMode)
+ const toggleMode = () => {
+ if (!coding || modeDisabled) return
+ setModeSelection({
+ mode: nextMode,
+ messageID: lastUser?.id,
+ revertID: session?.revert?.messageID,
+ })
+ onDraftModeChange?.(nextMode)
+ }
+ const showStarter =
+ !hasSession && !isPending && rows.length === 0 && !sendState && pendingInputs.length === 0
+ const showHistorySkeleton =
+ isPending && rows.length === 0 && !showStarter && pendingInputs.length === 0
const timelineRef = useRef(null)
const [timelineAtEnd, setTimelineAtEnd] = useState(true)
+ const composerDock = useRef(null)
+ const [composerHeight, setComposerHeight] = useState(224)
+ useEffect(() => {
+ const dock = composerDock.current
+ if (!dock) return
+ const observer = new ResizeObserver(([entry]) => {
+ if (entry)
+ setComposerHeight(Math.ceil(entry.borderBoxSize[0]?.blockSize ?? entry.contentRect.height))
+ })
+ observer.observe(dock)
+ return () => observer.disconnect()
+ }, [])
return (
@@ -1082,7 +1292,7 @@ function ChatInner({
className="h-full min-h-0 overflow-x-hidden overscroll-y-contain [overflow-anchor:none]"
data={rows}
estimatedItemSize={96}
- extraData={timelineIdentity}
+ extraData={actorProfiles}
initialScrollAtEnd
keyExtractor={(row) => row.key}
recycleItems={false}
@@ -1103,8 +1313,40 @@ function ChatInner({
)
}
ListFooterComponent={
-
-
+
+ {pendingInputs.map(({ id, text, files, status, author }) => (
+
+
+ {!coding && author ? (
+
{author}
+ ) : null}
+ {files.map((file, index) => (
+
+ ))}
+ {text ?
{text} : null}
+
+
+
+ {status}
+
+
+ ))}
) : null}
- {questionRequest ? (
-
void rejectQuestion()}
- onSubmit={(answers) => void submitQuestionAnswer(answers)}
- pending={isQuestionPending || isQuestionRejectPending}
+ {questionRequest && planApproval && session ? (
+
) : null}
}
- maintainScrollAtEnd={
- timelineAtEnd
- ? { animated: false, on: { dataChange: true, itemLayout: true, layout: true } }
- : false
- }
+ maintainScrollAtEnd={timelineAtEnd ? { animated: false } : false}
maintainVisibleContentPosition={{ data: true, size: true }}
onScroll={(event) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent
@@ -1146,7 +1387,7 @@ function ChatInner({
renderItem={({ item }) => (
@@ -1175,7 +1413,8 @@ function ChatInner({
{!timelineAtEnd && !showStarter ? (
{showStarter ? (
-
+
+ ) : null}
+ {questionRequest && !planApproval ? (
+
) : null}
+ !activeSteers.includes(item))}
+ submissions={pendingMessages.filter(({ input }) => input.delivery === "queue")}
+ error={queueError}
+ userID={authSession?.user.id}
+ coding={coding}
+ onUpdate={updateInput}
+ onRestore={restoreInput}
+ />
{
+ if (sessionId) queryClient.setQueryData(draftKey, message)
+ else onDraftChange?.(message)
+ }}
className="agentz-chat-composer chat-composer-glass-host relative z-10 rounded-[22px]"
controllerRef={composerRef}
+ disabled={inputDisabled}
globalDrop
maxFileSize={chatAttachmentConfig.maxFileSizeBytes}
maxFiles={chatAttachmentConfig.maxFileCount}
@@ -1214,20 +1482,82 @@ function ChatInner({
onError={(code) => {
toast.error(chatAttachmentErrorMessage(code))
}}
- onSubmit={handleSubmit}
+ onSubmit={(message) => handleSubmit(message)}
+ onQueue={(message) => handleSubmit(message, "queue")}
>
+ {coding ? (
+ agent.name === nextMode)
+ ? `Switch to ${nextMode === "plan" ? "Plan" : "Build"}`
+ : "Chat mode unavailable",
+ shortcut: "Shift+Tab",
+ }}
+ >
+ {mode === "plan" ? : }
+ {mode === "plan" ? "Plan" : mode === "build" ? "Build" : mode}
+
+ ) : null}
{
+ if (
+ event.key === "Tab" &&
+ !event.shiftKey &&
+ !event.altKey &&
+ !event.ctrlKey &&
+ !event.metaKey &&
+ !event.nativeEvent.isComposing
+ ) {
+ if (
+ !event.currentTarget.value.trim() &&
+ !composerRef.current?.getMessage().files.length
+ )
+ return
+ if (event.repeat) {
+ event.preventDefault()
+ return
+ }
+ event.preventDefault()
+ composerRef.current?.submit(true)
+ return
+ }
+ if (
+ !coding ||
+ modeDisabled ||
+ event.key !== "Tab" ||
+ !event.shiftKey ||
+ event.altKey ||
+ event.ctrlKey ||
+ event.metaKey ||
+ event.nativeEvent.isComposing
+ )
+ return
+ event.preventDefault()
+ if (!event.repeat) toggleMode()
+ }}
/>
@@ -1449,30 +1779,28 @@ function ChatInner({
layout="position"
transition={promptShiftTransition}
>
- void abortMessage(directory)
- : undefined
- }
- status={sendState ?? (isBusy ? "streaming" : undefined)}
- />
+
-
+
+ {composerContext?.(inputDisabled || hasSession || sendState === "submitted")}
@@ -1728,23 +2057,17 @@ function UserMessageAvatar({
function TimelineRowView({
agentName,
actorProfiles,
- isBusy,
- isLastBlock,
onRevert,
revertDisabled,
row,
- user,
workspaceId,
workspacePath,
}: {
agentName: string
actorProfiles: Map
- isBusy: boolean
- isLastBlock: boolean
onRevert: (messageID: string) => void
revertDisabled: boolean
row: TimelineRow
- user?: AuthUser
workspaceId: string
workspacePath: string
}) {
@@ -1757,37 +2080,6 @@ function TimelineRowView({
)
switch (row.type) {
- case "local": {
- return (
-
-
- {row.message.attachments.length > 0 ? (
-
- ) : null}
- {row.message.text.length > 0 ? (
- {row.message.text}
- ) : null}
-
-
-
- )
- }
-
case "user": {
const isEmpty = row.text.length === 0 && row.attachments.length === 0
if (isEmpty) return null
@@ -1817,6 +2109,11 @@ function TimelineRowView({
) : null}
+ {row.isWaiting ? (
+
+ Waiting for the current step...
+
+ ) : null}
@@ -1842,7 +2139,7 @@ function TimelineRowView({
case "assistant": {
const groups = groupEntries(row.entries)
const lastGroupIndex = groups.length - 1
- const showMeta = !(isBusy && isLastBlock)
+ const showMeta = !row.isStreaming
const copyText = row.entries
.filter((entry) => entry.type === "text")
.map((entry) => entry.content)
@@ -1863,7 +2160,7 @@ function TimelineRowView({
)
case "reasoning": {
- const isStreaming = isBusy && isLastBlock && groupIndex === lastGroupIndex
+ const isStreaming = row.isStreaming && groupIndex === lastGroupIndex
return (
@@ -1922,60 +2219,6 @@ function TimelineRowView({
)
}
- case "diff-summary": {
- const visible = row.diffs.slice(0, 10)
- return (
-
- {visible.map((diff) => {
- const value = diff.file ?? diff.patch ?? ""
- const path = value.replace(/\\/g, "/")
- const slash = path.lastIndexOf("/")
- const stat =
- diff.status === "added"
- ? "Added"
- : diff.status === "deleted"
- ? "Deleted"
- : `+${diff.additions} -${diff.deletions}`
- return (
-
-
-
-
- {slash >= 0 ? path.slice(slash + 1) : path}
-
- {value.includes("/") ? (
-
- {slash > 0 ? path.slice(0, slash) : "/"}
-
- ) : null}
-
- {stat}
-
-
- {diff.patch ? (
-
- {diff.patch}
-
- ) : (
-
- +{diff.additions} -{diff.deletions}
-
- )}
-
-
- )
- })}
- {row.diffs.length > visible.length ? (
-
- +{row.diffs.length - visible.length} more
-
- ) : null}
- {row.title ? {row.title}
: null}
- {row.body ? {row.body} : null}
-
- )
- }
-
case "checkpoint": {
return (
diff --git a/web/components/blocks/chat/docks.tsx b/web/components/blocks/chat/docks.tsx
index 79033393..71932b85 100644
--- a/web/components/blocks/chat/docks.tsx
+++ b/web/components/blocks/chat/docks.tsx
@@ -1,15 +1,33 @@
"use client"
-import { ChevronDownIcon, ChevronRightIcon, Redo2Icon } from "lucide-react"
+import {
+ CheckIcon,
+ ChevronDownIcon,
+ MessageCircleQuestionIcon,
+ PencilIcon,
+ ChevronRightIcon,
+ HammerIcon,
+ PencilRulerIcon,
+ Redo2Icon,
+} from "lucide-react"
import { cn } from "@/lib/utils"
+import { createAgentOpencodeClient } from "@/lib/opencode/client"
+import { MessageResponse } from "@/components/ai-elements/message"
import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import { FieldGroup, FieldSet, FieldLegend } from "@/components/ui/field"
-import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
+import { CopyButton } from "@/components/ui/copy-button"
+import { FieldSet, FieldLegend } from "@/components/ui/field"
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Spinner } from "@/components/ui/spinner"
import { Textarea } from "@/components/ui/textarea"
-import type { PermissionRequest, QuestionAnswer, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
-import { useCallback, useEffect, useRef, useState } from "react"
+import type {
+ PermissionRequest,
+ QuestionAnswer,
+ QuestionRequest,
+ Session,
+ Todo,
+} from "@opencode-ai/sdk/v2"
+import { queryOptions, useQuery } from "@tanstack/react-query"
+import { useCallback, useEffect, useId, useRef, useState } from "react"
const CUSTOM_ANSWER_KEY = "__custom__"
const QUESTION_CACHE_MAX = 8
@@ -20,100 +38,95 @@ const QUESTION_CACHE_MAX = 8
type QuestionCacheEntry = {
answers: Record
custom: Record
- customEnabled: Record
tab: number
}
const questionCache = new Map()
-function rememberAnswer(requestID: string, entry: QuestionCacheEntry) {
- questionCache.delete(requestID)
- questionCache.set(requestID, entry)
- if (questionCache.size > QUESTION_CACHE_MAX) {
- const oldest = questionCache.keys().next().value
- if (oldest) questionCache.delete(oldest)
- }
-}
-
-function emptyAnswers(count: number): QuestionCacheEntry {
- return {
- answers: Object.fromEntries(Array.from({ length: count }, (_, i) => [i, []])),
- custom: Object.fromEntries(Array.from({ length: count }, (_, i) => [i, ""])),
- customEnabled: Object.fromEntries(Array.from({ length: count }, (_, i) => [i, false])),
- tab: 0,
- }
-}
-
-function buildAnswers(entry: QuestionCacheEntry, request: QuestionRequest): QuestionAnswer[] {
- return request.questions.map((question, index) => {
- const selected = entry.answers[index] ?? []
- const custom = entry.custom[index]?.trim()
-
- if (question.multiple !== true) {
- return selected[0] === CUSTOM_ANSWER_KEY ? (custom ? [custom] : []) : selected.slice(0, 1)
- }
-
- const answers = selected.filter((item) => item !== CUSTOM_ANSWER_KEY)
- if ((entry.customEnabled[index] ?? false) && custom) answers.push(custom)
- return answers
- })
-}
-function AutoSizeTextarea({
- defaultValue,
- disabled,
- onCommit,
+export function PlanDock({
+ agentName,
+ workspaceId,
+ session,
+ request,
+ pending,
+ onSubmit,
}: {
- defaultValue: string
- disabled: boolean
- onCommit: (value: string) => void
+ agentName: string
+ workspaceId: string
+ session: Session
+ request: QuestionRequest
+ pending: boolean
+ onSubmit: (answers: QuestionAnswer[]) => void
}) {
- const [value, setValue] = useState(defaultValue)
- const ref = useRef(null)
-
- const resize = useCallback(() => {
- const el = ref.current
- if (!el) return
- el.style.height = "0px"
- el.style.height = `${el.scrollHeight}px`
- }, [])
-
- useEffect(() => {
- resize()
- }, [resize])
-
- // Escape abandons the edit without committing, mirroring opencode's behaviour
- // so the Escape key stays usable inside the custom-answer field.
- const handleKeyDown = (event: React.KeyboardEvent) => {
- if (event.key === "Escape") {
- event.preventDefault()
- event.currentTarget.parentElement
- ?.querySelector("button[data-question-dismiss]")
- ?.focus()
- return
- }
- if ((event.metaKey || event.ctrlKey) && !event.altKey && event.key === "Enter") {
- event.preventDefault()
- onCommit(value)
- return
- }
- }
+ // OpenCode's Session.plan names coding-worktree plans from session metadata.
+ // Each approval request gets a fresh read, including after plan revisions.
+ const path = `.opencode/plans/${session.time.created}-${session.slug}.md`
+ const plan = useQuery(
+ queryOptions({
+ queryKey: ["opencode-plan", workspaceId, agentName, session.directory, path, request.id],
+ queryFn: async ({ signal }) => {
+ const client = await createAgentOpencodeClient(agentName, workspaceId)
+ const { data } = await client.file.read(
+ { directory: session.directory, path },
+ { signal, throwOnError: true }
+ )
+ if (!data.content.trim()) throw new Error("The plan file is empty or missing.")
+ return data.content
+ },
+ refetchOnWindowFocus: false,
+ retry: false,
+ })
+ )
return (
-