diff --git a/package-lock.json b/package-lock.json index 59eecf817..3e9ea4e26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3266,9 +3266,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.15.13", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.13.tgz", - "integrity": "sha512-4TwojIoQ8EG6/mVBuUVYZXiFcwNmiiytEnjnvyuvSJjGwFIlw2YIBFxtSVC3FbwwbwHT63teh1RHiQUUC4U5xw==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.16.0.tgz", + "integrity": "sha512-S4H2e9j4rdHs5BQOCjmVEdqdXmKwPFKjXPbPUaWiRJpAjBcZ/uIBpoZkmV+x9BLzc+vrE6WAffMZieQgukt4DA==", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" @@ -13503,7 +13503,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "1.15.13", + "@opencode-ai/sdk": "1.16.0", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/ui/package.json b/packages/ui/package.json index 06740a563..bc2c83b20 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -13,7 +13,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "1.15.13", + "@opencode-ai/sdk": "1.16.0", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 2f90b3cb8..7b8128ad8 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -103,7 +103,6 @@ const App: Component = () => { binaryPath: string instanceId: string } | null>(null) - const phoneQuery = useMediaQuery("(max-width: 767px)") const isPhoneLayout = createMemo(() => phoneQuery()) @@ -418,7 +417,7 @@ const App: Component = () => { clearActiveParentSession(instanceId) try { - await fetchSessions(instanceId, { start: 0, limit: getSessionFetchLimit(instanceId) }) + await fetchSessions(instanceId, { reset: true, limit: getSessionFetchLimit(instanceId) }) } catch (error) { log.error("Failed to refresh sessions after closing", error) } diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index bb90f31ee..ab8cbfb36 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -514,6 +514,23 @@ const InstanceShell2: Component = (props) => { ) + const renderPreviewToggleButton = () => ( + + + {(() => { + const Icon = PreviewToggleIcon() + return + + ) + const handleCommandPaletteClick = () => { showCommandPalette(props.instance.id) } @@ -834,6 +851,50 @@ const InstanceShell2: Component = (props) => { } const showingInfoView = createMemo(() => activeSessionIdForInstance() === "info") + const activeSessionTitle = createMemo(() => { + if (showingInfoView()) return null + const title = activeSessionForInstance()?.title?.trim() + return title || t("sessionList.session.untitled") + }) + const showHeaderLeftSlot = createMemo(() => !leftPinned()) + const showHeaderSessionTitle = createMemo(() => !compactHeaderLayout() && showHeaderLeftSlot() && Boolean(activeSessionTitle())) + const headerToolbarHorizontalInset = createMemo(() => (isPhoneLayout() ? 16 : 24)) + const headerLeftSlotWidth = createMemo(() => Math.max(0, sessionSidebarWidth() - headerToolbarHorizontalInset())) + const headerLeftSlotStyle = createMemo(() => + leftDrawerState() === "floating-open" || showHeaderSessionTitle() ? { width: `${headerLeftSlotWidth()}px` } : undefined, + ) + + const renderActiveSessionHeaderTitle = () => ( + +
+ {activeSessionTitle()} +
+
+ ) + + const renderHeaderLeftSlot = () => ( + +
+ + + {leftAppBarButtonIcon()} + + + {renderActiveSessionHeaderTitle()} +
+
+ ) const isLaunching = createMemo(() => props.instance.status === "starting") @@ -933,94 +994,76 @@ const InstanceShell2: Component = (props) => { fallback={
- - - {leftAppBarButtonIcon()} - - + {renderHeaderLeftSlot()} -
- {renderSessionHeaderIndicators()} -
+
+ {renderSessionHeaderIndicators()} +
-
- +
+ + + + + + + + +
+ +
+ + + +
+ + + {renderPreviewToggleButton()} + + + - + {renderPreviewToggleButton()} + + + - {(() => { - const Icon = PreviewToggleIcon() - return - - - - -
- -
- - - -
- - - - - - - - - {rightAppBarButtonIcon()} - -
@@ -1038,18 +1081,7 @@ const InstanceShell2: Component = (props) => { } >
- - - {leftAppBarButtonIcon()} - - + {renderHeaderLeftSlot()} = (props) => { > diff --git a/packages/ui/src/components/message-item.tsx b/packages/ui/src/components/message-item.tsx index d0f60386c..0fc38c09e 100644 --- a/packages/ui/src/components/message-item.tsx +++ b/packages/ui/src/components/message-item.tsx @@ -168,6 +168,11 @@ export default function MessageItem(props: MessageItemProps) { return typeof firstText?.id === "string" ? firstText.id : null } + const primaryUserPromptDisplayMetadata = () => { + if (!isUser()) return undefined + return props.record.clientPromptDisplayMetadata + } + const fileAttachments = () => messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string") @@ -688,6 +693,7 @@ export default function MessageItem(props: MessageItemProps) { instanceId={props.instanceId} sessionId={props.sessionId} primaryUserTextPartId={primaryUserTextPartId()} + displayMetadataOverride={part.id === primaryUserTextPartId() ? primaryUserPromptDisplayMetadata() : undefined} onRendered={props.onContentRendered} />
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index b51a52820..df3d911d6 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1,8 +1,13 @@ -import { Match, Show, Suspense, Switch, lazy } from "solid-js" +import { For, Match, Show, Suspense, Switch, createMemo, createSignal, lazy } from "solid-js" +import { ChevronsDownUp, ChevronsUpDown, Copy } from "lucide-solid" import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state" import { Markdown } from "./markdown" import { useTheme } from "../lib/theme" import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message" +import { useI18n } from "../lib/i18n" +import { splitPromptDisplaySections, type PromptDisplayMetadata } from "../lib/prompt-display-metadata" +import { copyToClipboard } from "../lib/clipboard" +import { getPastedTextLineCount } from "../lib/pasted-text-display" type ToolCallPart = Extract @@ -16,11 +21,13 @@ interface MessagePartProps { // For user messages, keep the primary prompt text visible even when synthetic (optimistic). // Other synthetic text parts (tool traces, read outputs, etc.) should be hidden. primaryUserTextPartId?: string | null + displayMetadataOverride?: PromptDisplayMetadata onRendered?: () => void } export default function MessagePart(props: MessagePartProps) { + const { t } = useI18n() const { isDark } = useTheme() const partType = () => props.part?.type || "" const reasoningId = () => `reasoning-${props.part?.id || ""}` @@ -52,6 +59,14 @@ export default function MessagePart(props: MessagePartProps) { return typeof id === "string" && id.length > 0 } + const promptDisplaySegments = createMemo(() => { + if (props.messageType !== "user") return null + if (props.part?.type !== "text") return null + if (typeof props.part.text !== "string") return null + + return splitPromptDisplaySections(props.part.text, props.displayMetadataOverride) + }) + function reasoningSegmentHasText(segment: unknown): boolean { if (typeof segment === "string") { return segment.trim().length > 0 @@ -111,11 +126,90 @@ export default function MessagePart(props: MessagePartProps) { } } + function createSegmentTextPart(text: string, index: number): TextPart { + return { + id: `${String((props.part as { id?: string }).id ?? "text")}:display:${index}`, + type: "text", + text, + synthetic: false, + } + } + function handleReasoningClick(e: Event) { e.preventDefault() toggleItemExpanded(reasoningId()) } + function PastedTextDisclosure(disclosureProps: { text: string; index: number }) { + const [hasExpanded, setHasExpanded] = createSignal(false) + const [isOpen, setIsOpen] = createSignal(false) + const [copied, setCopied] = createSignal(false) + const lineCount = () => getPastedTextLineCount(disclosureProps.text) + const lineCountLabel = () => + lineCount() === 1 + ? t("messagePart.pastedText.lines.one", { count: String(lineCount()) }) + : t("messagePart.pastedText.lines.other", { count: String(lineCount()) }) + const copyLabel = () => (copied() ? t("codeBlockInline.actions.copied") : t("codeBlockInline.actions.copy")) + + const handleCopy = async (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + const success = await copyToClipboard(disclosureProps.text) + setCopied(success) + setTimeout(() => setCopied(false), 2000) + } + + return ( +
{ + const nextOpen = (event.currentTarget as HTMLDetailsElement).open + setIsOpen(nextOpen) + if (nextOpen) { + setHasExpanded(true) + } + }} + > + + + {t("messagePart.pastedText.summary")} + {lineCountLabel()} + + + + + + + +
+ +
+
+
+ ) + } + return ( @@ -127,16 +221,43 @@ export default function MessagePart(props: MessagePartProps) { data-part-type="text" data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined} > - {plainTextContent()}}> - + {plainTextContent()}}> + + + } + > + {(segments) => ( +
+ segment.text.length > 0)}> + {(segment, index) => + segment.kind === "pasted" ? ( + + ) : ( + + ) + } + +
+ )}
diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx index 33b6d2d32..d5a3c6b2c 100644 --- a/packages/ui/src/components/permission-approval-modal.tsx +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -1,11 +1,12 @@ import { For, Show, Suspense, createMemo, createSignal, createEffect, lazy, onCleanup, type Component } from "solid-js" -import type { PermissionRequestLike } from "../types/permission" +import type { PermissionRequest } from "../types/permission" import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission" import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question" import { useI18n } from "../lib/i18n" import { activeInterruption, getPermissionQueue, + getPermissionEnqueuedAtForInstance, getQuestionQueue, getQuestionEnqueuedAtForInstance, sendPermissionResponse, @@ -32,7 +33,7 @@ type ResolvedToolCall = { function resolveToolCallFromPermission( instanceId: string, - permission: PermissionRequestLike, + permission: PermissionRequest, ): ResolvedToolCall | null { const sessionId = getPermissionSessionId(permission) const messageId = getPermissionMessageId(permission) @@ -158,7 +159,7 @@ const PermissionApprovalModal: Component = (props) }) } - async function handlePermissionDecision(permission: PermissionRequestLike, response: "once" | "always" | "reject", message?: string) { + async function handlePermissionDecision(permission: PermissionRequest, response: "once" | "always" | "reject", message?: string) { const permissionId = permission?.id if (!permissionId) return @@ -168,7 +169,8 @@ const PermissionApprovalModal: Component = (props) setPermissionItemError(permissionId, null) try { - const sessionId = getPermissionSessionId(permission) || "" + const sessionId = getPermissionSessionId(permission) + if (!sessionId) throw new Error("Permission request is missing sessionID") await sendPermissionResponse(props.instanceId, sessionId, permissionId, response, message) if (rejectingPermissionId() === permissionId) { setRejectingPermissionId(null) @@ -189,7 +191,7 @@ const PermissionApprovalModal: Component = (props) const active = createMemo(() => activeInterruption().get(props.instanceId) ?? null) type InterruptionItem = - | { kind: "permission"; id: string; sessionId: string; createdAt: number; payload: PermissionRequestLike } + | { kind: "permission"; id: string; sessionId: string; createdAt: number; payload: PermissionRequest } | { kind: "question"; id: string; sessionId: string; createdAt: number; payload: QuestionRequest } const orderedQueue = createMemo(() => { @@ -197,7 +199,7 @@ const PermissionApprovalModal: Component = (props) kind: "permission" as const, id: permission.id, sessionId: getPermissionSessionId(permission) || "", - createdAt: (permission as any)?.time?.created ?? Date.now(), + createdAt: getPermissionEnqueuedAtForInstance(props.instanceId, permission.id), payload: permission, })) @@ -386,7 +388,7 @@ const PermissionApprovalModal: Component = (props) type="button" class="tool-call-permission-button" disabled={permissionSubmitting().has(item.id)} - onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "once")} + onClick={() => void handlePermissionDecision(item.payload as PermissionRequest, "once")} > {t("permissionApproval.actions.allowOnce")} @@ -394,7 +396,7 @@ const PermissionApprovalModal: Component = (props) type="button" class="tool-call-permission-button" disabled={permissionSubmitting().has(item.id)} - onClick={() => void handlePermissionDecision(item.payload as PermissionRequestLike, "always")} + onClick={() => void handlePermissionDecision(item.payload as PermissionRequest, "always")} > {t("permissionApproval.actions.alwaysAllow")} @@ -435,7 +437,7 @@ const PermissionApprovalModal: Component = (props) disabled={permissionSubmitting().has(item.id)} onClick={() => void handlePermissionDecision( - item.payload as PermissionRequestLike, + item.payload as PermissionRequest, "reject", rejectReason().trim() || undefined, ) diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index e5d562906..27acedcad 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -2,8 +2,8 @@ import { Suspense, createEffect, createSignal, lazy, on, onCleanup, Show } from import { ArrowBigUp, ArrowBigDown, Loader2, Mic, Paperclip, Volume2, X } from "lucide-solid" import ExpandButton from "./expand-button" import { clearAttachments, removeAttachment } from "../stores/attachments" -import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders" +import { preparePromptSubmission } from "./prompt-input/submitPrompt" import Kbd from "./kbd" import { getActiveInstance } from "../stores/instances" import { agents, executeCustomCommand } from "../stores/sessions" @@ -383,13 +383,16 @@ export default function PromptInput(props: PromptInputProps) { commandName.length > 0 && getCommands(props.instanceId).some((cmd) => cmd.name === commandName) - const resolvedCommandArgs = isKnownSlashCommand ? resolvePastedPlaceholders(commandArgs, currentAttachments) : "" - const resolvedPrompt = isKnownSlashCommand - ? resolvedCommandArgs - ? `${commandToken} ${resolvedCommandArgs}` - : commandToken - : resolvePastedPlaceholders(text, currentAttachments) - const historyEntry = resolvedPrompt + const submission = preparePromptSubmission({ + mode: isKnownSlashCommand ? "slash" : isShellMode ? "shell" : "message", + text, + attachments: currentAttachments, + commandToken, + commandArgs, + }) + const resolvedCommandArgs = submission.resolvedCommandArgs + const submitPrompt = submission.submitPrompt + const historyEntry = submission.historyEntry const refreshHistory = () => recordHistoryEntry(historyEntry) @@ -423,9 +426,9 @@ export default function PromptInput(props: PromptInputProps) { try { if (isShellMode) { if (props.onRunShell) { - await props.onRunShell(resolvedPrompt) + await props.onRunShell(submitPrompt) } else { - await props.onSend(resolvedPrompt, []) + await props.onSend(submitPrompt, []) } } else if (isKnownSlashCommand) { if (props.onCommand) { @@ -434,7 +437,7 @@ export default function PromptInput(props: PromptInputProps) { await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs) } } else { - await props.onSend(resolvedPrompt, currentAttachments) + await props.onSend(submitPrompt, currentAttachments) } if (!isKnownSlashCommand) { void refreshHistory() diff --git a/packages/ui/src/components/prompt-input/submitPrompt.test.ts b/packages/ui/src/components/prompt-input/submitPrompt.test.ts new file mode 100644 index 000000000..ade3a3809 --- /dev/null +++ b/packages/ui/src/components/prompt-input/submitPrompt.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { createTextAttachment } from "../../types/attachment" +import { preparePromptSubmission } from "./submitPrompt" + +describe("preparePromptSubmission", () => { + it("keeps placeholder-backed pasted text intact for message submission while resolving history text", () => { + const attachment = createTextAttachment("alpha\nbeta\ngamma\ndelta", "pasted #1 (4 lines)", "paste-1.txt") + + const result = preparePromptSubmission({ + mode: "message", + text: "Intro\n[pasted #1]\nOutro", + attachments: [attachment], + }) + + assert.equal(result.submitPrompt, "Intro\n[pasted #1]\nOutro") + assert.equal(result.historyEntry, "Intro\nalpha\nbeta\ngamma\ndelta\nOutro") + }) +}) diff --git a/packages/ui/src/components/prompt-input/submitPrompt.ts b/packages/ui/src/components/prompt-input/submitPrompt.ts new file mode 100644 index 000000000..8af78fce5 --- /dev/null +++ b/packages/ui/src/components/prompt-input/submitPrompt.ts @@ -0,0 +1,46 @@ +import { resolvePastedPlaceholders } from "../../lib/prompt-placeholders" +import type { Attachment } from "../../types/attachment" + +export type PromptSubmissionMode = "message" | "shell" | "slash" + +export interface PromptSubmissionResult { + historyEntry: string + submitPrompt: string + resolvedCommandArgs: string +} + +export function preparePromptSubmission(input: { + mode: PromptSubmissionMode + text: string + attachments: Attachment[] + commandToken?: string + commandArgs?: string +}): PromptSubmissionResult { + const attachments = input.attachments ?? [] + + if (input.mode === "slash") { + const resolvedCommandArgs = resolvePastedPlaceholders(input.commandArgs ?? "", attachments) + const historyEntry = resolvedCommandArgs ? `${input.commandToken ?? ""} ${resolvedCommandArgs}` : (input.commandToken ?? "") + return { + historyEntry, + submitPrompt: historyEntry, + resolvedCommandArgs, + } + } + + const resolvedPrompt = resolvePastedPlaceholders(input.text, attachments) + + if (input.mode === "message") { + return { + historyEntry: resolvedPrompt, + submitPrompt: input.text, + resolvedCommandArgs: "", + } + } + + return { + historyEntry: resolvedPrompt, + submitPrompt: resolvedPrompt, + resolvedCommandArgs: "", + } +} diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index cc10fceea..f72190b16 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -7,9 +7,9 @@ import { useGlobalCache } from "../lib/hooks/use-global-cache" import { useConfig } from "../stores/preferences" import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances" import { copyToClipboard } from "../lib/clipboard" -import type { PermissionRequestLike } from "../types/permission" +import type { PermissionRequest } from "../types/permission" import { getPermissionSessionId } from "../types/permission" -import type { QuestionRequest } from "@opencode-ai/sdk/v2" +import type { QuestionRequest } from "../types/question" import { useI18n } from "../lib/i18n" import { resolveToolRenderer } from "./tool-call/renderers" import { QuestionToolBlock } from "./tool-call/question-block" @@ -119,7 +119,7 @@ function ToolCallDetails(props: { isDark: () => boolean t: ReturnType["t"] store: () => ReturnType - pendingPermission: () => { permission: PermissionRequestLike; active: boolean } | undefined + pendingPermission: () => { permission: PermissionRequest; active: boolean } | undefined pendingQuestion: () => { request: QuestionRequest; active: boolean } | undefined isPermissionActive: () => boolean isQuestionActive: () => boolean @@ -226,12 +226,13 @@ function ToolCallDetails(props: { }) }) - async function handlePermissionResponse(permission: PermissionRequestLike, response: "once" | "always" | "reject", message?: string) { + async function handlePermissionResponse(permission: PermissionRequest, response: "once" | "always" | "reject", message?: string) { if (!permission) return setPermissionSubmitting(true) setPermissionError(null) try { - const sessionId = getPermissionSessionId(permission) || props.sessionId + const sessionId = getPermissionSessionId(permission) + if (!sessionId) throw new Error("Permission request is missing sessionID") await sendPermissionResponse(props.instanceId, sessionId, permission.id, response, message) } catch (error) { log.error("Failed to send permission response", error) @@ -292,8 +293,7 @@ function ToolCallDetails(props: { setQuestionSubmitting(true) setQuestionError(null) try { - const sessionId = (request as any).sessionID ?? (request as any).sessionId ?? props.sessionId - await sendQuestionReply(props.instanceId, sessionId, request.id, normalized) + await sendQuestionReply(props.instanceId, request.sessionID, request.id, normalized) } catch (error) { log.error("Failed to send question reply", error) setQuestionError(error instanceof Error ? error.message : props.t("toolCall.question.errors.unableToReply")) @@ -310,8 +310,7 @@ function ToolCallDetails(props: { setQuestionSubmitting(true) setQuestionError(null) try { - const sessionId = (request as any).sessionID ?? (request as any).sessionId ?? props.sessionId - await sendQuestionReject(props.instanceId, sessionId, request.id) + await sendQuestionReject(props.instanceId, request.sessionID, request.id) } catch (error) { log.error("Failed to reject question", error) setQuestionError(error instanceof Error ? error.message : props.t("toolCall.question.errors.unableToDismiss")) @@ -648,6 +647,7 @@ export default function ToolCall(props: ToolCallProps) { const isPermissionActive = createMemo(() => { const pending = pendingPermission() if (!pending?.permission) return false + if (pending.active) return true const active = activeRequest() return active?.kind === "permission" && active.id === pending.permission.id }) @@ -655,6 +655,7 @@ export default function ToolCall(props: ToolCallProps) { const isQuestionActive = createMemo(() => { const pending = pendingQuestion() if (!pending?.request) return false + if (pending.active) return true const active = activeRequest() return active?.kind === "question" && active.id === pending.request.id }) diff --git a/packages/ui/src/components/tool-call/permission-block.tsx b/packages/ui/src/components/tool-call/permission-block.tsx index 100b8bf43..a07fe4502 100644 --- a/packages/ui/src/components/tool-call/permission-block.tsx +++ b/packages/ui/src/components/tool-call/permission-block.tsx @@ -1,5 +1,5 @@ import { Show, createEffect, createSignal, type Accessor, type JSXElement } from "solid-js" -import type { PermissionRequestLike } from "../../types/permission" +import type { PermissionRequest } from "../../types/permission" import { getPermissionDisplayTitle, getPermissionKind } from "../../types/permission" import { getPermissionSessionId } from "../../types/permission" import { useI18n } from "../../lib/i18n" @@ -10,11 +10,11 @@ import { getRelativePath } from "./utils" type PermissionResponse = "once" | "always" | "reject" export type PermissionToolBlockProps = { - permission: Accessor + permission: Accessor active: Accessor submitting: Accessor error: Accessor - onRespond: (permission: PermissionRequestLike, sessionId: string, response: PermissionResponse, message?: string) => void | Promise + onRespond: (permission: PermissionRequest, sessionId: string, response: PermissionResponse, message?: string) => void | Promise onRejectReasonOpenChange?: (open: boolean) => void renderDiff: (payload: DiffPayload, options?: DiffRenderOptions) => JSXElement | null fallbackSessionId: Accessor diff --git a/packages/ui/src/components/tool-call/question-block.tsx b/packages/ui/src/components/tool-call/question-block.tsx index dee5fee91..b7542aa44 100644 --- a/packages/ui/src/components/tool-call/question-block.tsx +++ b/packages/ui/src/components/tool-call/question-block.tsx @@ -1,6 +1,6 @@ import { createMemo, Show, For, createEffect, type Accessor } from "solid-js" import type { ToolState } from "@opencode-ai/sdk/v2" -import type { QuestionRequest } from "@opencode-ai/sdk/v2" +import type { QuestionRequest } from "../../types/question" import { useI18n } from "../../lib/i18n" type QuestionOption = { label: string; description: string } diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 44b8183ff..dfc512ea0 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -50,7 +50,7 @@ import { attachEventSourceHandlers } from "./event-source-handlers" const RUNTIME_BASE = typeof window !== "undefined" ? window.location?.origin : undefined const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? RUNTIME_BASE : undefined const DEFAULT_EVENTS_PATH = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events" -const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE +const API_BASE = import.meta.env?.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE const EVENTS_URL = buildEventsUrl(API_BASE, DEFAULT_EVENTS_PATH) export const CODENOMAD_API_BASE = API_BASE diff --git a/packages/ui/src/lib/i18n/messages/en/messaging.ts b/packages/ui/src/lib/i18n/messages/en/messaging.ts index 31700b64d..6d2545f6a 100644 --- a/packages/ui/src/lib/i18n/messages/en/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/en/messaging.ts @@ -127,6 +127,10 @@ export const messagingMessages = { "messagePart.actions.deleteTitle": "Delete this item", "messagePart.actions.deleteFailedTitle": "Delete failed", "messagePart.actions.deleteFailedMessage": "Failed to delete item", + "messagePart.pastedText.summary": "Pasted text", + "messagePart.pastedText.lines.one": "{count} line", + "messagePart.pastedText.lines.other": "{count} lines", + "messagePart.pastedText.copyAriaLabel": "Copy pasted text block", "messageItem.attachment.defaultName": "attachment", "messageItem.attachment.downloadAriaLabel": "Download {name}", "messageItem.agentMeta.agentLabel": "Agent: {agent}", diff --git a/packages/ui/src/lib/i18n/messages/es/messaging.ts b/packages/ui/src/lib/i18n/messages/es/messaging.ts index a2e88568d..a10e98f60 100644 --- a/packages/ui/src/lib/i18n/messages/es/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/es/messaging.ts @@ -129,6 +129,10 @@ export const messagingMessages = { "messagePart.actions.deleteTitle": "Eliminar este elemento", "messagePart.actions.deleteFailedTitle": "Error al eliminar", "messagePart.actions.deleteFailedMessage": "No se pudo eliminar el elemento", + "messagePart.pastedText.summary": "Texto pegado", + "messagePart.pastedText.lines.one": "{count} línea", + "messagePart.pastedText.lines.other": "{count} líneas", + "messagePart.pastedText.copyAriaLabel": "Copiar bloque de texto pegado", "messageItem.attachment.defaultName": "adjunto", "messageItem.attachment.downloadAriaLabel": "Descargar {name}", "messageItem.agentMeta.agentLabel": "Agente: {agent}", diff --git a/packages/ui/src/lib/i18n/messages/fr/messaging.ts b/packages/ui/src/lib/i18n/messages/fr/messaging.ts index c15477e24..2c9ca6738 100644 --- a/packages/ui/src/lib/i18n/messages/fr/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/fr/messaging.ts @@ -129,6 +129,10 @@ export const messagingMessages = { "messagePart.actions.deleteTitle": "Supprimer cet élément", "messagePart.actions.deleteFailedTitle": "Échec de suppression", "messagePart.actions.deleteFailedMessage": "Impossible de supprimer l'élément", + "messagePart.pastedText.summary": "Texte collé", + "messagePart.pastedText.lines.one": "{count} ligne", + "messagePart.pastedText.lines.other": "{count} lignes", + "messagePart.pastedText.copyAriaLabel": "Copier le bloc de texte collé", "messageItem.attachment.defaultName": "piece-jointe", "messageItem.attachment.downloadAriaLabel": "Télécharger {name}", "messageItem.agentMeta.agentLabel": "Agent : {agent}", diff --git a/packages/ui/src/lib/i18n/messages/he/messaging.ts b/packages/ui/src/lib/i18n/messages/he/messaging.ts index 537db2db2..30cc7f3dd 100644 --- a/packages/ui/src/lib/i18n/messages/he/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/he/messaging.ts @@ -127,6 +127,10 @@ export const messagingMessages = { "messagePart.actions.deleteTitle": "מחק פריט זה", "messagePart.actions.deleteFailedTitle": "המחיקה נכשלה", "messagePart.actions.deleteFailedMessage": "מחיקת הפריט נכשלה", + "messagePart.pastedText.summary": "טקסט שהודבק", + "messagePart.pastedText.lines.one": "{count} שורה", + "messagePart.pastedText.lines.other": "{count} שורות", + "messagePart.pastedText.copyAriaLabel": "העתקת בלוק טקסט שהודבק", "messageItem.attachment.defaultName": "קובץ מצורף", "messageItem.attachment.downloadAriaLabel": "הורד {name}", "messageItem.agentMeta.agentLabel": "סוכן: {agent}", diff --git a/packages/ui/src/lib/i18n/messages/ja/messaging.ts b/packages/ui/src/lib/i18n/messages/ja/messaging.ts index 013d9b60b..d81388854 100644 --- a/packages/ui/src/lib/i18n/messages/ja/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ja/messaging.ts @@ -129,6 +129,10 @@ export const messagingMessages = { "messagePart.actions.deleteTitle": "この項目を削除", "messagePart.actions.deleteFailedTitle": "削除に失敗しました", "messagePart.actions.deleteFailedMessage": "項目の削除に失敗しました", + "messagePart.pastedText.summary": "貼り付けたテキスト", + "messagePart.pastedText.lines.one": "{count} 行", + "messagePart.pastedText.lines.other": "{count} 行", + "messagePart.pastedText.copyAriaLabel": "貼り付けたテキストブロックをコピー", "messageItem.attachment.defaultName": "添付ファイル", "messageItem.attachment.downloadAriaLabel": "{name} をダウンロード", "messageItem.agentMeta.agentLabel": "エージェント: {agent}", diff --git a/packages/ui/src/lib/i18n/messages/ru/messaging.ts b/packages/ui/src/lib/i18n/messages/ru/messaging.ts index a4ce41f2b..37434206a 100644 --- a/packages/ui/src/lib/i18n/messages/ru/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/ru/messaging.ts @@ -129,6 +129,10 @@ export const messagingMessages = { "messagePart.actions.deleteTitle": "Удалить этот элемент", "messagePart.actions.deleteFailedTitle": "Ошибка удаления", "messagePart.actions.deleteFailedMessage": "Не удалось удалить элемент", + "messagePart.pastedText.summary": "Вставленный текст", + "messagePart.pastedText.lines.one": "{count} строка", + "messagePart.pastedText.lines.other": "{count} строк", + "messagePart.pastedText.copyAriaLabel": "Скопировать блок вставленного текста", "messageItem.attachment.defaultName": "вложение", "messageItem.attachment.downloadAriaLabel": "Скачать {name}", "messageItem.agentMeta.agentLabel": "Агент: {agent}", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts index b00f7e676..40c3a81ff 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/messaging.ts @@ -129,6 +129,10 @@ export const messagingMessages = { "messagePart.actions.deleteTitle": "删除此项", "messagePart.actions.deleteFailedTitle": "删除失败", "messagePart.actions.deleteFailedMessage": "删除失败", + "messagePart.pastedText.summary": "粘贴的文本", + "messagePart.pastedText.lines.one": "{count} 行", + "messagePart.pastedText.lines.other": "{count} 行", + "messagePart.pastedText.copyAriaLabel": "复制粘贴文本块", "messageItem.attachment.defaultName": "附件", "messageItem.attachment.downloadAriaLabel": "下载 {name}", "messageItem.agentMeta.agentLabel": "智能体:{agent}", diff --git a/packages/ui/src/lib/pasted-text-display.test.ts b/packages/ui/src/lib/pasted-text-display.test.ts new file mode 100644 index 000000000..999abf3f3 --- /dev/null +++ b/packages/ui/src/lib/pasted-text-display.test.ts @@ -0,0 +1,14 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { getPastedTextLineCount } from "./pasted-text-display" + +describe("getPastedTextLineCount", () => { + it("counts single-line pasted text", () => { + assert.equal(getPastedTextLineCount("alpha"), 1) + }) + + it("counts multi-line pasted text", () => { + assert.equal(getPastedTextLineCount("alpha\nbeta\ngamma"), 3) + }) +}) diff --git a/packages/ui/src/lib/pasted-text-display.ts b/packages/ui/src/lib/pasted-text-display.ts new file mode 100644 index 000000000..93c32a977 --- /dev/null +++ b/packages/ui/src/lib/pasted-text-display.ts @@ -0,0 +1,4 @@ +export function getPastedTextLineCount(text: string): number { + if (!text) return 0 + return text.split("\n").length +} diff --git a/packages/ui/src/lib/prompt-display-metadata.test.ts b/packages/ui/src/lib/prompt-display-metadata.test.ts new file mode 100644 index 000000000..0a59cd65e --- /dev/null +++ b/packages/ui/src/lib/prompt-display-metadata.test.ts @@ -0,0 +1,86 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { createTextAttachment } from "../types/attachment" +import { preparePromptDisplayText, resolvePastedPlaceholders, splitPromptDisplaySections } from "./prompt-display-metadata" + +describe("preparePromptDisplayText", () => { + it("keeps pasted text fully visible to the model while storing display metadata", () => { + const attachment = createTextAttachment("line 1\nline 2\nline 3\nline 4", "pasted #1 (4 lines)", "paste-1.txt") + + const result = preparePromptDisplayText("Summarize this:\n[pasted #1]\nThanks", [attachment]) + + assert.equal(result.promptToSend, "Summarize this:\nline 1\nline 2\nline 3\nline 4\nThanks") + assert.deepEqual(result.displayMetadata, { + segments: [ + { kind: "inline", length: 16 }, + { kind: "pasted", length: 27 }, + { kind: "inline", length: 7 }, + ], + }) + }) + + it("falls back to plain text rendering when no placeholder-backed pasted structure remains", () => { + const pastedText = "line 1\nline 2\nline 3\nline 4" + const attachment = createTextAttachment(pastedText, "pasted #1 (4 lines)", "paste-1.txt") + + const result = preparePromptDisplayText(`Summarize this:\n${pastedText}\nThanks`, [attachment]) + + assert.equal(result.promptToSend, `Summarize this:\n${pastedText}\nThanks`) + assert.equal(result.displayMetadata, undefined) + }) + + it("resolves loose pasted placeholders consistently", () => { + const attachment = createTextAttachment("alpha\nbeta\ngamma\ndelta", "pasted #1 (4 lines)", "paste-1.txt") + + assert.equal(resolvePastedPlaceholders("Before [ pasted # 1 ] After", [attachment]), "Before alpha\nbeta\ngamma\ndelta After") + }) + + it("resolves pasted placeholders when the placeholder casing is edited", () => { + const attachment = createTextAttachment("alpha\nbeta\ngamma\ndelta", "pasted #1 (4 lines)", "paste-1.txt") + + const result = preparePromptDisplayText("Before [Pasted #1] After", [attachment]) + + assert.equal(result.promptToSend, "Before alpha\nbeta\ngamma\ndelta After") + assert.deepEqual(result.displayMetadata, { + segments: [ + { kind: "inline", length: 7 }, + { kind: "pasted", length: 22 }, + { kind: "inline", length: 6 }, + ], + }) + }) + + it("normalizes pasted CRLF content so collapsed metadata survives LF hydration", () => { + const attachment = createTextAttachment("a\r\nb\r\nc\r\nd", "pasted #1 (4 lines)", "paste-1.txt") + + const prepared = preparePromptDisplayText("Intro\n[pasted #1]\nOutro", [attachment]) + + assert.equal(prepared.promptToSend, "Intro\na\nb\nc\nd\nOutro") + assert.deepEqual(splitPromptDisplaySections("Intro\na\nb\nc\nd\nOutro", prepared.displayMetadata), [ + { kind: "inline", text: "Intro\n" }, + { kind: "pasted", text: "a\nb\nc\nd" }, + { kind: "inline", text: "\nOutro" }, + ]) + }) +}) + +describe("splitPromptDisplaySections", () => { + it("reconstructs inline and pasted display sections", () => { + const attachment = createTextAttachment("A\nB\nC\nD", "pasted #1 (4 lines)", "paste-1.txt") + const prepared = preparePromptDisplayText("Intro\n[pasted #1]\nOutro", [attachment]) + + assert.deepEqual(splitPromptDisplaySections(prepared.promptToSend, prepared.displayMetadata), [ + { kind: "inline", text: "Intro\n" }, + { kind: "pasted", text: "A\nB\nC\nD" }, + { kind: "inline", text: "\nOutro" }, + ]) + }) + + it("returns null when metadata no longer matches the text", () => { + assert.equal( + splitPromptDisplaySections("short", { segments: [{ kind: "inline", length: 10 }] }), + null, + ) + }) +}) diff --git a/packages/ui/src/lib/prompt-display-metadata.ts b/packages/ui/src/lib/prompt-display-metadata.ts new file mode 100644 index 000000000..395f9da8c --- /dev/null +++ b/packages/ui/src/lib/prompt-display-metadata.ts @@ -0,0 +1,212 @@ +import type { Attachment, FileSource, TextSource } from "../types/attachment" + +export type PromptDisplaySegmentKind = "inline" | "pasted" + +export interface PromptDisplaySegment { + kind: PromptDisplaySegmentKind + text: string +} + +export interface PromptDisplaySegmentMetadata { + kind: PromptDisplaySegmentKind + length: number +} + +export interface PromptDisplayMetadata { + segments: PromptDisplaySegmentMetadata[] +} + +export interface PreparedPromptDisplayText { + promptToSend: string + displayMetadata?: PromptDisplayMetadata +} + +const PASTED_PLACEHOLDER_REGEX = /\[\s*pasted\s*#\s*(\d+)\s*\]/gi + +function normalizeLineEndings(text: string): string { + return text.replace(/\r\n?/g, "\n") +} + +function hasPastedPlaceholders(text: string): boolean { + PASTED_PLACEHOLDER_REGEX.lastIndex = 0 + return PASTED_PLACEHOLDER_REGEX.test(text) +} + +function pushInlineSegment(segments: PromptDisplaySegment[], text: string): void { + if (!text) return + const previous = segments[segments.length - 1] + if (previous && previous.kind === "inline") { + previous.text += text + return + } + segments.push({ kind: "inline", text }) +} + +function pushPastedSegment(segments: PromptDisplaySegment[], text: string): void { + if (!text) return + segments.push({ kind: "pasted", text }) +} + +function resolvePathMentions(prompt: string, attachments: Attachment[] = []): string { + if (!prompt) { + return prompt + } + + const fileAttachments = new Set( + attachments + .filter((a): a is Attachment & { source: FileSource } => a.source.type === "file") + .map((a) => a.source.path), + ) + + const pathAttachments = new Set( + attachments + .filter( + (a): a is Attachment & { source: TextSource } => + a.source.type === "text" && typeof a.display === "string" && a.display.startsWith("path:"), + ) + .map((a) => a.source.value), + ) + + let result = prompt + + result = result.replace(/@(\.\/)/g, "___ROOT___") + result = result.replace(/@(\.)(?!\.)/g, "___ROOT_NOSLASH___") + + const allPaths = new Set() + for (const path of fileAttachments) { + if (path && path !== "." && path !== "./") allPaths.add(path) + } + for (const path of pathAttachments) { + if (path && path !== "." && path !== "./") allPaths.add(path) + } + + for (const path of allPaths) { + const withoutPrefix = path.startsWith("./") ? path.slice(2) : path + const withPrefix = path.startsWith("./") ? path : `./${path}` + result = result.replace(`@${withoutPrefix}`, withPrefix) + result = result.replace(`@${withoutPrefix}/`, `${withPrefix}/`) + } + + result = result.replace("___ROOT___", "./") + result = result.replace("___ROOT_NOSLASH___", "./") + + return result +} + +function createPastedLookup(attachments: Attachment[]): Map { + const lookup = new Map() + + for (const attachment of attachments) { + if (attachment?.source.type !== "text") continue + if (typeof attachment.display !== "string") continue + const match = attachment.display.match(/pasted #(\d+)/i) + if (!match) continue + if (!lookup.has(match[1])) { + lookup.set(match[1], normalizeLineEndings(attachment.source.value)) + } + } + + return lookup +} + +export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string { + const result = resolvePathMentions(prompt, attachments) + if (!hasPastedPlaceholders(result)) { + return result + } + + const lookup = createPastedLookup(attachments) + if (lookup.size === 0) { + return result + } + + return result.replace(PASTED_PLACEHOLDER_REGEX, (fullMatch, counter: string) => { + const replacement = lookup.get(counter) + return typeof replacement === "string" ? replacement : fullMatch + }) +} + +export function preparePromptDisplayText(prompt: string, attachments: Attachment[] = []): PreparedPromptDisplayText { + const resolvedBase = resolvePathMentions(prompt, attachments) + if (!hasPastedPlaceholders(resolvedBase)) { + return { promptToSend: resolvedBase } + } + + const lookup = createPastedLookup(attachments) + if (lookup.size === 0) { + return { promptToSend: resolvedBase } + } + + PASTED_PLACEHOLDER_REGEX.lastIndex = 0 + + const segments: PromptDisplaySegment[] = [] + let lastIndex = 0 + let foundResolvablePlaceholder = false + let failed = false + + for (const match of resolvedBase.matchAll(PASTED_PLACEHOLDER_REGEX)) { + const start = match.index ?? 0 + const counter = match[1] + const replacement = lookup.get(counter) + if (typeof replacement !== "string") { + failed = true + break + } + + pushInlineSegment(segments, resolvedBase.slice(lastIndex, start)) + pushPastedSegment(segments, replacement) + foundResolvablePlaceholder = true + lastIndex = start + match[0].length + } + + if (failed || !foundResolvablePlaceholder) { + return { promptToSend: resolvePastedPlaceholders(prompt, attachments) } + } + + pushInlineSegment(segments, resolvedBase.slice(lastIndex)) + + return { + promptToSend: segments.map((segment) => segment.text).join(""), + displayMetadata: { + segments: segments.map((segment) => ({ kind: segment.kind, length: segment.text.length })), + }, + } +} + +export function splitPromptDisplaySections( + text: string, + metadata: PromptDisplayMetadata | undefined, +): PromptDisplaySegment[] | null { + if (!metadata || !Array.isArray(metadata.segments) || metadata.segments.length === 0) { + return null + } + + const segments: PromptDisplaySegment[] = [] + let offset = 0 + + for (const segment of metadata.segments) { + if (!segment || typeof segment.length !== "number" || segment.length < 0) { + return null + } + if (segment.kind !== "inline" && segment.kind !== "pasted") { + return null + } + const nextOffset = offset + segment.length + if (nextOffset > text.length) { + return null + } + const nextText = text.slice(offset, nextOffset) + if (segment.kind === "inline") { + pushInlineSegment(segments, nextText) + } else { + pushPastedSegment(segments, nextText) + } + offset = nextOffset + } + + if (offset !== text.length) { + return null + } + + return segments +} diff --git a/packages/ui/src/lib/prompt-placeholders.ts b/packages/ui/src/lib/prompt-placeholders.ts index b5c8ee555..75bc4d2d1 100644 --- a/packages/ui/src/lib/prompt-placeholders.ts +++ b/packages/ui/src/lib/prompt-placeholders.ts @@ -1,83 +1 @@ -import type { Attachment, FileSource } from "../types/attachment" - -export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string { - if (!prompt) { - return prompt - } - - const fileAttachments = new Set( - attachments - .filter((a): a is Attachment & { source: FileSource } => a.source.type === "file") - .map((a) => a.source.path), - ) - - const pathAttachments = new Set( - attachments - .filter((a) => a.source.type === "text" && typeof a.display === "string" && a.display.startsWith("path:")) - .map((a) => (a.source as { value: string }).value), - ) - - let result = prompt - - // Step 1: Handle root paths FIRST using unique placeholders - // Replace longer pattern first to avoid partial match issues - result = result.replace(/@(\.\/)/g, "___ROOT___") - result = result.replace(/@(\.)(?!\.)/g, "___ROOT_NOSLASH___") - // Note: The regex @(\.)(?!\.) means @. NOT followed by another . - - // Step 2: Build set of non-root paths - const allPaths = new Set() - for (const p of fileAttachments) { - if (p && p !== "." && p !== "./") allPaths.add(p) - } - for (const p of pathAttachments) { - if (p && p !== "." && p !== "./") allPaths.add(p) - } - - // Step 3: Replace @path with ./path for non-root paths - for (const path of allPaths) { - if (!path) continue - const withoutPrefix = path.startsWith("./") ? path.slice(2) : path - const withPrefix = path.startsWith("./") ? path : "./" + path - result = result.replace("@" + withoutPrefix, withPrefix) - result = result.replace("@" + withoutPrefix + "/", withPrefix + "/") - } - - // Step 4: Convert placeholders back to ./ - result = result.replace("___ROOT___", "./") - result = result.replace("___ROOT_NOSLASH___", "./") - - // Step 5: Resolve [pasted #N] placeholders - if (!result.includes("[pasted #")) { - return result - } - - if (!attachments || attachments.length === 0) { - return result - } - - const lookup = new Map() - - for (const attachment of attachments) { - const source = attachment?.source - if (!source || source.type !== "text") continue - const display = attachment?.display - const value = (source as { value?: string }).value - if (typeof display !== "string" || typeof value !== "string") continue - const match = display.match(/pasted #(\d+)/) - if (!match) continue - const placeholder = `[pasted #${match[1]}]` - if (!lookup.has(placeholder)) { - lookup.set(placeholder, value) - } - } - - if (lookup.size === 0) { - return result - } - - return result.replace(/\[pasted #(\d+)\]/g, (fullMatch) => { - const replacement = lookup.get(fullMatch) - return typeof replacement === "string" ? replacement : fullMatch - }) -} +export { resolvePastedPlaceholders } from "./prompt-display-metadata" diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index e6354e2cd..8fa3dfdc6 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -16,6 +16,14 @@ import type { EventSessionUpdated, EventSessionStatus, } from "@opencode-ai/sdk" +import type { + EventPermissionV2Asked, + EventPermissionV2Replied, + EventQuestionV2Asked, + EventQuestionV2Rejected, + EventQuestionV2Replied, +} from "@opencode-ai/sdk/v2" +import type { LegacyPermissionAskedEvent, LegacyPermissionRepliedEvent } from "../types/permission" import { serverEvents } from "./server-events" import type { BackgroundProcess, @@ -73,10 +81,15 @@ type SSEEvent = | EventSessionError | EventSessionIdle | EventSessionStatus - | { type: "permission.updated" | "permission.asked"; properties?: any } - | { type: "permission.replied"; properties?: any } + | EventPermissionV2Asked + | EventPermissionV2Replied + | LegacyPermissionAskedEvent + | LegacyPermissionRepliedEvent | { type: "question.asked"; properties?: any } | { type: "question.replied" | "question.rejected"; properties?: any } + | EventQuestionV2Asked + | EventQuestionV2Replied + | EventQuestionV2Rejected | EventLspUpdated | TuiToastEvent | BackgroundProcessUpdatedEvent @@ -158,13 +171,19 @@ class SSEManager { case "session.diff": this.onSessionDiff?.(instanceId, event as EventSessionDiff) break - case "permission.updated": case "permission.asked": + case "permission.updated": this.onPermissionUpdated?.(instanceId, event as any) break case "permission.replied": this.onPermissionReplied?.(instanceId, event as any) break + case "permission.v2.asked": + this.onPermissionUpdated?.(instanceId, event as EventPermissionV2Asked) + break + case "permission.v2.replied": + this.onPermissionReplied?.(instanceId, event as EventPermissionV2Replied) + break case "question.asked": this.onQuestionAsked?.(instanceId, event as any) break @@ -172,6 +191,13 @@ class SSEManager { case "question.rejected": this.onQuestionAnswered?.(instanceId, event as any) break + case "question.v2.asked": + this.onQuestionAsked?.(instanceId, event as EventQuestionV2Asked) + break + case "question.v2.replied": + case "question.v2.rejected": + this.onQuestionAnswered?.(instanceId, event as EventQuestionV2Replied | EventQuestionV2Rejected) + break case "lsp.updated": this.onLspUpdated?.(instanceId, event as EventLspUpdated) break @@ -209,10 +235,10 @@ class SSEManager { onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void onSessionStatus?: (instanceId: string, event: EventSessionStatus) => void onSessionDiff?: (instanceId: string, event: EventSessionDiff) => void - onPermissionUpdated?: (instanceId: string, event: any) => void - onPermissionReplied?: (instanceId: string, event: any) => void - onQuestionAsked?: (instanceId: string, event: any) => void - onQuestionAnswered?: (instanceId: string, event: any) => void + onPermissionUpdated?: (instanceId: string, event: EventPermissionV2Asked | LegacyPermissionAskedEvent) => void + onPermissionReplied?: (instanceId: string, event: EventPermissionV2Replied | LegacyPermissionRepliedEvent) => void + onQuestionAsked?: (instanceId: string, event: EventQuestionV2Asked | { type: "question.asked"; properties?: any }) => void + onQuestionAnswered?: (instanceId: string, event: EventQuestionV2Replied | EventQuestionV2Rejected | { type: "question.replied" | "question.rejected"; properties?: any }) => void onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index b0aaa2e0f..e3bada698 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -1,9 +1,9 @@ import { createSignal } from "solid-js" import type { Instance, LogEntry } from "../types/instance" import type { LspStatus } from "@opencode-ai/sdk/v2" -import type { PermissionReply, PermissionRequestLike } from "../types/permission" -import { getPermissionCreatedAt, getPermissionSessionId, mergePermissionRequest } from "../types/permission" -import type { QuestionRequest } from "@opencode-ai/sdk/v2" +import type { PermissionReply, PermissionRequest, PermissionSource } from "../types/permission" +import { getPermissionSessionId, mergePermissionRequest } from "../types/permission" +import type { QuestionRequest, QuestionSource } from "../types/question" import { getQuestionSessionId } from "../types/question" import { requestData } from "../lib/opencode-api" import { buildInstanceBaseUrl, sdkManager } from "../lib/sdk-manager" @@ -22,11 +22,12 @@ import { import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, + getWorktrees, reloadWorktreeMap, reloadWorktrees, } from "./worktrees" import { getRootClient } from "./opencode-client" -import { clearOpenCodeWorkspaceCache, getOpenCodeWorkspaceIdForSession, syncOpenCodeWorkspaces } from "./opencode-workspaces" +import { clearOpenCodeWorkspaceCache, getOpenCodeWorkspaceIdForSession, getOpenCodeWorkspaceIdForWorktree, syncOpenCodeWorkspaces } from "./opencode-workspaces" import { fetchCommands, clearCommands } from "./commands" import { serverSettings } from "./preferences" import { sessions, setSessionPendingPermission, setSessionPendingQuestion } from "./session-state" @@ -52,6 +53,7 @@ import { getLogger } from "../lib/logger" import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata" import { showWorkspaceLaunchError } from "./launch-errors" import { activeSidecarToken } from "./sidecars" +import { buildV2RequestLocations, type V2Location } from "./request-locations" const log = getLogger("api") @@ -68,14 +70,47 @@ const [instanceLogs, setInstanceLogs] = createSignal>(ne const [logStreamingState, setLogStreamingState] = createSignal>(new Map()) // Interruption queues (permissions + questions) per instance -const [permissionQueues, setPermissionQueues] = createSignal>(new Map()) +const [permissionQueues, setPermissionQueues] = createSignal>(new Map()) const [activePermissionId, setActivePermissionId] = createSignal>(new Map()) const permissionSessionCounts = new Map>() +const permissionEnqueuedAt = new Map() +const permissionSourceByInstance = new Map>() const [questionQueues, setQuestionQueues] = createSignal>(new Map()) const [activeQuestionId, setActiveQuestionId] = createSignal>(new Map()) const questionSessionCounts = new Map>() const questionEnqueuedAt = new Map() +const questionSourceByInstance = new Map>() + +function ensurePermissionEnqueuedAt(permission: PermissionRequest): number { + const existing = permissionEnqueuedAt.get(permission.id) + if (existing) return existing + const now = Date.now() + permissionEnqueuedAt.set(permission.id, now) + return now +} + +function setPermissionSource(instanceId: string, requestId: string, source: PermissionSource): void { + let sources = permissionSourceByInstance.get(instanceId) + if (!sources) { + sources = new Map() + permissionSourceByInstance.set(instanceId, sources) + } + sources.set(requestId, source) +} + +function getPermissionSource(instanceId: string, requestId: string): PermissionSource { + return permissionSourceByInstance.get(instanceId)?.get(requestId) ?? "v2" +} + +function deletePermissionSource(instanceId: string, requestId: string): void { + const sources = permissionSourceByInstance.get(instanceId) + if (!sources) return + sources.delete(requestId) + if (sources.size === 0) { + permissionSourceByInstance.delete(instanceId) + } +} function ensureQuestionEnqueuedAt(request: QuestionRequest): number { const existing = questionEnqueuedAt.get(request.id) @@ -85,10 +120,47 @@ function ensureQuestionEnqueuedAt(request: QuestionRequest): number { return now } +function setQuestionSource(instanceId: string, requestId: string, source: QuestionSource): void { + let sources = questionSourceByInstance.get(instanceId) + if (!sources) { + sources = new Map() + questionSourceByInstance.set(instanceId, sources) + } + sources.set(requestId, source) +} + +function getQuestionSource(instanceId: string, requestId: string): QuestionSource { + return questionSourceByInstance.get(instanceId)?.get(requestId) ?? "v2" +} + +function deleteQuestionSource(instanceId: string, requestId: string): void { + const sources = questionSourceByInstance.get(instanceId) + if (!sources) return + sources.delete(requestId) + if (sources.size === 0) { + questionSourceByInstance.delete(instanceId) + } +} + type InterruptionKind = "permission" | "question" type ActiveInterruption = { kind: InterruptionKind; id: string } | null +async function getV2RequestLocations(instanceId: string): Promise { + const instance = instances().get(instanceId) + const worktrees = getWorktrees(instanceId) + const workspaceBySlug = new Map() + + for (const worktree of worktrees) { + if (!worktree.slug || worktree.slug === "root") continue + const workspace = await getOpenCodeWorkspaceIdForWorktree(instanceId, worktree.slug) + if (!workspace) continue + workspaceBySlug.set(worktree.slug, workspace) + } + + return buildV2RequestLocations(instance?.folder, worktrees, workspaceBySlug) +} + const [activeInterruption, setActiveInterruption] = createSignal>(new Map()) function syncHasInstancesFlag() { @@ -207,16 +279,36 @@ async function syncPendingPermissions(instanceId: string): Promise { try { const syncStartedAt = Date.now() - const remote = await requestData( + const remote: Array<{ request: PermissionRequest; source: PermissionSource }> = [] + const legacyRemote = await requestData( instance.client.permission.list(), "permission.list", - ) + ).catch((error) => { + log.warn("Failed to list legacy pending permissions", { instanceId, error }) + return [] + }) + for (const permission of legacyRemote) { + setPermissionSource(instanceId, permission.id, "legacy") + remote.push({ request: permission, source: "legacy" }) + } - const remotePendingIds = new Set(remote.map((item) => item.id)) + for (const location of await getV2RequestLocations(instanceId)) { + const response = await requestData<{ location?: unknown; data: PermissionRequest[] }>( + instance.client.v2.permission.request.list({ location }), + "v2.permission.request.list", + ) + log.info("v2.permission.request.list", { instanceId, location, resolvedLocation: response.location }) + for (const permission of response.data) { + setPermissionSource(instanceId, permission.id, "v2") + remote.push({ request: permission, source: "v2" }) + } + } + + const remotePendingIds = new Set(remote.map((item) => item.request.id)) pruneRepliedPermissions(instanceId, remotePendingIds, syncStartedAt) - const pendingRemote = remote.filter((item) => !hasRepliedPermission(instanceId, item.id)) - const remoteIds = new Set(pendingRemote.map((item) => item.id)) + const pendingRemote = remote.filter((item) => !hasRepliedPermission(instanceId, item.request.id)) + const remoteIds = new Set(pendingRemote.map((item) => item.request.id)) const local = getPermissionQueue(instanceId) // Remove any stale local permissions missing from server. @@ -228,8 +320,8 @@ async function syncPendingPermissions(instanceId: string): Promise { } // Upsert all server-side pending permissions. - for (const permission of pendingRemote) { - const queuedPermission = addPermissionToQueue(instanceId, permission) ?? permission + for (const { request: permission, source } of pendingRemote) { + const queuedPermission = addPermissionToQueue(instanceId, permission, source) ?? permission upsertPermissionV2(instanceId, queuedPermission) } drainAutoAcceptPermissions(instanceId, getPermissionQueue(instanceId), sendPermissionResponse, hasPendingPermission) @@ -243,12 +335,32 @@ async function syncPendingQuestions(instanceId: string): Promise { if (!instance?.client) return try { - const remote = await requestData( + const remote: Array<{ request: QuestionRequest; source: QuestionSource }> = [] + const legacyRemote = await requestData( instance.client.question.list(), "question.list", - ) + ).catch((error) => { + log.warn("Failed to list legacy pending questions", { instanceId, error }) + return [] + }) + for (const request of legacyRemote) { + setQuestionSource(instanceId, request.id, "legacy") + remote.push({ request, source: "legacy" }) + } + + for (const location of await getV2RequestLocations(instanceId)) { + const response = await requestData<{ location?: unknown; data: QuestionRequest[] }>( + instance.client.v2.question.request.list({ location }), + "v2.question.request.list", + ) + log.info("v2.question.request.list", { instanceId, location, resolvedLocation: response.location }) + for (const request of response.data) { + setQuestionSource(instanceId, request.id, "v2") + remote.push({ request, source: "v2" }) + } + } - const remoteIds = new Set(remote.map((item) => item.id)) + const remoteIds = new Set(remote.map((item) => item.request.id)) const local = getQuestionQueue(instanceId) // Remove any stale local requests missing from server. @@ -260,9 +372,9 @@ async function syncPendingQuestions(instanceId: string): Promise { } // Upsert all server-side pending questions. - for (const request of remote) { + for (const { request, source } of remote) { ensureQuestionEnqueuedAt(request) - addQuestionToQueue(instanceId, request) + addQuestionToQueue(instanceId, request, source) upsertQuestionV2(instanceId, request) } } catch (error) { @@ -668,7 +780,7 @@ function clearLogs(id: string) { } // Permission management functions -function getPermissionQueue(instanceId: string): PermissionRequestLike[] { +function getPermissionQueue(instanceId: string): PermissionRequest[] { const queue = permissionQueues().get(instanceId) if (!queue) { return [] @@ -706,6 +818,15 @@ function getQuestionEnqueuedAtForInstance(instanceId: string, requestId: string) return questionEnqueuedAt.get(requestId) ?? Date.now() } +function getPermissionEnqueuedAtForInstance(instanceId: string, permissionId: string): number { + const queue = getPermissionQueue(instanceId) + const match = queue.find((permission) => permission.id === permissionId) + if (match) { + return ensurePermissionEnqueuedAt(match) + } + return permissionEnqueuedAt.get(permissionId) ?? Date.now() +} + function computeActiveInterruption(instanceId: string): ActiveInterruption { const permissions = getPermissionQueue(instanceId) const questions = getQuestionQueue(instanceId) @@ -715,7 +836,7 @@ function computeActiveInterruption(instanceId: string): ActiveInterruption { if (firstPermission && !firstQuestion) return { kind: "permission", id: firstPermission.id } if (firstQuestion && !firstPermission) return { kind: "question", id: firstQuestion.id } - const permTime = getPermissionCreatedAt(firstPermission) + const permTime = firstPermission ? ensurePermissionEnqueuedAt(firstPermission) : Number.MAX_SAFE_INTEGER const quesTime = firstQuestion ? ensureQuestionEnqueuedAt(firstQuestion) : Number.MAX_SAFE_INTEGER if (permTime <= quesTime) return { kind: "permission", id: firstPermission.id } return { kind: "question", id: firstQuestion!.id } @@ -827,11 +948,12 @@ function clearQuestionSessionPendingCounts(instanceId: string): void { questionSessionCounts.delete(instanceId) } -function addPermissionToQueue(instanceId: string, permission: PermissionRequestLike): PermissionRequestLike | undefined { +function addPermissionToQueue(instanceId: string, permission: PermissionRequest, source: PermissionSource = "v2"): PermissionRequest | undefined { let inserted = false let updated = false - let previousPermission: PermissionRequestLike | undefined + let previousPermission: PermissionRequest | undefined let queuedPermission = permission + setPermissionSource(instanceId, permission.id, source) setPermissionQueues((prev) => { const next = new Map(prev) @@ -843,12 +965,13 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequestL queuedPermission = mergePermissionRequest(previousPermission, permission) const updatedQueue = queue.slice() updatedQueue[existingIndex] = queuedPermission - next.set(instanceId, updatedQueue.sort((a, b) => getPermissionCreatedAt(a) - getPermissionCreatedAt(b))) + next.set(instanceId, updatedQueue.sort((a, b) => ensurePermissionEnqueuedAt(a) - ensurePermissionEnqueuedAt(b))) updated = true return next } - const updatedQueue = [...queue, queuedPermission].sort((a, b) => getPermissionCreatedAt(a) - getPermissionCreatedAt(b)) + ensurePermissionEnqueuedAt(queuedPermission) + const updatedQueue = [...queue, queuedPermission].sort((a, b) => ensurePermissionEnqueuedAt(a) - ensurePermissionEnqueuedAt(b)) next.set(instanceId, updatedQueue) inserted = true return next @@ -880,12 +1003,12 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequestL } function removePermissionFromQueue(instanceId: string, permissionId: string): void { - let removedPermission: PermissionRequestLike | null = null + let removedPermission: PermissionRequest | null = null setPermissionQueues((prev) => { const next = new Map(prev) const queue = next.get(instanceId) ?? [] - const filtered: PermissionRequestLike[] = [] + const filtered: PermissionRequest[] = [] for (const item of queue) { if (item.id === permissionId) { @@ -904,6 +1027,8 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo }) recomputeActiveInterruption(instanceId) + permissionEnqueuedAt.delete(permissionId) + deletePermissionSource(instanceId, permissionId) const removed = removedPermission if (removed) { @@ -934,6 +1059,10 @@ function clearPermissionQueue(instanceId: string): void { clearAutoAcceptPermission(instanceId, sessionId, permission.id) } } + for (const permission of getPermissionQueue(instanceId)) { + permissionEnqueuedAt.delete(permission.id) + } + permissionSourceByInstance.delete(instanceId) setPermissionQueues((prev) => { const next = new Map(prev) next.delete(instanceId) @@ -948,8 +1077,9 @@ function clearPermissionQueue(instanceId: string): void { recomputeActiveInterruption(instanceId) } -function addQuestionToQueue(instanceId: string, request: QuestionRequest): void { +function addQuestionToQueue(instanceId: string, request: QuestionRequest, source: QuestionSource = "v2"): void { let inserted = false + setQuestionSource(instanceId, request.id, source) setQuestionQueues((prev) => { const next = new Map(prev) @@ -999,6 +1129,7 @@ function removeQuestionFromQueue(instanceId: string, requestId: string): void { }) questionEnqueuedAt.delete(requestId) + deleteQuestionSource(instanceId, requestId) recomputeActiveInterruption(instanceId) if (removedSessionId) { @@ -1011,6 +1142,7 @@ function clearQuestionQueue(instanceId: string): void { for (const request of getQuestionQueue(instanceId)) { questionEnqueuedAt.delete(request.id) } + questionSourceByInstance.delete(instanceId) setQuestionQueues((prev) => { const next = new Map(prev) next.delete(instanceId) @@ -1046,16 +1178,28 @@ async function sendQuestionReply( try { const client = getRootClient(instanceId) - const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null - - await requestData( - client.question.reply({ - requestID: requestId, - ...(workspace ? { workspace } : {}), - answers, - }), - "question.reply", - ) + const source = getQuestionSource(instanceId, requestId) + + if (source === "legacy") { + const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null + await requestData( + client.question.reply({ + requestID: requestId, + ...(workspace ? { workspace } : {}), + answers, + }), + "question.reply", + ) + } else { + await requestData( + client.v2.session.question.reply({ + sessionID: sessionId, + requestID: requestId, + questionV2Reply: { answers }, + }), + "v2.session.question.reply", + ) + } removeQuestionFromQueue(instanceId, requestId) } catch (error) { @@ -1072,15 +1216,26 @@ async function sendQuestionReject(instanceId: string, sessionId: string, request try { const client = getRootClient(instanceId) - const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null - - await requestData( - client.question.reject({ - requestID: requestId, - ...(workspace ? { workspace } : {}), - }), - "question.reject", - ) + const source = getQuestionSource(instanceId, requestId) + + if (source === "legacy") { + const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null + await requestData( + client.question.reject({ + requestID: requestId, + ...(workspace ? { workspace } : {}), + }), + "question.reject", + ) + } else { + await requestData( + client.v2.session.question.reject({ + sessionID: sessionId, + requestID: requestId, + }), + "v2.session.question.reject", + ) + } removeQuestionFromQueue(instanceId, requestId) } catch (error) { @@ -1103,17 +1258,30 @@ async function sendPermissionResponse( try { const client = getRootClient(instanceId) - const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null - - await requestData( - client.permission.reply({ - requestID: requestId, - ...(workspace ? { workspace } : {}), - reply, - ...(message ? { message } : {}), - }), - "permission.reply", - ) + const source = getPermissionSource(instanceId, requestId) + + if (source === "legacy") { + const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null + await requestData( + client.permission.reply({ + requestID: requestId, + ...(workspace ? { workspace } : {}), + reply, + ...(message ? { message } : {}), + }), + "permission.reply", + ) + } else { + await requestData( + client.v2.session.permission.reply({ + sessionID: sessionId, + requestID: requestId, + reply, + ...(message ? { message } : {}), + }), + "v2.session.permission.reply", + ) + } markPermissionReplied(instanceId, requestId) // Remove from both local queues after successful response; the SSE replied event @@ -1214,6 +1382,7 @@ export { activePermissionId, getPermissionQueue, getPermissionQueueLength, + getPermissionEnqueuedAtForInstance, addPermissionToQueue, removePermissionFromQueue, markPermissionReplied, diff --git a/packages/ui/src/stores/message-prompt-display.test.ts b/packages/ui/src/stores/message-prompt-display.test.ts new file mode 100644 index 000000000..3cbaa93c3 --- /dev/null +++ b/packages/ui/src/stores/message-prompt-display.test.ts @@ -0,0 +1,190 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import type { PromptDisplayMetadata } from "../lib/prompt-display-metadata" +import { + clearPromptDisplayOverride, + clearPromptDisplayOverridesForSession, + clearPromptDisplayOverridesForInstance, + getPromptDisplayOverride, + movePromptDisplayOverride, + resetPromptDisplayOverrideStateForTests, + setPromptDisplayOverride, +} from "./message-prompt-display" + +class MemoryStorage { + private entries = new Map() + + getItem(key: string): string | null { + return this.entries.has(key) ? this.entries.get(key)! : null + } + + setItem(key: string, value: string): void { + this.entries.set(key, value) + } + + removeItem(key: string): void { + this.entries.delete(key) + } + + clear(): void { + this.entries.clear() + } +} + +type WindowWithMemoryStorage = { + localStorage: { + getItem(key: string): string | null + setItem(key: string, value: string): void + removeItem(key: string): void + clear(): void + } +} + +describe("message prompt display overrides", () => { + it("persists and moves prompt display metadata by message id", () => { + const instanceId = `instance-${Date.now()}` + const sessionId = "session-1" + const oldMessageId = "temp-msg" + const newMessageId = "real-msg" + const storage = new MemoryStorage() + ;(globalThis as unknown as { window?: WindowWithMemoryStorage }).window = { localStorage: storage } + resetPromptDisplayOverrideStateForTests() + + clearPromptDisplayOverridesForInstance(instanceId) + + const metadata: PromptDisplayMetadata = { segments: [{ kind: "inline", length: 7 }, { kind: "pasted", length: 6 }] } + + setPromptDisplayOverride(instanceId, sessionId, oldMessageId, metadata) + assert.deepEqual( + getPromptDisplayOverride(instanceId, sessionId, oldMessageId), + metadata, + ) + + movePromptDisplayOverride(instanceId, sessionId, oldMessageId, newMessageId) + assert.equal(getPromptDisplayOverride(instanceId, sessionId, oldMessageId), undefined) + assert.deepEqual( + getPromptDisplayOverride(instanceId, sessionId, newMessageId), + metadata, + ) + + clearPromptDisplayOverride(instanceId, sessionId, newMessageId) + assert.equal(getPromptDisplayOverride(instanceId, sessionId, newMessageId), undefined) + + delete (globalThis as unknown as { window?: unknown }).window + }) + + it("finds persisted metadata after reopening with a different instance id", () => { + const firstInstanceId = `instance-a-${Date.now()}` + const reopenedInstanceId = `instance-b-${Date.now()}` + const sessionId = "session-stable" + const messageId = "msg-1" + const storage = new MemoryStorage() + ;(globalThis as unknown as { window?: WindowWithMemoryStorage }).window = { localStorage: storage } + resetPromptDisplayOverrideStateForTests() + + clearPromptDisplayOverridesForSession(firstInstanceId, sessionId) + + const metadata: PromptDisplayMetadata = { segments: [{ kind: "inline", length: 5 }, { kind: "pasted", length: 12 }] } + setPromptDisplayOverride(firstInstanceId, sessionId, messageId, metadata) + + assert.deepEqual(getPromptDisplayOverride(reopenedInstanceId, sessionId, messageId), metadata) + + clearPromptDisplayOverride(reopenedInstanceId, sessionId, messageId) + assert.equal(getPromptDisplayOverride(firstInstanceId, sessionId, messageId), undefined) + + delete (globalThis as unknown as { window?: unknown }).window + }) + + it("migrates legacy instance-scoped storage keys to stable reopen keys", () => { + const storage = new MemoryStorage() + const legacyInstanceId = "legacy-instance" + const reopenedInstanceId = "reopened-instance" + const sessionId = "session-legacy" + const messageId = "msg-legacy" + const metadata: PromptDisplayMetadata = { segments: [{ kind: "inline", length: 4 }, { kind: "pasted", length: 9 }] } + + storage.setItem( + "codenomad:prompt-display:v2", + JSON.stringify({ [`${legacyInstanceId}:${sessionId}:${messageId}`]: metadata }), + ) + ;(globalThis as unknown as { window?: WindowWithMemoryStorage }).window = { localStorage: storage } + resetPromptDisplayOverrideStateForTests() + + assert.deepEqual(getPromptDisplayOverride(reopenedInstanceId, sessionId, messageId), metadata) + assert.equal(storage.getItem("codenomad:prompt-display:v3")?.includes(`${sessionId}:${messageId}`), true) + assert.equal(storage.getItem("codenomad:prompt-display:v2"), null) + + clearPromptDisplayOverride(reopenedInstanceId, sessionId, messageId) + resetPromptDisplayOverrideStateForTests() + assert.equal(getPromptDisplayOverride(reopenedInstanceId, sessionId, messageId), undefined) + + delete (globalThis as unknown as { window?: unknown }).window + }) + + it("migrates legacy keys without rewriting stored stable v3 keys", () => { + const storage = new MemoryStorage() + const legacyInstanceId = "legacy-instance" + const reopenedInstanceId = "reopened-instance" + const sessionId = "session:with-colon" + const messageId = "msg-with-colon" + const stableSessionId = "stable-session" + const stableMessageId = "msg:with:colons" + const metadata: PromptDisplayMetadata = { segments: [{ kind: "inline", length: 4 }, { kind: "pasted", length: 9 }] } + + storage.setItem( + "codenomad:prompt-display:v2", + JSON.stringify({ [`${legacyInstanceId}:${sessionId}:${messageId}`]: metadata }), + ) + storage.setItem( + "codenomad:prompt-display:v3", + JSON.stringify({ [`${stableSessionId}:${stableMessageId}`]: metadata }), + ) + ;(globalThis as unknown as { window?: WindowWithMemoryStorage }).window = { localStorage: storage } + resetPromptDisplayOverrideStateForTests() + + assert.deepEqual(getPromptDisplayOverride(reopenedInstanceId, sessionId, messageId), metadata) + assert.deepEqual(getPromptDisplayOverride(reopenedInstanceId, stableSessionId, stableMessageId), metadata) + assert.equal(storage.getItem("codenomad:prompt-display:v2"), null) + + delete (globalThis as unknown as { window?: unknown }).window + }) + + it("clears stable v3 entries for a session", () => { + const instanceId = `instance-${Date.now()}` + const storage = new MemoryStorage() + ;(globalThis as unknown as { window?: WindowWithMemoryStorage }).window = { localStorage: storage } + resetPromptDisplayOverrideStateForTests() + + const metadata: PromptDisplayMetadata = { segments: [{ kind: "inline", length: 3 }, { kind: "pasted", length: 8 }] } + setPromptDisplayOverride(instanceId, "session-a", "msg-1", metadata) + setPromptDisplayOverride(instanceId, "session-b", "msg-2", metadata) + + clearPromptDisplayOverridesForSession(instanceId, "session-a") + + assert.equal(getPromptDisplayOverride("other-instance", "session-a", "msg-1"), undefined) + assert.deepEqual(getPromptDisplayOverride("other-instance", "session-b", "msg-2"), metadata) + + delete (globalThis as unknown as { window?: unknown }).window + }) + + it("clears stable v3 entries for all known instance sessions", () => { + const instanceId = `instance-${Date.now()}` + const storage = new MemoryStorage() + ;(globalThis as unknown as { window?: WindowWithMemoryStorage }).window = { localStorage: storage } + resetPromptDisplayOverrideStateForTests() + + const metadata: PromptDisplayMetadata = { segments: [{ kind: "inline", length: 2 }, { kind: "pasted", length: 5 }] } + setPromptDisplayOverride(instanceId, "session-a", "msg-1", metadata) + setPromptDisplayOverride(instanceId, "session-b", "msg-2", metadata) + setPromptDisplayOverride(instanceId, "session-c", "msg-3", metadata) + + clearPromptDisplayOverridesForInstance(instanceId, ["session-a", "session-b"]) + + assert.equal(getPromptDisplayOverride("reopened", "session-a", "msg-1"), undefined) + assert.equal(getPromptDisplayOverride("reopened", "session-b", "msg-2"), undefined) + assert.deepEqual(getPromptDisplayOverride("reopened", "session-c", "msg-3"), metadata) + + delete (globalThis as unknown as { window?: unknown }).window + }) +}) diff --git a/packages/ui/src/stores/message-prompt-display.ts b/packages/ui/src/stores/message-prompt-display.ts new file mode 100644 index 000000000..8b6d395af --- /dev/null +++ b/packages/ui/src/stores/message-prompt-display.ts @@ -0,0 +1,176 @@ +import type { PromptDisplayMetadata } from "../lib/prompt-display-metadata" + +const STORAGE_KEY = "codenomad:prompt-display:v3" +const LEGACY_STORAGE_KEY = "codenomad:prompt-display:v2" + +let loaded = false +const promptDisplayOverrides = new Map() + +function makeKey(_instanceId: string, sessionId: string, messageId: string): string { + return `${sessionId}:${messageId}` +} + +function isLegacyInstanceScopedKey(key: string): boolean { + const firstSeparator = key.indexOf(":") + if (firstSeparator <= 0) return false + const secondSeparator = key.indexOf(":", firstSeparator + 1) + return secondSeparator > firstSeparator + 1 && secondSeparator < key.length - 1 +} + +function migrateStoredKey(key: string): string { + if (!isLegacyInstanceScopedKey(key)) { + return key + } + return key.slice(key.indexOf(":") + 1) +} + +function readStorage(): Storage | null { + if (typeof window === "undefined" || !window.localStorage) { + return null + } + + return window.localStorage +} + +function ensureLoaded(): void { + if (loaded) return + loaded = true + + const storage = readStorage() + if (!storage) return + + try { + const raw = storage.getItem(STORAGE_KEY) + if (raw) { + loadStoredEntries(JSON.parse(raw) as Record, false) + } + const legacyRaw = storage.getItem(LEGACY_STORAGE_KEY) + if (legacyRaw) { + loadStoredEntries(JSON.parse(legacyRaw) as Record, true) + } + if (!raw && !legacyRaw) return + if (persist() && legacyRaw) storage.removeItem(LEGACY_STORAGE_KEY) + } catch { + promptDisplayOverrides.clear() + } +} + +function loadStoredEntries(parsed: Record, migrateLegacyKeys: boolean): void { + for (const [key, value] of Object.entries(parsed)) { + if (isPromptDisplayMetadata(value)) { + promptDisplayOverrides.set(migrateLegacyKeys ? migrateStoredKey(key) : key, value) + } + } +} + +function persist(): boolean { + const storage = readStorage() + if (!storage) return false + + try { + storage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(promptDisplayOverrides))) + return true + } catch { + // Ignore persistence failures. + return false + } +} + +function isPromptDisplayMetadata(value: unknown): value is PromptDisplayMetadata { + if (!value || typeof value !== "object") return false + const segments = (value as PromptDisplayMetadata).segments + if (!Array.isArray(segments) || segments.length === 0) return false + return segments.every( + (segment) => + segment && + typeof segment === "object" && + (segment.kind === "inline" || segment.kind === "pasted") && + typeof segment.length === "number" && + segment.length >= 0, + ) +} + +export function getPromptDisplayOverride( + instanceId: string, + sessionId: string, + messageId: string, +): PromptDisplayMetadata | undefined { + ensureLoaded() + return promptDisplayOverrides.get(makeKey(instanceId, sessionId, messageId)) +} + +export function setPromptDisplayOverride( + instanceId: string, + sessionId: string, + messageId: string, + displayMetadata: PromptDisplayMetadata | undefined, +): void { + ensureLoaded() + const key = makeKey(instanceId, sessionId, messageId) + const previous = promptDisplayOverrides.get(key) + if (displayMetadata && isPromptDisplayMetadata(displayMetadata)) { + const serialized = JSON.stringify(displayMetadata) + if (previous && JSON.stringify(previous) === serialized) return + promptDisplayOverrides.set(key, displayMetadata) + } else { + if (!promptDisplayOverrides.has(key)) return + promptDisplayOverrides.delete(key) + } + persist() +} + +export function movePromptDisplayOverride(instanceId: string, sessionId: string, oldMessageId: string, newMessageId: string): void { + ensureLoaded() + const oldKey = makeKey(instanceId, sessionId, oldMessageId) + const nextValue = promptDisplayOverrides.get(oldKey) + if (!nextValue) return + + const newKey = makeKey(instanceId, sessionId, newMessageId) + if (oldKey === newKey) return + promptDisplayOverrides.delete(oldKey) + promptDisplayOverrides.set(newKey, nextValue) + persist() +} + +export function clearPromptDisplayOverride(instanceId: string, sessionId: string, messageId: string): void { + ensureLoaded() + if (!promptDisplayOverrides.delete(makeKey(instanceId, sessionId, messageId))) { + return + } + persist() +} + +export function clearPromptDisplayOverridesForSession(instanceId: string, sessionId: string): void { + ensureLoaded() + const stablePrefix = `${sessionId}:` + const legacyPrefix = `${instanceId}:${sessionId}:` + let changed = false + for (const key of promptDisplayOverrides.keys()) { + if (key.startsWith(stablePrefix) || key.startsWith(legacyPrefix)) { + promptDisplayOverrides.delete(key) + changed = true + } + } + if (!changed) return + persist() +} + +export function clearPromptDisplayOverridesForInstance(instanceId: string, sessionIds: string[] = []): void { + ensureLoaded() + let changed = false + for (const key of promptDisplayOverrides.keys()) { + const shouldDeleteStableKey = sessionIds.some((sessionId) => key.startsWith(`${sessionId}:`)) + const shouldDeleteLegacyKey = key.startsWith(`${instanceId}:`) + if (shouldDeleteStableKey || shouldDeleteLegacyKey) { + promptDisplayOverrides.delete(key) + changed = true + } + } + if (!changed) return + persist() +} + +export function resetPromptDisplayOverrideStateForTests(): void { + loaded = false + promptDisplayOverrides.clear() +} diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index ef62f3d7d..6ef773b74 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -1,5 +1,5 @@ -import type { PermissionRequestLike } from "../../types/permission" -import { getPermissionCallId, getPermissionMessageId } from "../../types/permission" +import type { PermissionRequest } from "../../types/permission" +import { getPermissionCallId, getPermissionMessageId, getPermissionSessionId } from "../../types/permission" import type { QuestionRequest } from "../../types/question" import { getQuestionCallId, getQuestionMessageId } from "../../types/question" import type { Message, MessageInfo, ClientPart } from "../../types/message" @@ -127,11 +127,11 @@ export function replaceMessageIdV2(instanceId: string, oldId: string, newId: str store.replaceMessageId({ oldId, newId, ...(options ?? {}) }) } -function extractPermissionMessageId(permission: PermissionRequestLike): string | undefined { +function extractPermissionMessageId(permission: PermissionRequest): string | undefined { return getPermissionMessageId(permission) } -function extractPermissionPartId(permission: PermissionRequestLike): string | undefined { +function extractPermissionPartId(permission: PermissionRequest): string | undefined { const metadata = (permission as any).metadata || {} return ( (permission as any).partID || @@ -142,7 +142,7 @@ function extractPermissionPartId(permission: PermissionRequestLike): string | un ) } -function extractPermissionCallId(permission: PermissionRequestLike): string | undefined { +function extractPermissionCallId(permission: PermissionRequest): string | undefined { return getPermissionCallId(permission) } @@ -166,7 +166,7 @@ function resolvePartIdFromCallId(store: ReturnType { const store = createInstanceMessageStore("instance-1") store.upsertPermission({ - permission: { id: "permission-1", callID: "call-1", time: { created: 1_000 } }, + permission: { id: "permission-1", sessionID: "session-1", action: "edit", resources: ["file-a.ts"] }, enqueuedAt: 1_000, }) store.upsertPermission({ - permission: { id: "permission-1", tool: { callID: "call-1", messageID: "message-1" } }, + permission: { + id: "permission-1", + sessionID: "session-1", + action: "edit", + resources: ["file-a.ts"], + source: { type: "tool", callID: "call-1", messageID: "message-1" }, + }, messageId: "message-1", partId: "part-1", enqueuedAt: 2_000, @@ -20,15 +26,15 @@ describe("message-v2 permission state", () => { assert.equal(store.state.permissions.queue.length, 1) assert.equal(store.getPermissionState(undefined, "permission-1"), null) - assert.equal(store.getPermissionState("message-1", "part-1")?.entry.permission.callID, "call-1") + assert.equal((store.getPermissionState("message-1", "part-1")?.entry.permission as any).source?.callID, "call-1") assert.equal(store.getPermissionState("message-1", "part-1")?.active, true) }) it("recalculates the active permission after removing the first queue entry", () => { const store = createInstanceMessageStore("instance-1") - store.upsertPermission({ permission: { id: "permission-1" }, enqueuedAt: 1_000 }) - store.upsertPermission({ permission: { id: "permission-2" }, enqueuedAt: 2_000 }) + store.upsertPermission({ permission: { id: "permission-1", sessionID: "session-1", action: "edit", resources: [] }, enqueuedAt: 1_000 }) + store.upsertPermission({ permission: { id: "permission-2", sessionID: "session-1", action: "edit", resources: [] }, enqueuedAt: 2_000 }) store.removePermission("permission-1") assert.equal(store.state.permissions.active?.permission.id, "permission-2") diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 3373a4cdf..3ae5af561 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -2,8 +2,15 @@ import { batch } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import type { SetStoreFunction } from "solid-js/store" import { getLogger } from "../../lib/logger" +import { + clearPromptDisplayOverride, + clearPromptDisplayOverridesForInstance, + clearPromptDisplayOverridesForSession, + getPromptDisplayOverride, + movePromptDisplayOverride, + setPromptDisplayOverride, +} from "../message-prompt-display" import type { ClientPart, MessageInfo } from "../../types/message" -import type { PermissionRequestLike } from "../../types/permission" import { mergePermissionRequest } from "../../types/permission" import { clearRecordDisplayCacheForMessages } from "./record-display-cache" import type { @@ -106,6 +113,23 @@ function createEmptyUsageState(): SessionUsageState { } } +function resolveClientPromptDisplayText( + instanceId: string, + input: Pick, + previous?: Pick, +) { + if (input.clientPromptDisplayMetadata) { + return input.clientPromptDisplayMetadata + } + + const persisted = getPromptDisplayOverride(instanceId, input.sessionId, input.id) + if (persisted) { + return persisted + } + + return previous?.clientPromptDisplayMetadata +} + function extractUsageEntry(info: MessageInfo | undefined): UsageEntry | null { if (!info || info.role !== "assistant") return null const messageId = typeof info.id === "string" ? info.id : undefined @@ -417,6 +441,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt const normalizedParts = normalizeParts(input.id, input.parts) const shouldBump = Boolean(input.bumpRevision || normalizedParts) const previous = state.messages[input.id] + const clientPromptDisplayMetadata = resolveClientPromptDisplayText(instanceId, input, previous) normalizedRecords[input.id] = { id: input.id, sessionId: input.sessionId, @@ -425,10 +450,12 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt createdAt: input.createdAt ?? previous?.createdAt ?? now, updatedAt: input.updatedAt ?? now, isEphemeral: input.isEphemeral ?? previous?.isEphemeral ?? false, + clientPromptDisplayMetadata, revision: previous ? previous.revision + (shouldBump ? 1 : 0) : 0, partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [], parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {}, } + setPromptDisplayOverride(instanceId, input.sessionId, input.id, clientPromptDisplayMetadata) }) const infoList = infos ? Array.from(infos) : undefined @@ -519,6 +546,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt setState("messages", input.id, (previous) => { const revision = previous ? previous.revision + (shouldBump ? 1 : 0) : 0 + const clientPromptDisplayMetadata = resolveClientPromptDisplayText(instanceId, input, previous) const record: MessageRecord = { id: input.id, sessionId: input.sessionId, @@ -527,10 +555,12 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt createdAt: input.createdAt ?? previous?.createdAt ?? now, updatedAt: input.updatedAt ?? now, isEphemeral: input.isEphemeral ?? previous?.isEphemeral ?? false, + clientPromptDisplayMetadata, revision, partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [], parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {}, } + setPromptDisplayOverride(instanceId, input.sessionId, input.id, clientPromptDisplayMetadata) nextRecord = record return record }) @@ -704,6 +734,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt const record = state.messages[messageId] const sessionIds = new Set() + if (record?.sessionId) { + clearPromptDisplayOverride(instanceId, record.sessionId, messageId) + } + if (record?.sessionId) { sessionIds.add(record.sessionId) } else { @@ -814,6 +848,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt const existing = state.messages[options.oldId] if (!existing) return + movePromptDisplayOverride(instanceId, existing.sessionId, options.oldId, options.newId) + const cloned: MessageRecord = { ...existing, id: options.newId, @@ -1046,6 +1082,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt const keptIds = session.messageIds.slice(0, stopIndex) if (removedIds.length === 0) return + removedIds.forEach((messageId) => clearPromptDisplayOverride(instanceId, sessionId, messageId)) + setState("sessions", sessionId, "messageIds", keptIds) setState("messages", (prev) => { @@ -1121,8 +1159,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return state.scrollState[key] } - function clearSession(sessionId: string) { - if (!sessionId) return + function clearSession(sessionId: string) { + if (!sessionId) return + + clearPromptDisplayOverridesForSession(instanceId, sessionId) const messageIds = Object.values(state.messages) .filter((record) => record.sessionId === sessionId) @@ -1220,6 +1260,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt function clearInstance() { + clearPromptDisplayOverridesForInstance(instanceId, Object.keys(state.sessions)) messageInfoCache.clear() setState(reconcile(createInitialState(instanceId))) } diff --git a/packages/ui/src/stores/message-v2/types.ts b/packages/ui/src/stores/message-v2/types.ts index 6208d475e..2a418d8e3 100644 --- a/packages/ui/src/stores/message-v2/types.ts +++ b/packages/ui/src/stores/message-v2/types.ts @@ -1,5 +1,6 @@ import type { ClientPart } from "../../types/message" -import type { PermissionRequestLike } from "../../types/permission" +import type { PromptDisplayMetadata } from "../../lib/prompt-display-metadata" +import type { PermissionRequest } from "../../types/permission" import type { QuestionRequest } from "../../types/question" export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error" @@ -20,6 +21,7 @@ export interface MessageRecord { updatedAt: number revision: number isEphemeral?: boolean + clientPromptDisplayMetadata?: PromptDisplayMetadata partIds: string[] parts: Record } @@ -48,7 +50,7 @@ export interface PendingPartEntry { } export interface PermissionEntry { - permission: PermissionRequestLike + permission: PermissionRequest messageId?: string partId?: string enqueuedAt: number @@ -145,6 +147,7 @@ export interface MessageUpsertInput { createdAt?: number updatedAt?: number isEphemeral?: boolean + clientPromptDisplayMetadata?: PromptDisplayMetadata bumpRevision?: boolean } diff --git a/packages/ui/src/stores/permission-auto-accept.ts b/packages/ui/src/stores/permission-auto-accept.ts index 9ec0035c6..aca947936 100644 --- a/packages/ui/src/stores/permission-auto-accept.ts +++ b/packages/ui/src/stores/permission-auto-accept.ts @@ -1,5 +1,5 @@ import { createSignal } from "solid-js" -import type { PermissionReply, PermissionRequestLike } from "../types/permission" +import type { PermissionReply, PermissionRequest } from "../types/permission" import { getPermissionSessionId } from "../types/permission" import { getLogger } from "../lib/logger" @@ -121,7 +121,7 @@ export function clearAutoAcceptSession(instanceId: string, sessionId: string) { export function drainAutoAcceptPermission( instanceId: string, - permission: PermissionRequestLike, + permission: PermissionRequest, responder: AutoAcceptResponder, isPending: PendingPermissionChecker, ) { @@ -146,7 +146,7 @@ export function drainAutoAcceptPermission( export function drainAutoAcceptPermissions( instanceId: string, - permissions: PermissionRequestLike[], + permissions: PermissionRequest[], responder: AutoAcceptResponder, isPending: PendingPermissionChecker, ) { diff --git a/packages/ui/src/stores/request-locations.test.ts b/packages/ui/src/stores/request-locations.test.ts new file mode 100644 index 000000000..11e259a82 --- /dev/null +++ b/packages/ui/src/stores/request-locations.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { buildV2RequestLocations } from "./request-locations.ts" + +describe("buildV2RequestLocations", () => { + it("includes root and each workspace-backed worktree location", () => { + const locations = buildV2RequestLocations( + "/repo", + [ + { slug: "root" }, + { slug: "feature-a" }, + { slug: "feature-b" }, + { slug: "missing-workspace" }, + ], + new Map([ + ["feature-a", "workspace-a"], + ["feature-b", "workspace-b"], + ]), + ) + + assert.deepEqual(locations, [ + { directory: "/repo" }, + { directory: "/repo", workspace: "workspace-a" }, + { directory: "/repo", workspace: "workspace-b" }, + ]) + }) + + it("deduplicates repeated workspace locations", () => { + const locations = buildV2RequestLocations( + "/repo", + [{ slug: "feature-a" }, { slug: "feature-b" }], + new Map([ + ["feature-a", "workspace-shared"], + ["feature-b", "workspace-shared"], + ]), + ) + + assert.deepEqual(locations, [ + { directory: "/repo" }, + { directory: "/repo", workspace: "workspace-shared" }, + ]) + }) +}) diff --git a/packages/ui/src/stores/request-locations.ts b/packages/ui/src/stores/request-locations.ts new file mode 100644 index 000000000..0cefe5a28 --- /dev/null +++ b/packages/ui/src/stores/request-locations.ts @@ -0,0 +1,31 @@ +export type V2Location = { + directory?: string + workspace?: string +} + +export type V2RequestLocationWorktree = { + slug?: string +} + +export function buildV2RequestLocations( + directory: string | undefined, + worktrees: V2RequestLocationWorktree[], + workspaceBySlug: Map, +): V2Location[] { + const rootLocation: V2Location = directory ? { directory } : {} + const locations: V2Location[] = [rootLocation] + const seen = new Set([JSON.stringify(rootLocation)]) + + for (const worktree of worktrees) { + if (!worktree.slug || worktree.slug === "root") continue + const workspace = workspaceBySlug.get(worktree.slug) + if (!workspace) continue + const location: V2Location = { ...rootLocation, workspace } + const key = JSON.stringify(location) + if (seen.has(key)) continue + seen.add(key) + locations.push(location) + } + + return locations +} diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index 86aeecbf7..aa54c1442 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -1,4 +1,4 @@ -import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" +import { preparePromptDisplayText } from "../lib/prompt-display-metadata" import { instances } from "./instances" import { getRootClient } from "./opencode-client" import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces" @@ -102,13 +102,13 @@ async function sendMessage( const messageId = createId("msg") const textPartId = createId("prt") - const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments) + const preparedPrompt = preparePromptDisplayText(prompt, attachments) const optimisticParts: any[] = [ { id: textPartId, type: "text" as const, - text: resolvedPrompt, + text: preparedPrompt.promptToSend, synthetic: true, renderCache: undefined, }, @@ -117,7 +117,7 @@ async function sendMessage( const requestParts: any[] = [ { type: "text" as const, - text: resolvedPrompt, + text: preparedPrompt.promptToSend, }, ] @@ -182,6 +182,7 @@ async function sendMessage( createdAt, updatedAt: createdAt, isEphemeral: true, + clientPromptDisplayMetadata: preparedPrompt.displayMetadata, }) withSession(instanceId, sessionId, () => { diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 7770e9e56..cc54d2682 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -7,7 +7,7 @@ import { type SessionStatus, } from "../types/session" import type { Message } from "../types/message" -import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2/client" +import type { SnapshotFileDiff, SessionV2Info, V2SessionsResponse } from "@opencode-ai/sdk/v2/client" import { instances } from "./instances" import { preferences, setAgentModelPreference } from "./preferences" @@ -15,7 +15,7 @@ import { activeSessionId, agents, clearSessionDraftPrompt, - getChildSessions, + getDescendantSessions, isBlankSession, messagesLoaded, pruneDraftPrompts, @@ -27,6 +27,7 @@ import { setSessionInfoByInstance, setSessions, sessions, + getSessionRoot, withSession, loading, setLoading, @@ -34,7 +35,7 @@ import { syncInstanceSessionIndicator, updateThreadTotalsForParent, SESSION_PAGE_SIZE, - getSessionNextStart, + getSessionNextCursor, setSessionPage, prependSessionListId, removeSessionListId, @@ -54,11 +55,11 @@ import { requestData } from "../lib/opencode-api" import { getRootClient } from "./opencode-client" import { getWorktreeSlugForSession, migrateLegacyWorktreeMapToSessionMetadata, pruneStaleLegacyWorktreeMapEntries, removeLegacyParentSessionMapping, setWorktreeSlugForParentSession } from "./worktrees" import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces" +import { hydrateSessionMetadataWithClient } from "./session-metadata" const log = getLogger("api") const pendingSessionDiffFetches = new Map>() -const pendingSessionChildrenFetches = new Map>() async function getSessionWorkspacePayload(instanceId: string, sessionId: string): Promise<{ workspace?: string }> { const workspace = await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) @@ -127,7 +128,101 @@ interface SessionForkResponse { } } -async function fetchSessions(instanceId: string, options?: { start?: number; limit?: number }): Promise { +type V2SessionListOptions = { + directory?: string + limit?: number + search?: string + cursor?: string +} + +function getKnownParentId(session: SessionV2Info | Session): string | null | undefined { + return (session as any).parentID ?? (session as Session).parentId +} + +function hasMissingParentChain(session: SessionV2Info, loaded: Map): boolean { + let current: SessionV2Info | Session = session + const seen = new Set() + + while (getKnownParentId(current)) { + const parentId = getKnownParentId(current) + if (!parentId) return false + if (seen.has(parentId)) return false + seen.add(parentId) + const parent = loaded.get(parentId) + if (!parent) return true + current = parent + } + + return false +} + +async function fetchV2Sessions(instanceId: string, options: V2SessionListOptions): Promise { + const client = getRootClient(instanceId) + return requestData(client.v2.session.list(options), "v2.session.list") +} + +function getV2SessionItems(response: V2SessionsResponse): SessionV2Info[] { + return response.data +} + +function getV2NextCursor(response: V2SessionsResponse): string | undefined { + const next = (response as any)?.cursor?.next + return typeof next === "string" && next.length > 0 ? next : undefined +} + +async function hydrateMissingSessionMetadata(instanceId: string, sessionIds: string[]): Promise { + const uniqueIds = Array.from(new Set(sessionIds)).filter(Boolean) + if (uniqueIds.length === 0) return + + const client = getRootClient(instanceId) + for (const sessionId of uniqueIds) { + const session = sessions().get(instanceId)?.get(sessionId) + if (!session || session.metadata !== undefined) continue + try { + await hydrateSessionMetadataWithClient(client, instanceId, sessionId) + } catch (error) { + log.warn("Failed to hydrate session metadata", { instanceId, sessionId, error }) + } + } +} + +async function ensureV2ParentChainsLoaded(instanceId: string, apiSessions: SessionV2Info[], directory?: string): Promise { + const currentSessions = sessions().get(instanceId) ?? new Map() + const loaded = new Map(currentSessions) + for (const session of apiSessions) loaded.set(session.id, session) + + if (!apiSessions.some((session) => hasMissingParentChain(session, loaded))) return + + const limit = SESSION_PAGE_SIZE + let cursor: string | undefined + let remainingPages = 25 + + while (apiSessions.some((session) => hasMissingParentChain(session, loaded)) && remainingPages > 0) { + const page = await fetchV2Sessions(instanceId, { directory, limit, ...(cursor ? { cursor } : {}) }) + const items = getV2SessionItems(page) + if (items.length === 0) break + + setSessions((prev) => { + const next = new Map(prev) + const instanceSessions = new Map(next.get(instanceId) ?? new Map()) + + for (const apiSession of items) { + const existingSession = instanceSessions.get(apiSession.id) + instanceSessions.set(apiSession.id, toClientSessionV2(instanceId, apiSession, existingSession)) + loaded.set(apiSession.id, apiSession) + } + + next.set(instanceId, instanceSessions) + return next + }) + + cursor = getV2NextCursor(page) + if (!cursor) break + remainingPages -= 1 + } +} + +async function fetchSessions(instanceId: string, options?: { limit?: number; reset?: boolean; cursor?: string }): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { throw new Error("Instance not ready") @@ -142,29 +237,17 @@ async function fetchSessions(instanceId: string, options?: { start?: number; lim }) try { - const projectResponse = await rootClient.project.current() - const projectId = projectResponse.data?.id - const start = options?.start ?? 0 - const limit = options?.limit ?? SESSION_PAGE_SIZE - - const sessionListOptions: { directory?: string; roots?: boolean; start?: number; limit?: number } = { - roots: true, - start, - limit, - } - if (instance.folder) sessionListOptions.directory = instance.folder - - log.info("session.list", { instanceId, projectId, start, limit, directory: sessionListOptions.directory }) - const response = await rootClient.session.list(sessionListOptions) - - const sessionMap = new Map() + const limit = Math.min(options?.limit ?? SESSION_PAGE_SIZE, 200) - if (!response.data || !Array.isArray(response.data)) { - setSessionPage(instanceId, [], start, false) - return + const sessionListOptions: { directory?: string; limit?: number; cursor?: string } = { + limit, + ...(instance.folder ? { directory: instance.folder } : {}), + ...(options?.cursor ? { cursor: options.cursor } : {}), } - const hasMore = response.data.length >= limit + log.info("v2.session.list", { instanceId, limit, directory: sessionListOptions.directory, cursor: sessionListOptions.cursor }) + const response = await fetchV2Sessions(instanceId, sessionListOptions) + const nextCursor = getV2NextCursor(response) let statusById: Record = {} try { @@ -177,8 +260,9 @@ async function fetchSessions(instanceId: string, options?: { start?: number; lim } const existingSessions = sessions().get(instanceId) + const sessionMap = new Map() - for (const apiSession of response.data) { + for (const apiSession of getV2SessionItems(response)) { const existingSession = existingSessions?.get(apiSession.id) const existingStatus = existingSession?.status @@ -194,34 +278,17 @@ async function fetchSessions(instanceId: string, options?: { start?: number; lim retry = hasType ? mapSdkSessionRetry(rawStatus) : retry } + const session = toClientSessionV2(instanceId, apiSession, existingSession) sessionMap.set(apiSession.id, { - id: apiSession.id, - instanceId, - title: apiSession.title || "Untitled", - parentId: apiSession.parentID || null, - agent: existingSession?.agent ?? "", - model: existingSession?.model ?? { providerId: "", modelId: "" }, + ...session, + agent: session.agent, + model: session.model, status, retry, idleSince: getIdleSinceForStatusTransition(existingStatus, status, existingSession?.idleSince), - version: apiSession.version, - time: { - ...apiSession.time, - }, - metadata: apiSession.metadata ?? existingSession?.metadata, - revert: apiSession.revert - ? { - messageID: apiSession.revert.messageID, - partID: apiSession.revert.partID, - snapshot: apiSession.revert.snapshot, - diff: apiSession.revert.diff, - } - : undefined, }) } - const validSessionIds = new Set(sessionMap.keys()) - setSessions((prev) => { const next = new Map(prev) const instanceSessions = new Map(next.get(instanceId) ?? new Map()) @@ -232,7 +299,25 @@ async function fetchSessions(instanceId: string, options?: { start?: number; lim return next }) - setSessionPage(instanceId, Array.from(validSessionIds), start, hasMore) + const rootIds: string[] = [] + const missingRootSessionIds: string[] = [] + for (const apiSession of getV2SessionItems(response)) { + const root = getSessionRoot(instanceId, apiSession.id) + if (root) { + if (!rootIds.includes(root.id)) rootIds.push(root.id) + } else if (apiSession.parentID) { + missingRootSessionIds.push(apiSession.id) + } + } + + if (missingRootSessionIds.length > 0) { + log.warn("Some V2 session list items could not be attached to a loaded root", { + instanceId, + sessionIds: missingRootSessionIds, + }) + } + + setSessionPage(instanceId, rootIds, Boolean(nextCursor), options?.reset ?? true, nextCursor) syncInstanceSessionIndicator(instanceId) @@ -253,13 +338,8 @@ async function fetchSessions(instanceId: string, options?: { start?: number; lim pruneDraftPrompts(instanceId, new Set(sessions().get(instanceId)?.keys() ?? [])) - - const parentIds = Array.from(sessionMap.values()) - .filter((session) => session.parentId === null) - .map((session) => session.id) - - await Promise.all(parentIds.map((parentId) => fetchSessionChildren(instanceId, parentId))) void (async () => { + await hydrateMissingSessionMetadata(instanceId, rootIds) await migrateLegacyWorktreeMapToSessionMetadata(instanceId) await pruneStaleLegacyWorktreeMapEntries(instanceId) })().catch((error) => { @@ -278,7 +358,9 @@ async function fetchSessions(instanceId: string, options?: { start?: number; lim } async function loadMoreSessions(instanceId: string): Promise { - await fetchSessions(instanceId, { start: getSessionNextStart(instanceId), limit: SESSION_PAGE_SIZE }) + const cursor = getSessionNextCursor(instanceId) + if (!cursor) return + await fetchSessions(instanceId, { limit: SESSION_PAGE_SIZE, reset: false, cursor }) } async function searchSessions(instanceId: string, query: string): Promise { @@ -290,19 +372,18 @@ async function searchSessions(instanceId: string, query: string): Promise throw new Error("Instance not ready") } - const rootClient = getRootClient(instanceId) const requestId = beginSessionSearch(instanceId, trimmedQuery) try { - log.info("session.search", { instanceId, query: trimmedQuery, directory: instance.folder }) - const response = await rootClient.session.list({ + log.info("v2.session.search", { instanceId, query: trimmedQuery, directory: instance.folder }) + const response = await fetchV2Sessions(instanceId, { search: trimmedQuery, limit: SESSION_PAGE_SIZE, directory: instance.folder, }) if (!isLatestSessionSearch(instanceId, trimmedQuery, requestId)) return - const searchResults = response.data ?? [] + const searchResults = getV2SessionItems(response) if (searchResults.length === 0) { setSessionSearchResults(instanceId, trimmedQuery, [], requestId) @@ -315,43 +396,14 @@ async function searchSessions(instanceId: string, query: string): Promise for (const apiSession of searchResults) { const existingSession = instanceSessions.get(apiSession.id) - instanceSessions.set(apiSession.id, toClientSession(instanceId, apiSession, existingSession)) + instanceSessions.set(apiSession.id, toClientSessionV2(instanceId, apiSession, existingSession)) } next.set(instanceId, instanceSessions) return next }) - // Fetch any missing parents so child results are rendered correctly - const currentSessions = sessions().get(instanceId) - const missingParentIds = new Set() - for (const apiSession of searchResults) { - const parentId = apiSession.parentID - if (parentId && !currentSessions?.has(parentId)) { - missingParentIds.add(parentId) - } - } - - if (missingParentIds.size > 0) { - const parentFetches = Array.from(missingParentIds).map(async (parentId) => { - try { - const parentResponse = await rootClient.session.get({ sessionID: parentId }) - if (parentResponse.data) { - setSessions((prev) => { - const next = new Map(prev) - const instanceSessions = new Map(next.get(instanceId) ?? new Map()) - const existingSession = instanceSessions.get(parentId) - instanceSessions.set(parentId, toClientSession(instanceId, parentResponse.data, existingSession)) - next.set(instanceId, instanceSessions) - return next - }) - } - } catch (error) { - log.warn("Failed to fetch missing parent session:", { parentId, error }) - } - }) - await Promise.all(parentFetches) - } + await ensureV2ParentChainsLoaded(instanceId, searchResults, instance.folder) if (!isLatestSessionSearch(instanceId, trimmedQuery, requestId)) return @@ -377,147 +429,34 @@ async function searchSessions(instanceId: string, query: string): Promise } } -function toClientSession(instanceId: string, apiSession: any, existingSession?: Session): Session { +function toClientSessionV2(instanceId: string, apiSession: SessionV2Info, existingSession?: Session): Session { return { id: apiSession.id, instanceId, title: apiSession.title || existingSession?.title || "Untitled", parentId: apiSession.parentID || null, - agent: existingSession?.agent ?? "", - model: existingSession?.model ?? { providerId: "", modelId: "" }, + agent: apiSession.agent ?? existingSession?.agent ?? "", + model: apiSession.model + ? { + providerId: apiSession.model.providerID, + modelId: apiSession.model.id, + } + : existingSession?.model ?? { providerId: "", modelId: "" }, status: existingSession?.status ?? "idle", retry: existingSession?.retry ?? null, idleSince: existingSession?.idleSince ?? null, - version: apiSession.version, + version: existingSession?.version || "0", time: { ...apiSession.time, }, - metadata: apiSession.metadata ?? existingSession?.metadata, - revert: apiSession.revert - ? { - messageID: apiSession.revert.messageID, - partID: apiSession.revert.partID, - snapshot: apiSession.revert.snapshot, - diff: apiSession.revert.diff, - } - : existingSession?.revert, + metadata: existingSession?.metadata, + revert: existingSession?.revert, diff: existingSession?.diff, pendingPermission: existingSession?.pendingPermission, pendingQuestion: existingSession?.pendingQuestion, } } -async function fetchSessionChildren(instanceId: string, parentSessionId: string): Promise { - if (!instanceId || !parentSessionId) return [] - - const key = `${instanceId}:${parentSessionId}` - const pending = pendingSessionChildrenFetches.get(key) - if (pending) return pending - - const promise = (async () => { - const instance = instances().get(instanceId) - if (!instance || !instance.client) { - throw new Error("Instance not ready") - } - - const client = getRootClient(instanceId) - - log.info(`[HTTP] GET /session/{sessionID}/children for instance ${instanceId}`, { sessionId: parentSessionId }) - const apiChildren = await requestData( - client.session.children({ sessionID: parentSessionId, ...(await getSessionWorkspacePayload(instanceId, parentSessionId)) }), - "session.children", - ) - - if (!Array.isArray(apiChildren)) return [] - - const currentSessions = sessions().get(instanceId) - const children = apiChildren.map((apiSession) => toClientSession(instanceId, apiSession, currentSessions?.get(apiSession.id))) - const returnedChildIds = new Set(children.map((child) => child.id)) - let staleChildIds: string[] = [] - - setSessions((prev) => { - const next = new Map(prev) - const instanceSessions = new Map(next.get(instanceId)) - staleChildIds = [] - - for (const session of instanceSessions.values()) { - if (session.parentId === parentSessionId && !returnedChildIds.has(session.id)) { - staleChildIds.push(session.id) - } - } - - for (const staleChildId of staleChildIds) { - instanceSessions.delete(staleChildId) - } - - for (const child of children) { - instanceSessions.set(child.id, child) - } - next.set(instanceId, instanceSessions) - return next - }) - - if (staleChildIds.length > 0) { - const staleChildIdSet = new Set(staleChildIds) - - setMessagesLoaded((prev) => { - const loadedSet = prev.get(instanceId) - if (!loadedSet) return prev - const updated = new Set(loadedSet) - let changed = false - for (const staleChildId of staleChildIdSet) { - changed = updated.delete(staleChildId) || changed - } - if (!changed) return prev - const next = new Map(prev) - next.set(instanceId, updated) - return next - }) - - setSessionInfoByInstance((prev) => { - const instanceInfo = prev.get(instanceId) - if (!instanceInfo) return prev - const updated = new Map(instanceInfo) - let changed = false - for (const staleChildId of staleChildIdSet) { - changed = updated.delete(staleChildId) || changed - } - if (!changed) return prev - const next = new Map(prev) - next.set(instanceId, updated) - return next - }) - - for (const staleChildId of staleChildIds) { - messageStoreBus.getOrCreate(instanceId).clearSession(staleChildId) - clearCacheForSession(instanceId, staleChildId) - } - } - - syncInstanceSessionIndicator(instanceId) - updateThreadTotalsForParent(instanceId, parentSessionId) - - if (messagesLoaded().get(instanceId)?.has(parentSessionId)) { - for (const child of children) { - void loadMessages(instanceId, child.id, { skipDiff: true, skipChildren: true }).catch((error) => - log.error("Failed to load child session messages", { - instanceId, - sessionId: child.id, - parentSessionId, - error, - }), - ) - } - } - - return children - })() - - pendingSessionChildrenFetches.set(key, promise) - void promise.finally(() => pendingSessionChildrenFetches.delete(key)) - return promise -} - async function createSession(instanceId: string, agent?: string): Promise { const instance = instances().get(instanceId) if (!instance || !instance.client) { @@ -567,7 +506,7 @@ async function createSession(instanceId: string, agent?: string): Promise log.error("Failed to load child session messages", { instanceId, @@ -1098,7 +1037,6 @@ export { fetchSessions, loadMoreSessions, searchSessions, - fetchSessionChildren, forkSession, loadMessages, } diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index aa990e178..8f87e936b 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -24,10 +24,16 @@ import { getPermissionSessionId, getRequestIdFromPermissionReply, } from "../types/permission" -import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "../types/permission" +import type { LegacyPermissionAskedEvent, LegacyPermissionRepliedEvent, PermissionRequest } from "../types/permission" import { getQuestionId, getQuestionSessionId, getRequestIdFromQuestionReply } from "../types/question" -import type { QuestionRequest } from "../types/question" -import type { EventQuestionReplied, EventQuestionRejected } from "@opencode-ai/sdk/v2" +import type { LegacyQuestionAnsweredEvent, LegacyQuestionAskedEvent, QuestionRequest } from "../types/question" +import type { + EventPermissionV2Asked, + EventPermissionV2Replied, + EventQuestionV2Asked, + EventQuestionV2Rejected, + EventQuestionV2Replied, +} from "@opencode-ai/sdk/v2" import { showToastNotification, type ToastHandle, ToastVariant } from "../lib/notifications" import { sendOsNotification } from "../lib/os-notifications" import { preferences } from "./preferences" @@ -716,9 +722,10 @@ function handleTuiToast(_instanceId: string, event: TuiToastEvent): void { }) } -function handlePermissionUpdated(instanceId: string, event: { type: string; properties?: PermissionRequestLike } | any): void { - const permission = event?.properties as PermissionRequestLike | undefined +function handlePermissionUpdated(instanceId: string, event: EventPermissionV2Asked | LegacyPermissionAskedEvent): void { + const permission = event?.properties as PermissionRequest | undefined if (!permission) return + const source = event.type === "permission.v2.asked" ? "v2" : "legacy" const permissionId = getPermissionId(permission) if (permissionId && hasRepliedPermission(instanceId, permissionId)) { log.info(`[SSE] Ignoring stale permission request after local reply: ${permissionId}`) @@ -726,7 +733,7 @@ function handlePermissionUpdated(instanceId: string, event: { type: string; prop } log.info(`[SSE] Permission request: ${permissionId} (${getPermissionKind(permission)})`) - const queuedPermission = addPermissionToQueue(instanceId, permission) ?? permission + const queuedPermission = addPermissionToQueue(instanceId, permission, source) ?? permission upsertPermissionV2(instanceId, queuedPermission) const sessionId = getPermissionSessionId(permission) @@ -739,8 +746,8 @@ function handlePermissionUpdated(instanceId: string, event: { type: string; prop } } -function handlePermissionReplied(instanceId: string, event: { type: string; properties?: PermissionReplyEventPropertiesLike } | any): void { - const properties = event?.properties as PermissionReplyEventPropertiesLike | undefined +function handlePermissionReplied(instanceId: string, event: EventPermissionV2Replied | LegacyPermissionRepliedEvent): void { + const properties = event?.properties const requestId = getRequestIdFromPermissionReply(properties) if (!requestId) return @@ -750,12 +757,13 @@ function handlePermissionReplied(instanceId: string, event: { type: string; prop removePermissionV2(instanceId, requestId) } -function handleQuestionAsked(instanceId: string, event: { type: string; properties?: QuestionRequest } | any): void { +function handleQuestionAsked(instanceId: string, event: EventQuestionV2Asked | LegacyQuestionAskedEvent): void { const request = event?.properties as QuestionRequest | undefined if (!request) return + const source = event.type === "question.asked" ? "legacy" : "v2" log.info(`[SSE] Question asked: ${getQuestionId(request)}`) - addQuestionToQueue(instanceId, request) + addQuestionToQueue(instanceId, request, source) upsertQuestionV2(instanceId, request) const sessionId = getQuestionSessionId(request) @@ -770,9 +778,9 @@ function handleQuestionAsked(instanceId: string, event: { type: string; properti function handleQuestionAnswered( instanceId: string, - event: { type: string; properties?: EventQuestionReplied["properties"] | EventQuestionRejected["properties"] } | any, + event: EventQuestionV2Replied | EventQuestionV2Rejected | LegacyQuestionAnsweredEvent, ): void { - const properties = event?.properties as EventQuestionReplied["properties"] | EventQuestionRejected["properties"] | undefined + const properties = event?.properties const requestId = getRequestIdFromQuestionReply(properties) if (!requestId) return diff --git a/packages/ui/src/stores/session-metadata.ts b/packages/ui/src/stores/session-metadata.ts index 729b8923d..15ba37b60 100644 --- a/packages/ui/src/stores/session-metadata.ts +++ b/packages/ui/src/stores/session-metadata.ts @@ -70,7 +70,7 @@ export async function updateSessionMetadataWithClient( const latest = await requestData(client.session.get({ sessionID: sessionId }), "session.get") const nextMetadata = updater(normalizeMetadata(latest?.metadata)) const updated = await requestData( - client.session.update({ sessionID: sessionId, metadata: nextMetadata }), + client.session.update({ sessionID: sessionId, metadata: nextMetadata } as any), "session.update", ) const persistedMetadata = normalizeMetadata(updated?.metadata ?? nextMetadata) @@ -82,6 +82,21 @@ export async function updateSessionMetadataWithClient( return persistedMetadata } +export async function hydrateSessionMetadataWithClient( + client: OpencodeClient, + instanceId: string, + sessionId: string, +): Promise { + const latest = await requestData(client.session.get({ sessionID: sessionId }), "session.get") + const metadata = normalizeMetadata(latest?.metadata) + + withSession(instanceId, sessionId, (session) => { + session.metadata = metadata + }) + + return metadata +} + export async function updateCodeNomadSessionMetadataWithClient( client: OpencodeClient, instanceId: string, diff --git a/packages/ui/src/stores/session-pagination-model.ts b/packages/ui/src/stores/session-pagination-model.ts new file mode 100644 index 000000000..3d5e981f7 --- /dev/null +++ b/packages/ui/src/stores/session-pagination-model.ts @@ -0,0 +1,25 @@ +export type SessionPaginationState = { + ids: string[] + hasMore: boolean + nextCursor?: string +} + +export function getDefaultSessionPaginationState(): SessionPaginationState { + return { ids: [], hasMore: true, nextCursor: undefined } +} + +export function applySessionPage( + current: SessionPaginationState | undefined, + ids: string[], + hasMore: boolean, + reset = false, + nextCursor?: string, +): SessionPaginationState { + const previous = current ?? getDefaultSessionPaginationState() + const nextIds = reset ? ids : Array.from(new Set([...previous.ids, ...ids])) + return { + ids: nextIds, + hasMore, + nextCursor, + } +} diff --git a/packages/ui/src/stores/session-pagination.test.ts b/packages/ui/src/stores/session-pagination.test.ts new file mode 100644 index 000000000..13b71a7f8 --- /dev/null +++ b/packages/ui/src/stores/session-pagination.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { applySessionPage, getDefaultSessionPaginationState } from "./session-pagination-model.ts" + +describe("session pagination cursor state", () => { + it("stores the v2 next cursor and appends loaded pages", () => { + const firstPage = applySessionPage(getDefaultSessionPaginationState(), ["root-1", "root-2"], true, true, "cursor-page-2") + + assert.deepEqual(firstPage.ids, ["root-1", "root-2"]) + assert.equal(firstPage.hasMore, true) + assert.equal(firstPage.nextCursor, "cursor-page-2") + + const secondPage = applySessionPage(firstPage, ["root-2", "root-3"], false, false, undefined) + + assert.deepEqual(secondPage.ids, ["root-1", "root-2", "root-3"]) + assert.equal(secondPage.hasMore, false) + assert.equal(secondPage.nextCursor, undefined) + }) + + it("resets ids and cursor when a fresh first page is loaded", () => { + const previous = applySessionPage(getDefaultSessionPaginationState(), ["old-root"], true, true, "old-cursor") + const next = applySessionPage(previous, ["new-root"], false, true, undefined) + + assert.deepEqual(next.ids, ["new-root"]) + assert.equal(next.hasMore, false) + assert.equal(next.nextCursor, undefined) + }) +}) diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index 36f0cf955..1ed9c53b1 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -12,6 +12,7 @@ import { getRootClient } from "./opencode-client" import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces" import { tGlobal } from "../lib/i18n" import { computeThreadTotals, type ThreadTotals } from "../lib/thread-totals" +import { applySessionPage, getDefaultSessionPaginationState, type SessionPaginationState } from "./session-pagination-model" const log = getLogger("session") @@ -63,13 +64,7 @@ type InstanceIndicatorCounts = { const [instanceIndicatorCounts, setInstanceIndicatorCounts] = createSignal>(new Map()) -const SESSION_PAGE_SIZE = 100 - -type SessionPaginationState = { - ids: string[] - nextStart: number - hasMore: boolean -} +const SESSION_PAGE_SIZE = 200 type SessionSearchState = { query: string @@ -82,7 +77,7 @@ const [sessionPagination, setSessionPagination] = createSignal>(new Map()) function getSessionPaginationState(instanceId: string): SessionPaginationState { - return sessionPagination().get(instanceId) ?? { ids: [], nextStart: 0, hasMore: true } + return sessionPagination().get(instanceId) ?? getDefaultSessionPaginationState() } function getSessionListIds(instanceId: string): string[] { @@ -93,20 +88,14 @@ function getSessionFetchLimit(instanceId: string): number { return Math.max(getSessionPaginationState(instanceId).ids.length, SESSION_PAGE_SIZE) } -function getSessionNextStart(instanceId: string): number { - return getSessionPaginationState(instanceId).nextStart +function getSessionNextCursor(instanceId: string): string | undefined { + return getSessionPaginationState(instanceId).nextCursor } -function setSessionPage(instanceId: string, ids: string[], start: number, hasMore: boolean): void { +function setSessionPage(instanceId: string, ids: string[], hasMore: boolean, reset = false, nextCursor?: string): void { setSessionPagination((prev) => { const next = new Map(prev) - const current = prev.get(instanceId) ?? { ids: [], nextStart: 0, hasMore: true } - const nextIds = start <= 0 ? ids : Array.from(new Set([...current.ids, ...ids])) - next.set(instanceId, { - ids: nextIds, - nextStart: start + ids.length, - hasMore, - }) + next.set(instanceId, applySessionPage(prev.get(instanceId), ids, hasMore, reset, nextCursor)) return next }) } @@ -118,7 +107,7 @@ function getSessionHasMore(instanceId: string): boolean { function resetSessionPagination(instanceId: string): void { setSessionPagination((prev) => { const next = new Map(prev) - next.set(instanceId, { ids: [], nextStart: 0, hasMore: true }) + next.set(instanceId, getDefaultSessionPaginationState()) return next }) } @@ -126,9 +115,9 @@ function resetSessionPagination(instanceId: string): void { function prependSessionListId(instanceId: string, sessionId: string): void { setSessionPagination((prev) => { const next = new Map(prev) - const current = prev.get(instanceId) ?? { ids: [], nextStart: 0, hasMore: true } + const current = prev.get(instanceId) ?? { ids: [], hasMore: true } const ids = [sessionId, ...current.ids.filter((id) => id !== sessionId)] - next.set(instanceId, { ...current, ids, nextStart: current.nextStart + (current.ids.includes(sessionId) ? 0 : 1) }) + next.set(instanceId, { ...current, ids }) return next }) } @@ -136,9 +125,9 @@ function prependSessionListId(instanceId: string, sessionId: string): void { function removeSessionListId(instanceId: string, sessionId: string): void { setSessionPagination((prev) => { const next = new Map(prev) - const current = prev.get(instanceId) ?? { ids: [], nextStart: 0, hasMore: true } + const current = prev.get(instanceId) ?? { ids: [], hasMore: true } const ids = current.ids.filter((id) => id !== sessionId) - next.set(instanceId, { ...current, ids, nextStart: Math.max(0, current.nextStart - (ids.length === current.ids.length ? 0 : 1)) }) + next.set(instanceId, { ...current, ids }) return next }) } @@ -578,14 +567,66 @@ function getChildSessions(instanceId: string, parentId: string): Session[] { return allSessions.filter((s) => s.parentId === parentId) } +function getDescendantSessions(instanceId: string, parentId: string): Session[] { + const allSessions = getSessions(instanceId) + const childrenByParent = new Map() + + for (const session of allSessions) { + if (!session.parentId) continue + const children = childrenByParent.get(session.parentId) + if (children) { + children.push(session) + } else { + childrenByParent.set(session.parentId, [session]) + } + } + + const descendants: Session[] = [] + const stack = [...(childrenByParent.get(parentId) ?? [])] + const seen = new Set() + + while (stack.length > 0) { + const session = stack.shift() + if (!session || seen.has(session.id)) continue + seen.add(session.id) + descendants.push(session) + stack.push(...(childrenByParent.get(session.id) ?? [])) + } + + descendants.sort((a, b) => (b.time.updated ?? 0) - (a.time.updated ?? 0)) + return descendants +} + function getSessionFamily(instanceId: string, parentId: string): Session[] { const parent = sessions().get(instanceId)?.get(parentId) if (!parent) return [] - const children = getChildSessions(instanceId, parentId) + const children = getDescendantSessions(instanceId, parentId) return [parent, ...children] } +function getSessionRoot(instanceId: string, sessionId: string): Session | null { + const instanceSessions = sessions().get(instanceId) + if (!instanceSessions) return null + return getSessionRootFromMap(instanceSessions, sessionId) +} + +function getSessionRootFromMap(instanceSessions: Map, sessionId: string): Session | null { + let current = instanceSessions.get(sessionId) + if (!current) return null + + const seen = new Set() + while (current.parentId) { + if (seen.has(current.id)) return null + seen.add(current.id) + const parent = instanceSessions.get(current.parentId) + if (!parent) return null + current = parent + } + + return current +} + type SessionThreadCacheEntry = { signature: string thread: SessionThread @@ -616,17 +657,18 @@ function buildSessionThreads(instanceId: string, rootIds: string[], childIds?: S const cache = getOrCreateSessionThreadCache(instanceId) const seenParents = new Set() - const childrenByParent = new Map() + const childrenByRoot = new Map() for (const session of instanceSessions.values()) { - const parentId = session.parentId - if (!parentId) continue + if (!session.parentId) continue if (childIds && !childIds.has(session.id)) continue - const children = childrenByParent.get(parentId) + const root = getSessionRootFromMap(instanceSessions, session.id) + if (!root) continue + const children = childrenByRoot.get(root.id) if (children) { children.push(session) } else { - childrenByParent.set(parentId, [session]) + childrenByRoot.set(root.id, [session]) } } @@ -638,7 +680,7 @@ function buildSessionThreads(instanceId: string, rootIds: string[], childIds?: S seenParents.add(parent.id) - const children = childrenByParent.get(parent.id) ?? [] + const children = childrenByRoot.get(parent.id) ?? [] if (children.length > 1) { children.sort((a, b) => (b.time.updated ?? 0) - (a.time.updated ?? 0)) } @@ -698,7 +740,8 @@ function getSessionSearchThreads(instanceId: string): SessionThread[] { rootIds.push(session.id) } else { childIds.add(session.id) - if (!rootIds.includes(session.parentId)) rootIds.push(session.parentId) + const root = getSessionRootFromMap(instanceSessions, session.id) + if (root && !rootIds.includes(root.id)) rootIds.push(root.id) } } @@ -979,6 +1022,8 @@ export { getSessions, getParentSessions, getChildSessions, + getDescendantSessions, + getSessionRoot, getSessionFamily, getSessionThreads, getSessionSearchThreads, @@ -998,7 +1043,7 @@ export { sessionSearch, getSessionListIds, getSessionFetchLimit, - getSessionNextStart, + getSessionNextCursor, setSessionPage, getSessionHasMore, resetSessionPagination, diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index 1430e1002..bd2513906 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -13,6 +13,8 @@ import { getActiveParentSession, getActiveSession, getChildSessions, + getDescendantSessions, + getSessionRoot, getParentSessions, getSessionDraftPrompt, getSessionFamily, @@ -55,7 +57,6 @@ import { fetchSessions, loadMoreSessions, searchSessions, - fetchSessionChildren, forkSession, loadMessages, } from "./session-api" @@ -122,11 +123,12 @@ export { fetchSessions, loadMoreSessions, searchSessions, - fetchSessionChildren, forkSession, getActiveParentSession, getActiveSession, getChildSessions, + getDescendantSessions, + getSessionRoot, getDefaultModel, getParentSessions, getSessionDraftPrompt, diff --git a/packages/ui/src/styles/panels/session-layout.css b/packages/ui/src/styles/panels/session-layout.css index 07738886a..c6223f981 100644 --- a/packages/ui/src/styles/panels/session-layout.css +++ b/packages/ui/src/styles/panels/session-layout.css @@ -105,6 +105,32 @@ color: var(--text-primary); } +.session-header-left-slot { + @apply flex items-center gap-2 min-w-0; + flex: 0 0 auto; +} + +.session-header-active-title { + display: flex; + align-items: center; + flex: 1 1 auto; + min-width: 0; + align-self: stretch; + padding-inline: 0.75rem; + border-inline: 1px solid color-mix(in oklab, var(--border-base) 72%, transparent); + color: var(--text-secondary); +} + +.session-header-active-title-text { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + font-size: 0.8125rem; + font-weight: 500; + line-height: 1.15; +} + .session-sidebar-shortcuts { @apply flex flex-col gap-1; } diff --git a/packages/ui/src/types/message.ts b/packages/ui/src/types/message.ts index 95fec10dd..faba9d9d1 100644 --- a/packages/ui/src/types/message.ts +++ b/packages/ui/src/types/message.ts @@ -9,7 +9,7 @@ import type { AssistantMessage as SDKAssistantMessageV2, } from "@opencode-ai/sdk/v2" -import type { PermissionRequestLike } from "./permission" +import type { PermissionRequest } from "./permission" // Re-export for other modules export type { @@ -44,7 +44,7 @@ export interface RenderCache { } export interface PendingPermissionState { - permission: PermissionRequestLike + permission: PermissionRequest active: boolean } diff --git a/packages/ui/src/types/permission.test.ts b/packages/ui/src/types/permission.test.ts index e82151259..91dd57e91 100644 --- a/packages/ui/src/types/permission.test.ts +++ b/packages/ui/src/types/permission.test.ts @@ -1,49 +1,42 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { mergePermissionRequest, type PermissionRequestLike } from "./permission.ts" +import { mergePermissionRequest, type PermissionRequest } from "./permission.ts" describe("mergePermissionRequest", () => { - it("preserves known routing metadata when duplicate payloads are sparse", () => { - const previous: PermissionRequestLike = { + it("preserves v2 source metadata when duplicate payload omits it", () => { + const previous: PermissionRequest = { id: "permission-1", sessionID: "session-1", - messageID: "message-1", - callID: "call-1", + action: "edit", + resources: ["file-a.ts"], metadata: { - callID: "metadata-call-1", - messageID: "metadata-message-1", + path: "file-a.ts", }, - tool: { - callID: "tool-call-1", + source: { + type: "tool", + callID: "call-1", messageID: "tool-message-1", }, - time: { created: 1_000 }, } - const next: PermissionRequestLike = { + const next: PermissionRequest = { id: "permission-1", - sessionID: undefined, - messageID: undefined, - callID: undefined, + sessionID: "session-1", + action: "edit", + resources: ["file-b.ts"], metadata: { - callID: undefined, - }, - tool: { - callID: undefined, + diff: "diff --git a/file-b.ts b/file-b.ts", }, - time: { created: undefined }, - } as PermissionRequestLike + } const merged = mergePermissionRequest(previous, next) assert.equal(merged.sessionID, "session-1") - assert.equal(merged.messageID, "message-1") - assert.equal(merged.callID, "call-1") - assert.equal(merged.metadata?.callID, "metadata-call-1") - assert.equal(merged.metadata?.messageID, "metadata-message-1") - assert.equal(merged.tool?.callID, "tool-call-1") - assert.equal(merged.tool?.messageID, "tool-message-1") - assert.equal(merged.time?.created, 1_000) + assert.deepEqual((merged as any).resources, ["file-b.ts"]) + assert.equal(merged.metadata?.path, "file-a.ts") + assert.equal(merged.metadata?.diff, "diff --git a/file-b.ts b/file-b.ts") + assert.equal((merged as any).source?.callID, "call-1") + assert.equal((merged as any).source?.messageID, "tool-message-1") }) }) diff --git a/packages/ui/src/types/permission.ts b/packages/ui/src/types/permission.ts index 5d873460d..ab1e9c1c5 100644 --- a/packages/ui/src/types/permission.ts +++ b/packages/ui/src/types/permission.ts @@ -1,23 +1,25 @@ -export type PermissionReply = "once" | "always" | "reject" +import type { PermissionV2Reply, PermissionV2Request, EventPermissionV2Replied } from "@opencode-ai/sdk/v2" -export interface PermissionToolRefLike { - messageID?: string - messageId?: string - callID?: string - callId?: string -} +export type PermissionReply = PermissionV2Reply +export type PermissionSource = "legacy" | "v2" -// Compat type that covers both the legacy Permission.Info payload and the new -// PermissionNext.Request payload. -export interface PermissionRequestLike { +export interface LegacyPermissionRequest { id: string - - // Legacy fields + sessionID?: string + sessionId?: string + permission?: string type?: string pattern?: string + patterns?: string[] + always?: string[] title?: string - sessionID?: string - sessionId?: string + metadata?: Record + tool?: { + messageID?: string + messageId?: string + callID?: string + callId?: string + } messageID?: string messageId?: string callID?: string @@ -26,141 +28,90 @@ export interface PermissionRequestLike { partId?: string toolCallID?: string toolCallId?: string - metadata?: Record time?: { created?: number } - - // New fields - permission?: string - patterns?: string[] - always?: string[] - tool?: PermissionToolRefLike } -export interface PermissionReplyEventPropertiesLike { - sessionID?: string - sessionId?: string - permissionID?: string - permissionId?: string - requestID?: string - requestId?: string - response?: PermissionReply - reply?: PermissionReply -} +export type PermissionRequest = PermissionV2Request | LegacyPermissionRequest -// Permission payloads can come from legacy/new SDK shapes. Preserve known -// top-level routing aliases when an out-of-order duplicate omits them. -const TOP_LEVEL_ROUTING_ALIAS_KEYS = [ - "sessionID", - "sessionId", - "messageID", - "messageId", - "callID", - "callId", - "partID", - "partId", - "toolCallID", - "toolCallId", -] as const satisfies ReadonlyArray +export type LegacyPermissionAskedEvent = { + type: "permission.asked" | "permission.updated" + properties?: LegacyPermissionRequest +} -function mergeRecordPreservingKnown>(previous: T | undefined, next: T | undefined): T | undefined { - if (!previous) return next - if (!next) return previous - const merged: Record = { ...previous, ...next } - for (const key of Object.keys(previous)) { - if (next[key] == null && previous[key] != null) { - merged[key] = previous[key] - } +export type LegacyPermissionRepliedEvent = { + type: "permission.replied" + properties?: { + requestID?: string + requestId?: string + permissionID?: string + permissionId?: string } - return merged as T } -export function mergePermissionRequest(previous: PermissionRequestLike | undefined, next: PermissionRequestLike): PermissionRequestLike { +function isV2Permission(permission: PermissionRequest | null | undefined): permission is PermissionV2Request { + return Boolean(permission && "action" in permission && Array.isArray((permission as PermissionV2Request).resources)) +} + +export function mergePermissionRequest(previous: PermissionRequest | undefined, next: PermissionRequest): PermissionRequest { if (!previous) return next - const merged = { + const previousMetadata = previous.metadata ?? {} + const nextMetadata = next.metadata ?? {} + return { ...previous, ...next, - metadata: mergeRecordPreservingKnown(previous.metadata, next.metadata), - time: mergeRecordPreservingKnown(previous.time as Record | undefined, next.time as Record | undefined) as PermissionRequestLike["time"], - tool: mergeRecordPreservingKnown(previous.tool as Record | undefined, next.tool as Record | undefined) as PermissionRequestLike["tool"], - } - for (const key of TOP_LEVEL_ROUTING_ALIAS_KEYS) { - if ((next as any)[key] == null && (previous as any)[key] != null) { - ;(merged as any)[key] = (previous as any)[key] - } + metadata: { + ...previousMetadata, + ...nextMetadata, + }, + source: isV2Permission(next) ? next.source ?? (isV2Permission(previous) ? previous.source : undefined) : undefined, + tool: !isV2Permission(next) ? next.tool ?? (!isV2Permission(previous) ? previous.tool : undefined) : undefined, } - return merged } -export function getPermissionId(permission: PermissionRequestLike | null | undefined): string { +export function getPermissionId(permission: PermissionRequest | null | undefined): string { return permission?.id ?? "" } -export function getPermissionSessionId(permission: PermissionRequestLike | null | undefined): string | undefined { - return ( - (permission as any)?.sessionID ?? - (permission as any)?.sessionId ?? - undefined - ) +export function getPermissionSessionId(permission: PermissionRequest | null | undefined): string | undefined { + return permission?.sessionID ?? (!isV2Permission(permission) ? permission?.sessionId : undefined) } -export function getPermissionMessageId(permission: PermissionRequestLike | null | undefined): string | undefined { - const tool = (permission as any)?.tool as PermissionToolRefLike | undefined - return ( - tool?.messageID ?? - tool?.messageId ?? - (permission as any)?.messageID ?? - (permission as any)?.messageId ?? - undefined - ) +export function getPermissionMessageId(permission: PermissionRequest | null | undefined): string | undefined { + if (isV2Permission(permission)) return permission.source?.messageID + return permission?.tool?.messageID ?? permission?.tool?.messageId ?? permission?.messageID ?? permission?.messageId } -export function getPermissionCallId(permission: PermissionRequestLike | null | undefined): string | undefined { - const tool = (permission as any)?.tool as PermissionToolRefLike | undefined - const metadata = (permission as any)?.metadata || {} +export function getPermissionCallId(permission: PermissionRequest | null | undefined): string | undefined { + if (isV2Permission(permission)) return permission.source?.callID + const metadata = permission?.metadata ?? {} return ( - tool?.callID ?? - tool?.callId ?? - (permission as any)?.callID ?? - (permission as any)?.callId ?? - (permission as any)?.toolCallID ?? - (permission as any)?.toolCallId ?? - metadata.callID ?? - metadata.callId ?? - undefined + permission?.tool?.callID ?? + permission?.tool?.callId ?? + permission?.callID ?? + permission?.callId ?? + permission?.toolCallID ?? + permission?.toolCallId ?? + (metadata.callID as string | undefined) ?? + (metadata.callId as string | undefined) ) } -export function getPermissionCreatedAt(permission: PermissionRequestLike | null | undefined): number { - const created = (permission as any)?.time?.created - return typeof created === "number" ? created : Date.now() -} - -export function getPermissionKind(permission: PermissionRequestLike | null | undefined): string { - return ( - (permission as any)?.permission ?? - (permission as any)?.type ?? - "permission" - ) +export function getPermissionKind(permission: PermissionRequest | null | undefined): string { + if (isV2Permission(permission)) return permission.action + return permission?.permission ?? permission?.type ?? "permission" } -export function getPermissionPatterns(permission: PermissionRequestLike | null | undefined): string[] { - const patterns = (permission as any)?.patterns - if (Array.isArray(patterns)) { - return patterns.filter((value) => typeof value === "string") - } - const pattern = (permission as any)?.pattern - if (typeof pattern === "string" && pattern.length > 0) { - return [pattern] - } - return [] +export function getPermissionPatterns(permission: PermissionRequest | null | undefined): string[] { + if (isV2Permission(permission)) return permission.resources.filter((value) => typeof value === "string") + const patterns = permission?.patterns + if (Array.isArray(patterns)) return patterns.filter((value) => typeof value === "string") + return typeof permission?.pattern === "string" && permission.pattern.length > 0 ? [permission.pattern] : [] } -export function getPermissionDisplayTitle(permission: PermissionRequestLike | null | undefined): string { - const title = (permission as any)?.title - if (typeof title === "string" && title.trim().length > 0) { - return title +export function getPermissionDisplayTitle(permission: PermissionRequest | null | undefined): string { + if (!isV2Permission(permission) && typeof permission?.title === "string" && permission.title.trim().length > 0) { + return permission.title } - const kind = getPermissionKind(permission) const patterns = getPermissionPatterns(permission) if (patterns.length > 0) { @@ -169,12 +120,8 @@ export function getPermissionDisplayTitle(permission: PermissionRequestLike | nu return kind } -export function getRequestIdFromPermissionReply(properties: PermissionReplyEventPropertiesLike | null | undefined): string | undefined { - return ( - (properties as any)?.requestID ?? - (properties as any)?.requestId ?? - (properties as any)?.permissionID ?? - (properties as any)?.permissionId ?? - undefined - ) +export function getRequestIdFromPermissionReply( + properties: EventPermissionV2Replied["properties"] | LegacyPermissionRepliedEvent["properties"] | null | undefined, +): string | undefined { + return properties?.requestID ?? (properties as LegacyPermissionRepliedEvent["properties"])?.requestId ?? (properties as LegacyPermissionRepliedEvent["properties"])?.permissionID ?? (properties as LegacyPermissionRepliedEvent["properties"])?.permissionId } diff --git a/packages/ui/src/types/question.ts b/packages/ui/src/types/question.ts index 02291d5dd..e2d5ad8d1 100644 --- a/packages/ui/src/types/question.ts +++ b/packages/ui/src/types/question.ts @@ -1,10 +1,24 @@ import type { - QuestionRequest, - EventQuestionReplied, - EventQuestionRejected, + QuestionV2Request, + EventQuestionV2Replied, + EventQuestionV2Rejected, } from "@opencode-ai/sdk/v2" -export type { QuestionRequest } +export type QuestionSource = "legacy" | "v2" + +export type QuestionRequest = QuestionV2Request & { + version?: string +} + +export type LegacyQuestionAskedEvent = { + type: "question.asked" + properties?: QuestionRequest +} + +export type LegacyQuestionAnsweredEvent = { + type: "question.replied" | "question.rejected" + properties?: { requestID?: string } +} export function getQuestionId(question: QuestionRequest | null | undefined): string { return question?.id ?? "" @@ -28,7 +42,7 @@ export function getQuestionCreatedAt(question: QuestionRequest | null | undefine } export function getRequestIdFromQuestionReply( - properties: EventQuestionReplied["properties"] | EventQuestionRejected["properties"] | null | undefined, + properties: EventQuestionV2Replied["properties"] | EventQuestionV2Rejected["properties"] | LegacyQuestionAnsweredEvent["properties"] | null | undefined, ): string | undefined { return properties?.requestID }