Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/show-dismissed-question-content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Fixed dismissed question tool content not showing in chat history. Dismissed questions now render with a "Dismissed" label and "N dismissed" subtitle instead of being invisible.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 33 additions & 9 deletions packages/kilo-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1159,6 +1159,12 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
const i18n = useI18n()
const part = props.part as ToolPart
const hideQuestion = createMemo(() => part.tool === "question" && busy(part.state.status))
const isDismissedQuestionError = createMemo(() => {
if (part.tool !== "question") return false
if (part.state.status !== "error" || !part.state.error) return false
const errStr = typeof part.state.error === "string" ? part.state.error : ""
return errStr.includes("dismissed this question")
})

const emptyInput: Record<string, any> = {}
const emptyMetadata: Record<string, any> = {}
Expand All @@ -1177,13 +1183,24 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
<Match when={part.state.status === "error" && part.state.error}>
{(error) => {
const cleaned = error().replace("Error: ", "")
if (part.tool === "question" && cleaned.includes("dismissed this question")) {
if (isDismissedQuestionError()) {
return (
<div style="width: 100%; display: flex; justify-content: flex-end;">
<span class="text-13-regular text-text-weak cursor-default">
{i18n.t("ui.messagePart.questions.dismissed")}
</span>
</div>
<Dynamic
component={render()}
input={input()}
tool={part.tool}
partID={part.id}
callID={part.callID}
metadata={meta()}
partMetadata={top()}
// @ts-expect-error
output={part.state.output}
status={part.state.status}
hideDetails={props.hideDetails}
defaultOpen={props.defaultOpen}
animate
reveal={props.animate}
/>
)
}
const hint =
Expand Down Expand Up @@ -2827,12 +2844,15 @@ ToolRegistry.register({
const i18n = useI18n()
const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[])
const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[])
const dismissed = createMemo(() => props.metadata.dismissed === true || props.status === "error")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Dismissed detection relies on implicit caller-side gating

dismissed() treats any status === "error" as a dismissal without inspecting the error text. That's only safe today because the caller (ToolPartDisplay above, via isDismissedQuestionError()) only routes an error-status question part into this renderer when the message already contains "dismissed this question" — any other error takes the Card fallback and never reaches this component. If that caller-side gating ever changes, a genuine tool error could silently render as "Dismissed" here instead of as an error. Consider checking the error text directly (e.g. a shared helper) rather than relying on status === "error" alone, so this renderer is correct independent of the caller.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

const completed = createMemo(() => answers().length > 0)
const pending = createMemo(() => busy(props.status))
const hasContent = createMemo(() => completed() || dismissed())

const subtitle = createMemo(() => {
const count = questions().length
if (count === 0) return ""
if (dismissed()) return i18n.t("ui.question.subtitle.dismissed", { count })
if (completed()) return i18n.t("ui.question.subtitle.answered", { count })
return `${count} ${i18n.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}`
})
Expand All @@ -2851,15 +2871,19 @@ ToolRegistry.register({
/>
}
>
<Show when={completed()}>
<div data-component="question-answers">
<Show when={hasContent()}>
<div data-component="question-answers" data-dismissed={dismissed() ? "" : undefined}>
<For each={questions()}>
{(q, i) => {
const answer = () => answers()[i()] ?? []
const answerText = () => {
if (dismissed()) return i18n.t("ui.question.answer.dismissed")
return answer().join(", ") || i18n.t("ui.question.answer.none")
}
return (
<div data-slot="question-answer-item">
<div data-slot="question-text">{q.question}</div>
<div data-slot="answer-text">{answer().join(", ") || i18n.t("ui.question.answer.none")}</div>
<div data-slot="answer-text">{answerText()}</div>
</div>
)
}}
Expand Down
136 changes: 136 additions & 0 deletions packages/kilo-ui/src/stories/message-part.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -529,10 +529,146 @@ const hintErrors: ToolPart[] = [

const mockDataHintErrors = createMockData(hintErrors)

// --- Question tool: answered (reference) ---

const questionAnsweredPart: ToolPart = {
id: "part-question-answered",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
callID: "call-question-answered",
tool: "question",
state: {
status: "completed",
input: {
questions: [
{
question: "Should I continue with this approach?",
header: "Continue?",
options: [
{ label: "Yes", description: "Proceed with the current plan" },
{ label: "No", description: "Stop and reconsider" },
],
},
{
question: "Which library should I use for date formatting?",
header: "Library",
options: [
{ label: "date-fns", description: "Lightweight, tree-shakeable" },
{ label: "luxon", description: "Full-featured DateTime library" },
{ label: "dayjs", description: "Moment.js compatible, 2kB" },
],
},
],
},
output: 'User answered: "Should I continue?"="Yes", "Which library?"="date-fns"',
title: "Asked 2 questions",
metadata: { answers: [["Yes"], ["date-fns"]] },
time: { start: now - 8000, end: now - 7000 },
},
}

// --- Question tool: dismissed (exercises the fix) ---

const questionDismissedPart: ToolPart = {
id: "part-question-dismissed",
sessionID: SESSION_ID,
messageID: ASST_MSG_ID,
type: "tool",
callID: "call-question-dismissed",
tool: "question",
state: {
status: "completed",
input: {
questions: [
{
question: "Should I continue with this approach?",
header: "Continue?",
options: [
{ label: "Yes", description: "Proceed with the current plan" },
{ label: "No", description: "Stop and reconsider" },
],
},
{
question: "Which library should I use for date formatting?",
header: "Library",
options: [
{ label: "date-fns", description: "Lightweight, tree-shakeable" },
{ label: "luxon", description: "Full-featured DateTime library" },
],
},
],
},
output: "User dismissed the question.",
title: "Question dismissed",
metadata: { answers: [], dismissed: true },
time: { start: now - 8000, end: now - 7000 },
},
}

const mockDataQuestionAnswered = createMockData([questionAnsweredPart, textPart])
const mockDataQuestionDismissed = createMockData([questionDismissedPart, textPart])

export const ToolHintErrors: Story = {
render: () => (
<AllProviders data={mockDataHintErrors}>
<AssistantParts messages={[mockAssistantMessage]} />
</AllProviders>
),
}

// --- Question tool: answered (collapsed) ---

export const QuestionAnswered: Story = {
name: "QuestionAnswered",
render: () => (
<AllProviders data={mockDataQuestionAnswered}>
<AssistantParts messages={[mockAssistantMessage]} />
</AllProviders>
),
}

// --- Question tool: answered (expanded) ---

export const QuestionAnsweredExpanded: Story = {
name: "QuestionAnswered (expanded)",
render: () => (
<AllProviders data={mockDataQuestionAnswered}>
<AssistantParts messages={[mockAssistantMessage]} />
</AllProviders>
),
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
const trigger = canvasElement
.querySelector('[data-slot="basic-tool-tool-title"]')
?.closest("button")
if (trigger) trigger.click()
},
}

// --- Question tool: dismissed (collapsed — "2 dismissed" subtitle) ---

export const QuestionDismissed: Story = {
name: "QuestionDismissed",
render: () => (
<AllProviders data={mockDataQuestionDismissed}>
<AssistantParts messages={[mockAssistantMessage]} />
</AllProviders>
),
}

// --- Question tool: dismissed (expanded — shows questions with "Dismissed" labels) ---

export const QuestionDismissedExpanded: Story = {
name: "QuestionDismissed (expanded)",
render: () => (
<AllProviders data={mockDataQuestionDismissed}>
<AssistantParts messages={[mockAssistantMessage]} />
</AllProviders>
),
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
const trigger = canvasElement
.querySelector('[data-slot="basic-tool-tool-title"]')
?.closest("button")
if (trigger) trigger.click()
},
}
Original file line number Diff line number Diff line change
Expand Up @@ -1010,22 +1010,40 @@ function Question(props: ToolProps) {
arrayValue(props.input.questions).flatMap((item) => (isRecord(item) ? [item] : [])),
)
const answers = createMemo(() => arrayValue(props.metadata.answers))
// kilocode_change start - show dismissed question content; use questions()
// presence (not answers) so dismissed/answered/error states all render content.
const dismissed = createMemo(
() =>
props.metadata.dismissed === true ||
(props.part.state.status === "error" && String(props.part.state.error?.message ?? "").includes("dismissed")),
)

function format(answer: unknown) {
if (dismissed()) return "Dismissed"
return formatAnswer(answer)
}

const title = createMemo(() => (dismissed() ? "# Questions (dismissed)" : "# Questions"))
// kilocode_change end

return (
<Switch>
<Match when={answers().length > 0}>
<BlockTool title="# Questions" part={props.part}>
{/* kilocode_change start - gate on dismissed or answers so dismissed/answered render, pending falls through to Asking... */}
<Match when={dismissed() || answers().length > 0}>
<BlockTool title={title()} part={props.part}>
<box gap={1}>
<For each={questions()}>
{(question, index) => (
<box>
<text fg={theme.textMuted}>{stringValue(question.question)}</text>
<text fg={theme.text}>{formatAnswer(answers()[index()])}</text>
<text fg={theme.text}>{format(answers()[index()])}</text>
</box>
)}
</For>
</box>
</BlockTool>
</Match>
{/* kilocode_change end */}
<Match when={true}>
<InlineTool icon="→" pending="Asking questions..." complete={questions().length} part={props.part}>
Asked {questions().length} question{questions().length === 1 ? "" : "s"}
Expand Down
62 changes: 49 additions & 13 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2815,28 +2815,64 @@ function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
function Question(props: ToolProps<typeof QuestionTool>) {
const { theme } = useTheme()
const count = createMemo(() => props.input.questions?.length ?? 0)
// kilocode_change start - show dismissed question content with toggle;
// use input.questions presence (not metadata) so dismissed/answered/error
// states all render content. Clicking the one-liner expands to the full
// block; clicking the block title collapses back.
const dismissed = createMemo(
() =>
props.metadata.dismissed === true ||
(props.part.state.status === "error" && String(props.part.state.error ?? "").includes("dismissed")),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Looser/inconsistent dismissal match vs. the webview renderer

This matches the substring "dismissed", while the equivalent check in packages/kilo-ui/src/components/message-part.tsx (isDismissedQuestionError) matches the more specific "dismissed this question". The same loose "dismissed" match is duplicated in session-v2.tsx. A broader match here could flag an unrelated tool error as dismissed if its message happens to contain that word, and the three separate implementations can drift over time. Consider extracting one shared predicate (or at least aligning the substring) used by all three renderers.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

)
const [expanded, setExpanded] = createSignal(false)

function format(answer?: ReadonlyArray<string>) {
if (dismissed()) return "Dismissed"
if (!answer?.length) return "(no answer)"
return answer.join(", ")
}

const title = createMemo(() => (dismissed() ? "# Questions (dismissed)" : "# Questions"))
const subtitle = createMemo(() => {
if (dismissed()) return `${count()} dismissed`
if ((props.metadata.answers?.length ?? 0) > 0) return `${count()} answered`
return `${count()} question${count() !== 1 ? "s" : ""}`
})
// kilocode_change end

return (
<Switch>
<Match when={props.metadata.answers}>
<BlockTool title="# Questions" part={props.part}>
<box gap={1}>
<For each={props.input.questions ?? []}>
{(q, i) => (
<box flexDirection="column">
<text fg={theme.textMuted}>{q.question}</text>
<text fg={theme.text}>{format(props.metadata.answers?.[i()])}</text>
</box>
)}
</For>
</box>
</BlockTool>
{/* kilocode_change start - toggle between one-liner and full block */}
<Match when={count() > 0}>
<Show
when={expanded()}
fallback={
<InlineTool
icon="→"
complete={count()}
pending="Asking questions..."
part={props.part}
onClick={() => setExpanded(true)}
>
{subtitle()}
</InlineTool>
}
>
<BlockTool title={title()} part={props.part} onClick={() => setExpanded(false)}>
<box gap={1}>
<For each={props.input.questions ?? []}>
{(q, i) => (
<box flexDirection="column">
<text fg={theme.textMuted}>{q.question}</text>
<text fg={theme.text}>{format(props.metadata.answers?.[i()])}</text>
</box>
)}
</For>
</box>
</BlockTool>
</Show>
</Match>
{/* kilocode_change end */}
<Match when={true}>
<InlineTool icon="→" pending="Asking questions..." complete={count()} part={props.part}>
Asked {count()} question{count() !== 1 ? "s" : ""}
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/i18n/ar.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/ui/src/i18n/br.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/ui/src/i18n/bs.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading