feat(client): project ask_user transcript records

This commit is contained in:
Federico Jaramillo Martinez
2026-07-27 01:09:28 +02:00
parent 4973adaa80
commit 7bbf5aa73c
9 changed files with 332 additions and 22 deletions
+1 -1
View File
@@ -290,7 +290,7 @@ function parseAskUserQuestionRecord(value: unknown): AskUserQuestionRecord {
return { question, answered, values, ...(otherText === undefined ? {} : { otherText }) }; return { question, answered, values, ...(otherText === undefined ? {} : { otherText }) };
} }
function parseAskUserOutcome(value: unknown): AskUserOutcome { export function parseAskUserOutcome(value: unknown): AskUserOutcome {
const record = requireRecord(value); const record = requireRecord(value);
const questions = boundedArrayOf(record["questions"], parseAskUserQuestionRecord, ASK_USER_QUESTION_LIMIT, "questions"); const questions = boundedArrayOf(record["questions"], parseAskUserQuestionRecord, ASK_USER_QUESTION_LIMIT, "questions");
const answeredCount = requireNonNegativeSafeInteger(record, "answeredCount"); const answeredCount = requireNonNegativeSafeInteger(record, "answeredCount");
+1 -1
View File
@@ -67,6 +67,6 @@ function toolNameFromParts(parts: ChatPart[]): string | undefined {
function isReadablePart(message: ChatLine, part: ChatPart): boolean { function isReadablePart(message: ChatLine, part: ChatPart): boolean {
if (message.source === "compaction" || message.source === "branch_summary") return false; if (message.source === "compaction" || message.source === "branch_summary") return false;
if (part.type === "skillInvocation" || part.type === "skillRead" || part.type === "image") return true; if (part.type === "skillInvocation" || part.type === "skillRead" || part.type === "image" || part.type === "askUserRecord") return true;
return part.type === "text" && (message.role === "user" || message.role === "assistant" || message.role === "system" || message.role === "bash"); return part.type === "text" && (message.role === "user" || message.role === "assistant" || message.role === "system" || message.role === "bash");
} }
+72
View File
@@ -1,6 +1,39 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { ASK_USER_ANSWERS_CUSTOM_TYPE, type AskUserOutcome } from "../../shared/apiTypes";
import { groupChatMessages } from "./chatGroups";
import { appendText, appendThinking, normalizeMessage, normalizeMessages, textMessage } from "./chatMessages"; import { appendText, appendThinking, normalizeMessage, normalizeMessages, textMessage } from "./chatMessages";
const askUserOutcome: AskUserOutcome = {
askId: "ask-1",
reason: "submitted",
askedAt: "2026-07-20T10:00:00.000Z",
closedAt: "2026-07-20T10:05:00.000Z",
questions: [
{
question: { id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }] },
answered: true,
values: ["pg"],
},
{
question: { id: "cache", question: "Which cache?", options: [{ value: "redis", label: "Redis" }] },
answered: false,
values: [],
},
],
answeredCount: 1,
unansweredIds: ["cache"],
summary: "Answered 1 of 2; unanswered: cache",
};
const supersededAskUserOutcome: AskUserOutcome = {
...askUserOutcome,
reason: "superseded",
questions: askUserOutcome.questions.map((record) => ({ question: record.question, answered: false, values: [] })),
answeredCount: 0,
unansweredIds: ["db", "cache"],
summary: "Answered 0 of 2; unanswered: db, cache",
};
describe("chat message normalization", () => { describe("chat message normalization", () => {
it("normalizes simple text messages and drops empty content", () => { it("normalizes simple text messages and drops empty content", () => {
expect(normalizeMessages([ expect(normalizeMessages([
@@ -20,6 +53,45 @@ describe("chat message normalization", () => {
expect(normalizeMessages([{ role: "user", content: "raw" }, line])).toEqual([textMessage("user", "raw"), line]); expect(normalizeMessages([{ role: "user", content: "raw" }, line])).toEqual([textMessage("user", "raw"), line]);
}); });
it("projects ask_user answer messages into visible read-only record parts", () => {
const normalized = normalizeMessage({
role: "custom",
customType: ASK_USER_ANSWERS_CUSTOM_TYPE,
content: "model-facing answer text",
details: askUserOutcome,
});
const recordLine = { role: "system" as const, parts: [{ type: "askUserRecord" as const, outcome: askUserOutcome }] };
expect(normalized).toEqual([recordLine]);
expect(groupChatMessages(normalized)).toEqual([{ kind: "message", index: 0, message: recordLine }]);
});
it("falls back to model-facing text when an ask_user answer record is malformed", () => {
expect(normalizeMessage({
role: "custom",
customType: ASK_USER_ANSWERS_CUSTOM_TYPE,
content: "Answered 0 of 1; unanswered: db",
details: { askId: "missing-the-rest" },
})).toEqual([textMessage("system", "Answered 0 of 1; unanswered: db")]);
});
it("projects a superseded ask from the later ask_user tool result", () => {
const normalized = normalizeMessages([
{ role: "assistant", content: [{ type: "toolCall", id: "ask-call", name: "ask_user", arguments: { questions: [] } }] },
{
role: "toolResult",
toolCallId: "ask-call",
toolName: "ask_user",
content: [{ type: "text", text: "Posted a newer question set." }],
details: { ask: { askId: "ask-2" }, superseded: supersededAskUserOutcome },
isError: false,
},
]);
expect(normalized[1]).toEqual({ role: "tool", parts: [{ type: "askUserRecord", outcome: supersededAskUserOutcome }] });
expect(groupChatMessages(normalized).map((group) => group.kind)).toEqual(["group", "message"]);
});
it("normalizes tool calls and tool results", () => { it("normalizes tool calls and tool results", () => {
expect(normalizeMessage({ role: "assistant", content: [{ type: "toolCall", name: "bash", arguments: { command: "npm test" } }] })).toEqual([ expect(normalizeMessage({ role: "assistant", content: [{ type: "toolCall", name: "bash", arguments: { command: "npm test" } }] })).toEqual([
{ role: "assistant", parts: [{ type: "toolCall", toolName: "bash", summary: "npm test", args: { command: "npm test" } }] }, { role: "assistant", parts: [{ type: "toolCall", toolName: "bash", summary: "npm test", args: { command: "npm test" } }] },
+32 -2
View File
@@ -1,3 +1,5 @@
import { ASK_USER_ANSWERS_CUSTOM_TYPE } from "../../shared/apiTypes";
import { parseAskUserOutcome } from "./api/parsers";
import type { ChatLine, ChatPart, ToolExecutionPart, ToolPreview } from "./components/shared"; import type { ChatLine, ChatPart, ToolExecutionPart, ToolPreview } from "./components/shared";
export function normalizeMessages(messages: unknown[]): ChatLine[] { export function normalizeMessages(messages: unknown[]): ChatLine[] {
@@ -44,8 +46,13 @@ export function appendThinking(messages: ChatLine[], text: string): ChatLine[] {
export function normalizeMessage(message: unknown): ChatLine[] { export function normalizeMessage(message: unknown): ChatLine[] {
if (isChatLine(message)) return [message]; if (isChatLine(message)) return [message];
if (getString(message, "role") === "bashExecution") return [withMessageMeta(normalizeBashExecution(message), message)]; if (getString(message, "role") === "bashExecution") return [withMessageMeta(normalizeBashExecution(message), message)];
const role = normalizeRole(getString(message, "role")); const rawRole = getString(message, "role");
const parts = normalizeContent(getProperty(message, "content"), message); const role = normalizeRole(rawRole);
const contentParts = normalizeContent(getProperty(message, "content"), message);
const supersededRecord = rawRole === "toolResult"
? askUserRecordFromToolDetails(getString(message, "toolName") ?? "", getProperty(message, "details"))
: undefined;
const parts = supersededRecord === undefined ? contentParts : [...contentParts, supersededRecord];
const skillLines = role === "user" ? normalizeSkillInvocation(parts) : undefined; const skillLines = role === "user" ? normalizeSkillInvocation(parts) : undefined;
if (skillLines !== undefined) return skillLines.map((line) => withMessageMeta(line, message)); if (skillLines !== undefined) return skillLines.map((line) => withMessageMeta(line, message));
const source = normalizeSource(message); const source = normalizeSource(message);
@@ -148,6 +155,8 @@ function normalizeRole(role: unknown): ChatLine["role"] {
} }
function normalizeContent(content: unknown, message: unknown): ChatPart[] { function normalizeContent(content: unknown, message: unknown): ChatPart[] {
const askUserRecord = askUserRecordPart(message);
if (askUserRecord !== undefined) return [askUserRecord];
if (typeof content === "string") return content !== "" ? [{ type: "text", text: content }] : []; if (typeof content === "string") return content !== "" ? [{ type: "text", text: content }] : [];
if (!Array.isArray(content)) return objectFallback(content); if (!Array.isArray(content)) return objectFallback(content);
@@ -179,6 +188,27 @@ function normalizeContent(content: unknown, message: unknown): ChatPart[] {
: part); : part);
} }
function askUserRecordPart(message: unknown): Extract<ChatPart, { type: "askUserRecord" }> | undefined {
if (getString(message, "role") !== "custom" || getString(message, "customType") !== ASK_USER_ANSWERS_CUSTOM_TYPE) return undefined;
return parsedAskUserRecord(getProperty(message, "details"));
}
/** Project the superseded ask carried by an `ask_user` tool result, if any. */
export function askUserRecordFromToolDetails(toolName: string, details: unknown): Extract<ChatPart, { type: "askUserRecord" }> | undefined {
if (toolName !== "ask_user") return undefined;
return parsedAskUserRecord(getProperty(details, "superseded"));
}
function parsedAskUserRecord(value: unknown): Extract<ChatPart, { type: "askUserRecord" }> | undefined {
try {
return { type: "askUserRecord", outcome: parseAskUserOutcome(value) };
} catch {
// A malformed legacy/session entry must not make the whole transcript fail.
// Fall back to its model-facing text through the ordinary normalizer.
return undefined;
}
}
function toolResultPartFromText(text: string, message: unknown): Extract<ChatPart, { type: "toolResult" }> { function toolResultPartFromText(text: string, message: unknown): Extract<ChatPart, { type: "toolResult" }> {
const toolCallId = getString(message, "toolCallId"); const toolCallId = getString(message, "toolCallId");
const content = getProperty(message, "content"); const content = getProperty(message, "content");
+91
View File
@@ -1,9 +1,41 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { ASK_USER_ANSWERS_CUSTOM_TYPE, type AskUserOutcome } from "../../shared/apiTypes";
import { groupChatMessages } from "./chatGroups"; import { groupChatMessages } from "./chatGroups";
import { normalizeMessages, textMessage } from "./chatMessages"; import { normalizeMessages, textMessage } from "./chatMessages";
import { applyTranscriptEvent, seedStreamingPartial } from "./chatTranscript"; import { applyTranscriptEvent, seedStreamingPartial } from "./chatTranscript";
import type { ChatLine } from "./components/shared"; import type { ChatLine } from "./components/shared";
const askUserOutcome: AskUserOutcome = {
askId: "ask-1",
reason: "submitted",
askedAt: "2026-07-20T10:00:00.000Z",
closedAt: "2026-07-20T10:05:00.000Z",
questions: [
{
question: { id: "editor", question: "Which editor?", options: [{ value: "vim", label: "Vim" }] },
answered: true,
values: ["vim"],
},
{
question: { id: "region", question: "Which region?", options: [{ value: "eu", label: "Europe" }] },
answered: false,
values: [],
},
],
answeredCount: 1,
unansweredIds: ["region"],
summary: "Answered 1 of 2; unanswered: region",
};
const supersededAskUserOutcome: AskUserOutcome = {
...askUserOutcome,
reason: "superseded",
questions: askUserOutcome.questions.map((record) => ({ question: record.question, answered: false, values: [] })),
answeredCount: 0,
unansweredIds: ["editor", "region"],
summary: "Answered 0 of 2; unanswered: editor, region",
};
const finalAssistant = { const finalAssistant = {
role: "assistant", role: "assistant",
content: [ content: [
@@ -27,6 +59,65 @@ describe("applyTranscriptEvent", () => {
]); ]);
}); });
it("projects finalized ask_user answers identically to rehydrated history", () => {
const rawMessage = {
role: "custom",
customType: ASK_USER_ANSWERS_CUSTOM_TYPE,
content: "The user submitted answers to your questions.",
details: askUserOutcome,
};
const hydrated = normalizeMessages([rawMessage]);
const live = applyTranscriptEvent([], { type: "message.end", message: rawMessage });
expect(live).toEqual(hydrated);
expect(live).toEqual([{
role: "system",
parts: [{ type: "askUserRecord", outcome: askUserOutcome }],
}]);
expect(applyTranscriptEvent(live ?? [], { type: "message.end", message: rawMessage })).toEqual(live);
const nextOutcome = { ...askUserOutcome, askId: "ask-2" };
const nextRawMessage = { ...rawMessage, details: nextOutcome };
expect(applyTranscriptEvent(live ?? [], { type: "message.end", message: nextRawMessage })).toEqual([
...hydrated,
...normalizeMessages([nextRawMessage]),
]);
});
it("keeps superseded ask records identical across live tool events and hydrated history", () => {
const args = { questions: [{ id: "next", question: "Try again?", options: [] }] };
const details = { ask: { askId: "ask-2" }, superseded: supersededAskUserOutcome };
const finalResult = {
role: "toolResult",
toolCallId: "ask-call",
toolName: "ask_user",
content: [{ type: "text", text: "Posted a newer question set." }],
details,
isError: false,
};
const hydrated = normalizeMessages([
{ role: "assistant", content: [{ type: "toolCall", id: "ask-call", name: "ask_user", arguments: args }] },
finalResult,
]);
let live: ChatLine[] = [];
live = applyTranscriptEvent(live, { type: "tool.start", toolName: "ask_user", toolCallId: "ask-call", summary: "", args }) ?? live;
live = applyTranscriptEvent(live, {
type: "tool.end",
toolName: "ask_user",
toolCallId: "ask-call",
text: "Posted a newer question set.",
content: finalResult.content,
details,
isError: false,
}) ?? live;
live = applyTranscriptEvent(live, { type: "message.end", message: finalResult }) ?? live;
expect(live).toEqual(hydrated);
expect(live.filter((line) => line.parts.some((part) => part.type === "askUserRecord"))).toHaveLength(1);
expect(groupChatMessages(live).map((group) => group.kind)).toEqual(["group", "message"]);
});
it("replaces the streamed assistant message with the finalized history shape", () => { it("replaces the streamed assistant message with the finalized history shape", () => {
const streamed: ChatLine[] = [ const streamed: ChatLine[] = [
textMessage("user", "question"), textMessage("user", "question"),
+42 -3
View File
@@ -1,4 +1,4 @@
import { appendText, appendThinking, normalizeMessage, normalizeMessages, previewFromDetails, summarizeArgs, textMessage } from "./chatMessages"; import { appendText, appendThinking, askUserRecordFromToolDetails, normalizeMessage, normalizeMessages, previewFromDetails, summarizeArgs, textMessage } from "./chatMessages";
import type { ChatLine, ToolExecutionPart } from "./components/shared"; import type { ChatLine, ToolExecutionPart } from "./components/shared";
import { appendShellChunk, finalizeShellMessage, shellStartMessage } from "./shellMessages"; import { appendShellChunk, finalizeShellMessage, shellStartMessage } from "./shellMessages";
import type { SessionUiEvent } from "./sessionSocket"; import type { SessionUiEvent } from "./sessionSocket";
@@ -82,12 +82,27 @@ function applyFinalMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[
function applyFinalLine(messages: ChatLine[], displayEnded: ChatLine): ChatLine[] { function applyFinalLine(messages: ChatLine[], displayEnded: ChatLine): ChatLine[] {
const skillReadIndexes = findMatchingSkillReadIndexes(messages, displayEnded); const skillReadIndexes = findMatchingSkillReadIndexes(messages, displayEnded);
if (skillReadIndexes.length > 0) return replaceSkillReadLines(messages, skillReadIndexes, displayEnded); if (skillReadIndexes.length > 0) return replaceSkillReadLines(messages, skillReadIndexes, displayEnded);
const askUserRecord = displayEnded.parts.find((part) => part.type === "askUserRecord");
if (askUserRecord !== undefined) return reconcileFinalAskUserRecord(messages, displayEnded, askUserRecord);
const last = messages.at(-1); const last = messages.at(-1);
if (last?.role !== displayEnded.role) return [...messages, displayEnded]; if (last?.role !== displayEnded.role) return [...messages, displayEnded];
if (displayEnded.role === "assistant" || sameMessageText(last, displayEnded)) return [...messages.slice(0, -1), displayEnded]; if (displayEnded.role === "assistant" || sameMessageText(last, displayEnded)) return [...messages.slice(0, -1), displayEnded];
return [...messages, displayEnded]; return [...messages, displayEnded];
} }
function reconcileFinalAskUserRecord(
messages: ChatLine[],
displayEnded: ChatLine,
record: Extract<ChatLine["parts"][number], { type: "askUserRecord" }>,
): ChatLine[] {
for (let index = messages.length - 1; index >= 0; index--) {
if (lineHasAskUserRecord(messages[index], record)) {
return [...messages.slice(0, index), displayEnded, ...messages.slice(index + 1)];
}
}
return [...messages, displayEnded];
}
function withoutToolCalls(message: ChatLine): ChatLine { function withoutToolCalls(message: ChatLine): ChatLine {
return { ...message, parts: message.parts.filter((part) => part.type !== "toolCall") }; return { ...message, parts: message.parts.filter((part) => part.type !== "toolCall") };
} }
@@ -143,8 +158,9 @@ function finalizeToolExecution(messages: ChatLine[], result: ToolResultUpdate):
...(preview === undefined ? {} : { preview }), ...(preview === undefined ? {} : { preview }),
}; };
}, (line) => reconcileToolResultPresentation(line, presentation)); }, (line) => reconcileToolResultPresentation(line, presentation));
if (updated !== messages) return updated;
let finalized = updated;
if (updated === messages) {
const preview = previewFromDetails(details); const preview = previewFromDetails(details);
const part: ToolExecutionPart = { const part: ToolExecutionPart = {
type: "toolExecution", type: "toolExecution",
@@ -157,7 +173,30 @@ function finalizeToolExecution(messages: ChatLine[], result: ToolResultUpdate):
...(details === undefined ? {} : { details }), ...(details === undefined ? {} : { details }),
...(preview === undefined ? {} : { preview }), ...(preview === undefined ? {} : { preview }),
}; };
return [...messages, reconcileToolResultPresentation({ role: "tool", parts: [part] }, presentation)]; finalized = [...messages, reconcileToolResultPresentation({ role: "tool", parts: [part] }, presentation)];
}
return reconcileAskUserToolRecord(finalized, toolName, details, presentation.meta);
}
function reconcileAskUserToolRecord(messages: ChatLine[], toolName: string, details: unknown, meta: ChatLine["meta"] | undefined): ChatLine[] {
const record = askUserRecordFromToolDetails(toolName, details);
if (record === undefined) return messages;
for (let index = messages.length - 1; index >= 0; index--) {
const line = messages[index];
if (!lineHasAskUserRecord(line, record)) continue;
if (meta === undefined || line === undefined) return messages;
return [...messages.slice(0, index), { ...line, meta }, ...messages.slice(index + 1)];
}
return [...messages, { role: "tool", parts: [record], ...(meta === undefined ? {} : { meta }) }];
}
function lineHasAskUserRecord(
line: ChatLine | undefined,
record: Extract<ChatLine["parts"][number], { type: "askUserRecord" }>,
): boolean {
return line?.parts.some((part) => part.type === "askUserRecord"
&& part.outcome.askId === record.outcome.askId
&& part.outcome.reason === record.outcome.reason) === true;
} }
function updateToolExecution( function updateToolExecution(
@@ -0,0 +1,62 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import type { AskUserOutcome } from "../../../shared/apiTypes";
import { AskUserCard } from "./AskUserCard";
import { ChatView } from "./ChatView";
afterEach(() => {
document.body.replaceChildren();
});
describe("ChatView ask_user transcript records", () => {
it("renders a projected outcome as the read-only question card with the machine-scoped draft key", async () => {
const outcome: AskUserOutcome = {
askId: "ask-1",
reason: "submitted",
askedAt: "2026-07-20T10:00:00.000Z",
closedAt: "2026-07-20T10:05:00.000Z",
questions: [
{
question: { id: "editor", question: "Which editor?", options: [{ value: "vim", label: "Vim" }] },
answered: true,
values: ["vim"],
},
{
question: { id: "region", question: "Which region?", options: [{ value: "eu", label: "Europe" }] },
answered: false,
values: [],
},
],
answeredCount: 1,
unansweredIds: ["region"],
summary: "Answered 1 of 2; unanswered: region",
};
const view = new ChatView();
view.sessionId = "session-1";
view.askDraftSessionId = "remote-a:session-1";
view.messages = [{ role: "system", parts: [{ type: "askUserRecord", outcome }] }];
document.body.append(view);
await view.updateComplete;
const card = requiredElement(view.shadowRoot?.querySelector("ask-user-card"), "ask_user record card");
expect(card).toBeInstanceOf(AskUserCard);
expect(card.outcome).toEqual(outcome);
expect(card.ask).toBeUndefined();
expect(card.draftSessionId).toBe("remote-a:session-1");
await card.updateComplete;
const cardRoot = requiredElement(card.shadowRoot, "ask_user record shadow root");
expect(cardRoot.textContent).toContain("Answers sent");
expect(cardRoot.textContent).toContain("Vim");
expect(cardRoot.textContent).toContain("Which region?");
expect(cardRoot.textContent).toContain("Unanswered");
expect(cardRoot.querySelector("input, textarea, button, select")).toBeNull();
expect(view.shadowRoot?.querySelector("article.ask-user-record-shell .msg-header")).toBeNull();
});
});
function requiredElement<T>(value: T | null | undefined, label: string): T {
if (value === null || value === undefined) throw new Error(`Expected ${label}`);
return value;
}
+15 -2
View File
@@ -730,10 +730,12 @@ export class ChatView extends LitElement {
private renderMessage(message: ChatLine, index: number) { private renderMessage(message: ChatLine, index: number) {
const toolOnly = this.isToolExecutionOnlyMessage(message); const toolOnly = this.isToolExecutionOnlyMessage(message);
const askUserRecordOnly = this.isAskUserRecordOnlyMessage(message);
const shellClass = toolOnly ? "msg tool-execution-shell" : "msg ask-user-record-shell";
return html` return html`
${this.renderScrollMarker(this.messageScrollMarkerId(index))} ${this.renderScrollMarker(this.messageScrollMarkerId(index))}
<article class=${toolOnly ? "msg tool-execution-shell" : `msg ${message.role}`} data-index=${index} data-scroll-anchor-id=${this.messageAnchorKey(index)}> <article class=${toolOnly || askUserRecordOnly ? shellClass : `msg ${message.role}`} data-index=${index} data-scroll-anchor-id=${this.messageAnchorKey(index)}>
${toolOnly ? null : this.renderMessageHeader(message, String(index))} ${toolOnly || askUserRecordOnly ? null : this.renderMessageHeader(message, String(index))}
${message.parts.map((part) => this.renderPart(part, message))} ${message.parts.map((part) => this.renderPart(part, message))}
</article> </article>
`; `;
@@ -754,6 +756,10 @@ export class ChatView extends LitElement {
return message.role === "tool" && message.parts.length > 0 && message.parts.every((part) => part.type === "toolExecution"); return message.role === "tool" && message.parts.length > 0 && message.parts.every((part) => part.type === "toolExecution");
} }
private isAskUserRecordOnlyMessage(message: ChatLine): boolean {
return message.parts.length > 0 && message.parts.every((part) => part.type === "askUserRecord");
}
private renderMessageGroup(messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) { private renderMessageGroup(messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) {
const disclosureKey = this.groupDisclosureKey(startIndex, endIndex, defaultOpen); const disclosureKey = this.groupDisclosureKey(startIndex, endIndex, defaultOpen);
const open = this.disclosures.isOpen(disclosureKey, defaultOpen); const open = this.disclosures.isOpen(disclosureKey, defaultOpen);
@@ -873,6 +879,13 @@ export class ChatView extends LitElement {
<small>read ${part.path}</small> <small>read ${part.path}</small>
</div> </div>
`; `;
if (part.type === "askUserRecord") return html`
<ask-user-card
class="part"
.outcome=${part.outcome}
.draftSessionId=${this.askDraftSessionId}
></ask-user-card>
`;
if (part.type === "image") { if (part.type === "image") {
const { src, alt } = chatImagePartSource(part); const { src, alt } = chatImagePartSource(part);
return html`<img class="part chat-image" src=${src} alt=${alt} loading="lazy" role="button" tabindex="0" title="Click to enlarge" @load=${this.onImageLoad} @click=${() => { this.openImageZoom(src, alt); }} @keydown=${(event: KeyboardEvent) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); this.openImageZoom(src, alt); } }} />`; return html`<img class="part chat-image" src=${src} alt=${alt} loading="lazy" role="button" tabindex="0" title="Click to enlarge" @load=${this.onImageLoad} @click=${() => { this.openImageZoom(src, alt); }} @keydown=${(event: KeyboardEvent) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); this.openImageZoom(src, alt); } }} />`;
+4 -1
View File
@@ -1,4 +1,5 @@
import { css, svg, type TemplateResult } from "lit"; import { css, svg, type TemplateResult } from "lit";
import type { AskUserOutcome } from "../../../shared/apiTypes";
import type { SessionWarningSeverity } from "../api"; import type { SessionWarningSeverity } from "../api";
export function renderSessionWarningIcon(severity: SessionWarningSeverity, className: string): TemplateResult { export function renderSessionWarningIcon(severity: SessionWarningSeverity, className: string): TemplateResult {
@@ -54,6 +55,7 @@ export type ChatPart =
| { type: "thinking"; text: string } | { type: "thinking"; text: string }
| { type: "skillInvocation"; name: string; location: string; content: string } | { type: "skillInvocation"; name: string; location: string; content: string }
| { type: "skillRead"; name: string; path: string; toolCallId?: string } | { type: "skillRead"; name: string; path: string; toolCallId?: string }
| { type: "askUserRecord"; outcome: AskUserOutcome }
| { type: "toolCall"; toolCallId?: string; toolName: string; summary: string; args?: unknown } | { type: "toolCall"; toolCallId?: string; toolName: string; summary: string; args?: unknown }
| ToolExecutionPart | ToolExecutionPart
| { type: "toolResult"; toolCallId?: string; toolName: string; text: string; isError: boolean; content?: unknown; details?: unknown } | { type: "toolResult"; toolCallId?: string; toolName: string; text: string; isError: boolean; content?: unknown; details?: unknown }
@@ -374,7 +376,8 @@ export const chatStyles = css`
.msg.assistant, .msg.tool-image-output { background: var(--pi-surface); } .msg.assistant, .msg.tool-image-output { background: var(--pi-surface); }
.msg.user { border-color: var(--pi-accent-border); background: var(--pi-selection-bg); } .msg.user { border-color: var(--pi-accent-border); background: var(--pi-selection-bg); }
.msg.tool { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-warning); } .msg.tool { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-warning); }
.msg.tool-execution-shell { padding: 0; border: 0; background: transparent; color: var(--pi-text); } .msg.tool-execution-shell, .msg.ask-user-record-shell { padding: 0; border: 0; background: transparent; color: var(--pi-text); }
.msg.ask-user-record-shell ask-user-card { margin: 0 auto; }
.msg.system { color: var(--pi-danger); } .msg.system { color: var(--pi-danger); }
.msg.bash { border-color: var(--pi-success); background: var(--pi-success-bg); } .msg.bash { border-color: var(--pi-success); background: var(--pi-success-bg); }
.msg.skill { border-color: var(--pi-purple-border); background: var(--pi-purple-surface); } .msg.skill { border-color: var(--pi-purple-border); background: var(--pi-purple-surface); }