feat(desktop): Ask Question Tool Support (#8232)
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
|||||||
type LspStatus,
|
type LspStatus,
|
||||||
type VcsInfo,
|
type VcsInfo,
|
||||||
type PermissionRequest,
|
type PermissionRequest,
|
||||||
|
type QuestionRequest,
|
||||||
createOpencodeClient,
|
createOpencodeClient,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
@@ -49,6 +50,9 @@ type State = {
|
|||||||
permission: {
|
permission: {
|
||||||
[sessionID: string]: PermissionRequest[]
|
[sessionID: string]: PermissionRequest[]
|
||||||
}
|
}
|
||||||
|
question: {
|
||||||
|
[sessionID: string]: QuestionRequest[]
|
||||||
|
}
|
||||||
mcp: {
|
mcp: {
|
||||||
[name: string]: McpStatus
|
[name: string]: McpStatus
|
||||||
}
|
}
|
||||||
@@ -98,6 +102,7 @@ function createGlobalSync() {
|
|||||||
session_diff: {},
|
session_diff: {},
|
||||||
todo: {},
|
todo: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
|
question: {},
|
||||||
mcp: {},
|
mcp: {},
|
||||||
lsp: [],
|
lsp: [],
|
||||||
vcs: undefined,
|
vcs: undefined,
|
||||||
@@ -208,6 +213,38 @@ function createGlobalSync() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
sdk.question.list().then((x) => {
|
||||||
|
const grouped: Record<string, QuestionRequest[]> = {}
|
||||||
|
for (const question of x.data ?? []) {
|
||||||
|
if (!question?.id || !question.sessionID) continue
|
||||||
|
const existing = grouped[question.sessionID]
|
||||||
|
if (existing) {
|
||||||
|
existing.push(question)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
grouped[question.sessionID] = [question]
|
||||||
|
}
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
for (const sessionID of Object.keys(store.question)) {
|
||||||
|
if (grouped[sessionID]) continue
|
||||||
|
setStore("question", sessionID, [])
|
||||||
|
}
|
||||||
|
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||||
|
setStore(
|
||||||
|
"question",
|
||||||
|
sessionID,
|
||||||
|
reconcile(
|
||||||
|
questions
|
||||||
|
.filter((q) => !!q?.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
]).then(() => {
|
]).then(() => {
|
||||||
setStore("status", "complete")
|
setStore("status", "complete")
|
||||||
})
|
})
|
||||||
@@ -396,6 +433,44 @@ function createGlobalSync() {
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case "question.asked": {
|
||||||
|
const sessionID = event.properties.sessionID
|
||||||
|
const questions = store.question[sessionID]
|
||||||
|
if (!questions) {
|
||||||
|
setStore("question", sessionID, [event.properties])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = Binary.search(questions, event.properties.id, (q) => q.id)
|
||||||
|
if (result.found) {
|
||||||
|
setStore("question", sessionID, result.index, reconcile(event.properties))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore(
|
||||||
|
"question",
|
||||||
|
sessionID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 0, event.properties)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "question.replied":
|
||||||
|
case "question.rejected": {
|
||||||
|
const questions = store.question[event.properties.sessionID]
|
||||||
|
if (!questions) break
|
||||||
|
const result = Binary.search(questions, event.properties.requestID, (q) => q.id)
|
||||||
|
if (!result.found) break
|
||||||
|
setStore(
|
||||||
|
"question",
|
||||||
|
event.properties.sessionID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(result.index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
case "lsp.updated": {
|
case "lsp.updated": {
|
||||||
const sdk = createOpencodeClient({
|
const sdk = createOpencodeClient({
|
||||||
baseUrl: globalSDK.url,
|
baseUrl: globalSDK.url,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { LocalProvider } from "@/context/local"
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
import { DataProvider } from "@opencode-ai/ui/context"
|
import { DataProvider } from "@opencode-ai/ui/context"
|
||||||
import { iife } from "@opencode-ai/util/iife"
|
import { iife } from "@opencode-ai/util/iife"
|
||||||
|
import type { QuestionAnswer } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
export default function Layout(props: ParentProps) {
|
export default function Layout(props: ParentProps) {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -27,6 +28,11 @@ export default function Layout(props: ParentProps) {
|
|||||||
response: "once" | "always" | "reject"
|
response: "once" | "always" | "reject"
|
||||||
}) => sdk.client.permission.respond(input)
|
}) => sdk.client.permission.respond(input)
|
||||||
|
|
||||||
|
const replyToQuestion = (input: { requestID: string; answers: QuestionAnswer[] }) =>
|
||||||
|
sdk.client.question.reply(input)
|
||||||
|
|
||||||
|
const rejectQuestion = (input: { requestID: string }) => sdk.client.question.reject(input)
|
||||||
|
|
||||||
const navigateToSession = (sessionID: string) => {
|
const navigateToSession = (sessionID: string) => {
|
||||||
navigate(`/${params.dir}/session/${sessionID}`)
|
navigate(`/${params.dir}/session/${sessionID}`)
|
||||||
}
|
}
|
||||||
@@ -36,6 +42,8 @@ export default function Layout(props: ParentProps) {
|
|||||||
data={sync.data}
|
data={sync.data}
|
||||||
directory={directory()}
|
directory={directory()}
|
||||||
onPermissionRespond={respond}
|
onPermissionRespond={respond}
|
||||||
|
onQuestionReply={replyToQuestion}
|
||||||
|
onQuestionReject={rejectQuestion}
|
||||||
onNavigateToSession={navigateToSession}
|
onNavigateToSession={navigateToSession}
|
||||||
>
|
>
|
||||||
<LocalProvider>{props.children}</LocalProvider>
|
<LocalProvider>{props.children}</LocalProvider>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export namespace ToolRegistry {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
InvalidTool,
|
InvalidTool,
|
||||||
...(Flag.OPENCODE_CLIENT === "cli" ? [QuestionTool] : []),
|
...(["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) ? [QuestionTool] : []),
|
||||||
BashTool,
|
BashTool,
|
||||||
ReadTool,
|
ReadTool,
|
||||||
GlobTool,
|
GlobTool,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface BasicToolProps {
|
|||||||
hideDetails?: boolean
|
hideDetails?: boolean
|
||||||
defaultOpen?: boolean
|
defaultOpen?: boolean
|
||||||
forceOpen?: boolean
|
forceOpen?: boolean
|
||||||
|
locked?: boolean
|
||||||
onSubtitleClick?: () => void
|
onSubtitleClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,8 +36,13 @@ export function BasicTool(props: BasicToolProps) {
|
|||||||
if (props.forceOpen) setOpen(true)
|
if (props.forceOpen) setOpen(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleOpenChange = (value: boolean) => {
|
||||||
|
if (props.locked && !value) return
|
||||||
|
setOpen(value)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible open={open()} onOpenChange={setOpen}>
|
<Collapsible open={open()} onOpenChange={handleOpenChange}>
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
<div data-component="tool-trigger">
|
<div data-component="tool-trigger">
|
||||||
<div data-slot="basic-tool-tool-trigger-content">
|
<div data-slot="basic-tool-tool-trigger-content">
|
||||||
@@ -95,7 +101,7 @@ export function BasicTool(props: BasicToolProps) {
|
|||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Show when={props.children && !props.hideDetails}>
|
<Show when={props.children && !props.hideDetails && !props.locked}>
|
||||||
<Collapsible.Arrow />
|
<Collapsible.Arrow />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -405,7 +405,8 @@
|
|||||||
[data-component="tool-part-wrapper"] {
|
[data-component="tool-part-wrapper"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&[data-permission="true"] {
|
&[data-permission="true"],
|
||||||
|
&[data-question="true"] {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: calc(2px + var(--sticky-header-height, 40px));
|
top: calc(2px + var(--sticky-header-height, 40px));
|
||||||
bottom: 0px;
|
bottom: 0px;
|
||||||
@@ -490,3 +491,193 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-component="question-prompt"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: var(--surface-inset-base);
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
[data-slot="question-tabs"] {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
[data-slot="question-tab"] {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--surface-base);
|
||||||
|
color: var(--text-base);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 0.15s,
|
||||||
|
background-color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--surface-base-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-active="true"] {
|
||||||
|
background-color: var(--surface-raised-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-answered="true"] {
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="question-content"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
[data-slot="question-text"] {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-base);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="question-options"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
[data-slot="question-option"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: var(--surface-base);
|
||||||
|
border: 1px solid var(--border-weaker-base);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
transition:
|
||||||
|
background-color 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--surface-base-hover);
|
||||||
|
border-color: var(--border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-picked="true"] {
|
||||||
|
[data-component="icon"] {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="option-label"] {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-base);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="option-description"] {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-weak);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="custom-input-form"] {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
align-items: stretch;
|
||||||
|
|
||||||
|
[data-slot="custom-input"] {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: var(--surface-base);
|
||||||
|
color: var(--text-base);
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--border-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--text-weak);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="button"] {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="question-review"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
[data-slot="review-title"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="review-item"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
[data-slot="review-label"] {
|
||||||
|
color: var(--text-weak);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="review-value"] {
|
||||||
|
color: var(--text-strong);
|
||||||
|
|
||||||
|
&[data-answered="false"] {
|
||||||
|
color: var(--text-weak);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="question-actions"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="question-answers"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
|
||||||
|
[data-slot="question-answer-item"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
[data-slot="question-text"] {
|
||||||
|
color: var(--text-weak);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="answer-text"] {
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ import {
|
|||||||
ToolPart,
|
ToolPart,
|
||||||
UserMessage,
|
UserMessage,
|
||||||
Todo,
|
Todo,
|
||||||
|
QuestionRequest,
|
||||||
|
QuestionAnswer,
|
||||||
|
QuestionInfo,
|
||||||
} from "@opencode-ai/sdk/v2"
|
} from "@opencode-ai/sdk/v2"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { useData } from "../context"
|
import { useData } from "../context"
|
||||||
import { useDiffComponent } from "../context/diff"
|
import { useDiffComponent } from "../context/diff"
|
||||||
import { useCodeComponent } from "../context/code"
|
import { useCodeComponent } from "../context/code"
|
||||||
@@ -238,6 +242,11 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo {
|
|||||||
icon: "checklist",
|
icon: "checklist",
|
||||||
title: "Read to-dos",
|
title: "Read to-dos",
|
||||||
}
|
}
|
||||||
|
case "question":
|
||||||
|
return {
|
||||||
|
icon: "bubble-5",
|
||||||
|
title: "Questions",
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
icon: "mcp",
|
icon: "mcp",
|
||||||
@@ -438,6 +447,7 @@ export interface ToolProps {
|
|||||||
hideDetails?: boolean
|
hideDetails?: boolean
|
||||||
defaultOpen?: boolean
|
defaultOpen?: boolean
|
||||||
forceOpen?: boolean
|
forceOpen?: boolean
|
||||||
|
locked?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ToolComponent = Component<ToolProps>
|
export type ToolComponent = Component<ToolProps>
|
||||||
@@ -475,7 +485,15 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const questionRequest = createMemo(() => {
|
||||||
|
const next = data.store.question?.[props.message.sessionID]?.[0]
|
||||||
|
if (!next || !next.tool) return undefined
|
||||||
|
if (next.tool!.callID !== part.callID) return undefined
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
const [showPermission, setShowPermission] = createSignal(false)
|
const [showPermission, setShowPermission] = createSignal(false)
|
||||||
|
const [showQuestion, setShowQuestion] = createSignal(false)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const perm = permission()
|
const perm = permission()
|
||||||
@@ -487,9 +505,19 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const question = questionRequest()
|
||||||
|
if (question) {
|
||||||
|
const timeout = setTimeout(() => setShowQuestion(true), 50)
|
||||||
|
onCleanup(() => clearTimeout(timeout))
|
||||||
|
} else {
|
||||||
|
setShowQuestion(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const [forceOpen, setForceOpen] = createSignal(false)
|
const [forceOpen, setForceOpen] = createSignal(false)
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (permission()) setForceOpen(true)
|
if (permission() || questionRequest()) setForceOpen(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
const respond = (response: "once" | "always" | "reject") => {
|
const respond = (response: "once" | "always" | "reject") => {
|
||||||
@@ -512,7 +540,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
|||||||
const render = ToolRegistry.render(part.tool) ?? GenericTool
|
const render = ToolRegistry.render(part.tool) ?? GenericTool
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-component="tool-part-wrapper" data-permission={showPermission()}>
|
<div data-component="tool-part-wrapper" data-permission={showPermission()} data-question={showQuestion()}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={part.state.status === "error" && part.state.error}>
|
<Match when={part.state.status === "error" && part.state.error}>
|
||||||
{(error) => {
|
{(error) => {
|
||||||
@@ -549,6 +577,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
|||||||
status={part.state.status}
|
status={part.state.status}
|
||||||
hideDetails={props.hideDetails}
|
hideDetails={props.hideDetails}
|
||||||
forceOpen={forceOpen()}
|
forceOpen={forceOpen()}
|
||||||
|
locked={showPermission() || showQuestion()}
|
||||||
defaultOpen={props.defaultOpen}
|
defaultOpen={props.defaultOpen}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
@@ -568,6 +597,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={showQuestion() && questionRequest()}>{(request) => <QuestionPrompt request={request()} />}</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1042,3 +1072,288 @@ ToolRegistry.register({
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ToolRegistry.register({
|
||||||
|
name: "question",
|
||||||
|
render(props) {
|
||||||
|
const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[])
|
||||||
|
const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[])
|
||||||
|
const completed = createMemo(() => answers().length > 0)
|
||||||
|
|
||||||
|
const subtitle = createMemo(() => {
|
||||||
|
const count = questions().length
|
||||||
|
if (count === 0) return ""
|
||||||
|
if (completed()) return `${count} answered`
|
||||||
|
return `${count} question${count > 1 ? "s" : ""}`
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BasicTool
|
||||||
|
{...props}
|
||||||
|
defaultOpen={completed()}
|
||||||
|
icon="bubble-5"
|
||||||
|
trigger={{
|
||||||
|
title: "Questions",
|
||||||
|
subtitle: subtitle(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={completed()}>
|
||||||
|
<div data-component="question-answers">
|
||||||
|
<For each={questions()}>
|
||||||
|
{(q, i) => {
|
||||||
|
const answer = () => answers()[i()] ?? []
|
||||||
|
return (
|
||||||
|
<div data-slot="question-answer-item">
|
||||||
|
<div data-slot="question-text">{q.question}</div>
|
||||||
|
<div data-slot="answer-text">{answer().join(", ") || "(no answer)"}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</BasicTool>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||||
|
const data = useData()
|
||||||
|
const questions = createMemo(() => props.request.questions)
|
||||||
|
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
|
||||||
|
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
tab: 0,
|
||||||
|
answers: [] as QuestionAnswer[],
|
||||||
|
custom: [] as string[],
|
||||||
|
editing: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const question = createMemo(() => questions()[store.tab])
|
||||||
|
const confirm = createMemo(() => !single() && store.tab === questions().length)
|
||||||
|
const options = createMemo(() => question()?.options ?? [])
|
||||||
|
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||||
|
const multi = createMemo(() => question()?.multiple === true)
|
||||||
|
const customPicked = createMemo(() => {
|
||||||
|
const value = input()
|
||||||
|
if (!value) return false
|
||||||
|
return store.answers[store.tab]?.includes(value) ?? false
|
||||||
|
})
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const answers = questions().map((_, i) => store.answers[i] ?? [])
|
||||||
|
data.replyToQuestion?.({
|
||||||
|
requestID: props.request.id,
|
||||||
|
answers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function reject() {
|
||||||
|
data.rejectQuestion?.({
|
||||||
|
requestID: props.request.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(answer: string, custom: boolean = false) {
|
||||||
|
const answers = [...store.answers]
|
||||||
|
answers[store.tab] = [answer]
|
||||||
|
setStore("answers", answers)
|
||||||
|
if (custom) {
|
||||||
|
const inputs = [...store.custom]
|
||||||
|
inputs[store.tab] = answer
|
||||||
|
setStore("custom", inputs)
|
||||||
|
}
|
||||||
|
if (single()) {
|
||||||
|
data.replyToQuestion?.({
|
||||||
|
requestID: props.request.id,
|
||||||
|
answers: [[answer]],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStore("tab", store.tab + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(answer: string) {
|
||||||
|
const existing = store.answers[store.tab] ?? []
|
||||||
|
const next = [...existing]
|
||||||
|
const index = next.indexOf(answer)
|
||||||
|
if (index === -1) next.push(answer)
|
||||||
|
if (index !== -1) next.splice(index, 1)
|
||||||
|
const answers = [...store.answers]
|
||||||
|
answers[store.tab] = next
|
||||||
|
setStore("answers", answers)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTab(index: number) {
|
||||||
|
setStore("tab", index)
|
||||||
|
setStore("editing", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectOption(optIndex: number) {
|
||||||
|
if (optIndex === options().length) {
|
||||||
|
setStore("editing", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const opt = options()[optIndex]
|
||||||
|
if (!opt) return
|
||||||
|
if (multi()) {
|
||||||
|
toggle(opt.label)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pick(opt.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCustomSubmit(e: Event) {
|
||||||
|
e.preventDefault()
|
||||||
|
const value = input().trim()
|
||||||
|
if (!value) {
|
||||||
|
setStore("editing", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (multi()) {
|
||||||
|
const existing = store.answers[store.tab] ?? []
|
||||||
|
const next = [...existing]
|
||||||
|
if (!next.includes(value)) next.push(value)
|
||||||
|
const answers = [...store.answers]
|
||||||
|
answers[store.tab] = next
|
||||||
|
setStore("answers", answers)
|
||||||
|
setStore("editing", false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pick(value, true)
|
||||||
|
setStore("editing", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-component="question-prompt">
|
||||||
|
<Show when={!single()}>
|
||||||
|
<div data-slot="question-tabs">
|
||||||
|
<For each={questions()}>
|
||||||
|
{(q, index) => {
|
||||||
|
const active = () => index() === store.tab
|
||||||
|
const answered = () => (store.answers[index()]?.length ?? 0) > 0
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
data-slot="question-tab"
|
||||||
|
data-active={active()}
|
||||||
|
data-answered={answered()}
|
||||||
|
onClick={() => selectTab(index())}
|
||||||
|
>
|
||||||
|
{q.header}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
<button data-slot="question-tab" data-active={confirm()} onClick={() => selectTab(questions().length)}>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!confirm()}>
|
||||||
|
<div data-slot="question-content">
|
||||||
|
<div data-slot="question-text">
|
||||||
|
{question()?.question}
|
||||||
|
{multi() ? " (select all that apply)" : ""}
|
||||||
|
</div>
|
||||||
|
<div data-slot="question-options">
|
||||||
|
<For each={options()}>
|
||||||
|
{(opt, i) => {
|
||||||
|
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||||
|
return (
|
||||||
|
<button data-slot="question-option" data-picked={picked()} onClick={() => selectOption(i())}>
|
||||||
|
<span data-slot="option-label">{opt.label}</span>
|
||||||
|
<Show when={opt.description}>
|
||||||
|
<span data-slot="option-description">{opt.description}</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={picked()}>
|
||||||
|
<Icon name="check-small" size="normal" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
<button
|
||||||
|
data-slot="question-option"
|
||||||
|
data-picked={customPicked()}
|
||||||
|
onClick={() => selectOption(options().length)}
|
||||||
|
>
|
||||||
|
<span data-slot="option-label">Type your own answer</span>
|
||||||
|
<Show when={!store.editing && input()}>
|
||||||
|
<span data-slot="option-description">{input()}</span>
|
||||||
|
</Show>
|
||||||
|
<Show when={customPicked()}>
|
||||||
|
<Icon name="check-small" size="normal" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
<Show when={store.editing}>
|
||||||
|
<form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
|
||||||
|
<input
|
||||||
|
ref={(el) => setTimeout(() => el.focus(), 0)}
|
||||||
|
type="text"
|
||||||
|
data-slot="custom-input"
|
||||||
|
placeholder="Type your answer..."
|
||||||
|
value={input()}
|
||||||
|
onInput={(e) => {
|
||||||
|
const inputs = [...store.custom]
|
||||||
|
inputs[store.tab] = e.currentTarget.value
|
||||||
|
setStore("custom", inputs)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="submit" variant="primary" size="small">
|
||||||
|
{multi() ? "Add" : "Submit"}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="small" onClick={() => setStore("editing", false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={confirm()}>
|
||||||
|
<div data-slot="question-review">
|
||||||
|
<div data-slot="review-title">Review your answers</div>
|
||||||
|
<For each={questions()}>
|
||||||
|
{(q, index) => {
|
||||||
|
const value = () => store.answers[index()]?.join(", ") ?? ""
|
||||||
|
const answered = () => Boolean(value())
|
||||||
|
return (
|
||||||
|
<div data-slot="review-item">
|
||||||
|
<span data-slot="review-label">{q.question}</span>
|
||||||
|
<span data-slot="review-value" data-answered={answered()}>
|
||||||
|
{answered() ? value() : "(not answered)"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div data-slot="question-actions">
|
||||||
|
<Button variant="ghost" size="small" onClick={reject}>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
<Show when={!single()}>
|
||||||
|
<Show when={confirm()}>
|
||||||
|
<Button variant="primary" size="small" onClick={submit}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
|
<Show when={!confirm() && multi()}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => selectTab(store.tab + 1)}
|
||||||
|
disabled={(store.answers[store.tab]?.length ?? 0) === 0}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import type { Message, Session, Part, FileDiff, SessionStatus, PermissionRequest } from "@opencode-ai/sdk/v2"
|
import type {
|
||||||
|
Message,
|
||||||
|
Session,
|
||||||
|
Part,
|
||||||
|
FileDiff,
|
||||||
|
SessionStatus,
|
||||||
|
PermissionRequest,
|
||||||
|
QuestionRequest,
|
||||||
|
QuestionAnswer,
|
||||||
|
} from "@opencode-ai/sdk/v2"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||||
|
|
||||||
@@ -16,6 +25,9 @@ type Data = {
|
|||||||
permission?: {
|
permission?: {
|
||||||
[sessionID: string]: PermissionRequest[]
|
[sessionID: string]: PermissionRequest[]
|
||||||
}
|
}
|
||||||
|
question?: {
|
||||||
|
[sessionID: string]: QuestionRequest[]
|
||||||
|
}
|
||||||
message: {
|
message: {
|
||||||
[sessionID: string]: Message[]
|
[sessionID: string]: Message[]
|
||||||
}
|
}
|
||||||
@@ -30,6 +42,10 @@ export type PermissionRespondFn = (input: {
|
|||||||
response: "once" | "always" | "reject"
|
response: "once" | "always" | "reject"
|
||||||
}) => void
|
}) => void
|
||||||
|
|
||||||
|
export type QuestionReplyFn = (input: { requestID: string; answers: QuestionAnswer[] }) => void
|
||||||
|
|
||||||
|
export type QuestionRejectFn = (input: { requestID: string }) => void
|
||||||
|
|
||||||
export type NavigateToSessionFn = (sessionID: string) => void
|
export type NavigateToSessionFn = (sessionID: string) => void
|
||||||
|
|
||||||
export const { use: useData, provider: DataProvider } = createSimpleContext({
|
export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
@@ -38,6 +54,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
data: Data
|
data: Data
|
||||||
directory: string
|
directory: string
|
||||||
onPermissionRespond?: PermissionRespondFn
|
onPermissionRespond?: PermissionRespondFn
|
||||||
|
onQuestionReply?: QuestionReplyFn
|
||||||
|
onQuestionReject?: QuestionRejectFn
|
||||||
onNavigateToSession?: NavigateToSessionFn
|
onNavigateToSession?: NavigateToSessionFn
|
||||||
}) => {
|
}) => {
|
||||||
return {
|
return {
|
||||||
@@ -48,6 +66,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
return props.directory
|
return props.directory
|
||||||
},
|
},
|
||||||
respondToPermission: props.onPermissionRespond,
|
respondToPermission: props.onPermissionRespond,
|
||||||
|
replyToQuestion: props.onQuestionReply,
|
||||||
|
rejectQuestion: props.onQuestionReject,
|
||||||
navigateToSession: props.onNavigateToSession,
|
navigateToSession: props.onNavigateToSession,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user