Merge pull request #10243 from Kilo-Org/holistic-diadem

fix(ui): preserve chat tool collapse state
This commit is contained in:
Marius
2026-05-14 13:43:52 +02:00
committed by GitHub
8 changed files with 123 additions and 5 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/kilo-ui": patch
---
Keep chat tool cards open or closed when streaming updates remount them.
+27 -1
View File
@@ -1 +1,27 @@
export * from "@opencode-ai/ui/basic-tool"
import { BasicTool as Base, GenericTool } from "@opencode-ai/ui/basic-tool"
import type { BasicToolProps as BaseProps, TriggerTitle } from "@opencode-ai/ui/basic-tool"
import { toolOpenKey, readToolOpen, writeToolOpen } from "./tool-open-state"
export { GenericTool }
export type { TriggerTitle }
export interface BasicToolProps extends BaseProps {
tool?: string
callID?: string
partID?: string
}
export function BasicTool(props: BasicToolProps) {
const key = () => toolOpenKey(props)
const initial = () => (props.forceOpen ? true : readToolOpen(key(), props.defaultOpen))
return (
<Base
{...props}
defaultOpen={initial()}
onOpenChange={(open) => {
writeToolOpen(key(), open)
props.onOpenChange?.(open)
}}
/>
)
}
@@ -50,6 +50,7 @@ import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { GrowBox } from "./grow-box"
import { COLLAPSIBLE_SPRING } from "./motion"
import { busy, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils"
import { readToolOpen, toolOpenKey } from "./tool-open-state"
import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results"
import { ShellRollingResults } from "./shell-rolling-results"
import { extractFilePathFromHref } from "../file-path"
@@ -1119,6 +1120,9 @@ function McpTool(props: ToolProps) {
<BasicTool
icon="mcp"
status={props.status}
tool={props.tool}
partID={props.partID}
callID={props.callID}
trigger={{ title: props.tool, subtitle: subtitle(), args: inputArgs() }}
defaultOpen={props.defaultOpen}
forceOpen={props.forceOpen}
@@ -2143,7 +2147,8 @@ ToolRegistry.register({
const pending = () => busy(props.status)
const reveal = useToolReveal(pending, () => props.reveal !== false)
const subtitle = () => props.input.description ?? props.metadata.description
const [open, setOpen] = createSignal(props.defaultOpen ?? true)
const key = () => toolOpenKey(props)
const [open, setOpen] = createSignal(readToolOpen(key(), props.defaultOpen ?? true) ?? true)
// also apply processCarriageReturns for Windows CLI tools
const cmd = createMemo(() => {
@@ -13,6 +13,7 @@ import { Tooltip } from "./tooltip"
import { GROW_SPRING } from "./motion"
import { useSpring } from "./motion-spring"
import { busy, createThrottledValue, updateScrollMask, useCollapsible, useRowWipe, useToolFade } from "./tool-utils"
import { readToolOpen, toolOpenKey, writeToolOpen } from "./tool-open-state"
function ShellRollingSubtitle(props: { text: string; animate?: boolean }) {
let ref: HTMLSpanElement | undefined
@@ -195,7 +196,8 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean;
const reduce = useReducedMotion()
const wiped = new Set<string>()
const [mounted, setMounted] = createSignal(false)
const [open, setOpen] = createSignal(props.defaultOpen ?? true)
const key = () => toolOpenKey({ tool: props.part.tool, callID: props.part.callID, partID: props.part.id })
const [open, setOpen] = createSignal(readToolOpen(key(), props.defaultOpen ?? true) ?? true)
onMount(() => setMounted(true))
const state = createMemo(() => props.part.state as Record<string, any>)
const pending = createMemo(() => busy(props.part.state.status))
@@ -227,7 +229,9 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean;
const el = headerClipRef
const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null
const beforeY = el?.getBoundingClientRect().top ?? 0
setOpen((prev) => !prev)
const next = !open()
setOpen(next)
writeToolOpen(key(), next)
if (viewport && el) {
requestAnimationFrame(() => {
const afterY = el.getBoundingClientRect().top
@@ -0,0 +1,33 @@
type Props = {
tool?: string
callID?: string
partID?: string
}
const MAX = 2000
const state = new Map<string, boolean>()
export function toolOpenKey(props: Props) {
const id = props.callID || props.partID
if (!id) return
if (!props.tool) return id
return `${props.tool}:${id}`
}
export function readToolOpen(key: string | undefined, fallback: boolean | undefined) {
if (key && state.has(key)) return state.get(key)
return fallback
}
export function writeToolOpen(key: string | undefined, value: boolean) {
if (!key) return
if (!state.has(key) && state.size >= MAX) {
const first = state.keys().next().value
if (first) state.delete(first)
}
state.set(key, value)
}
export function resetToolOpenState() {
state.clear()
}
@@ -0,0 +1,34 @@
import { describe, expect, it, beforeEach } from "bun:test"
import {
readToolOpen,
resetToolOpenState,
toolOpenKey,
writeToolOpen,
} from "../../../kilo-ui/src/components/tool-open-state"
describe("tool open state", () => {
beforeEach(() => {
resetToolOpenState()
})
it("keys state by tool and stable call id", () => {
const key = toolOpenKey({ tool: "bash", callID: "call_1", partID: "part_1" })
expect(key).toBe("bash:call_1")
})
it("falls back until the user toggles a tool", () => {
const key = toolOpenKey({ tool: "bash", callID: "call_1" })
expect(readToolOpen(key, false)).toBe(false)
writeToolOpen(key, true)
expect(readToolOpen(key, false)).toBe(true)
})
it("separates different tool instances", () => {
const bash = toolOpenKey({ tool: "bash", callID: "call_1" })
const grep = toolOpenKey({ tool: "grep", callID: "call_1" })
writeToolOpen(bash, true)
writeToolOpen(grep, false)
expect(readToolOpen(bash, false)).toBe(true)
expect(readToolOpen(grep, true)).toBe(false)
})
})
@@ -85,6 +85,8 @@ function TodoToolCard(props: { part: ToolPart }) {
input={state()?.input ?? {}}
metadata={state()?.metadata ?? {}}
tool={props.part.tool}
partID={props.part.id}
callID={props.part.callID}
output={state()?.output}
status={state()?.status}
defaultOpen
@@ -116,7 +116,15 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
return (
<div data-component="tool-part-wrapper">
<BasicTool icon="task" status={props.status} trigger={trigger()} defaultOpen>
<BasicTool
icon="task"
status={props.status}
tool={props.tool}
partID={props.partID}
callID={props.callID}
trigger={trigger()}
defaultOpen
>
<div ref={autoScroll.scrollRef} onScroll={autoScroll.handleScroll} data-component="tool-output" data-scrollable>
<div ref={autoScroll.contentRef} data-component="task-tools">
<Show when={running() && childToolParts().length === 0}>