mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
Merge pull request #10243 from Kilo-Org/holistic-diadem
fix(ui): preserve chat tool collapse state
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
"@kilocode/kilo-ui": patch
|
||||
---
|
||||
|
||||
Keep chat tool cards open or closed when streaming updates remount them.
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user