refactor(vscode): remove duplicate and unused UI code

This commit is contained in:
marius-kilocode
2026-08-28 11:02:07 +02:00
parent 9f4c69804d
commit 1f547ade8b
14 changed files with 123 additions and 409 deletions
@@ -360,7 +360,7 @@ describe("Agent Manager Model Picker", () => {
describe("Agent Manager Worktree Actions", () => {
it("opens the configuration dialog from the primary plus action", () => {
const source = fs.readFileSync(path.join(ROOT, "webview-ui/agent-manager/WorktreeSectionActions.tsx"), "utf-8")
const source = fs.readFileSync(path.join(ROOT, "webview-ui/agent-manager/ProjectActions.tsx"), "utf-8")
const start = source.indexOf('<div class="am-split-button">')
const end = source.indexOf("</div>", start)
const actions = source.slice(start, end)
@@ -47,7 +47,7 @@ describe("planDisplayPath", () => {
describe("plan_exit renderer uses openFile not openDiff (source)", () => {
const ROOT = path.resolve(import.meta.dir, "../..")
const FILE = path.join(ROOT, "webview-ui/src/components/chat/AssistantMessage.tsx")
const TURN_FILE = path.join(ROOT, "webview-ui/src/components/chat/VscodeSessionTurn.tsx")
const TURN_FILE = path.join(ROOT, "webview-ui/src/components/chat/TranscriptRow.tsx")
const src = fs.readFileSync(FILE, "utf-8")
const turnSrc = fs.readFileSync(TURN_FILE, "utf-8")
@@ -76,6 +76,7 @@ describe("plan_exit renderer uses openFile not openDiff (source)", () => {
expect(src).not.toContain("Object.values(data.store.part ?? {}).flat()")
expect(src).not.toContain("[...props.parts, ...all()]")
expect(src).not.toContain("turnParts")
expect(turnSrc).toContain("parts={row().parts as unknown as SDKPart[]}")
expect(turnSrc).not.toContain("assistantMessages().flatMap")
expect(turnSrc).not.toContain("turnParts={assistantParts()}")
})
@@ -3,7 +3,7 @@ import fs from "node:fs"
import path from "node:path"
const ROOT = path.resolve(import.meta.dir, "../..")
const TURN_FILE = path.join(ROOT, "webview-ui/src/components/chat/VscodeSessionTurn.tsx")
const TURN_FILE = path.join(ROOT, "webview-ui/src/components/chat/TranscriptRow.tsx")
const PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts")
const BANNER_FILE = path.join(ROOT, "webview-ui/src/components/chat/RevertBanner.tsx")
const SESSION_FILE = path.join(ROOT, "webview-ui/src/types/messages/sessions.ts")
@@ -32,12 +32,12 @@ function exported(name: string) {
describe("message revert checkpoints", () => {
it("keeps revert actions available after a session is already reverted", () => {
expect(src).toMatch(/onRevert=\{\s*assistantMessages\(\)\.length > 0\s*\? \(\) =>/)
expect(src).toMatch(/onRevert=\{\s*row\(\)\.answered\s*\? \(\) =>/)
expect(src).not.toMatch(/onRevert=\{[\s\S]*?&& !session\.revert\(\)[\s\S]*?\? \(\) =>/)
})
it("only marks revert disabled while the agent is busy", () => {
expect(src).toMatch(/data-revert-disabled=\{\s*assistantMessages\(\)\.length > 0 && session\.status\(\) !== "idle"/)
expect(src).toMatch(/data-revert-disabled=\{\s*row\(\)\.answered && session\.status\(\) !== "idle"/)
expect(src).not.toMatch(/data-revert-disabled=\{[\s\S]*?!session\.revert\(\)/)
})
})
@@ -12,6 +12,7 @@ import {
buildFileAnnotations,
clearReviewComposer,
createReviewComposer,
labels,
reviewAnnotationSpeechKey,
reviewComposerDraft,
reviewComposerEdit,
@@ -441,3 +442,19 @@ describe("getFilename", () => {
expect(getFilename("src/components/Button.tsx")).toBe("Button.tsx")
})
})
it("shares review action labels and translates line numbers", () => {
const value = labels((key, params) => (params ? `${key}:${params.line}` : key))
expect(value).toMatchObject({
placeholder: "agentManager.review.commentPlaceholder",
cancel: "common.cancel",
comment: "agentManager.review.commentAction",
send: "prompt.action.send",
save: "common.save",
sendToChat: "agentManager.review.sendToChat",
edit: "common.edit",
delete: "common.delete",
})
expect(value.commentOnLine(3)).toBe("agentManager.review.commentOnLine:3")
expect(value.editCommentOnLine(7)).toBe("agentManager.review.editCommentOnLine:7")
})
@@ -48,7 +48,7 @@ import {
reviewDraftSpeechKey,
reviewEditSpeechKey,
sendReviewComments,
type AnnotationLabels,
labels,
type AnnotationMeta,
type ReviewComposer,
type ReviewDraft,
@@ -130,18 +130,6 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
const sendAllKeybind = () =>
isMac ? t("agentManager.review.sendAllShortcut.mac") : t("agentManager.review.sendAllShortcut.other")
const labels = (): AnnotationLabels => ({
commentOnLine: (line) => t("agentManager.review.commentOnLine", { line }),
editCommentOnLine: (line) => t("agentManager.review.editCommentOnLine", { line }),
placeholder: t("agentManager.review.commentPlaceholder"),
cancel: t("common.cancel"),
comment: t("agentManager.review.commentAction"),
send: t("prompt.action.send"),
save: t("common.save"),
sendToChat: t("agentManager.review.sendToChat"),
edit: t("common.edit"),
delete: t("common.delete"),
})
const localComposer = createReviewComposer()
const composer = () => props.composer ?? localComposer
const [manualOpen, setManualOpen] = createSignal<Record<string, string[]>>({})
@@ -437,7 +425,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
updateComment,
deleteComment,
cancelDraft,
labels: labels(),
labels: labels(t),
activeTerminalId: props.activeTerminalId,
speech: reviewSpeech,
})
@@ -465,16 +453,7 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
const sendAllToChat = () => {
const all = comments()
if (all.length === 0) return
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: props.activeTerminalId ? "appendReviewCommentsToTerminal" : "appendReviewComments",
comments: all,
autoSend: true,
targetTerminalId: props.activeTerminalId,
},
}),
)
sendReviewComments(all, props.activeTerminalId)
preserveScroll(() => setComments([]))
props.onSendAll?.()
}
@@ -8,7 +8,7 @@ import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import type { LanguageContextValue } from "../src/context/language"
import { parseBindingTokens } from "./keybind-tokens"
interface Props {
export interface WorktreeCreateProps {
branch: string
bindings: Record<string, string>
loaded: boolean
@@ -16,59 +16,62 @@ interface Props {
onCreate: () => void
onNew: () => void
onSection: () => void
onSettings: () => void
}
export const ProjectActions: Component<Props> = (props) => (
<div class="am-project-actions">
<div class="am-split-button">
<TooltipKeybind
title={props.t("agentManager.shortcuts.advancedWorktree")}
keybind={props.bindings.newWorktree ?? ""}
export const WorktreeCreate: Component<WorktreeCreateProps> = (props) => (
<div class="am-split-button">
<TooltipKeybind
title={props.t("agentManager.shortcuts.advancedWorktree")}
keybind={props.bindings.newWorktree ?? ""}
>
<IconButton
icon="plus"
size="small"
variant="ghost"
label={props.t("agentManager.worktree.new")}
onClick={props.onNew}
disabled={!props.loaded}
/>
</TooltipKeybind>
<DropdownMenu gutter={4} placement="bottom-end">
<DropdownMenu.Trigger
class="am-split-arrow"
aria-label={props.t("agentManager.worktree.advancedOptions")}
disabled={!props.loaded}
>
<IconButton
icon="plus"
size="small"
variant="ghost"
label={props.t("agentManager.worktree.new")}
onClick={props.onNew}
disabled={!props.loaded}
/>
</TooltipKeybind>
<DropdownMenu gutter={4} placement="bottom-end">
<DropdownMenu.Trigger
class="am-split-arrow"
aria-label={props.t("agentManager.worktree.advancedOptions")}
disabled={!props.loaded}
>
<Icon name="chevron-down" size="small" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="am-split-menu">
<DropdownMenu.Item onSelect={props.onCreate}>
<span class="am-worktree-menu-gap" aria-hidden="true" />
<DropdownMenu.ItemLabel class="am-worktree-menu-label">
<span>{props.t("sidebar.session.newWorktree.from")}</span>
<span class="am-worktree-menu-branch">
<Icon name="branch" size="small" />
<strong>{props.branch}</strong>
</span>
</DropdownMenu.ItemLabel>
<span class="am-menu-shortcut">
{parseBindingTokens(props.bindings.quickWorktree ?? "").map((token) => (
<kbd class="am-menu-key">{token}</kbd>
))}
<Icon name="chevron-down" size="small" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="am-split-menu">
<DropdownMenu.Item onSelect={props.onCreate}>
<span class="am-worktree-menu-gap" aria-hidden="true" />
<DropdownMenu.ItemLabel class="am-worktree-menu-label">
<span>{props.t("sidebar.session.newWorktree.from")}</span>
<span class="am-worktree-menu-branch">
<Icon name="branch" size="small" />
<strong>{props.branch}</strong>
</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={props.onSection}>
<Icon name="plus" size="small" />
<DropdownMenu.ItemLabel>{props.t("agentManager.worktree.newSection")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</DropdownMenu.ItemLabel>
<span class="am-menu-shortcut">
{parseBindingTokens(props.bindings.quickWorktree ?? "").map((token) => (
<kbd class="am-menu-key">{token}</kbd>
))}
</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={props.onSection}>
<Icon name="plus" size="small" />
<DropdownMenu.ItemLabel>{props.t("agentManager.worktree.newSection")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)
export const ProjectActions: Component<WorktreeCreateProps & { onSettings: () => void }> = (props) => (
<div class="am-project-actions">
<WorktreeCreate {...props} />
<IconButton
icon="settings-gear"
size="small"
@@ -2,28 +2,18 @@
import type { Accessor, Component } from "solid-js"
import { Show } from "solid-js"
import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu"
import { Icon } from "@kilocode/kilo-ui/icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import type { LanguageContextValue } from "../src/context/language"
import { parseBindingTokens } from "./keybind-tokens"
import { WorktreeCreate, type WorktreeCreateProps } from "./ProjectActions"
import { SidebarSearchMenu, type SidebarSearchMenuRef } from "./SidebarSearchMenu"
import type { SidebarSearchItem } from "./sidebar-search"
interface WorktreeSectionActionsProps {
interface WorktreeSectionActionsProps extends WorktreeCreateProps {
items: Accessor<SidebarSearchItem[]>
current: Accessor<SidebarSearchItem | undefined>
bindings: Record<string, string>
branch: string
git: boolean
loaded: boolean
t: LanguageContextValue["t"]
onRef: (ref: SidebarSearchMenuRef) => void
onSelect: (item: SidebarSearchItem) => void
onCreate: () => void
onNew: () => void
onSection: () => void
onShortcuts: () => void
onSettings: () => void
onHistory: () => void
@@ -47,54 +37,7 @@ export const WorktreeSectionActions: Component<WorktreeSectionActionsProps> = (p
onSelect={props.onSelect}
/>
<Show when={props.git}>
<div class="am-split-button">
<TooltipKeybind
title={props.t("agentManager.shortcuts.advancedWorktree")}
keybind={props.bindings.newWorktree ?? ""}
>
<IconButton
icon="plus"
size="small"
variant="ghost"
label={props.t("agentManager.worktree.new")}
onClick={props.onNew}
disabled={!props.loaded}
/>
</TooltipKeybind>
<DropdownMenu gutter={4} placement="bottom-end">
<DropdownMenu.Trigger
class="am-split-arrow"
aria-label={props.t("agentManager.worktree.advancedOptions")}
disabled={!props.loaded}
>
<Icon name="chevron-down" size="small" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="am-split-menu">
<DropdownMenu.Item onSelect={props.onCreate}>
<span class="am-worktree-menu-gap" aria-hidden="true" />
<DropdownMenu.ItemLabel class="am-worktree-menu-label">
<span>{props.t("sidebar.session.newWorktree.from")}</span>
<span class="am-worktree-menu-branch">
<Icon name="branch" size="small" />
<strong>{props.branch}</strong>
</span>
</DropdownMenu.ItemLabel>
<span class="am-menu-shortcut">
{parseBindingTokens(props.bindings.quickWorktree ?? "").map((token) => (
<kbd class="am-menu-key">{token}</kbd>
))}
</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={props.onSection}>
<Icon name="plus" size="small" />
<DropdownMenu.ItemLabel>{props.t("agentManager.worktree.newSection")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
<WorktreeCreate {...props} />
<TooltipKeybind
title={props.t("agentManager.shortcuts.title")}
keybind={props.bindings.showShortcuts ?? ""}
@@ -41,7 +41,7 @@ import {
reviewDraftSpeechKey,
reviewEditSpeechKey,
sendReviewComments,
type AnnotationLabels,
labels,
type AnnotationMeta,
type ReviewComposer,
type ReviewDraft,
@@ -122,18 +122,6 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
const sendAllKeybind = () =>
isMac ? t("agentManager.review.sendAllShortcut.mac") : t("agentManager.review.sendAllShortcut.other")
const labels = (): AnnotationLabels => ({
commentOnLine: (line) => t("agentManager.review.commentOnLine", { line }),
editCommentOnLine: (line) => t("agentManager.review.editCommentOnLine", { line }),
placeholder: t("agentManager.review.commentPlaceholder"),
cancel: t("common.cancel"),
comment: t("agentManager.review.commentAction"),
send: t("prompt.action.send"),
save: t("common.save"),
sendToChat: t("agentManager.review.sendToChat"),
edit: t("common.edit"),
delete: t("common.delete"),
})
const localComposer = createReviewComposer()
const composer = () => props.composer ?? localComposer
const [manualOpen, setManualOpen] = createSignal<Record<string, string[]>>({})
@@ -437,7 +425,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
updateComment,
deleteComment,
cancelDraft,
labels: labels(),
labels: labels(t),
activeTerminalId: props.activeTerminalId,
speech: reviewSpeech,
})
@@ -458,16 +446,7 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
const sendAllToChat = () => {
const all = comments()
if (all.length === 0) return
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: props.activeTerminalId ? "appendReviewCommentsToTerminal" : "appendReviewComments",
comments: all,
autoSend: true,
targetTerminalId: props.activeTerminalId,
},
}),
)
sendReviewComments(all, props.activeTerminalId)
preserveScroll(() => setComments([]))
props.onSendAll?.()
}
@@ -16,6 +16,21 @@ export interface AnnotationLabels {
delete: string
}
export function labels(t: (key: string, params?: Record<string, string | number>) => string): AnnotationLabels {
return {
commentOnLine: (line) => t("agentManager.review.commentOnLine", { line }),
editCommentOnLine: (line) => t("agentManager.review.editCommentOnLine", { line }),
placeholder: t("agentManager.review.commentPlaceholder"),
cancel: t("common.cancel"),
comment: t("agentManager.review.commentAction"),
send: t("prompt.action.send"),
save: t("common.save"),
sendToChat: t("agentManager.review.sendToChat"),
edit: t("common.edit"),
delete: t("common.delete"),
}
}
// A draft is the active unsaved inline comment composer opened from the gutter.
// It becomes a normal comment only after the user submits the textarea.
export interface AnnotationMeta {
@@ -18,12 +18,12 @@ import {
buildReviewAnnotation,
createReviewComposer,
sendReviewComments,
type AnnotationLabels,
labels,
type AnnotationMeta,
type ReviewComposer,
type ReviewDraft,
} from "../diff-viewer/review-annotations"
import { lineCount } from "../diff-viewer/review-comments"
import { getFilename, lineCount } from "../diff-viewer/review-comments"
import { useLanguage } from "../src/context/language"
export interface DocumentPanelProps {
@@ -43,10 +43,6 @@ export interface DocumentPanelProps {
visible: Accessor<boolean>
}
function pathName(file: string): string {
return file.slice(file.lastIndexOf("/") + 1)
}
function virtualDiff(file: string, content: string): WorktreeFileDiff {
return {
file,
@@ -98,18 +94,6 @@ export const DocumentPanel: Component<DocumentPanelProps> = (props) => {
const file = () => selected()?.file ?? ""
const content = () => data()?.content ?? ""
const diff = () => virtualDiff(file(), content())
const labels = (): AnnotationLabels => ({
commentOnLine: (line) => t("agentManager.review.commentOnLine", { line }),
editCommentOnLine: (line) => t("agentManager.review.editCommentOnLine", { line }),
placeholder: t("agentManager.review.commentPlaceholder"),
cancel: t("common.cancel"),
comment: t("agentManager.review.commentAction"),
send: t("prompt.action.send"),
save: t("common.save"),
sendToChat: t("agentManager.review.sendToChat"),
edit: t("common.edit"),
delete: t("common.delete"),
})
const updateComments = (next: ReviewComment[]) => props.onCommentsChange(next)
const comments = () => props.comments.filter((item) => item.file === file())
@@ -176,7 +160,7 @@ export const DocumentPanel: Component<DocumentPanelProps> = (props) => {
updateComment,
deleteComment,
cancelDraft,
labels: labels(),
labels: labels(t),
activeTerminalId: props.activeTerminalId,
})
const gutter = (range: SelectedLineRange) => {
@@ -293,7 +277,7 @@ export const DocumentPanel: Component<DocumentPanelProps> = (props) => {
<SortableClosableTab
id={id}
class="am-document-tab"
label={pathName(tab.file)}
label={getFilename(tab.file)}
tooltip={tab.file}
icon="open-file"
iconNode={<FileIcon node={{ path: tab.file, type: "file" }} class="am-document-tab-icon" />}
@@ -3,9 +3,6 @@
/**
* MessageList component
* Scrollable turn-based message list with virtualization.
* Each user message is rendered as a VscodeSessionTurn — a custom component that
* renders all assistant parts as a flat, verbose list with no context grouping,
* and fully expands sub-agent (task tool) parts inline.
* Shows recent sessions in the empty state for quick resumption.
*/
@@ -1,210 +0,0 @@
/**
* VscodeSessionTurn component
* Custom replacement for the upstream SessionTurn, designed for the VS Code sidebar.
*
* Key differences from upstream SessionTurn:
* - No "Gathered context" grouping — each tool call is rendered individually
* - Sub-agents are fully expanded inline via TaskToolExpanded
* - No per-turn auto-scroll (MessageList handles it)
* - Simpler flat structure without overflow containers
*/
import { Component, createMemo, For, Show, createEffect } from "solid-js"
import { UserMessageDisplay } from "@kilocode/kilo-ui/message-part"
import { DiffChanges } from "@kilocode/kilo-ui/diff-changes"
import { Icon } from "@kilocode/kilo-ui/icon"
import { useData } from "@kilocode/kilo-ui/context/data"
import { useI18n } from "@kilocode/kilo-ui/context/i18n"
import { AssistantMessage } from "./AssistantMessage"
import type {
AssistantMessage as SDKAssistantMessage,
Message as SDKMessage,
Part as SDKPart,
SnapshotFileDiff,
} from "@kilocode/sdk/v2"
import { ErrorDisplay } from "./ErrorDisplay"
import { useServer } from "../../context/server"
import { useSession } from "../../context/session"
import { useLanguage } from "../../context/language"
import { useVSCode } from "../../context/vscode"
import { useFeedback } from "../../context/feedback"
import { visibleError } from "../../context/session-errors"
import type { ErrorDisplayProps } from "./ErrorDisplay"
import type { Message as WebMessage } from "../../types/messages"
export interface VscodeTurn {
id: string
user: WebMessage
assistant: WebMessage[]
partial?: boolean
}
interface VscodeSessionTurnProps {
turn: VscodeTurn
queued?: boolean
onForkMessage?: (sessionId: string, messageId: string) => void
}
export const VscodeSessionTurn: Component<VscodeSessionTurnProps> = (props) => {
const data = useData()
const i18n = useI18n()
const server = useServer()
const session = useSession()
const language = useLanguage()
const vscode = useVSCode()
const feedback = useFeedback()
const emptyParts: SDKPart[] = []
const emptyDiffs: SnapshotFileDiff[] = []
createEffect(() => {
const turn = props.turn
const ids = turn.partial ? turn.assistant.map((m) => m.id) : [turn.user.id, ...turn.assistant.map((m) => m.id)]
session.hydrateParts(ids)
})
const message = createMemo(() => props.turn.user as SDKMessage & { role: "user" })
const parts = createMemo(() => {
const msg = message()
return (data.store.part?.[msg.id] ?? emptyParts) as SDKPart[]
})
const assistantMessages = createMemo(() => props.turn.assistant as SDKAssistantMessage[])
const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError"))
const error = createMemo(() => visibleError(assistantMessages(), session.isErrorHidden))
// Diffs from message summary
const diffs = createMemo(() => {
const rawDiffs = (message() as unknown as { summary?: { diffs?: unknown[] } } | undefined)?.summary?.diffs
if (!rawDiffs?.length) return emptyDiffs
const seen = new Set<string>()
return (rawDiffs as SnapshotFileDiff[])
.reduceRight<SnapshotFileDiff[]>((result, diff) => {
const file = diff.file ?? ""
if (seen.has(file)) return result
seen.add(file)
result.push(diff)
return result
}, [])
.reverse()
})
const openChanges = () => vscode.postMessage({ type: "openChanges", turnId: message().id })
// Copy part ID — the last text part from the last assistant message.
// Synthetic parts (e.g. "Initializing snapshot…" from the slow-repo guard)
// are transient status lines, not assistant output: they must never win
// this lookup, otherwise the copy button renders beside the spinner
// instead of the real response.
const showAssistantCopyPartID = createMemo(() => {
const msgs = assistantMessages()
for (let i = msgs.length - 1; i >= 0; i--) {
const msg = msgs[i]
if (!msg) continue
const msgParts = (data.store.part?.[msg.id] ?? emptyParts) as SDKPart[]
for (let j = msgParts.length - 1; j >= 0; j--) {
const part = msgParts[j]
if (!part || part.type !== "text") continue
if ((part as SDKPart & { synthetic?: boolean }).synthetic) continue
if ((part as SDKPart & { text: string }).text?.trim()) return part.id
}
}
return undefined
})
return (
<Show when={message()}>
{(msg) => (
<div class="vscode-session-turn" data-message={msg().id}>
{/* User message */}
<Show when={!props.turn.partial}>
<div
class="vscode-session-turn-user"
data-revert-disabled={assistantMessages().length > 0 && session.status() !== "idle" ? "" : undefined}
title={
assistantMessages().length > 0 && session.status() !== "idle"
? language.t("revert.disabled.agentBusy")
: undefined
}
>
<UserMessageDisplay
message={msg() as unknown as Parameters<typeof UserMessageDisplay>[0]["message"]}
parts={parts() as unknown as Parameters<typeof UserMessageDisplay>[0]["parts"]}
interrupted={interrupted()}
queued={props.queued}
onFork={props.onForkMessage ? () => props.onForkMessage?.(msg().sessionID, msg().id) : undefined}
onRevert={
assistantMessages().length > 0
? () => {
if (session.status() !== "idle") return
session.revertSession(msg().id)
}
: undefined
}
/>
</div>
</Show>
{/* Assistant parts — flat list, no context grouping */}
<Show when={assistantMessages().length > 0}>
<div class="vscode-session-turn-assistant">
<For each={assistantMessages()}>
{(amsg) => (
<AssistantMessage
message={amsg}
showAssistantCopyPartID={showAssistantCopyPartID()}
feedback={{
enabled: feedback.telemetryEnabled(),
rating: feedback.getRating(amsg.id),
onRate: (next) =>
feedback.rate({
messageID: amsg.id,
sessionID: amsg.sessionID,
parentMessageID: amsg.parentID,
providerID: amsg.providerID,
modelID: amsg.modelID,
variant: (amsg as SDKAssistantMessage & { variant?: string }).variant,
next,
}),
}}
/>
)}
</For>
</div>
</Show>
{/* Diff summary — shown after completion. Click opens the changes view. */}
<Show when={diffs().length > 0 && server.gitInstalled()}>
<div class="vscode-session-turn-diffs" data-component="session-turn">
<button
type="button"
class="vscode-session-turn-diffs-trigger"
onClick={openChanges}
aria-label={i18n.t("ui.sessionReview.change.modified")}
>
<span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
<span data-slot="session-turn-diffs-count">
{diffs().length} {i18n.t(diffs().length === 1 ? "ui.common.file.one" : "ui.common.file.other")}
</span>
<span data-slot="session-turn-diffs-meta">
<DiffChanges changes={diffs()} variant="bars" />
</span>
<span data-slot="session-turn-diffs-chevron" aria-hidden="true">
<Icon name="chevron-right" size="small" />
</span>
</button>
</div>
</Show>
{/* Error handling */}
<Show when={error()}>
{(err) => <ErrorDisplay error={err() as ErrorDisplayProps["error"]} onLogin={server.goToLogin} />}
</Show>
</div>
)}
</Show>
)
}
@@ -166,7 +166,6 @@ interface SessionContextValue {
isErrorHidden: (messageID: string) => boolean
// Move stashed parts into the reactive store for the given message IDs.
// Called by VscodeSessionTurn when the virtualizer renders a turn.
hydrateParts: (messageIDs: string[]) => void
// Todos for current session
@@ -363,7 +362,7 @@ export const SessionProvider: ParentComponent = (props) => {
const [pages, setPages] = createStore<Record<string, MessagePageState>>({})
// Parts stash: holds parts from messagesLoaded outside the reactive store
// until a VscodeSessionTurn is rendered by the virtualizer and calls
// until a TranscriptRowView is rendered by the virtualizer and calls
// hydrateParts(). This avoids writing parts for off-screen messages into
// the store, which would trigger expensive DOM work for invisible content.
const stash = new PartStash()
@@ -11,7 +11,10 @@ import type { Meta, StoryObj } from "storybook-solidjs-vite"
import type { AssistantMessage as SDKAssistantMessage, ReasoningPart, TextPart, ToolPart } from "@kilocode/sdk/v2"
import { StoryProviders, defaultMockData, mockSessionValue } from "./StoryProviders"
import { AssistantMessage } from "../components/chat/AssistantMessage"
import { VscodeSessionTurn } from "../components/chat/VscodeSessionTurn"
import { For } from "solid-js"
import { TranscriptRowView } from "../components/chat/TranscriptRow"
import { messageTurns } from "../context/session-queue"
import { transcriptRows } from "../context/transcript-rows"
import { ChatView } from "../components/chat/ChatView"
import { Part } from "@kilocode/kilo-ui/message-part"
import { registerVscodeToolOverrides } from "../components/chat/VscodeToolOverrides"
@@ -1380,20 +1383,28 @@ export const DiffSummaryCollapsed: Story = {
{
id: USER_MSG_ID,
sessionID: SESSION_ID,
role: "user",
role: "user" as const,
createdAt: new Date(now - 10000).toISOString(),
time: { created: now - 10000 },
summary: { diffs: mockDiffs },
},
{ ...baseAssistantMessage, parentID: USER_MSG_ID },
{ ...baseAssistantMessage, parentID: USER_MSG_ID, createdAt: new Date(now - 9000).toISOString() },
],
},
part: {
[USER_MSG_ID]: [
{ id: "part-user-text", sessionID: SESSION_ID, messageID: USER_MSG_ID, type: "text", text: "Fix the bug" },
{
id: "part-user-text",
sessionID: SESSION_ID,
messageID: USER_MSG_ID,
type: "text" as const,
text: "Fix the bug",
},
],
[ASST_MSG_ID]: [textPart],
},
}
const parts = new Map(Object.entries(data.part))
const session = {
...mockSessionValue({ id: SESSION_ID, status: "idle" }),
messages: () => data.message[SESSION_ID],
@@ -1419,13 +1430,9 @@ export const DiffSummaryCollapsed: Story = {
<ServerContext.Provider value={server as any}>
<SessionContext.Provider value={session as any}>
<div style={{ width: "380px", padding: "12px" }}>
<VscodeSessionTurn
turn={{
id: USER_MSG_ID,
user: data.message[SESSION_ID][0] as any,
assistant: [data.message[SESSION_ID][1] as any],
}}
/>
<For each={transcriptRows(messageTurns(data.message[SESSION_ID]), (id) => parts.get(id) ?? [])}>
{(row) => <TranscriptRowView row={row} />}
</For>
</div>
</SessionContext.Provider>
</ServerContext.Provider>