From 72f420b6f6b322aebd62d7a864001d55b749c5cc Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sat, 10 Jan 2026 09:46:23 +0000 Subject: [PATCH 01/15] feat(ui): support question tool requests Add question queue hydration, inline answering UI, and unify pending requests with permissions. --- package-lock.json | 8 +- packages/opencode-config/package.json | 2 +- packages/ui/package.json | 2 +- .../components/permission-approval-modal.tsx | 154 ++++++-- .../permission-notification-banner.tsx | 18 +- packages/ui/src/components/session-list.tsx | 10 +- .../src/components/session/session-view.tsx | 29 +- packages/ui/src/components/tool-call.tsx | 334 +++++++++++++++++- .../components/tool-call/renderers/index.ts | 2 + .../tool-call/renderers/question.tsx | 17 + packages/ui/src/components/tool-call/utils.ts | 2 + packages/ui/src/lib/sse-manager.ts | 11 + packages/ui/src/stores/instances.ts | 331 +++++++++++++++-- packages/ui/src/stores/message-v2/bridge.ts | 61 ++++ .../src/stores/message-v2/instance-store.ts | 101 +++++- packages/ui/src/stores/message-v2/types.ts | 15 + packages/ui/src/stores/session-api.ts | 6 +- packages/ui/src/stores/session-events.ts | 38 +- packages/ui/src/stores/session-state.ts | 14 +- packages/ui/src/stores/sessions.ts | 4 + packages/ui/src/types/question.ts | 34 ++ packages/ui/src/types/session.ts | 1 + 22 files changed, 1098 insertions(+), 96 deletions(-) create mode 100644 packages/ui/src/components/tool-call/renderers/question.tsx create mode 100644 packages/ui/src/types/question.ts diff --git a/package-lock.json b/package-lock.json index 3883a12c..bf5e1a50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1096,9 +1096,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.1.tgz", - "integrity": "sha512-PfXujMrHGeMnpS8Gd2BXSY+zZajlztcAvcokf06NtAhd0Mbo/hCLXgW0NBCQ+3FX3e/G2PNwz2DqMdtzyIZaCQ==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz", + "integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==", "license": "MIT" }, "node_modules/@pinojs/redact": { @@ -7469,7 +7469,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "1.1.1", + "@opencode-ai/sdk": "1.1.11", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/opencode-config/package.json b/packages/opencode-config/package.json index fa8aa1ea..a3a9bcac 100644 --- a/packages/opencode-config/package.json +++ b/packages/opencode-config/package.json @@ -3,6 +3,6 @@ "version": "0.5.0", "private": true, "dependencies": { - "@opencode-ai/plugin": "1.1.8" + "@opencode-ai/plugin": "1.1.10" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index d18b89e4..fadc82ac 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -12,7 +12,7 @@ "dependencies": { "@git-diff-view/solid": "^0.0.8", "@kobalte/core": "0.13.11", - "@opencode-ai/sdk": "1.1.1", + "@opencode-ai/sdk": "1.1.11", "@solidjs/router": "^0.13.0", "@suid/icons-material": "^0.9.0", "@suid/material": "^0.19.0", diff --git a/packages/ui/src/components/permission-approval-modal.tsx b/packages/ui/src/components/permission-approval-modal.tsx index 33970125..66e79be8 100644 --- a/packages/ui/src/components/permission-approval-modal.tsx +++ b/packages/ui/src/components/permission-approval-modal.tsx @@ -1,7 +1,15 @@ import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js" import type { PermissionRequestLike } from "../types/permission" import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission" -import { activePermissionId, getPermissionQueue } from "../stores/instances" +import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question" +import { + activeInterruption, + getPermissionQueue, + getQuestionQueue, + getQuestionEnqueuedAtForInstance, + setActivePermissionIdForInstance, + setActiveQuestionIdForInstance, +} from "../stores/instances" import { loadMessages, setActiveSession } from "../stores/sessions" import { messageStoreBus } from "../stores/message-v2/bus" import ToolCall from "./tool-call" @@ -88,24 +96,72 @@ function resolveToolCallFromPermission( return null } +function resolveToolCallFromQuestion(instanceId: string, request: QuestionRequest): ResolvedToolCall | null { + const sessionId = getQuestionSessionId(request) + const messageId = getQuestionMessageId(request) + if (!sessionId || !messageId) return null + + const store = messageStoreBus.getInstance(instanceId) + if (!store) return null + + const record = store.getMessage(messageId) + if (!record) return null + + const callId = getQuestionCallId(request) + if (!callId) return null + + for (const partId of record.partIds) { + const partRecord = record.parts?.[partId] + const part = partRecord?.data as any + if (!part || part.type !== "tool") continue + const partCallId = part.callID ?? part.callId ?? part.toolCallID ?? part.toolCallId ?? undefined + if (partCallId !== callId) continue + + if (typeof part.id !== "string" || part.id.length === 0) continue + return { + messageId, + sessionId, + toolPart: part as ResolvedToolCall["toolPart"], + messageVersion: record.revision, + partVersion: partRecord?.revision ?? 0, + } + } + + return null +} + const PermissionApprovalModal: Component = (props) => { const [loadingSession, setLoadingSession] = createSignal(null) - const queue = createMemo(() => getPermissionQueue(props.instanceId)) - const activePermId = createMemo(() => activePermissionId().get(props.instanceId) ?? null) - - const orderedQueue = createMemo(() => { - const current = queue() - const activeId = activePermId() - if (!activeId) return current - const index = current.findIndex((entry) => entry.id === activeId) - if (index <= 0) return current - const active = current[index] - if (!active) return current - return [active, ...current.slice(0, index), ...current.slice(index + 1)] + const permissionQueue = createMemo(() => getPermissionQueue(props.instanceId)) + const questionQueue = createMemo(() => getQuestionQueue(props.instanceId)) + const active = createMemo(() => activeInterruption().get(props.instanceId) ?? null) + + type InterruptionItem = + | { kind: "permission"; id: string; sessionId: string; createdAt: number; payload: PermissionRequestLike } + | { kind: "question"; id: string; sessionId: string; createdAt: number; payload: QuestionRequest } + + const orderedQueue = createMemo(() => { + const permissions = permissionQueue().map((permission) => ({ + kind: "permission" as const, + id: permission.id, + sessionId: getPermissionSessionId(permission) || "", + createdAt: (permission as any)?.time?.created ?? Date.now(), + payload: permission, + })) + + const questions = questionQueue().map((question) => ({ + kind: "question" as const, + id: question.id, + sessionId: getQuestionSessionId(question) || "", + createdAt: getQuestionEnqueuedAtForInstance(props.instanceId, question.id), + payload: question, + })) + + return [...permissions, ...questions].sort((a, b) => a.createdAt - b.createdAt) }) - const hasPermissions = createMemo(() => queue().length > 0) + const hasRequests = createMemo(() => orderedQueue().length > 0) const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") { @@ -122,7 +178,7 @@ const PermissionApprovalModal: Component = (props) createEffect(() => { if (!props.isOpen) return - if (queue().length === 0) { + if (orderedQueue().length === 0) { props.onClose() } }) @@ -156,10 +212,10 @@ const PermissionApprovalModal: Component = (props)

- Permissions + Requests

- 0}> - {queue().length} + 0}> + {orderedQueue().length}
- No pending permissions.
}> + No pending requests.}>
- {(permission) => { - const sessionId = getPermissionSessionId(permission) || "" - const isActive = () => permission.id === activePermId() - const resolved = createMemo(() => resolveToolCallFromPermission(props.instanceId, permission)) + {(item) => { + const isActive = () => active()?.kind === item.kind && active()?.id === item.id + const sessionId = () => item.sessionId + + const resolved = createMemo(() => { + if (item.kind === "permission") { + return resolveToolCallFromPermission(props.instanceId, item.payload) + } + return resolveToolCallFromQuestion(props.instanceId, item.payload) + }) const showFallback = () => !resolved() + const kindLabel = () => (item.kind === "permission" ? "Permission" : "Question") + + const primaryTitle = () => { + if (item.kind === "permission") { + return getPermissionDisplayTitle(item.payload) + } + const first = item.payload.questions?.[0]?.question + return typeof first === "string" && first.trim().length > 0 ? first : "Question" + } + + const secondaryTitle = () => { + if (item.kind === "permission") { + return getPermissionKind(item.payload) + } + const count = item.payload.questions?.length ?? 0 + return count === 1 ? "1 question" : `${count} questions` + } + + const handleActivate = () => { + if (item.kind === "permission") { + setActivePermissionIdForInstance(props.instanceId, item.id) + } else { + setActiveQuestionIdForInstance(props.instanceId, item.id) + } + } + return (
- {getPermissionKind(permission)} + {kindLabel()} + {secondaryTitle()} Active @@ -195,7 +285,10 @@ const PermissionApprovalModal: Component = (props) @@ -203,10 +296,13 @@ const PermissionApprovalModal: Component = (props)
@@ -217,7 +313,7 @@ const PermissionApprovalModal: Component = (props) fallback={
- {getPermissionDisplayTitle(permission)} + {primaryTitle()}
Load session for more information.
diff --git a/packages/ui/src/components/permission-notification-banner.tsx b/packages/ui/src/components/permission-notification-banner.tsx index 17e04907..8c6f97ca 100644 --- a/packages/ui/src/components/permission-notification-banner.tsx +++ b/packages/ui/src/components/permission-notification-banner.tsx @@ -1,6 +1,6 @@ import { Show, createMemo, type Component } from "solid-js" import { ShieldAlert } from "lucide-solid" -import { getPermissionQueueLength } from "../stores/instances" +import { getPermissionQueueLength, getQuestionQueueLength } from "../stores/instances" interface PermissionNotificationBannerProps { instanceId: string @@ -8,15 +8,21 @@ interface PermissionNotificationBannerProps { } const PermissionNotificationBanner: Component = (props) => { - const queueLength = createMemo(() => getPermissionQueueLength(props.instanceId)) - const hasPermissions = createMemo(() => queueLength() > 0) + const permissionCount = createMemo(() => getPermissionQueueLength(props.instanceId)) + const questionCount = createMemo(() => getQuestionQueueLength(props.instanceId)) + const queueLength = createMemo(() => permissionCount() + questionCount()) + const hasRequests = createMemo(() => queueLength() > 0) const label = createMemo(() => { - const count = queueLength() - return `${count} permission${count === 1 ? "" : "s"} pending approval` + const total = queueLength() + const parts: string[] = [] + if (permissionCount() > 0) parts.push(`${permissionCount()} permission${permissionCount() === 1 ? "" : "s"}`) + if (questionCount() > 0) parts.push(`${questionCount()} question${questionCount() === 1 ? "" : "s"}`) + const detail = parts.length ? ` (${parts.join(", ")})` : "" + return `${total} pending request${total === 1 ? "" : "s"}${detail}` }) return ( - + +
+ +
+ +
+ ) + }} + + + +
+
+ + +
+ +
+ Enter + Submit + Esc + Dismiss +
+ + +
{questionError()}
+
+
+
+ + +

Waiting for earlier responses.

+
+ + + + ) + } + + createEffect(() => { + const request = questionDetails() + if (!request) { + setQuestionSubmitting(false) + setQuestionError(null) + return + } + setQuestionError(null) + const requestId = request.id + setQuestionDraftAnswers((prev) => { + if (prev[requestId]) return prev + const initial = request.questions.map(() => []) + return { ...prev, [requestId]: initial } + }) + setQuestionCustomDraft((prev) => { + if (prev[requestId]) return prev + const initial = request.questions.map(() => "") + return { ...prev, [requestId]: initial } + }) + }) + const status = () => toolState()?.status || "" onCleanup(() => { @@ -993,6 +1310,7 @@ export default function ToolCall(props: ToolCallProps) { {renderError()} {renderPermissionBlock()} + {renderQuestionBlock()}
diff --git a/packages/ui/src/components/tool-call/renderers/index.ts b/packages/ui/src/components/tool-call/renderers/index.ts index 3bc838ad..c261a1bb 100644 --- a/packages/ui/src/components/tool-call/renderers/index.ts +++ b/packages/ui/src/components/tool-call/renderers/index.ts @@ -9,6 +9,7 @@ import { todoRenderer } from "./todo" import { webfetchRenderer } from "./webfetch" import { writeRenderer } from "./write" import { invalidRenderer } from "./invalid" +import { questionRenderer } from "./question" const TOOL_RENDERERS: ToolRenderer[] = [ bashRenderer, @@ -19,6 +20,7 @@ const TOOL_RENDERERS: ToolRenderer[] = [ webfetchRenderer, todoRenderer, taskRenderer, + questionRenderer, invalidRenderer, ] diff --git a/packages/ui/src/components/tool-call/renderers/question.tsx b/packages/ui/src/components/tool-call/renderers/question.tsx new file mode 100644 index 00000000..dbfd7e54 --- /dev/null +++ b/packages/ui/src/components/tool-call/renderers/question.tsx @@ -0,0 +1,17 @@ +import type { ToolRenderer } from "../types" + +export const questionRenderer: ToolRenderer = { + tools: ["question"], + getAction: () => "Awaiting answers...", + getTitle({ toolState }) { + const state = toolState() + if (!state) return "Questions" + if (state.status === "completed") return "Questions" + return "Asking questions" + }, + renderBody() { + // The question tool UI is rendered by ToolCall itself so + // it can share the same layout for pending/completed. + return null + }, +} diff --git a/packages/ui/src/components/tool-call/utils.ts b/packages/ui/src/components/tool-call/utils.ts index 229bd0f4..ac32ba60 100644 --- a/packages/ui/src/components/tool-call/utils.ts +++ b/packages/ui/src/components/tool-call/utils.ts @@ -45,6 +45,8 @@ export function getToolIcon(tool: string): string { case "todowrite": case "todoread": return "📋" + case "question": + return "❓" case "list": return "📁" case "patch": diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index 847ca660..98d32d81 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -63,6 +63,8 @@ type SSEEvent = | EventSessionIdle | { type: "permission.updated" | "permission.asked"; properties?: any } | { type: "permission.replied"; properties?: any } + | { type: "question.asked"; properties?: any } + | { type: "question.replied" | "question.rejected"; properties?: any } | EventLspUpdated | TuiToastEvent | BackgroundProcessUpdatedEvent @@ -144,6 +146,13 @@ class SSEManager { case "permission.replied": this.onPermissionReplied?.(instanceId, event as any) break + case "question.asked": + this.onQuestionAsked?.(instanceId, event as any) + break + case "question.replied": + case "question.rejected": + this.onQuestionAnswered?.(instanceId, event as any) + break case "lsp.updated": this.onLspUpdated?.(instanceId, event as EventLspUpdated) break @@ -178,6 +187,8 @@ class SSEManager { onSessionStatus?: (instanceId: string, event: EventSessionStatus) => 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 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 adaa4a8e..99a55fde 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -3,6 +3,8 @@ 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 } from "../types/permission" +import type { QuestionRequest } from "@opencode-ai/sdk/v2" +import { getQuestionSessionId } from "../types/question" import { requestData } from "../lib/opencode-api" import { sdkManager } from "../lib/sdk-manager" import { sseManager } from "../lib/sse-manager" @@ -18,10 +20,10 @@ import { } from "./sessions" import { fetchCommands, clearCommands } from "./commands" import { preferences } from "./preferences" -import { setSessionPendingPermission } from "./session-state" +import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state" import { setHasInstances } from "./ui" import { messageStoreBus } from "./message-v2/bus" -import { upsertPermissionV2, removePermissionV2 } from "./message-v2/bridge" +import { upsertPermissionV2, removePermissionV2, upsertQuestionV2, removeQuestionV2 } from "./message-v2/bridge" import { clearCacheForInstance } from "../lib/global-cache" import { getLogger } from "../lib/logger" import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata" @@ -34,11 +36,30 @@ const [activeInstanceId, setActiveInstanceId] = createSignal(null const [instanceLogs, setInstanceLogs] = createSignal>(new Map()) const [logStreamingState, setLogStreamingState] = createSignal>(new Map()) -// Permission queue management per instance +// Interruption queues (permissions + questions) per instance const [permissionQueues, setPermissionQueues] = createSignal>(new Map()) const [activePermissionId, setActivePermissionId] = createSignal>(new Map()) const permissionSessionCounts = new Map>() +const [questionQueues, setQuestionQueues] = createSignal>(new Map()) +const [activeQuestionId, setActiveQuestionId] = createSignal>(new Map()) +const questionSessionCounts = new Map>() +const questionEnqueuedAt = new Map() + +function ensureQuestionEnqueuedAt(request: QuestionRequest): number { + const existing = questionEnqueuedAt.get(request.id) + if (existing) return existing + const now = Date.now() + questionEnqueuedAt.set(request.id, now) + return now +} + +type InterruptionKind = "permission" | "question" + +type ActiveInterruption = { kind: InterruptionKind; id: string } | null + +const [activeInterruption, setActiveInterruption] = createSignal>(new Map()) + function syncHasInstancesFlag() { const readyExists = Array.from(instances().values()).some((instance) => instance.status === "ready") setHasInstances(readyExists) @@ -156,6 +177,38 @@ async function syncPendingPermissions(instanceId: string): Promise { } } +async function syncPendingQuestions(instanceId: string): Promise { + const instance = instances().get(instanceId) + if (!instance?.client) return + + try { + const remote = await requestData( + instance.client.question.list(), + "question.list", + ) + + const remoteIds = new Set(remote.map((item) => item.id)) + const local = getQuestionQueue(instanceId) + + // Remove any stale local requests missing from server. + for (const entry of local) { + if (!remoteIds.has(entry.id)) { + removeQuestionFromQueue(instanceId, entry.id) + removeQuestionV2(instanceId, entry.id) + } + } + + // Upsert all server-side pending questions. + for (const request of remote) { + ensureQuestionEnqueuedAt(request) + addQuestionToQueue(instanceId, request) + upsertQuestionV2(instanceId, request) + } + } catch (error) { + log.warn("Failed to sync pending questions", { instanceId, error }) + } +} + async function hydrateInstanceData(instanceId: string) { try { await fetchSessions(instanceId) @@ -166,6 +219,7 @@ async function hydrateInstanceData(instanceId: string) { if (!instance?.client) return await fetchCommands(instanceId, instance.client) await syncPendingPermissions(instanceId) + await syncPendingQuestions(instanceId) } catch (error) { log.error("Failed to fetch initial data", error) } @@ -327,6 +381,7 @@ function removeInstance(id: string) { removeLogContainer(id) clearCommands(id) clearPermissionQueue(id) + clearQuestionQueue(id) clearInstanceMetadata(id) if (activeInstanceId() === id) { @@ -429,6 +484,79 @@ function getPermissionQueueLength(instanceId: string): number { return getPermissionQueue(instanceId).length } +function getQuestionQueue(instanceId: string): QuestionRequest[] { + const queue = questionQueues().get(instanceId) + if (!queue) { + return [] + } + return queue +} + +function getQuestionQueueLength(instanceId: string): number { + return getQuestionQueue(instanceId).length +} + +function getQuestionEnqueuedAtForInstance(instanceId: string, requestId: string): number { + // Ensure we have a stable timestamp for sorting/ordering. + const queue = getQuestionQueue(instanceId) + const match = queue.find((q) => q.id === requestId) + if (match) { + return ensureQuestionEnqueuedAt(match) + } + return questionEnqueuedAt.get(requestId) ?? Date.now() +} + +function computeActiveInterruption(instanceId: string): ActiveInterruption { + const permissions = getPermissionQueue(instanceId) + const questions = getQuestionQueue(instanceId) + const firstPermission = permissions[0] + const firstQuestion = questions[0] + if (!firstPermission && !firstQuestion) return null + if (firstPermission && !firstQuestion) return { kind: "permission", id: firstPermission.id } + if (firstQuestion && !firstPermission) return { kind: "question", id: firstQuestion.id } + + const permTime = getPermissionCreatedAt(firstPermission) + const quesTime = firstQuestion ? ensureQuestionEnqueuedAt(firstQuestion) : Number.MAX_SAFE_INTEGER + if (permTime <= quesTime) return { kind: "permission", id: firstPermission.id } + return { kind: "question", id: firstQuestion!.id } +} + +function setActiveInterruptionForInstance(instanceId: string, nextActive: ActiveInterruption): void { + setActiveInterruption((prev) => { + const next = new Map(prev) + if (!nextActive) { + next.set(instanceId, null) + } else { + next.set(instanceId, nextActive) + } + return next + }) + + setActivePermissionId((prev) => { + const next = new Map(prev) + if (nextActive?.kind === "permission") { + next.set(instanceId, nextActive.id) + } else { + next.set(instanceId, null) + } + return next + }) + + setActiveQuestionId((prev) => { + const next = new Map(prev) + if (nextActive?.kind === "question") { + next.set(instanceId, nextActive.id) + } else { + next.set(instanceId, null) + } + return next + }) +} + +function recomputeActiveInterruption(instanceId: string): void { + setActiveInterruptionForInstance(instanceId, computeActiveInterruption(instanceId)) +} + function incrementSessionPendingCount(instanceId: string, sessionId: string): void { let sessionCounts = permissionSessionCounts.get(instanceId) if (!sessionCounts) { @@ -464,6 +592,41 @@ function clearSessionPendingCounts(instanceId: string): void { permissionSessionCounts.delete(instanceId) } +function incrementQuestionSessionPendingCount(instanceId: string, sessionId: string): void { + let sessionCounts = questionSessionCounts.get(instanceId) + if (!sessionCounts) { + sessionCounts = new Map() + questionSessionCounts.set(instanceId, sessionCounts) + } + const current = sessionCounts.get(sessionId) ?? 0 + sessionCounts.set(sessionId, current + 1) +} + +function decrementQuestionSessionPendingCount(instanceId: string, sessionId: string): number { + const sessionCounts = questionSessionCounts.get(instanceId) + if (!sessionCounts) return 0 + const current = sessionCounts.get(sessionId) ?? 0 + if (current <= 1) { + sessionCounts.delete(sessionId) + if (sessionCounts.size === 0) { + questionSessionCounts.delete(instanceId) + } + return 0 + } + const nextValue = current - 1 + sessionCounts.set(sessionId, nextValue) + return nextValue +} + +function clearQuestionSessionPendingCounts(instanceId: string): void { + const sessionCounts = questionSessionCounts.get(instanceId) + if (!sessionCounts) return + for (const sessionId of sessionCounts.keys()) { + setSessionPendingQuestion(instanceId, sessionId, false) + } + questionSessionCounts.delete(instanceId) +} + function addPermissionToQueue(instanceId: string, permission: PermissionRequestLike): void { let inserted = false @@ -485,13 +648,7 @@ function addPermissionToQueue(instanceId: string, permission: PermissionRequestL return } - setActivePermissionId((prev) => { - const next = new Map(prev) - if (!next.get(instanceId)) { - next.set(instanceId, permission.id) - } - return next - }) + recomputeActiveInterruption(instanceId) const sessionId = getPermissionSessionId(permission) if (sessionId) { @@ -526,15 +683,7 @@ function removePermissionFromQueue(instanceId: string, permissionId: string): vo const updatedQueue = getPermissionQueue(instanceId) - setActivePermissionId((prev) => { - const next = new Map(prev) - const activeId = next.get(instanceId) - if (activeId === permissionId) { - const nextPermission = updatedQueue.length > 0 ? (updatedQueue[0] as PermissionRequestLike) : null - next.set(instanceId, nextPermission?.id ?? null) - } - return next - }) + recomputeActiveInterruption(instanceId) const removed = removedPermission if (removed) { @@ -558,16 +707,140 @@ function clearPermissionQueue(instanceId: string): void { return next }) clearSessionPendingCounts(instanceId) + recomputeActiveInterruption(instanceId) } +function addQuestionToQueue(instanceId: string, request: QuestionRequest): void { + let inserted = false + setQuestionQueues((prev) => { + const next = new Map(prev) + const queue = next.get(instanceId) ?? ([] as QuestionRequest[]) -function setActivePermissionIdForInstance(instanceId: string, permissionId: string): void { - setActivePermissionId((prev) => { + if (queue.some((q) => q.id === request.id)) { + return next + } + + ensureQuestionEnqueuedAt(request) + const updatedQueue = [...queue, request].sort((a, b) => { + return ensureQuestionEnqueuedAt(a) - ensureQuestionEnqueuedAt(b) + }) + next.set(instanceId, updatedQueue) + inserted = true + return next + }) + + if (!inserted) { + return + } + + recomputeActiveInterruption(instanceId) + + const sessionId = getQuestionSessionId(request) + if (sessionId) { + incrementQuestionSessionPendingCount(instanceId, sessionId) + setSessionPendingQuestion(instanceId, sessionId, true) + } +} + +function removeQuestionFromQueue(instanceId: string, requestId: string): void { + const removedSessionId = getQuestionSessionId(getQuestionQueue(instanceId).find((q) => q.id === requestId)) + + setQuestionQueues((prev) => { + const next = new Map(prev) + const queue = next.get(instanceId) ?? ([] as QuestionRequest[]) + const filtered = queue.filter((item) => item.id !== requestId) + + if (filtered.length > 0) { + next.set(instanceId, filtered) + } else { + next.delete(instanceId) + } + return next + }) + + questionEnqueuedAt.delete(requestId) + recomputeActiveInterruption(instanceId) + + if (removedSessionId) { + const remaining = decrementQuestionSessionPendingCount(instanceId, removedSessionId) + setSessionPendingQuestion(instanceId, removedSessionId, remaining > 0) + } +} + +function clearQuestionQueue(instanceId: string): void { + for (const request of getQuestionQueue(instanceId)) { + questionEnqueuedAt.delete(request.id) + } + + setQuestionQueues((prev) => { const next = new Map(prev) - next.set(instanceId, permissionId) + next.delete(instanceId) return next }) + setActiveQuestionId((prev) => { + const next = new Map(prev) + next.delete(instanceId) + return next + }) + clearQuestionSessionPendingCounts(instanceId) + recomputeActiveInterruption(instanceId) +} + +function setActivePermissionIdForInstance(instanceId: string, permissionId: string): void { + setActiveInterruptionForInstance(instanceId, { kind: "permission", id: permissionId }) +} + +function setActiveQuestionIdForInstance(instanceId: string, requestId: string): void { + setActiveInterruptionForInstance(instanceId, { kind: "question", id: requestId }) +} + +async function sendQuestionReply( + instanceId: string, + _sessionId: string, + requestId: string, + answers: string[][], +): Promise { + const instance = instances().get(instanceId) + if (!instance?.client) { + throw new Error("Instance not ready") + } + + try { + await requestData( + instance.client.question.reply({ + requestID: requestId, + answers, + }), + "question.reply", + ) + + removeQuestionFromQueue(instanceId, requestId) + } catch (error) { + log.error("Failed to send question reply", error) + throw error + } +} + +async function sendQuestionReject(instanceId: string, _sessionId: string, requestId: string): Promise { + const instance = instances().get(instanceId) + if (!instance?.client) { + throw new Error("Instance not ready") + } + + try { + await requestData( + instance.client.question.reject({ + requestID: requestId, + }), + "question.reject", + ) + + removeQuestionFromQueue(instanceId, requestId) + } catch (error) { + log.error("Failed to send question reject", error) + throw error + } } async function sendPermissionResponse( @@ -655,7 +928,7 @@ export { getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming, - // Permission management + // Permission + question management permissionQueues, activePermissionId, getPermissionQueue, @@ -665,6 +938,18 @@ export { clearPermissionQueue, sendPermissionResponse, setActivePermissionIdForInstance, + questionQueues, + activeQuestionId, + activeInterruption, + getQuestionQueue, + getQuestionQueueLength, + getQuestionEnqueuedAtForInstance, + addQuestionToQueue, + removeQuestionFromQueue, + clearQuestionQueue, + sendQuestionReply, + sendQuestionReject, + setActiveQuestionIdForInstance, disconnectedInstance, acknowledgeDisconnectedInstance, fetchLspStatus, diff --git a/packages/ui/src/stores/message-v2/bridge.ts b/packages/ui/src/stores/message-v2/bridge.ts index a2a40abf..65e20bd8 100644 --- a/packages/ui/src/stores/message-v2/bridge.ts +++ b/packages/ui/src/stores/message-v2/bridge.ts @@ -1,5 +1,7 @@ import type { PermissionRequestLike } from "../../types/permission" import { getPermissionCallId, getPermissionMessageId } from "../../types/permission" +import type { QuestionRequest } from "../../types/question" +import { getQuestionCallId, getQuestionMessageId } from "../../types/question" import type { Message, MessageInfo, ClientPart } from "../../types/message" import type { Session } from "../../types/session" import { messageStoreBus } from "./bus" @@ -192,6 +194,65 @@ export function reconcilePendingPermissionsV2(instanceId: string, sessionId?: st } } +function extractQuestionMessageId(request: QuestionRequest): string | undefined { + return getQuestionMessageId(request) +} + +function extractQuestionCallId(request: QuestionRequest): string | undefined { + return getQuestionCallId(request) +} + +export function upsertQuestionV2(instanceId: string, request: QuestionRequest): void { + if (!request) return + const store = messageStoreBus.getOrCreate(instanceId) + const messageId = extractQuestionMessageId(request) + let partId: string | undefined = undefined + const callId = extractQuestionCallId(request) + if (callId) { + partId = resolvePartIdFromCallId(store, messageId, callId) + } + store.upsertQuestion({ + request, + messageId, + partId, + enqueuedAt: (request as any).time?.created ?? Date.now(), + }) +} + +export function reconcilePendingQuestionsV2(instanceId: string, sessionId?: string): void { + const store = messageStoreBus.getOrCreate(instanceId) + const pending = store.state.questions.queue + if (!pending || pending.length === 0) return + + for (const entry of pending) { + if (!entry || entry.partId) continue + const request = entry.request + if (!request) continue + + const questionSessionId = request.sessionID + if (sessionId && questionSessionId && questionSessionId !== sessionId) { + continue + } + + const messageId = entry.messageId ?? extractQuestionMessageId(request) + const callId = extractQuestionCallId(request) + const resolvedPartId = resolvePartIdFromCallId(store, messageId, callId) + if (!resolvedPartId) continue + + store.upsertQuestion({ + ...entry, + messageId, + partId: resolvedPartId, + }) + } +} + +export function removeQuestionV2(instanceId: string, requestId: string): void { + if (!requestId) return + const store = messageStoreBus.getOrCreate(instanceId) + store.removeQuestion(requestId) +} + export function removePermissionV2(instanceId: string, permissionId: string): void { if (!permissionId) return const store = messageStoreBus.getOrCreate(instanceId) diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index dc073db6..0ebc3c8b 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -12,6 +12,7 @@ import type { PartUpdateInput, PendingPartEntry, PermissionEntry, + QuestionEntry, ReplaceMessageIdOptions, ScrollSnapshot, SessionRecord, @@ -40,6 +41,11 @@ function createInitialState(instanceId: string): InstanceMessageState { active: null, byMessage: {}, }, + questions: { + queue: [], + active: null, + byMessage: {}, + }, usage: {}, scrollState: {}, latestTodos: {}, @@ -193,6 +199,9 @@ export interface InstanceMessageStore { upsertPermission: (entry: PermissionEntry) => void removePermission: (permissionId: string) => void getPermissionState: (messageId?: string, partId?: string) => { entry: PermissionEntry; active: boolean } | null + upsertQuestion: (entry: QuestionEntry) => void + removeQuestion: (requestId: string) => void + getQuestionState: (messageId?: string, partId?: string) => { entry: QuestionEntry; active: boolean } | null setSessionRevert: (sessionId: string, revert?: SessionRecord["revert"] | null) => void getSessionRevert: (sessionId: string) => SessionRecord["revert"] | undefined | null rebuildUsage: (sessionId: string, infos: Iterable) => void @@ -757,6 +766,18 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt }) } + const questionMap = state.questions.byMessage[options.oldId] + if (questionMap) { + setState("questions", "byMessage", options.newId, questionMap) + setState("questions", (prev) => { + const next = { ...prev } + const nextByMessage = { ...next.byMessage } + delete nextByMessage[options.oldId] + next.byMessage = nextByMessage + return next + }) + } + const pending = state.pendingParts[options.oldId] if (pending) { setState("pendingParts", options.newId, pending) @@ -832,6 +853,60 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return { entry, active } } + function upsertQuestion(entry: QuestionEntry) { + const messageKey = entry.messageId ?? "__global__" + const partKey = entry.partId ?? entry.request?.id ?? "__global__" + + setState( + "questions", + produce((draft) => { + draft.byMessage[messageKey] = draft.byMessage[messageKey] ?? {} + draft.byMessage[messageKey][partKey] = entry + const existingIndex = draft.queue.findIndex((item) => item.request.id === entry.request.id) + if (existingIndex === -1) { + draft.queue.push(entry) + } else { + draft.queue[existingIndex] = entry + } + if (!draft.active || draft.active.request.id === entry.request.id) { + draft.active = entry + } + }), + ) + } + + function removeQuestion(requestId: string) { + setState( + "questions", + produce((draft) => { + draft.queue = draft.queue.filter((item) => item.request.id !== requestId) + if (draft.active?.request.id === requestId) { + draft.active = draft.queue[0] ?? null + } + Object.keys(draft.byMessage).forEach((messageKey) => { + const partEntries = draft.byMessage[messageKey] + Object.keys(partEntries).forEach((partKey) => { + if (partEntries[partKey].request.id === requestId) { + delete partEntries[partKey] + } + }) + if (Object.keys(partEntries).length === 0) { + delete draft.byMessage[messageKey] + } + }) + }), + ) + } + + function getQuestionState(messageId?: string, partId?: string) { + const messageKey = messageId ?? "__global__" + const partKey = partId ?? "__global__" + const entry = state.questions.byMessage[messageKey]?.[partKey] + if (!entry) return null + const active = state.questions.active?.request.id === entry.request.id + return { entry, active } + } + function pruneMessagesAfterRevert(sessionId: string, revertMessageId: string) { const session = state.sessions[sessionId] if (!session) return @@ -873,6 +948,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return next }) + setState("questions", "byMessage", (prev) => { + const next = { ...prev } + removedIds.forEach((id) => { + if (next[id]) delete next[id] + }) + return next + }) + withUsageState(sessionId, (draft) => { removedIds.forEach((id) => removeUsageEntry(draft, id)) }) @@ -948,6 +1031,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return next }) + setState("questions", "byMessage", (prev) => { + const next = { ...prev } + messageIds.forEach((id) => { + if (next[id]) delete next[id] + }) + return next + }) + setState("usage", (prev) => { const next = { ...prev } delete next[sessionId] @@ -1012,9 +1103,13 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt replaceMessageId, setMessageInfo, getMessageInfo, - upsertPermission, - removePermission, - getPermissionState, + upsertPermission, + removePermission, + getPermissionState, + upsertQuestion, + removeQuestion, + getQuestionState, + setSessionRevert, getSessionRevert, rebuildUsage, diff --git a/packages/ui/src/stores/message-v2/types.ts b/packages/ui/src/stores/message-v2/types.ts index 7a52a7c6..4d41cabc 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 { QuestionRequest } from "../../types/question" export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error" export type MessageRole = "user" | "assistant" @@ -59,6 +60,19 @@ export interface InstancePermissionState { byMessage: Record> } +export interface QuestionEntry { + request: QuestionRequest + messageId?: string + partId?: string + enqueuedAt: number +} + +export interface InstanceQuestionState { + queue: QuestionEntry[] + active: QuestionEntry | null + byMessage: Record> +} + export interface ScrollSnapshot { scrollTop: number atBottom: boolean @@ -103,6 +117,7 @@ export interface InstanceMessageState { pendingParts: Record sessionRevisions: Record permissions: InstancePermissionState + questions: InstanceQuestionState usage: Record scrollState: Record latestTodos: Record diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 35b9657c..a2c53276 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -27,7 +27,7 @@ import { import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models" import { normalizeMessagePart } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" -import { seedSessionMessagesV2, reconcilePendingPermissionsV2 } from "./message-v2/bridge" +import { seedSessionMessagesV2, reconcilePendingPermissionsV2, reconcilePendingQuestionsV2 } from "./message-v2/bridge" import { messageStoreBus } from "./message-v2/bus" import { clearCacheForSession } from "../lib/global-cache" import { getLogger } from "../lib/logger" @@ -649,7 +649,9 @@ async function loadMessages(instanceId: string, sessionId: string, force = false // Permissions can be hydrated before messages/tool parts exist in the store. // After message hydration, try to attach any pending permissions to tool-call part ids. reconcilePendingPermissionsV2(instanceId, sessionId) - + reconcilePendingQuestionsV2(instanceId, sessionId) + + } catch (error) { log.error("Failed to load messages:", error) diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 2f62e0bf..ab658e7a 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -18,8 +18,17 @@ import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" import { getPermissionId, getPermissionKind, getRequestIdFromPermissionReply } from "../types/permission" import type { PermissionReplyEventPropertiesLike, PermissionRequestLike } from "../types/permission" +import { getQuestionId, getRequestIdFromQuestionReply } from "../types/question" +import type { QuestionRequest } from "../types/question" +import type { EventQuestionReplied, EventQuestionRejected } from "@opencode-ai/sdk/v2" import { showToastNotification, ToastVariant } from "../lib/notifications" -import { instances, addPermissionToQueue, removePermissionFromQueue } from "./instances" +import { + instances, + addPermissionToQueue, + removePermissionFromQueue, + addQuestionToQueue, + removeQuestionFromQueue, +} from "./instances" import { showAlertDialog } from "./alerts" import { createClientSession, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session" import { sessions, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" @@ -32,9 +41,11 @@ import { replaceMessageIdV2, upsertMessageInfoV2, upsertPermissionV2, + upsertQuestionV2, removeMessagePartV2, removeMessageV2, removePermissionV2, + removeQuestionV2, setSessionRevertV2, } from "./message-v2/bridge" import { messageStoreBus } from "./message-v2/bus" @@ -102,6 +113,7 @@ async function fetchSessionInfo(instanceId: string, sessionId: string): Promise< model: existing?.model ?? fetched.model, status: existing?.status === "compacting" ? "compacting" : fetched.status, pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission, + pendingQuestion: existing?.pendingQuestion ?? false, } instanceSessions.set(sessionId, merged) next.set(instanceId, instanceSessions) @@ -469,12 +481,36 @@ function handlePermissionReplied(instanceId: string, event: { type: string; prop removePermissionV2(instanceId, requestId) } +function handleQuestionAsked(instanceId: string, event: { type: string; properties?: QuestionRequest } | any): void { + const request = event?.properties as QuestionRequest | undefined + if (!request) return + + log.info(`[SSE] Question asked: ${getQuestionId(request)}`) + addQuestionToQueue(instanceId, request) + upsertQuestionV2(instanceId, request) +} + +function handleQuestionAnswered( + instanceId: string, + event: { type: string; properties?: EventQuestionReplied["properties"] | EventQuestionRejected["properties"] } | any, +): void { + const properties = event?.properties as EventQuestionReplied["properties"] | EventQuestionRejected["properties"] | undefined + const requestId = getRequestIdFromQuestionReply(properties) + if (!requestId) return + + log.info(`[SSE] Question answered: ${requestId}`) + removeQuestionFromQueue(instanceId, requestId) + removeQuestionV2(instanceId, requestId) +} + export { handleMessagePartRemoved, handleMessageRemoved, handleMessageUpdate, handlePermissionReplied, handlePermissionUpdated, + handleQuestionAsked, + handleQuestionAnswered, handleSessionCompacted, handleSessionError, handleSessionIdle, diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index ce36090d..fbf2a982 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -58,8 +58,8 @@ type InstanceIndicatorCounts = { const [instanceIndicatorCounts, setInstanceIndicatorCounts] = createSignal>(new Map()) -function getIndicatorBucket(session: Pick): InstanceSessionIndicatorStatus | "idle" { - if (session.pendingPermission) { +function getIndicatorBucket(session: Pick): InstanceSessionIndicatorStatus | "idle" { + if (session.pendingPermission || session.pendingQuestion) { return "permission" } const status = session.status ?? "idle" @@ -126,7 +126,7 @@ function recomputeIndicatorCounts(instanceId: string, instanceSessions: Map { + if (session.pendingQuestion === pending) return false + session.pendingQuestion = pending + }) +} + function setActiveSession(instanceId: string, sessionId: string): void { setActiveSessionId((prev) => { const next = new Map(prev) @@ -660,6 +667,7 @@ export { pruneDraftPrompts, withSession, setSessionPendingPermission, + setSessionPendingQuestion, setSessionStatus, setActiveSession, diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index 286f23dc..48f5298c 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -61,6 +61,8 @@ import { handleMessageUpdate, handlePermissionReplied, handlePermissionUpdated, + handleQuestionAnswered, + handleQuestionAsked, handleSessionCompacted, handleSessionError, handleSessionIdle, @@ -81,6 +83,8 @@ sseManager.onSessionStatus = handleSessionStatus sseManager.onTuiToast = handleTuiToast sseManager.onPermissionUpdated = handlePermissionUpdated sseManager.onPermissionReplied = handlePermissionReplied +sseManager.onQuestionAsked = handleQuestionAsked +sseManager.onQuestionAnswered = handleQuestionAnswered export { abortSession, diff --git a/packages/ui/src/types/question.ts b/packages/ui/src/types/question.ts new file mode 100644 index 00000000..02291d5d --- /dev/null +++ b/packages/ui/src/types/question.ts @@ -0,0 +1,34 @@ +import type { + QuestionRequest, + EventQuestionReplied, + EventQuestionRejected, +} from "@opencode-ai/sdk/v2" + +export type { QuestionRequest } + +export function getQuestionId(question: QuestionRequest | null | undefined): string { + return question?.id ?? "" +} + +export function getQuestionSessionId(question: QuestionRequest | null | undefined): string | undefined { + return question?.sessionID +} + +export function getQuestionMessageId(question: QuestionRequest | null | undefined): string | undefined { + return question?.tool?.messageID +} + +export function getQuestionCallId(question: QuestionRequest | null | undefined): string | undefined { + return question?.tool?.callID +} + +export function getQuestionCreatedAt(question: QuestionRequest | null | undefined): number { + // v2 schema doesn't include created time; best effort for ordering. + return Date.now() +} + +export function getRequestIdFromQuestionReply( + properties: EventQuestionReplied["properties"] | EventQuestionRejected["properties"] | null | undefined, +): string | undefined { + return properties?.requestID +} diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index 102e2285..6ed17571 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -37,6 +37,7 @@ export interface Session } version: string // Include version from SDK Session pendingPermission?: boolean // Indicates if session is waiting on user permission + pendingQuestion?: boolean // Indicates if session is waiting on user input status: SessionStatus // Single source of truth for session status } From f06359a1fc83ed0b3f52152a53e41802d62bd41e Mon Sep 17 00:00:00 2001 From: bizzkoot Date: Sun, 11 Jan 2026 20:04:25 +0800 Subject: [PATCH 02/15] feat: add expand state signal and height calculation helpers --- packages/ui/src/components/prompt-input.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index 6bd83631..792b7880 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -46,10 +46,28 @@ export default function PromptInput(props: PromptInputProps) { const [pasteCount, setPasteCount] = createSignal(0) const [imageCount, setImageCount] = createSignal(0) const [mode, setMode] = createSignal<"normal" | "shell">("normal") + const [expandState, setExpandState] = createSignal<"normal" | "fifty" | "eighty">("normal") const SELECTION_INSERT_MAX_LENGTH = 2000 let textareaRef: HTMLTextAreaElement | undefined let containerRef: HTMLDivElement | undefined + const calculateContainerHeight = () => { + if (!containerRef) return 0 + const rect = containerRef.getBoundingClientRect() + const root = containerRef.closest(".session-view") + if (!root) return 0 + const rootRect = root.getBoundingClientRect() + return rootRect.height - rect.top + } + + const getExpandedHeight = (): string => { + const state = expandState() + if (state === "normal") return "auto" + const containerHeight = calculateContainerHeight() + if (state === "fifty") return `${containerHeight * 0.5}px` + return `${containerHeight * 0.8}px` + } + From 1122c196487377afdd2a5008029107df9add9d87 Mon Sep 17 00:00:00 2001 From: bizzkoot Date: Sun, 11 Jan 2026 20:05:16 +0800 Subject: [PATCH 03/15] feat: create ExpandButton component with click/double-click logic --- packages/ui/src/components/expand-button.tsx | 72 ++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 packages/ui/src/components/expand-button.tsx diff --git a/packages/ui/src/components/expand-button.tsx b/packages/ui/src/components/expand-button.tsx new file mode 100644 index 00000000..3efe8cfb --- /dev/null +++ b/packages/ui/src/components/expand-button.tsx @@ -0,0 +1,72 @@ +import { createSignal, Show } from "solid-js" +import { Maximize2, Minimize2 } from "lucide-solid" + +interface ExpandButtonProps { + expandState: () => "normal" | "fifty" | "eighty" + onToggleExpand: (nextState: "normal" | "fifty" | "eighty") => void +} + +export default function ExpandButton(props: ExpandButtonProps) { + const [clickTime, setClickTime] = createSignal(0) + const DOUBLE_CLICK_THRESHOLD = 300 + + function handleClick() { + const now = Date.now() + const lastClick = clickTime() + const isDoubleClick = now - lastClick < DOUBLE_CLICK_THRESHOLD + + setClickTime(now) + + const current = props.expandState() + + if (isDoubleClick) { + // Double click behavior + if (current === "normal") { + props.onToggleExpand("fifty") + } else if (current === "fifty") { + props.onToggleExpand("eighty") + } else { + props.onToggleExpand("normal") + } + } else { + // Single click behavior + if (current === "normal") { + props.onToggleExpand("fifty") + } else { + props.onToggleExpand("normal") + } + } + + // Reset click timer after threshold + setTimeout(() => setClickTime(0), DOUBLE_CLICK_THRESHOLD) + } + + const getTooltip = () => { + const current = props.expandState() + if (current === "normal") { + return "Click to expand (50%) • Double-click to expand further (80%)" + } else if (current === "fifty") { + return "Double-click to expand to 80% • Click to minimize" + } else { + return "Click to minimize • Double-click to expand to 50%" + } + } + + return ( + + ) +} From 0fefff3b0a9ca1cbfd13be4c9b6cb5b3394427fd Mon Sep 17 00:00:00 2001 From: bizzkoot Date: Sun, 11 Jan 2026 20:07:48 +0800 Subject: [PATCH 04/15] feat: integrate ExpandButton and apply dynamic height to textarea --- packages/ui/src/components/prompt-input.tsx | 22 +++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index 792b7880..78431371 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -1,6 +1,7 @@ import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack } from "solid-js" import { ArrowBigUp, ArrowBigDown } from "lucide-solid" import UnifiedPicker from "./unified-picker" +import ExpandButton from "./expand-button" import { addToHistory, getHistory } from "../stores/message-history" import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" @@ -719,7 +720,13 @@ export default function PromptInput(props: PromptInputProps) { if (!props.onAbortSession || !props.isSessionBusy) return void props.onAbortSession() } - + + function handleExpandToggle(nextState: "normal" | "fifty" | "eighty") { + setExpandState(nextState) + // Keep focus on textarea + textareaRef?.focus() + } + function handleInput(e: Event) { const target = e.target as HTMLTextAreaElement @@ -1197,7 +1204,12 @@ export default function PromptInput(props: PromptInputProps) { onBlur={() => setIsFocused(false)} disabled={props.disabled} rows={4} - style={attachments().length > 0 ? { "padding-top": "8px" } : {}} + style={{ + "padding-top": attachments().length > 0 ? "8px" : "0", + "height": getExpandedHeight(), + "overflow-y": expandState() !== "normal" ? "auto" : "visible", + "transition": "height 0.25s ease", + }} spellcheck={false} autocorrect="off" autoCapitalize="off" @@ -1227,6 +1239,12 @@ export default function PromptInput(props: PromptInputProps) {
+
+ +
Date: Sun, 11 Jan 2026 20:09:13 +0800 Subject: [PATCH 05/15] style: add expand button positioning and styles --- .../ui/src/styles/messaging/prompt-input.css | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/ui/src/styles/messaging/prompt-input.css b/packages/ui/src/styles/messaging/prompt-input.css index 8a7baaac..efca4f8d 100644 --- a/packages/ui/src/styles/messaging/prompt-input.css +++ b/packages/ui/src/styles/messaging/prompt-input.css @@ -27,6 +27,7 @@ flex: 1 1 auto; height: 100%; min-width: 0; + transition: height 0.25s ease; } .prompt-input-field { @@ -105,6 +106,40 @@ cursor: not-allowed; } +.prompt-expand-top { + position: absolute; + top: 0.3rem; + right: 3.5rem; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; +} + +.prompt-expand-button { + @apply w-9 h-9 flex items-center justify-center rounded-md; + color: var(--text-muted); + background-color: rgba(15, 23, 42, 0.04); + transition: background-color 0.15s ease, color 0.15s ease; + padding: 0; +} + +.prompt-expand-button:hover:not(:disabled) { + background-color: var(--surface-secondary); + color: var(--text-primary); +} + +.prompt-expand-button:active:not(:disabled) { + background-color: var(--accent-primary); + color: var(--text-inverted); + transform: scale(0.95); +} + +.prompt-expand-button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + .prompt-overlay-text { display: inline-flex; align-items: center; From bf9cef4cd5190a87ae3ad60f8edc5ae0d49bacc6 Mon Sep 17 00:00:00 2001 From: bizzkoot Date: Sun, 11 Jan 2026 20:17:19 +0800 Subject: [PATCH 06/15] docs: add expand chat input implementation plan --- docs/plans/2025-01-11-expand-chat-input.md | 521 +++++++++++++++++++++ 1 file changed, 521 insertions(+) create mode 100644 docs/plans/2025-01-11-expand-chat-input.md diff --git a/docs/plans/2025-01-11-expand-chat-input.md b/docs/plans/2025-01-11-expand-chat-input.md new file mode 100644 index 00000000..9e836544 --- /dev/null +++ b/docs/plans/2025-01-11-expand-chat-input.md @@ -0,0 +1,521 @@ +# Expand Chat Input Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add an expand/minimize button to the chat input section that allows users to expand the textarea to 50% or 80% of the chat window height with single/double-click, maintaining Gemini-like UX. + +**Architecture:** Add a new state signal `expandState` to track 3 states (normal/50%/80%), create an `ExpandButton` component positioned alongside history buttons, calculate dynamic heights based on parent container, apply smooth CSS transitions, and add comprehensive hover tooltips explaining both click behaviors. + +**Tech Stack:** SolidJS (signals/effects), lucide-solid icons (Maximize2/Minimize2), CSS transitions, existing button styling patterns from prompt-input.css + +--- + +## Task 1: Add expand state signal and types + +**Files:** +- Modify: `packages/ui/src/components/prompt-input.tsx:33-52` + +**Step 1: Understand current state structure** + +Read lines 33-52 to see how signals like `prompt`, `mode`, `showPicker` are created. + +Expected: See `createSignal` patterns for boolean and string states. + +**Step 2: Add expand state signal after line 47** + +In `packages/ui/src/components/prompt-input.tsx`, after line 47 (`const [mode, setMode] = createSignal<"normal" | "shell">("normal")`), add: + +```typescript +const [expandState, setExpandState] = createSignal<"normal" | "fifty" | "eighty">("normal") +``` + +**Step 3: Add height calculation memos after line 58** + +Add these utility functions right after the signal definitions (before the createEffect on line 59): + +```typescript +const calculateContainerHeight = () => { + if (!containerRef) return 0 + const rect = containerRef.getBoundingClientRect() + const root = containerRef.closest(".session-view") + if (!root) return 0 + const rootRect = root.getBoundingClientRect() + return rootRect.height - rect.top +} + +const getExpandedHeight = (): string => { + const state = expandState() + if (state === "normal") return "auto" + const containerHeight = calculateContainerHeight() + if (state === "fifty") return `${containerHeight * 0.5}px` + return `${containerHeight * 0.8}px` +} +``` + +**Step 4: Verify no syntax errors** + +Run in terminal: +```bash +cd packages/ui && npm run type-check +``` + +Expected: No TypeScript errors in prompt-input.tsx. + +**Step 5: Commit** + +```bash +git add packages/ui/src/components/prompt-input.tsx +git commit -m "feat: add expand state signal and height calculation helpers" +``` + +--- + +## Task 2: Create ExpandButton component + +**Files:** +- Create: `packages/ui/src/components/expand-button.tsx` + +**Step 1: Create the component file** + +Create `packages/ui/src/components/expand-button.tsx` with this content: + +```typescript +import { createSignal, Show } from "solid-js" +import { Maximize2, Minimize2 } from "lucide-solid" + +interface ExpandButtonProps { + expandState: () => "normal" | "fifty" | "eighty" + onToggleExpand: (nextState: "normal" | "fifty" | "eighty") => void +} + +export default function ExpandButton(props: ExpandButtonProps) { + const [clickTime, setClickTime] = createSignal(0) + const DOUBLE_CLICK_THRESHOLD = 300 + + function handleClick() { + const now = Date.now() + const lastClick = clickTime() + const isDoubleClick = now - lastClick < DOUBLE_CLICK_THRESHOLD + + setClickTime(now) + + const current = props.expandState() + + if (isDoubleClick) { + // Double click behavior + if (current === "normal") { + props.onToggleExpand("fifty") + } else if (current === "fifty") { + props.onToggleExpand("eighty") + } else { + props.onToggleExpand("normal") + } + } else { + // Single click behavior + if (current === "normal") { + props.onToggleExpand("fifty") + } else { + props.onToggleExpand("normal") + } + } + + // Reset click timer after threshold + setTimeout(() => setClickTime(0), DOUBLE_CLICK_THRESHOLD) + } + + const getTooltip = () => { + const current = props.expandState() + if (current === "normal") { + return "Click to expand (50%) • Double-click to expand further (80%)" + } else if (current === "fifty") { + return "Double-click to expand to 80% • Click to minimize" + } else { + return "Click to minimize • Double-click to expand to 50%" + } + } + + return ( + + ) +} +``` + +**Step 2: Verify component syntax** + +Run in terminal: +```bash +cd packages/ui && npm run type-check +``` + +Expected: No TypeScript errors in expand-button.tsx. + +**Step 3: Commit** + +```bash +git add packages/ui/src/components/expand-button.tsx +git commit -m "feat: create ExpandButton component with click/double-click logic" +``` + +--- + +## Task 3: Integrate ExpandButton into PromptInput + +**Files:** +- Modify: `packages/ui/src/components/prompt-input.tsx:1-3, 1040-1250` + +**Step 1: Add import for ExpandButton** + +In `packages/ui/src/components/prompt-input.tsx`, at line 1 after the existing imports, add: + +```typescript +import ExpandButton from "./expand-button" +``` + +**Step 2: Add expand handler function** + +After the `handleAbort` function (around line 703), add: + +```typescript +function handleExpandToggle(nextState: "normal" | "fifty" | "eighty") { + setExpandState(nextState) + // Keep focus on textarea + textareaRef?.focus() +} +``` + +**Step 3: Render ExpandButton in JSX** + +Find the section where history buttons are rendered (around line 1188-1211). Right before the closing `` tag of the history buttons, add the ExpandButton: + +```typescript + +
+ +
+
+``` + +Wait - actually, the expand button should always show (not conditionally with history). Let me correct: + +Replace the above with - add this RIGHT AFTER the `` closing tag for history buttons (after line 1211): + +```typescript +
+ +
+``` + +**Step 4: Apply dynamic height to textarea** + +Find the textarea element (around line 1166-1187). Modify the style binding on the textarea to include dynamic height: + +Change from: +```typescript +style={attachments().length > 0 ? { "padding-top": "8px" } : {}} +``` + +To: +```typescript +style={{ + "padding-top": attachments().length > 0 ? "8px" : "0", + "height": getExpandedHeight(), + "overflow-y": expandState() !== "normal" ? "auto" : "visible", + "transition": "height 0.25s ease", +}} +``` + +**Step 5: Verify no errors** + +Run in terminal: +```bash +cd packages/ui && npm run type-check +``` + +Expected: No TypeScript errors. + +**Step 6: Commit** + +```bash +git add packages/ui/src/components/prompt-input.tsx +git commit -m "feat: integrate ExpandButton and apply dynamic height to textarea" +``` + +--- + +## Task 4: Add CSS styles for expand button positioning + +**Files:** +- Modify: `packages/ui/src/styles/messaging/prompt-input.css:72-107` + +**Step 1: Add prompt-expand-top styles** + +After the `.prompt-history-bottom` rule (around line 88), add: + +```css +.prompt-expand-top { + position: absolute; + top: 0.3rem; + right: 3.5rem; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; +} + +.prompt-expand-button { + @apply w-9 h-9 flex items-center justify-center rounded-md; + color: var(--text-muted); + background-color: rgba(15, 23, 42, 0.04); + transition: background-color 0.15s ease, color 0.15s ease; + padding: 0; +} + +.prompt-expand-button:hover:not(:disabled) { + background-color: var(--surface-secondary); + color: var(--text-primary); +} + +.prompt-expand-button:active:not(:disabled) { + background-color: var(--accent-primary); + color: var(--text-inverted); + transform: scale(0.95); +} + +.prompt-expand-button:disabled { + opacity: 0.4; + cursor: not-allowed; +} +``` + +**Step 2: Adjust prompt-input-field-container for expanded states** + +After the `.prompt-input-field` rule (around line 37), add: + +```css +.prompt-input-field-container { + position: relative; + width: 100%; + min-height: 56px; + flex: 1 1 auto; + height: 100%; + min-width: 0; + transition: height 0.25s ease; +} +``` + +Actually, the min-height should remain. Let me provide the corrected version - modify the existing `.prompt-input-field-container` rule (lines 23-30) to add the transition: + +Change line 24 from: +```css +.prompt-input-field-container { + position: relative; + width: 100%; + min-height: 56px; + flex: 1 1 auto; + height: 100%; + min-width: 0; +} +``` + +To: +```css +.prompt-input-field-container { + position: relative; + width: 100%; + min-height: 56px; + flex: 1 1 auto; + height: 100%; + min-width: 0; + transition: height 0.25s ease; +} +``` + +**Step 3: Verify CSS syntax** + +Run in terminal: +```bash +npm run build --workspace @neuralnomads/codenomad-ui +``` + +Expected: Build succeeds with no CSS errors. + +**Step 4: Commit** + +```bash +git add packages/ui/src/styles/messaging/prompt-input.css +git commit -m "style: add expand button positioning and styles" +``` + +--- + +## Task 5: Test expand/minimize functionality in dev + +**Files:** +- No new files + +**Step 1: Start dev server** + +Run in terminal: +```bash +npm run dev --workspace @neuralnomads/codenomad-ui +``` + +Wait for the server to start and print the local URL. + +**Step 2: Test expand button visibility** + +- Open the dev app in browser +- Create/open a session +- Verify expand button appears in top-right corner of input area, left of arrow buttons +- Verify button shows Maximize2 icon + +**Step 3: Test single-click to 50% expand** + +- Click the expand button once +- Verify textarea height increases to ~50% of chat window +- Verify icon changes to Minimize2 +- Verify tooltip text updates + +**Step 4: Test double-click from normal to 80% expand** + +- Click expand button twice rapidly (within 300ms) +- Verify textarea height jumps to ~80% of chat window +- Verify scrollbar appears if content is long +- Type some text to verify scrolling works + +**Step 5: Test minimize from 80%** + +- Click expand button once +- Verify textarea collapses back to normal height (56px min-height) +- Verify icon changes back to Maximize2 + +**Step 6: Test expand button with 50% state** + +- Single-click expand button → should go to 50% +- Double-click expand button → should go to 80% +- Verify tooltip updates to "Double-click to expand to 80% • Click to minimize" + +**Step 7: Verify arrow buttons unchanged** + +- Verify up/down arrow buttons still visible and functional +- Verify Stop/Start buttons position unchanged +- Verify all buttons maintain original sizes + +**Step 8: Verify smooth transitions** + +- Watch height changes - should see smooth 250ms transition +- No jarring jumps or layout shifts + +**Expected Results:** +- All expand/minimize transitions work as specified +- No overlapping buttons +- Scrollbar appears correctly when needed +- Tooltips are comprehensive and helpful + +If any test fails, return to the task that needs fixing before continuing. + +**Step 9: Manual testing complete** + +Once all tests pass, stop the dev server: +```bash +Ctrl+C +``` + +**Step 10: Commit test verification** + +```bash +git add -A +git commit -m "test: verify expand button functionality and UX" +``` + +--- + +## Task 6: Final cleanup and verification + +**Files:** +- Review: `packages/ui/src/components/prompt-input.tsx` +- Review: `packages/ui/src/components/expand-button.tsx` +- Review: `packages/ui/src/styles/messaging/prompt-input.css` + +**Step 1: Verify no console errors** + +Restart dev server and check browser console: +```bash +npm run dev --workspace @neuralnomads/codenomad-ui +``` + +Open DevTools console - should show no warnings or errors related to expand button. + +**Step 2: Check responsive behavior** + +Resize browser window to different sizes - verify button positioning remains correct at all sizes. + +**Step 3: Verify with attachments** + +- Add an attachment (paste an image or large text) +- Expand the input +- Verify attachment chips display correctly above expanded textarea + +**Step 4: Test with history buttons** + +- Navigate through prompt history with arrow buttons while expanded +- Verify history navigation still works +- Verify expand button doesn't interfere with history buttons + +**Step 5: Build for production** + +```bash +npm run build --workspace @neuralnomads/codenomad-ui +``` + +Expected: Build succeeds with no errors. + +**Step 6: Final commit** + +```bash +git add -A +git commit -m "feat: complete expand chat input feature with full UX" +``` + +--- + +## Summary + +This plan implements the expand chat input feature across 6 tasks: + +1. ✅ Add expand state signal and height helpers +2. ✅ Create ExpandButton component with click/double-click logic +3. ✅ Integrate ExpandButton and apply dynamic heights +4. ✅ Add CSS styles for button and positioning +5. ✅ Test all functionality in dev +6. ✅ Final verification and build + +**Estimated time:** 45-60 minutes total + +**Key points:** +- Expand button positioned top-right, left of arrow buttons +- 3 states: normal (56px min) → 50% height → 80% height +- Single-click advances one state, double-click skips to 80% +- Smooth 250ms transitions with scrollbar support +- Comprehensive hover tooltips explain all behaviors +- Maintains all existing button positions and sizes From 0d8a844af886b7ac94bab83916860cffe40a665b Mon Sep 17 00:00:00 2001 From: bizzkoot Date: Sun, 11 Jan 2026 21:59:28 +0800 Subject: [PATCH 07/15] feat: implement expandable chat input with double-click detection and gradient tooltip - Add expand button with Maximize2/Minimize2 icons - Implement 3-state height management (normal/50%/80%) - Smart double-click detection with 300ms delay - Height calculation based on session-view - 200px message space - Custom CSS tooltip with dark gradient background and backdrop blur - Send button anchored at bottom via margin-top: auto - Smooth CSS transitions throughout - Double-click at 80% now reduces to 50% (not normal) - Removed all debug console.log statements --- packages/ui/src/components/expand-button.tsx | 43 +++++++---- packages/ui/src/components/prompt-input.tsx | 73 ++++++++++++------- .../ui/src/styles/messaging/prompt-input.css | 31 +++++++- 3 files changed, 103 insertions(+), 44 deletions(-) diff --git a/packages/ui/src/components/expand-button.tsx b/packages/ui/src/components/expand-button.tsx index 3efe8cfb..3c3d92b6 100644 --- a/packages/ui/src/components/expand-button.tsx +++ b/packages/ui/src/components/expand-button.tsx @@ -8,6 +8,7 @@ interface ExpandButtonProps { export default function ExpandButton(props: ExpandButtonProps) { const [clickTime, setClickTime] = createSignal(0) + const [clickTimer, setClickTimer] = createSignal(null) const DOUBLE_CLICK_THRESHOLD = 300 function handleClick() { @@ -15,30 +16,42 @@ export default function ExpandButton(props: ExpandButtonProps) { const lastClick = clickTime() const isDoubleClick = now - lastClick < DOUBLE_CLICK_THRESHOLD - setClickTime(now) + // Clear any pending single-click timer + const timer = clickTimer() + if (timer !== null) { + clearTimeout(timer) + setClickTimer(null) + } const current = props.expandState() if (isDoubleClick) { - // Double click behavior + // Double click behavior - execute immediately if (current === "normal") { - props.onToggleExpand("fifty") + props.onToggleExpand("eighty") } else if (current === "fifty") { props.onToggleExpand("eighty") } else { - props.onToggleExpand("normal") - } - } else { - // Single click behavior - if (current === "normal") { props.onToggleExpand("fifty") - } else { - props.onToggleExpand("normal") } - } + // Reset click time to prevent triple-click issues + setClickTime(0) + } else { + // Single click behavior - delay to wait for potential double-click + setClickTime(now) - // Reset click timer after threshold - setTimeout(() => setClickTime(0), DOUBLE_CLICK_THRESHOLD) + const newTimer = window.setTimeout(() => { + const currentState = props.expandState() + if (currentState === "normal") { + props.onToggleExpand("fifty") + } else { + props.onToggleExpand("normal") + } + setClickTimer(null) + }, DOUBLE_CLICK_THRESHOLD) + + setClickTimer(newTimer) + } } const getTooltip = () => { @@ -48,7 +61,7 @@ export default function ExpandButton(props: ExpandButtonProps) { } else if (current === "fifty") { return "Double-click to expand to 80% • Click to minimize" } else { - return "Click to minimize • Double-click to expand to 50%" + return "Click to minimize • Double-click to reduce to 50%" } } @@ -59,7 +72,7 @@ export default function ExpandButton(props: ExpandButtonProps) { onClick={handleClick} disabled={false} aria-label="Toggle chat input height" - title={getTooltip()} + data-tooltip={getTooltip()} > { - if (!containerRef) return 0 - const rect = containerRef.getBoundingClientRect() - const root = containerRef.closest(".session-view") - if (!root) return 0 - const rootRect = root.getBoundingClientRect() - return rootRect.height - rect.top - } + const calculateExpandedHeight = () => { + if (!containerRef) { + return 0 + } - const getExpandedHeight = (): string => { - const state = expandState() - if (state === "normal") return "auto" - const containerHeight = calculateContainerHeight() - if (state === "fifty") return `${containerHeight * 0.5}px` - return `${containerHeight * 0.8}px` - } + const root = containerRef.closest(".session-view") + if (!root) { + return 0 + } + const rootRect = root.getBoundingClientRect() + + // Reserve minimum space for message section (200px minimum) + const MIN_MESSAGE_SPACE = 200 + const availableForInput = rootRect.height - MIN_MESSAGE_SPACE + + return availableForInput + } + + const expandedHeight = createMemo(() => { + const state = expandState() + if (state === "normal") return "auto" + + const availableHeight = calculateExpandedHeight() + + if (state === "fifty") { + return `${availableHeight * 0.5}px` + } + return `${availableHeight * 0.8}px` + }) @@ -721,11 +734,11 @@ export default function PromptInput(props: PromptInputProps) { void props.onAbortSession() } - function handleExpandToggle(nextState: "normal" | "fifty" | "eighty") { - setExpandState(nextState) - // Keep focus on textarea - textareaRef?.focus() - } + function handleExpandToggle(nextState: "normal" | "fifty" | "eighty") { + setExpandState(nextState) + // Keep focus on textarea + textareaRef?.focus() + } function handleInput(e: Event) { @@ -1186,7 +1199,13 @@ export default function PromptInput(props: PromptInputProps) {
-
+