diff --git a/.changeset/calm-cats-review.md b/.changeset/calm-cats-review.md new file mode 100644 index 00000000000..6271f5b67c2 --- /dev/null +++ b/.changeset/calm-cats-review.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show submitted review comments as interactive message cards instead of raw markdown. diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/user-message-review-comments-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/user-message-review-comments-chromium-linux.png new file mode 100644 index 00000000000..606fab3eb74 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/user-message-review-comments-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ae8197b9d63cf977fa5112a1b02eb48bc22a1577b4dee15abd475805f0db5b0 +size 11769 diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index ba9527f8deb..bb63ab85c1b 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -731,6 +731,9 @@ export function UserMessageDisplay(props: { interrupted?: boolean animate?: boolean queued?: boolean + text?: string + copyText?: string + header?: JSX.Element onFork?: () => void onRevert?: () => void }) { @@ -743,7 +746,7 @@ export function UserMessageDisplay(props: { () => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined, ) - const text = createMemo(() => textPart()?.text || "") + const text = createMemo(() => props.text ?? textPart()?.text ?? "") const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? []) @@ -797,7 +800,7 @@ export function UserMessageDisplay(props: { } const handleCopy = async () => { - const content = text() + const content = props.copyText ?? text() if (!content) return await navigator.clipboard.writeText(content) setCopied(true) @@ -840,12 +843,15 @@ export function UserMessageDisplay(props: { - + <>
-
- -
+ {props.header} + +
+ +
+
diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 48328ea1ba8..74e7df97e52 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -126,6 +126,7 @@ import { } from "./kilo-provider/handlers/question" import { fetchAndSendPendingSuggestions } from "./kilo-provider/handlers/suggestion" import { nativeTitle } from "./kilo-provider/native-tab-title" +import { parseReview, reviewMetadata, type ReviewMessageData } from "./shared/review-comments" import { buildActionContext, @@ -798,6 +799,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper message.agent, message.variant, parseMessageFiles(message.files), + parseReview(message.review, message.text), typeof message.agentManagerContext === "string" ? message.agentManagerContext : undefined, typeof msg.contextDirectory === "string" ? msg.contextDirectory : undefined, ) @@ -1122,6 +1124,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper message.agent, message.variant, files, + parseReview(message.review, message.text), typeof message.command === "string" ? message.command : undefined, typeof message.commandArgs === "string" ? message.commandArgs : undefined, ) @@ -2580,6 +2583,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper agent?: string, variant?: string, files?: MessageFile[], + review?: ReviewMessageData, context?: string, contextDirectory?: string, ): Promise { @@ -2592,6 +2596,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper draftID, messageID, files, + review, }) return } @@ -2606,7 +2611,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper parts.push({ type: "file", mime: f.mime, url: f.url, filename: f.filename, source: f.source }) } } - parts.push({ type: "text", text }) + parts.push({ type: "text", text, metadata: review ? reviewMetadata(review) : undefined }) const sid = resolved!.sid const dir = resolved!.dir @@ -2644,6 +2649,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper draftID, messageID, files, + review, }) } } diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts index f2cab56fab4..19a3b2ab60d 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts @@ -9,6 +9,7 @@ import type { KiloClient, Session, TextPartInput, FilePartInput } from "@kilocod import type { CloudSessionData, EditorContext } from "../../services/cli-backend/types" import { getErrorMessage, sessionToWebview, mapCloudSessionMessageToWebviewMessage } from "../../kilo-provider-utils" import type { MessageFile } from "../message-files" +import { reviewMetadata, type ReviewMessageData } from "../../shared/review-comments" const TIMEOUT = 30_000 @@ -119,6 +120,7 @@ export async function handleImportAndSend( agent?: string, variant?: string, files?: MessageFile[], + review?: ReviewMessageData, command?: string, commandArgs?: string, ): Promise { @@ -213,7 +215,7 @@ export async function handleImportAndSend( parts.push({ type: "file", mime: f.mime, url: f.url, filename: f.filename, source: f.source }) } } - parts.push({ type: "text", text }) + parts.push({ type: "text", text, metadata: review ? reviewMetadata(review) : undefined }) const editorContext = await ctx.gatherEditorContext() await client.session.promptAsync( @@ -240,6 +242,7 @@ export async function handleImportAndSend( draftID: session.id, messageID, files, + review: command ? undefined : review, }) } } diff --git a/packages/kilo-vscode/src/shared/review-comments.ts b/packages/kilo-vscode/src/shared/review-comments.ts new file mode 100644 index 00000000000..1e5ae19d653 --- /dev/null +++ b/packages/kilo-vscode/src/shared/review-comments.ts @@ -0,0 +1,118 @@ +export interface ReviewCommentData { + id: string + file: string + side: "additions" | "deletions" + line: number + comment: string + selectedText: string +} + +export interface ReviewMessageData { + version: 1 + comments: ReviewCommentData[] +} + +interface ReviewMessageView { + data: ReviewMessageData + body: string +} + +const LIMIT = 100 +const TOTAL_LIMIT = 1_000_000 +const TEXT_LIMIT = 100_000 +const SELECTION_LIMIT = 200_000 + +function escapeInline(value: string): string { + return value.replace(/([\\`*_\[\]{}()#+\-!|])/g, "\\$1") +} + +export function formatReviewCommentMarkdown(comment: ReviewCommentData): string { + const lines = [`**${escapeInline(comment.file)}** (line ${comment.line}):`] + if (comment.selectedText) { + const matches = comment.selectedText.match(/`+/g) ?? [] + const longest = matches.reduce((max, item) => Math.max(max, item.length), 0) + const fence = "`".repeat(Math.max(3, longest + 1)) + lines.push(fence, comment.selectedText, fence) + } + lines.push(comment.comment) + return lines.join("\n") +} + +export function formatReviewCommentsMarkdown(comments: ReviewCommentData[]): string { + const lines = ["## Review Comments", ""] + for (const item of comments) { + lines.push(formatReviewCommentMarkdown(item), "") + } + return lines.join("\n").trimEnd() +} + +function record(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined + return value as Record +} + +function text(value: unknown, limit: number): string | undefined { + if (typeof value !== "string" || value.length > limit) return undefined + return value +} + +function parseComment(value: unknown): ReviewCommentData | undefined { + const item = record(value) + if (!item) return undefined + + const id = text(item.id, 512) + const file = text(item.file, 4_096) + const comment = text(item.comment, TEXT_LIMIT) + const selectedText = text(item.selectedText, SELECTION_LIMIT) + const side = item.side + const line = item.line + if (!id || !file || comment === undefined || selectedText === undefined) return undefined + const absolute = file.startsWith("/") || file.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(file) + const traversal = file.split(/[\\/]/).includes("..") + if (absolute || traversal || file.includes("\0")) return undefined + if (side !== "additions" && side !== "deletions") return undefined + if (typeof line !== "number" || !Number.isInteger(line) || line < 1) return undefined + + return { id, file, side, line, comment, selectedText } +} + +function view(value: unknown, content: string): ReviewMessageView | undefined { + const data = record(value) + if (!data || data.version !== 1 || !Array.isArray(data.comments)) return undefined + if (data.comments.length === 0 || data.comments.length > LIMIT) return undefined + + const comments: ReviewCommentData[] = [] + for (const value of data.comments) { + const item = parseComment(value) + if (!item) return undefined + comments.push(item) + } + const size = comments.reduce( + (total, item) => total + item.id.length + item.file.length + item.comment.length + item.selectedText.length, + 0, + ) + if (size > TOTAL_LIMIT) return undefined + + const prefix = formatReviewCommentsMarkdown(comments) + if (content === prefix) return { data: { version: 1, comments }, body: "" } + if (!content.startsWith(`${prefix}\n\n`)) return undefined + return { data: { version: 1, comments }, body: content.slice(prefix.length + 2) } +} + +export function parseReview(value: unknown, content: string): ReviewMessageData | undefined { + return view(value, content)?.data +} + +export function reviewMetadata(review: ReviewMessageData): Record { + return { kilo: { review } } +} + +export function reviewBody(review: ReviewMessageData, content: string): string | undefined { + return view(review, content)?.body +} + +export function partReview(metadata: unknown, content: string): ReviewMessageView | undefined { + const root = record(metadata) + const kilo = record(root?.kilo) + return view(kilo?.review, content) +} diff --git a/packages/kilo-vscode/tests/unit/review-comments.test.ts b/packages/kilo-vscode/tests/unit/review-comments.test.ts index 91adacced9c..7e40570bf58 100644 --- a/packages/kilo-vscode/tests/unit/review-comments.test.ts +++ b/packages/kilo-vscode/tests/unit/review-comments.test.ts @@ -19,6 +19,7 @@ import { reviewEditSpeechKey, } from "../../webview-ui/diff-viewer/review-annotations" import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages" +import { parseReview, partReview, reviewMetadata } from "../../src/shared/review-comments" function diff(file: string, before: string, after: string): WorktreeFileDiff { return { file, before, after, additions: 1, deletions: 0 } @@ -169,6 +170,41 @@ describe("formatReviewCommentsMarkdown", () => { }) }) +describe("review message metadata", () => { + const comments = [comment({ file: "src/a.ts", line: 5, comment: "Fix this", selectedText: "const a = 1" })] + const content = `${formatReviewCommentsMarkdown(comments)}\n\nPlease address this feedback.` + const review = { version: 1 as const, comments } + + it("round-trips review comments and extracts the visible body", () => { + expect(partReview(reviewMetadata(review), content)).toEqual({ + data: review, + body: "Please address this feedback.", + }) + }) + + it("extracts an empty body from a review-only message", () => { + expect(partReview(reviewMetadata(review), formatReviewCommentsMarkdown(comments))?.body).toBe("") + }) + + it("rejects malformed review comments", () => { + expect(parseReview({ ...review, comments: [{ ...comments[0], line: 0 }] }, content)).toBeUndefined() + expect(parseReview({ ...review, comments: [{ ...comments[0], side: "context" }] }, content)).toBeUndefined() + expect(parseReview({ ...review, comments: [{ ...comments[0], file: "../secret" }] }, content)).toBeUndefined() + expect(parseReview({ ...review, comments: [{ ...comments[0], file: "/tmp/secret" }] }, content)).toBeUndefined() + }) + + it("rejects metadata that does not match the hidden text", () => { + expect(parseReview(review, `unrelated hidden text\n\n${content}`)).toBeUndefined() + }) + + it("rejects oversized aggregate metadata before formatting it", () => { + const oversized = Array.from({ length: 6 }, (_, index) => + comment({ id: `comment-${index}`, selectedText: "x".repeat(200_000) }), + ) + expect(parseReview({ version: 1, comments: oversized }, "irrelevant")).toBeUndefined() + }) +}) + // ── extractLines ──────────────────────────────────────────────────────────── describe("extractLines", () => { diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 898301bc4e7..36876e5c418 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -5,19 +5,16 @@ import { createSignal, createEffect, on, For, Index, onCleanup, Show, untrack, type Component } from "solid-js" import { Button } from "@kilocode/kilo-ui/button" -import { Dialog } from "@kilocode/kilo-ui/dialog" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { FileIcon } from "@kilocode/kilo-ui/file-icon" import { Icon } from "@kilocode/kilo-ui/icon" import { showToast } from "@kilocode/kilo-ui/toast" -import { useDialog } from "@kilocode/kilo-ui/context/dialog" import { useSession } from "../../context/session" import { useServer } from "../../context/server" import { useIndexing } from "../../context/indexing" import { useLanguage } from "../../context/language" import { useVSCode } from "../../context/vscode" -import { useWorktreeMode } from "../../context/worktree-mode" import { useConfig } from "../../context/config" import { useProvider } from "../../context/provider" import { ModelSelector } from "../shared/ModelSelector" @@ -46,9 +43,11 @@ import { isPromptBusy, isPathMention, } from "./prompt-input-utils" -import type { ReviewComment, TextPart } from "../../types/messages" +import type { ReviewComment, SendMessageFailedMessage, TextPart } from "../../types/messages" import { formatReviewCommentsMarkdown } from "../../utils/review-comment-markdown" import { pendingDraftKey, scopeDraftKey, sessionDraftKey } from "../../utils/prompt-drafts" +import { ReviewComments } from "./ReviewComments" +import { partReview, reviewBody } from "../../../../src/shared/review-comments" // Per-session input text storage (module-level so it survives remounts) const drafts = new Map() @@ -82,8 +81,6 @@ export const PromptInput: Component = (props) => { const provider = useProvider() const language = useLanguage() const vscode = useVSCode() - const worktree = useWorktreeMode() - const dialog = useDialog() const sid = () => session.currentSessionID() ?? props.pendingSessionID ?? session.draftSessionID() ?? undefined const ctx = () => { const id = props.boxId @@ -165,54 +162,6 @@ export const PromptInput: Component = (props) => { replaceReviewComments(reviewComments().filter((item) => item.id !== id)) } - const openReviewFile = (item: ReviewComment) => { - const id = session.currentSessionID() - if (worktree && id) { - vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: item.file, line: item.line }) - dialog.close() - return - } - vscode.postMessage({ type: "openFile", filePath: item.file, line: item.line, column: 1 }) - dialog.close() - } - - const side = (item: ReviewComment) => (item.side === "deletions" ? "-" : "+") - const reviewChipTitle = (item: ReviewComment) => `${fileName(item.file)} ${side(item)}${item.line}` - - const showReviewCommentDialog = (item: ReviewComment) => { - dialog.show(() => ( - -
-
- {reviewChipTitle(item)} - - openReviewFile(item)} - /> - -
- -
- {language.t("agentManager.review.metaFile")} - {item.file} - {language.t("agentManager.review.metaLine")} - L{item.line} - {language.t("agentManager.review.metaComment")} - {item.comment} -
- - -
{item.selectedText}
-
-
-
- )) - } - let textareaRef: HTMLTextAreaElement | undefined let highlightRef: HTMLDivElement | undefined let dropdownRef: HTMLDivElement | undefined @@ -252,11 +201,10 @@ export const PromptInput: Component = (props) => { if (msgs.length === 0) return const texts = msgs.map((m) => { const parts = session.getParts(m.id) - const raw = parts - .filter((p): p is TextPart => p.type === "text") - .map((p) => p.text) + return parts + .filter((part): part is TextPart => part.type === "text") + .map((part) => partReview(part.metadata, part.text)?.body ?? part.text.replace(REVIEW_PREFIX, "")) .join("") - return raw.replace(REVIEW_PREFIX, "") }) history.seed(texts) }) @@ -386,6 +334,39 @@ export const PromptInput: Component = (props) => { } }) + const restoreFailed = (failed: SendMessageFailedMessage) => { + // Only restore a failed draft when the user has not started another one. + const target = scopeDraftKey( + boxKey(), + sessionDraftKey(failed.sessionID) ?? pendingDraftKey(failed.draftID) ?? "new", + ) + if (target !== draftKey() || text().trim() || reviewComments().length > 0 || imageAttach.images().length > 0) return + + const draft = failed.review ? reviewBody(failed.review, failed.text) : failed.text + if (draft === undefined) return + if (failed.review) replaceReviewComments(failed.review.comments) + if (draft) { + setText(draft) + mention.seedFromText(draft) + if (textareaRef) { + textareaRef.value = draft + adjustHeight() + textareaRef.focus() + } + } + const images = (failed.files ?? []) + .filter((file) => file.mime.startsWith("image/") && file.url.startsWith("data:")) + .map((file) => ({ + id: crypto.randomUUID(), + filename: file.filename ?? "image", + mime: file.mime, + dataUrl: file.url, + })) + if (images.length === 0) return + imageAttach.replace(images) + imageDrafts.set(target, images) + } + const unsubscribe = vscode.onMessage((message) => { if (message.type === "setChatBoxMessage") { setText(message.text) @@ -428,36 +409,7 @@ export const PromptInput: Component = (props) => { } if (message.type === "sendMessageFailed") { - const failed = message as import("../../types/messages").SendMessageFailedMessage - // Only restore draft if the failure is for the current session and the - // input is empty (user hasn't started typing something new). - const target = scopeDraftKey( - boxKey(), - sessionDraftKey(failed.sessionID) ?? pendingDraftKey(failed.draftID) ?? "new", - ) - if (target === draftKey() && !text().trim() && imageAttach.images().length === 0) { - if (failed.text) { - setText(failed.text) - mention.seedFromText(failed.text) - if (textareaRef) { - textareaRef.value = failed.text - adjustHeight() - textareaRef.focus() - } - } - const images = (failed.files ?? []) - .filter((f) => f.mime.startsWith("image/") && f.url.startsWith("data:")) - .map((f) => ({ - id: crypto.randomUUID(), - filename: f.filename ?? "image", - mime: f.mime, - dataUrl: f.url, - })) - if (images.length > 0) { - imageAttach.replace(images) - imageDrafts.set(target, images) - } - } + restoreFailed(message as SendMessageFailedMessage) } if (message.type === "sessionCreated" && message.draftID) { @@ -772,6 +724,7 @@ export const PromptInput: Component = (props) => { const pending = reviewComments() const review = pending.length > 0 ? formatReviewCommentsMarkdown(pending) : "" const message = draft && review ? `${review}\n\n${draft}` : draft || review + const data = review ? { version: 1 as const, comments: pending } : undefined if ( (!message && imgs.length === 0) || isDisabled() || @@ -812,12 +765,11 @@ export const PromptInput: Component = (props) => { const attachments = allFiles.length > 0 ? allFiles : undefined // Server-side slash command (cmdMatch/matched already computed above) - if (matched) { - const rest = draft.slice(cmdMatch![0].length).trim() - const args = review && rest ? `${review}\n\n${rest}` : rest || review + if (matched && !data) { + const args = draft.slice(cmdMatch![0].length).trim() session.sendCommand(matched.name, args, sel?.providerID, sel?.modelID, attachments, pendingId, context) } else { - session.sendMessage(message, sel?.providerID, sel?.modelID, attachments, pendingId, context) + session.sendMessage(message, sel?.providerID, sel?.modelID, attachments, pendingId, context, data) } drafts.delete(key) @@ -845,54 +797,12 @@ export const PromptInput: Component = (props) => { onDrop={imageAttach.handleDrop} > 0}> -
-
- - {language.t("agentManager.review.inlineCount", { count: reviewComments().length })} - - -
-
- - {(item) => ( -
- - -
- )} -
-
-
+
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ReviewComments.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ReviewComments.tsx new file mode 100644 index 00000000000..ccc09604985 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ReviewComments.tsx @@ -0,0 +1,129 @@ +import { For, Show, type Component } from "solid-js" +import { Button } from "@kilocode/kilo-ui/button" +import { Dialog } from "@kilocode/kilo-ui/dialog" +import { Icon } from "@kilocode/kilo-ui/icon" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" +import { useDialog } from "@kilocode/kilo-ui/context/dialog" +import { useLanguage } from "../../context/language" +import { useVSCode } from "../../context/vscode" +import { useWorktreeMode } from "../../context/worktree-mode" +import type { ReviewComment } from "../../types/messages" +import { fileName } from "./prompt-input-utils" + +interface ReviewCommentsProps { + comments: ReviewComment[] + sessionID?: string + variant?: "draft" | "message" + onRemove?: (id: string) => void + onClear?: () => void +} + +export const ReviewComments: Component = (props) => { + const language = useLanguage() + const vscode = useVSCode() + const worktree = useWorktreeMode() + const dialog = useDialog() + const side = (item: ReviewComment) => (item.side === "deletions" ? "-" : "+") + const title = (item: ReviewComment) => `${fileName(item.file)} ${side(item)}${item.line}` + + const open = (item: ReviewComment) => { + if (worktree && props.sessionID) { + vscode.postMessage({ + type: "agentManager.openFile", + sessionId: props.sessionID, + filePath: item.file, + line: item.line, + }) + dialog.close() + return + } + vscode.postMessage({ type: "openFile", filePath: item.file, line: item.line, column: 1 }) + dialog.close() + } + + const show = (item: ReviewComment) => { + dialog.show(() => ( + +
+
+ {title(item)} + + open(item)} + /> + +
+ +
+ {language.t("agentManager.review.metaFile")} + {item.file} + {language.t("agentManager.review.metaLine")} + L{item.line} + {language.t("agentManager.review.metaComment")} + {item.comment} +
+ + +
{item.selectedText}
+
+
+
+ )) + } + + return ( +
+
+ + {language.t("agentManager.review.inlineCount", { count: props.comments.length })} + + + + +
+
+ + {(item) => ( +
+ + + + +
+ )} +
+
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx index 69a5c1cce2a..2e19b4c7d33 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TranscriptRow.tsx @@ -1,5 +1,4 @@ import { type Component, 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 { useI18n } from "@kilocode/kilo-ui/context/i18n" @@ -12,6 +11,7 @@ import { useVSCode } from "../../context/vscode" import { useFeedback } from "../../context/feedback" import { AssistantMessage } from "./AssistantMessage" import { ErrorDisplay, type ErrorDisplayProps } from "./ErrorDisplay" +import { VscodeUserMessage } from "./VscodeUserMessage" interface TranscriptRowViewProps { row: TranscriptRow @@ -48,9 +48,9 @@ export const TranscriptRowView: Component = (props) => { data-revert-disabled={row().answered && session.status() !== "idle" ? "" : undefined} title={row().answered && session.status() !== "idle" ? language.t("revert.disabled.agentBusy") : undefined} > - [0]["message"]} - parts={row().parts as unknown as Parameters[0]["parts"]} + void + onRevert?: () => void +} + +export const VscodeUserMessage: Component = (props) => { + const text = createMemo(() => props.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic)) + const review = createMemo(() => { + const part = text() + if (!part) return undefined + return partReview(part.metadata, part.text) + }) + const body = createMemo(() => review()?.body) + + return ( + [0]["message"]} + parts={props.parts as unknown as Parameters[0]["parts"]} + text={body()} + copyText={review() ? text()?.text : undefined} + header={ + review() ? ( + + ) : undefined + } + interrupted={props.interrupted} + queued={props.queued} + onFork={props.onFork} + onRevert={props.onRevert} + /> + ) +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index e589b4bde0b..705c366812a 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -70,6 +70,7 @@ import { mergeParts, sameParts } from "./session-parts" import { state as todoState } from "./todo-revert" import { getVariant, sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store" import { KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "../../../src/shared/provider-model" +import { reviewMetadata, type ReviewMessageData } from "../../../src/shared/review-comments" import { visibleMessages as filterVisibleMessages } from "./session-queue" const RECENT_LIMIT = 5 @@ -245,6 +246,7 @@ interface SessionContextValue { files?: FileAttachment[], draftID?: string, context?: string, + review?: ReviewMessageData, ) => void sendCommand: ( command: string, @@ -2055,7 +2057,13 @@ export const SessionProvider: ParentComponent = (props) => { } /** Create an optimistic user message + parts in the store so the UI updates instantly. */ - function addOptimistic(sid: string, messageID: string, text: string, files?: FileAttachment[]) { + function addOptimistic( + sid: string, + messageID: string, + text: string, + files?: FileAttachment[], + review?: ReviewMessageData, + ) { const now = Date.now() const temp: Message = { id: messageID, @@ -2070,7 +2078,13 @@ export const SessionProvider: ParentComponent = (props) => { const parts: Part[] = [] if (text) { - parts.push({ type: "text" as const, id: Identifier.ascending("part"), messageID, text }) + parts.push({ + type: "text" as const, + id: Identifier.ascending("part"), + messageID, + text, + metadata: review ? reviewMetadata(review) : undefined, + }) } for (const file of files ?? []) { parts.push({ @@ -2097,6 +2111,7 @@ export const SessionProvider: ParentComponent = (props) => { files?: FileAttachment[], draftID?: string, context?: string, + review?: ReviewMessageData, ) { if (!server.isConnected()) { console.warn("[Kilo New] Cannot send message: not connected") @@ -2119,6 +2134,7 @@ export const SessionProvider: ParentComponent = (props) => { agent, variant: currentVariant(scope), files, + review, }) return } @@ -2133,7 +2149,7 @@ export const SessionProvider: ParentComponent = (props) => { const scope = draftID ?? sid if (scope) { clearClose(scope) - addOptimistic(scope, messageID, text, files) + addOptimistic(scope, messageID, text, files, review) startSubmission(scope, messageID) if (!sid) setDraftSessionID(scope) } @@ -2150,6 +2166,7 @@ export const SessionProvider: ParentComponent = (props) => { agent, variant: currentVariant(scope), files, + review, agentManagerContext: context, }) } diff --git a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx index cdc7006d904..32bace4175c 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx @@ -16,11 +16,14 @@ import { TaskHeader } from "../components/chat/TaskHeader" import { QuestionDock } from "../components/chat/QuestionDock" import { SuggestBar } from "../components/chat/SuggestBar" import { MessageList } from "../components/chat/MessageList" +import { VscodeUserMessage } from "../components/chat/VscodeUserMessage" import { TurnOutcome } from "../components/shared/TurnOutcome" import { SessionContext } from "../context/session" import { ServerContext } from "../context/server" import { WorktreeModeProvider } from "../context/worktree-mode" -import type { Message, Part, QuestionRequest, SuggestionRequest, TodoItem } from "../types/messages" +import type { Message, Part, QuestionRequest, ReviewComment, SuggestionRequest, TodoItem } from "../types/messages" +import { formatReviewCommentsMarkdown } from "../utils/review-comment-markdown" +import { reviewMetadata } from "../../../src/shared/review-comments" const SESSION_ID = "story-session-chat-001" @@ -174,6 +177,58 @@ export const ChatViewAgentManagerCompleted: Story = { }, } +export const UserMessageReviewComments: Story = { + name: "User message — interactive review comments", + render: () => { + const comments: ReviewComment[] = [ + { + id: "review-1", + file: "src/components/chat/KiloBackendChatManager.kt", + side: "additions", + line: 114, + comment: "Keep this state synchronized when the active session changes.", + selectedText: "private val activeSession = MutableStateFlow(null)", + }, + { + id: "review-2", + file: "resources/messages/KiloBundle_bs.properties", + side: "deletions", + line: 235, + comment: "Translate the modified setting description.", + selectedText: "settings.models.smallModel.description=The lightweight model used for quick tasks.", + }, + ] + const prefix = formatReviewCommentsMarkdown(comments) + const text = `${prefix}\n\nPlease address these review comments.` + const review = { version: 1 as const, comments } + const message: Message = { + id: "review-user-message", + sessionID: SESSION_ID, + role: "user", + createdAt: new Date(0).toISOString(), + time: { created: 0 }, + } + const parts: Part[] = [ + { + id: "review-user-part", + sessionID: SESSION_ID, + messageID: message.id, + type: "text", + text, + metadata: reviewMetadata(review), + }, + ] + + return ( + +
+ +
+
+ ) + }, +} + /** * ChatView with a pending question tool call and an empty input. * diff --git a/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css b/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css index 333249377ae..86a473842fb 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css +++ b/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css @@ -27,6 +27,10 @@ padding: 6px; } +.prompt-review-comments--message { + margin: 0; +} + .prompt-review-comments-header { display: flex; align-items: center; diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts b/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts index d97e1fc955b..0a28d60db91 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/agent-manager.ts @@ -166,14 +166,7 @@ export interface LocalGitStats { behind: number } -export interface ReviewComment { - id: string - file: string - side: "additions" | "deletions" - line: number - comment: string - selectedText: string -} +export type { ReviewCommentData as ReviewComment } from "../../../../src/shared/review-comments" /** * Maximum number of parallel worktree versions for multi-version mode. diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index aeb7a2464f0..95d64f15dcb 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -103,6 +103,7 @@ export interface SendMessageFailedMessage { draftID?: string messageID?: string files?: FileAttachment[] + review?: import("../../../../src/shared/review-comments").ReviewMessageData } // Wire shape lives in src/shared/stream-messages.ts; narrow `part` to the diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/parts.ts b/packages/kilo-vscode/webview-ui/src/types/messages/parts.ts index 336458f2cfc..f720ab8841a 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/parts.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/parts.ts @@ -24,6 +24,7 @@ export interface TextPart extends BasePart { text: string synthetic?: boolean time?: { start: number; end?: number } + metadata?: Record } export interface FilePartSource { diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 680cc412f1b..ee7ffcc40c0 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -5,6 +5,7 @@ import type { PermissionFileDiff } from "./permissions" import type { ModelSelection, ProviderConfig } from "./providers" import type { Config } from "./config" import type { ModelAllocation, ReviewComment } from "./agent-manager" +import type { ReviewMessageData } from "../../../../src/shared/review-comments" import type { WorkStyle, WorkStyleState } from "../../../../src/shared/work-style-presets" import type { ClearLegacyDataMessage, @@ -29,6 +30,7 @@ export interface SendMessageRequest { agent?: string variant?: string files?: FileAttachment[] + review?: ReviewMessageData agentManagerContext?: string contextDirectory?: string } @@ -105,6 +107,7 @@ export interface ImportAndSendMessage { agent?: string variant?: string files?: FileAttachment[] + review?: ReviewMessageData command?: string commandArgs?: string } diff --git a/packages/kilo-vscode/webview-ui/src/utils/review-comment-markdown.ts b/packages/kilo-vscode/webview-ui/src/utils/review-comment-markdown.ts index 36ee8dbf615..1c569cf115c 100644 --- a/packages/kilo-vscode/webview-ui/src/utils/review-comment-markdown.ts +++ b/packages/kilo-vscode/webview-ui/src/utils/review-comment-markdown.ts @@ -1,33 +1,5 @@ -import type { ReviewComment } from "../types/messages" - -function escapeInline(value: string): string { - return value.replace(/([\\`*_\[\]{}()#+\-!|])/g, "\\$1") -} - -function fenceFor(value: string): string { - const matches = value.match(/`+/g) ?? [] - const longest = matches.reduce((max, item) => Math.max(max, item.length), 0) - return "`".repeat(Math.max(3, longest + 1)) -} - -function formatCode(value: string): string[] { - const fence = fenceFor(value) - return [fence, value, fence] -} - -export function formatReviewCommentMarkdown(comment: ReviewComment): string { - const lines = [`**${escapeInline(comment.file)}** (line ${comment.line}):`] - if (comment.selectedText) { - lines.push(...formatCode(comment.selectedText)) - } - lines.push(comment.comment) - return lines.join("\n") -} - -export function formatReviewCommentsMarkdown(comments: ReviewComment[]): string { - const lines = ["## Review Comments", ""] - for (const item of comments) { - lines.push(formatReviewCommentMarkdown(item), "") - } - return lines.join("\n").trimEnd() -} +export { + formatReviewCommentMarkdown, + formatReviewCommentsMarkdown, + type ReviewCommentData as ReviewComment, +} from "../../../src/shared/review-comments"