Archived
feat(ui): render extension dialogs inline in the transcript
This commit is contained in:
@@ -0,0 +1,126 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { PendingExtensionDialog } from "../api";
|
||||||
|
import type { ClosedExtensionDialog } from "../appState";
|
||||||
|
import { ChatView } from "./ChatView";
|
||||||
|
import { ExtensionDialogCard } from "./ExtensionDialogCard";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChatView open extension dialogs", () => {
|
||||||
|
it("renders the oldest pending dialog at the transcript foot with a stable chat-scroll anchor", async () => {
|
||||||
|
const view = await mountView();
|
||||||
|
const oldest = openDialog("dlg-1", "Allow file writes?");
|
||||||
|
view.pendingDialogs = [oldest, openDialog("dlg-2", "Pick a region", { kind: "select", options: ["eu", "us"] })];
|
||||||
|
await view.updateComplete;
|
||||||
|
|
||||||
|
const card = requiredElement(view.shadowRoot?.querySelector<ExtensionDialogCard>(".chat > extension-dialog-card.open-dialog-card"), "open dialog card");
|
||||||
|
expect(card).toBeInstanceOf(ExtensionDialogCard);
|
||||||
|
expect(card.getAttribute("data-scroll-anchor-id")).toBe("dialog:dlg-1");
|
||||||
|
expect(card.dialog).toBe(oldest);
|
||||||
|
expect(view.shadowRoot?.querySelector(".queued-dialogs")?.textContent).toContain("1 more extension dialog queued");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders no queued affordance for a single pending dialog", async () => {
|
||||||
|
const view = await mountView();
|
||||||
|
view.pendingDialogs = [openDialog("dlg-1", "Allow file writes?")];
|
||||||
|
await view.updateComplete;
|
||||||
|
|
||||||
|
expect(view.shadowRoot?.querySelector(".chat > extension-dialog-card.open-dialog-card")).not.toBeNull();
|
||||||
|
expect(view.shadowRoot?.querySelector(".queued-dialogs")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scrolls a newly opened dialog to its start", async () => {
|
||||||
|
const view = await mountView();
|
||||||
|
let dialogStartScrolls = 0;
|
||||||
|
let bottomScrolls = 0;
|
||||||
|
if (!Reflect.set(view, "scrollToOpenDialog", () => { dialogStartScrolls += 1; })) throw new Error("Could not observe ChatView.scrollToOpenDialog");
|
||||||
|
if (!Reflect.set(view, "scrollToBottom", () => { bottomScrolls += 1; })) throw new Error("Could not observe ChatView.scrollToBottom");
|
||||||
|
|
||||||
|
view.pendingDialogs = [openDialog("dlg-1", "Allow file writes?")];
|
||||||
|
await view.updateComplete;
|
||||||
|
|
||||||
|
expect(dialogStartScrolls).toBe(1);
|
||||||
|
expect(bottomScrolls).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards the answer and cancel callbacks to the open dialog card", async () => {
|
||||||
|
const view = await mountView();
|
||||||
|
const onAnswerDialog = vi.fn();
|
||||||
|
const onCancelDialog = vi.fn();
|
||||||
|
view.onAnswerDialog = onAnswerDialog;
|
||||||
|
view.onCancelDialog = onCancelDialog;
|
||||||
|
view.pendingDialogs = [openDialog("dlg-1", "Allow file writes?")];
|
||||||
|
await view.updateComplete;
|
||||||
|
|
||||||
|
const card = requiredElement(view.shadowRoot?.querySelector<ExtensionDialogCard>("extension-dialog-card.open-dialog-card"), "open dialog card");
|
||||||
|
void card.onAnswer?.("dlg-1", true);
|
||||||
|
void card.onCancel?.("dlg-1");
|
||||||
|
|
||||||
|
expect(onAnswerDialog).toHaveBeenCalledWith("dlg-1", true);
|
||||||
|
expect(onCancelDialog).toHaveBeenCalledWith("dlg-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChatView closed extension dialogs", () => {
|
||||||
|
it("renders closed dialogs transiently above the open one and forwards the dismiss callback", async () => {
|
||||||
|
const view = await mountView();
|
||||||
|
const onDismissClosedDialog = vi.fn();
|
||||||
|
view.onDismissClosedDialog = onDismissClosedDialog;
|
||||||
|
const closed = closedDialog("dlg-0", "Allow reads?", "answered", true);
|
||||||
|
view.closedDialogs = [closed];
|
||||||
|
view.pendingDialogs = [openDialog("dlg-1", "Allow file writes?")];
|
||||||
|
await view.updateComplete;
|
||||||
|
|
||||||
|
const cards = [...(view.shadowRoot?.querySelectorAll<ExtensionDialogCard>(".chat > extension-dialog-card") ?? [])];
|
||||||
|
expect(cards).toHaveLength(2);
|
||||||
|
const closedCard = requiredElement(cards[0], "closed dialog card");
|
||||||
|
expect(closedCard.classList.contains("closed-dialog-card")).toBe(true);
|
||||||
|
expect(closedCard.getAttribute("data-scroll-anchor-id")).toBe("closed-dialog:dlg-0");
|
||||||
|
expect(closedCard.outcome).toBe(closed);
|
||||||
|
expect(cards[1]?.classList.contains("open-dialog-card")).toBe(true);
|
||||||
|
|
||||||
|
closedCard.onDismiss?.("dlg-0");
|
||||||
|
expect(onDismissClosedDialog).toHaveBeenCalledWith("dlg-0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mountView(): Promise<ChatView> {
|
||||||
|
const view = new ChatView();
|
||||||
|
view.sessionId = "session-1";
|
||||||
|
document.body.append(view);
|
||||||
|
await view.updateComplete;
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredElement<T>(value: T | null | undefined, label: string): T {
|
||||||
|
if (value === null || value === undefined) throw new Error(`Expected ${label}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDialog(dialogId: string, title: string, overrides: Partial<PendingExtensionDialog> = {}): PendingExtensionDialog {
|
||||||
|
return {
|
||||||
|
dialogId,
|
||||||
|
kind: "confirm",
|
||||||
|
title,
|
||||||
|
askedAt: "2026-07-27T10:00:00.000Z",
|
||||||
|
runScoped: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function closedDialog(
|
||||||
|
dialogId: string,
|
||||||
|
title: string,
|
||||||
|
reason: ClosedExtensionDialog["reason"],
|
||||||
|
answer?: ClosedExtensionDialog["answer"],
|
||||||
|
): ClosedExtensionDialog {
|
||||||
|
return {
|
||||||
|
dialog: openDialog(dialogId, title),
|
||||||
|
reason,
|
||||||
|
...(answer === undefined ? {} : { answer }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ import { writeClipboardText } from "../clipboard";
|
|||||||
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
|
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
|
||||||
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
|
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
|
||||||
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
|
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
|
||||||
import type { AskUserSubmission, PendingAskUser, QueuedSessionMessage, SessionActivity, SessionStatus, SessionWarningSeverity } from "../api";
|
import type { AskUserSubmission, PendingAskUser, PendingExtensionDialog, QueuedSessionMessage, SessionActivity, SessionStatus, SessionWarningSeverity } from "../api";
|
||||||
|
import type { ClosedExtensionDialog } from "../appState";
|
||||||
import {
|
import {
|
||||||
notificationAnnouncementLabel,
|
notificationAnnouncementLabel,
|
||||||
notificationDismissLabel,
|
notificationDismissLabel,
|
||||||
@@ -27,6 +28,8 @@ import {
|
|||||||
import type { ChatLine, ChatPart } from "./shared";
|
import type { ChatLine, ChatPart } from "./shared";
|
||||||
import { chatStyles, renderSessionWarningIcon } from "./shared";
|
import { chatStyles, renderSessionWarningIcon } from "./shared";
|
||||||
import "./AskUserCard";
|
import "./AskUserCard";
|
||||||
|
import "./ExtensionDialogCard";
|
||||||
|
import type { ExtensionDialogAnswerCallback, ExtensionDialogCancelCallback, ExtensionDialogDismissCallback } from "./ExtensionDialogCard";
|
||||||
import "./ConversationMeter";
|
import "./ConversationMeter";
|
||||||
import "./FormattedText";
|
import "./FormattedText";
|
||||||
import "./ToolExecutionView";
|
import "./ToolExecutionView";
|
||||||
@@ -196,6 +199,11 @@ export class ChatView extends LitElement {
|
|||||||
@property({ attribute: false }) pendingAsk?: PendingAskUser;
|
@property({ attribute: false }) pendingAsk?: PendingAskUser;
|
||||||
@property({ attribute: false }) askDraftSessionId = "";
|
@property({ attribute: false }) askDraftSessionId = "";
|
||||||
@property({ attribute: false }) onSubmitAsk?: (askId: string, submission: AskUserSubmission) => void | Promise<void>;
|
@property({ attribute: false }) onSubmitAsk?: (askId: string, submission: AskUserSubmission) => void | Promise<void>;
|
||||||
|
@property({ attribute: false }) pendingDialogs: PendingExtensionDialog[] = [];
|
||||||
|
@property({ attribute: false }) closedDialogs: ClosedExtensionDialog[] = [];
|
||||||
|
@property({ attribute: false }) onAnswerDialog?: ExtensionDialogAnswerCallback;
|
||||||
|
@property({ attribute: false }) onCancelDialog?: ExtensionDialogCancelCallback;
|
||||||
|
@property({ attribute: false }) onDismissClosedDialog?: ExtensionDialogDismissCallback;
|
||||||
@property({ attribute: false }) notificationInbox?: SelectedSessionNotificationView;
|
@property({ attribute: false }) notificationInbox?: SelectedSessionNotificationView;
|
||||||
@property({ type: Boolean }) canClearServerQueue = false;
|
@property({ type: Boolean }) canClearServerQueue = false;
|
||||||
@property({ attribute: false }) onClearServerQueue?: () => void;
|
@property({ attribute: false }) onClearServerQueue?: () => void;
|
||||||
@@ -222,6 +230,7 @@ export class ChatView extends LitElement {
|
|||||||
private loadMoreCheckFrame: number | undefined;
|
private loadMoreCheckFrame: number | undefined;
|
||||||
private scrollToBottomFrame: number | undefined;
|
private scrollToBottomFrame: number | undefined;
|
||||||
private scrollToOpenAskFrame: number | undefined;
|
private scrollToOpenAskFrame: number | undefined;
|
||||||
|
private scrollToOpenDialogFrame: number | undefined;
|
||||||
private conversationRailFrame: number | undefined;
|
private conversationRailFrame: number | undefined;
|
||||||
private groupedMessagesInput?: ChatLine[];
|
private groupedMessagesInput?: ChatLine[];
|
||||||
private groupedMessagesStart = 0;
|
private groupedMessagesStart = 0;
|
||||||
@@ -284,6 +293,10 @@ export class ChatView extends LitElement {
|
|||||||
cancelAnimationFrame(this.scrollToOpenAskFrame);
|
cancelAnimationFrame(this.scrollToOpenAskFrame);
|
||||||
this.scrollToOpenAskFrame = undefined;
|
this.scrollToOpenAskFrame = undefined;
|
||||||
}
|
}
|
||||||
|
if (this.scrollToOpenDialogFrame !== undefined) {
|
||||||
|
cancelAnimationFrame(this.scrollToOpenDialogFrame);
|
||||||
|
this.scrollToOpenDialogFrame = undefined;
|
||||||
|
}
|
||||||
if (this.conversationRailFrame !== undefined) cancelAnimationFrame(this.conversationRailFrame);
|
if (this.conversationRailFrame !== undefined) cancelAnimationFrame(this.conversationRailFrame);
|
||||||
window.removeEventListener("resize", this.onViewportResize);
|
window.removeEventListener("resize", this.onViewportResize);
|
||||||
window.removeEventListener("pagehide", this.onPageHide);
|
window.removeEventListener("pagehide", this.onPageHide);
|
||||||
@@ -314,6 +327,10 @@ export class ChatView extends LitElement {
|
|||||||
cancelAnimationFrame(this.scrollToOpenAskFrame);
|
cancelAnimationFrame(this.scrollToOpenAskFrame);
|
||||||
this.scrollToOpenAskFrame = undefined;
|
this.scrollToOpenAskFrame = undefined;
|
||||||
}
|
}
|
||||||
|
if (this.scrollToOpenDialogFrame !== undefined) {
|
||||||
|
cancelAnimationFrame(this.scrollToOpenDialogFrame);
|
||||||
|
this.scrollToOpenDialogFrame = undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override willUpdate(changed: Map<string, unknown>): void {
|
protected override willUpdate(changed: Map<string, unknown>): void {
|
||||||
@@ -324,7 +341,7 @@ export class ChatView extends LitElement {
|
|||||||
this.pendingNotificationFocus = undefined;
|
this.pendingNotificationFocus = undefined;
|
||||||
this.retainedEmptyNotificationTrayTargetKey = undefined;
|
this.retainedEmptyNotificationTrayTargetKey = undefined;
|
||||||
}
|
}
|
||||||
if (changed.has("messages") || changed.has("pendingAsk")) this.pinnedToBottom = this.pinnedToBottom && (this.didChatHeightChange() || this.isNearBottom());
|
if (changed.has("messages") || changed.has("pendingAsk") || changed.has("pendingDialogs") || changed.has("closedDialogs")) this.pinnedToBottom = this.pinnedToBottom && (this.didChatHeightChange() || this.isNearBottom());
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override update(changed: Map<string, unknown>): void {
|
protected override update(changed: Map<string, unknown>): void {
|
||||||
@@ -338,12 +355,14 @@ export class ChatView extends LitElement {
|
|||||||
if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false;
|
if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false;
|
||||||
if (changed.has("sessionId")) this.restoreScrollPosition();
|
if (changed.has("sessionId")) this.restoreScrollPosition();
|
||||||
const openedAsk = changed.has("pendingAsk") && this.isNewPendingAsk(changed.get("pendingAsk"));
|
const openedAsk = changed.has("pendingAsk") && this.isNewPendingAsk(changed.get("pendingAsk"));
|
||||||
|
const openedDialog = changed.has("pendingDialogs") && this.isNewOpenDialog(changed.get("pendingDialogs"));
|
||||||
// The form uses the transcript scroller. Start a new long form at question
|
// The form uses the transcript scroller. Start a new long form at question
|
||||||
// one rather than applying the usual live-tail scroll and landing at its end.
|
// one rather than applying the usual live-tail scroll and landing at its end.
|
||||||
if (!changed.has("sessionId") && openedAsk && this.pinnedToBottom) this.scrollToOpenAsk();
|
if (!changed.has("sessionId") && openedAsk && this.pinnedToBottom) this.scrollToOpenAsk();
|
||||||
else if (!changed.has("sessionId") && (changed.has("messages") || changed.has("pendingAsk")) && this.pinnedToBottom) this.scrollToBottom();
|
else if (!changed.has("sessionId") && openedDialog && this.pinnedToBottom) this.scrollToOpenDialog();
|
||||||
|
else if (!changed.has("sessionId") && (changed.has("messages") || changed.has("pendingAsk") || changed.has("pendingDialogs") || changed.has("closedDialogs")) && this.pinnedToBottom) this.scrollToBottom();
|
||||||
if (changed.has("messages") || changed.has("messageStart") || changed.has("messageTotal") || changed.has("hasMore") || changed.has("loadingMore")) this.scheduleConversationRailUpdate();
|
if (changed.has("messages") || changed.has("messageStart") || changed.has("messageTotal") || changed.has("hasMore") || changed.has("loadingMore")) this.scheduleConversationRailUpdate();
|
||||||
if (changed.has("messages") || changed.has("messageStart") || changed.has("hasMore") || changed.has("loadingMore") || changed.has("pendingAsk")) this.continuePendingScrollRestore();
|
if (changed.has("messages") || changed.has("messageStart") || changed.has("hasMore") || changed.has("loadingMore") || changed.has("pendingAsk") || changed.has("pendingDialogs") || changed.has("closedDialogs")) this.continuePendingScrollRestore();
|
||||||
if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded();
|
if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded();
|
||||||
if (changed.has("notificationInbox") && this.pendingNotificationFocus !== undefined) this.focusPendingNotificationTarget();
|
if (changed.has("notificationInbox") && this.pendingNotificationFocus !== undefined) this.focusPendingNotificationTarget();
|
||||||
if (changed.has("zoomedImage")) this.syncImageZoomDialog();
|
if (changed.has("zoomedImage")) this.syncImageZoomDialog();
|
||||||
@@ -383,6 +402,7 @@ export class ChatView extends LitElement {
|
|||||||
${this.renderQueuedMessages()}
|
${this.renderQueuedMessages()}
|
||||||
${this.renderSessionActivity()}
|
${this.renderSessionActivity()}
|
||||||
${this.renderOpenAsk()}
|
${this.renderOpenAsk()}
|
||||||
|
${this.renderExtensionDialogs()}
|
||||||
</div>
|
</div>
|
||||||
${this.renderActivityDock()}
|
${this.renderActivityDock()}
|
||||||
</div>
|
</div>
|
||||||
@@ -668,6 +688,38 @@ export class ChatView extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderExtensionDialogs() {
|
||||||
|
const open = this.pendingDialogs[0];
|
||||||
|
if (open === undefined && this.closedDialogs.length === 0) return null;
|
||||||
|
const queuedCount = this.pendingDialogs.length - 1;
|
||||||
|
return html`
|
||||||
|
${repeat(
|
||||||
|
this.closedDialogs,
|
||||||
|
(closed) => closed.dialog.dialogId,
|
||||||
|
(closed) => html`
|
||||||
|
<extension-dialog-card
|
||||||
|
class="closed-dialog-card"
|
||||||
|
data-scroll-anchor-id=${`closed-dialog:${closed.dialog.dialogId}`}
|
||||||
|
.outcome=${closed}
|
||||||
|
.onDismiss=${this.onDismissClosedDialog}
|
||||||
|
></extension-dialog-card>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
${open === undefined ? null : html`
|
||||||
|
<extension-dialog-card
|
||||||
|
class="open-dialog-card"
|
||||||
|
data-scroll-anchor-id=${`dialog:${open.dialogId}`}
|
||||||
|
.dialog=${open}
|
||||||
|
.onAnswer=${this.onAnswerDialog}
|
||||||
|
.onCancel=${this.onCancelDialog}
|
||||||
|
></extension-dialog-card>
|
||||||
|
${queuedCount > 0
|
||||||
|
? html`<p class="queued-dialogs" role="status">${String(queuedCount)} more extension ${queuedCount === 1 ? "dialog" : "dialogs"} queued</p>`
|
||||||
|
: null}
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
private renderSessionActivity() {
|
private renderSessionActivity() {
|
||||||
if (!this.isCompacting) return null;
|
if (!this.isCompacting) return null;
|
||||||
return html`
|
return html`
|
||||||
@@ -1030,6 +1082,14 @@ export class ChatView extends LitElement {
|
|||||||
&& (typeof previous !== "object" || previous === null || Reflect.get(previous, "askId") !== this.pendingAsk.askId);
|
&& (typeof previous !== "object" || previous === null || Reflect.get(previous, "askId") !== this.pendingAsk.askId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isNewOpenDialog(previous: unknown): boolean {
|
||||||
|
const oldest = this.pendingDialogs[0];
|
||||||
|
if (oldest === undefined) return false;
|
||||||
|
if (!Array.isArray(previous)) return true;
|
||||||
|
const previousOldest: unknown = previous[0];
|
||||||
|
return typeof previousOldest !== "object" || previousOldest === null || Reflect.get(previousOldest, "dialogId") !== oldest.dialogId;
|
||||||
|
}
|
||||||
|
|
||||||
private scrollToOpenAsk(): void {
|
private scrollToOpenAsk(): void {
|
||||||
if (this.scrollToOpenAskFrame !== undefined) return;
|
if (this.scrollToOpenAskFrame !== undefined) return;
|
||||||
if (this.scrollToBottomFrame !== undefined) {
|
if (this.scrollToBottomFrame !== undefined) {
|
||||||
@@ -1052,6 +1112,28 @@ export class ChatView extends LitElement {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scrollToOpenDialog(): void {
|
||||||
|
if (this.scrollToOpenDialogFrame !== undefined) return;
|
||||||
|
if (this.scrollToBottomFrame !== undefined) {
|
||||||
|
cancelAnimationFrame(this.scrollToBottomFrame);
|
||||||
|
this.scrollToBottomFrame = undefined;
|
||||||
|
}
|
||||||
|
this.scrollToOpenDialogFrame = requestAnimationFrame(() => {
|
||||||
|
this.scrollToOpenDialogFrame = undefined;
|
||||||
|
this.withSuppressedScrollSave(() => { this.alignOpenDialogToTop(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private alignOpenDialogToTop(): boolean {
|
||||||
|
const chat = this.chat;
|
||||||
|
const card = this.renderRoot.querySelector<HTMLElement>(".chat > extension-dialog-card.open-dialog-card");
|
||||||
|
if (chat === undefined || card === null) return false;
|
||||||
|
chat.scrollTop += card.getBoundingClientRect().top - chat.getBoundingClientRect().top;
|
||||||
|
this.syncScrollMetrics();
|
||||||
|
this.pinnedToBottom = this.isNearBottom();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
restoreScrollPosition() {
|
restoreScrollPosition() {
|
||||||
const sessionId = this.sessionId;
|
const sessionId = this.sessionId;
|
||||||
if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame);
|
if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame);
|
||||||
@@ -1060,6 +1142,7 @@ export class ChatView extends LitElement {
|
|||||||
if (this.sessionId !== sessionId) return;
|
if (this.sessionId !== sessionId) return;
|
||||||
this.withSuppressedScrollSave(() => {
|
this.withSuppressedScrollSave(() => {
|
||||||
if (this.pendingAsk !== undefined && this.scrollController.readPosition(sessionId) === undefined && this.alignOpenAskToTop()) return;
|
if (this.pendingAsk !== undefined && this.scrollController.readPosition(sessionId) === undefined && this.alignOpenAskToTop()) return;
|
||||||
|
if (this.pendingDialogs.length > 0 && this.scrollController.readPosition(sessionId) === undefined && this.alignOpenDialogToTop()) return;
|
||||||
const result = this.scrollController.restorePosition(sessionId, this.chat, this.scrollAnchorElements(), { fallbackToBottom: this.shouldFallbackToBottomForMissingAnchor() });
|
const result = this.scrollController.restorePosition(sessionId, this.chat, this.scrollAnchorElements(), { fallbackToBottom: this.shouldFallbackToBottomForMissingAnchor() });
|
||||||
this.handleScrollRestoreResult(sessionId, result);
|
this.handleScrollRestoreResult(sessionId, result);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { PendingExtensionDialog } from "../../../shared/apiTypes";
|
||||||
|
import type { ClosedExtensionDialog } from "../appState";
|
||||||
|
import {
|
||||||
|
ExtensionDialogCard,
|
||||||
|
extensionDialogCloseLabel,
|
||||||
|
extensionDialogCloseSummary,
|
||||||
|
extensionDialogCountdownText,
|
||||||
|
type ExtensionDialogAnswerCallback,
|
||||||
|
type ExtensionDialogCancelCallback,
|
||||||
|
type ExtensionDialogDismissCallback,
|
||||||
|
} from "./ExtensionDialogCard";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extension-dialog-card confirm dialog", () => {
|
||||||
|
it("renders the title and message and answers Yes/No or cancels through the rendered buttons", async () => {
|
||||||
|
const onAnswer = vi.fn<ExtensionDialogAnswerCallback>();
|
||||||
|
const onCancel = vi.fn<ExtensionDialogCancelCallback>();
|
||||||
|
const card = await mountOpenDialog(openDialog({ message: "The extension wants to write files." }), { onAnswer, onCancel });
|
||||||
|
const root = renderRoot(card);
|
||||||
|
|
||||||
|
expect(root.querySelector("h2")?.textContent).toBe("Allow file writes?");
|
||||||
|
expect(root.querySelector(".dialog-message")?.textContent).toBe("The extension wants to write files.");
|
||||||
|
expect(root.querySelector("input, select, textarea")).toBeNull();
|
||||||
|
|
||||||
|
buttonWithText(root, "Yes").click();
|
||||||
|
await flushClose(card);
|
||||||
|
expect(onAnswer).toHaveBeenCalledWith("dlg-1", true);
|
||||||
|
|
||||||
|
buttonWithText(root, "No").click();
|
||||||
|
await flushClose(card);
|
||||||
|
expect(onAnswer).toHaveBeenCalledWith("dlg-1", false);
|
||||||
|
|
||||||
|
buttonWithText(root, "Cancel").click();
|
||||||
|
await flushClose(card);
|
||||||
|
expect(onCancel).toHaveBeenCalledWith("dlg-1");
|
||||||
|
expect(onCancel).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the answer controls while a close is in flight", async () => {
|
||||||
|
let resolveAnswer: (() => void) | undefined;
|
||||||
|
const onAnswer = vi.fn<ExtensionDialogAnswerCallback>(() => new Promise<void>((resolve) => { resolveAnswer = resolve; }));
|
||||||
|
const card = await mountOpenDialog(openDialog(), { onAnswer });
|
||||||
|
const root = renderRoot(card);
|
||||||
|
|
||||||
|
const yes = buttonWithText(root, "Yes");
|
||||||
|
yes.click();
|
||||||
|
await card.updateComplete;
|
||||||
|
|
||||||
|
expect(yes.disabled).toBe(true);
|
||||||
|
expect(buttonWithText(root, "No").disabled).toBe(true);
|
||||||
|
expect(buttonWithText(root, "Cancel").disabled).toBe(true);
|
||||||
|
|
||||||
|
resolveAnswer?.();
|
||||||
|
await flushClose(card);
|
||||||
|
expect(yes.disabled).toBe(false);
|
||||||
|
expect(onAnswer).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extension-dialog-card select dialog", () => {
|
||||||
|
it("answers with the clicked option", async () => {
|
||||||
|
const onAnswer = vi.fn<ExtensionDialogAnswerCallback>();
|
||||||
|
const card = await mountOpenDialog(openDialog({
|
||||||
|
kind: "select",
|
||||||
|
title: "Deploy where?",
|
||||||
|
options: ["Staging", "Production"],
|
||||||
|
}), { onAnswer });
|
||||||
|
const root = renderRoot(card);
|
||||||
|
|
||||||
|
expect(buttonsWithText(root, "Yes")).toHaveLength(0);
|
||||||
|
buttonWithText(root, "Production").click();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(onAnswer).toHaveBeenCalledWith("dlg-1", "Production");
|
||||||
|
expect(onAnswer).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extension-dialog-card input dialog", () => {
|
||||||
|
it("sends the typed text and keeps the placeholder and length bound", async () => {
|
||||||
|
const onAnswer = vi.fn<ExtensionDialogAnswerCallback>();
|
||||||
|
const card = await mountOpenDialog(openDialog({
|
||||||
|
kind: "input",
|
||||||
|
title: "Name the branch",
|
||||||
|
placeholder: "feature/…",
|
||||||
|
}), { onAnswer });
|
||||||
|
const root = renderRoot(card);
|
||||||
|
const input = requiredElement(root.querySelector("input"), "dialog input");
|
||||||
|
|
||||||
|
expect(input.placeholder).toBe("feature/…");
|
||||||
|
expect(input.maxLength).toBe(4000);
|
||||||
|
|
||||||
|
input.value = "feature/dialogs";
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
|
||||||
|
await card.updateComplete;
|
||||||
|
buttonWithText(root, "Send").click();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(onAnswer).toHaveBeenCalledWith("dlg-1", "feature/dialogs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends an empty string without typing", async () => {
|
||||||
|
const onAnswer = vi.fn<ExtensionDialogAnswerCallback>();
|
||||||
|
const card = await mountOpenDialog(openDialog({ kind: "input", title: "Notes?" }), { onAnswer });
|
||||||
|
const root = renderRoot(card);
|
||||||
|
|
||||||
|
const send = buttonWithText(root, "Send");
|
||||||
|
expect(send.disabled).toBe(false);
|
||||||
|
send.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(onAnswer).toHaveBeenCalledWith("dlg-1", "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a half-typed answer when the same dialog is re-projected from a status refresh", async () => {
|
||||||
|
const card = await mountOpenDialog(openDialog({ kind: "input", title: "Notes?" }));
|
||||||
|
const root = renderRoot(card);
|
||||||
|
const input = requiredElement(root.querySelector("input"), "dialog input");
|
||||||
|
input.value = "half typed";
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
|
||||||
|
await card.updateComplete;
|
||||||
|
|
||||||
|
card.dialog = { ...openDialog({ kind: "input", title: "Notes?" }) };
|
||||||
|
await card.updateComplete;
|
||||||
|
|
||||||
|
expect(requiredElement(root.querySelector("input"), "dialog input").value).toBe("half typed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extension-dialog-card countdown", () => {
|
||||||
|
it("shows the remaining time and ticks down each second", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-07-27T10:00:00.000Z"));
|
||||||
|
const card = await mountOpenDialog(openDialog({ timeoutAt: "2026-07-27T10:01:30.000Z" }));
|
||||||
|
const root = renderRoot(card);
|
||||||
|
const status = requiredElement(root.querySelector("[role='status']"), "countdown status");
|
||||||
|
|
||||||
|
expect(status.textContent).toBe("Auto-cancels in 1m 30s");
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
await card.updateComplete;
|
||||||
|
expect(status.textContent).toBe("Auto-cancels in 1m 0s");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders no countdown when the dialog waits forever", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const card = await mountOpenDialog(openDialog());
|
||||||
|
const root = renderRoot(card);
|
||||||
|
|
||||||
|
expect(root.querySelector("[role='status']")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops ticking once the dialog closes", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-07-27T10:00:00.000Z"));
|
||||||
|
const card = await mountOpenDialog(openDialog({ timeoutAt: "2026-07-27T10:01:30.000Z" }));
|
||||||
|
card.outcome = closedDialog("timeout");
|
||||||
|
await card.updateComplete;
|
||||||
|
|
||||||
|
const before = renderRoot(card).textContent;
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
await card.updateComplete;
|
||||||
|
|
||||||
|
expect(renderRoot(card).textContent).toBe(before);
|
||||||
|
expect(renderRoot(card).querySelector("[role='status']")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extension-dialog-card closed outcome", () => {
|
||||||
|
it("shows the given answer and dismisses through the dismiss control", async () => {
|
||||||
|
const onDismiss = vi.fn<ExtensionDialogDismissCallback>();
|
||||||
|
const card = new ExtensionDialogCard();
|
||||||
|
card.outcome = closedDialog("answered", true);
|
||||||
|
card.onDismiss = onDismiss;
|
||||||
|
document.body.append(card);
|
||||||
|
await card.updateComplete;
|
||||||
|
const root = renderRoot(card);
|
||||||
|
|
||||||
|
expect(root.querySelector(".header-status")?.textContent).toBe("Answered");
|
||||||
|
expect(root.querySelector(".closed-summary")?.textContent).toBe("Answered: Yes");
|
||||||
|
expect(root.querySelector("input, select, textarea")).toBeNull();
|
||||||
|
expect(buttonsWithText(root, "Yes")).toHaveLength(0);
|
||||||
|
|
||||||
|
buttonWithText(root, "Dismiss").click();
|
||||||
|
expect(onDismiss).toHaveBeenCalledWith("dlg-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the timeout outcome without an answer", async () => {
|
||||||
|
const card = new ExtensionDialogCard();
|
||||||
|
card.outcome = closedDialog("timeout");
|
||||||
|
document.body.append(card);
|
||||||
|
await card.updateComplete;
|
||||||
|
const root = renderRoot(card);
|
||||||
|
|
||||||
|
expect(root.querySelector(".header-status")?.textContent).toBe("Timed out");
|
||||||
|
expect(root.querySelector(".closed-summary")?.textContent).toContain("timed out");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extensionDialogCountdownText", () => {
|
||||||
|
const now = Date.parse("2026-07-27T10:00:00.000Z");
|
||||||
|
|
||||||
|
it("is undefined without a deadline or with an unparseable one", () => {
|
||||||
|
expect(extensionDialogCountdownText(undefined, now)).toBeUndefined();
|
||||||
|
expect(extensionDialogCountdownText("not-a-date", now)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats seconds, minutes, and hours", () => {
|
||||||
|
expect(extensionDialogCountdownText("2026-07-27T10:00:45.000Z", now)).toBe("Auto-cancels in 45s");
|
||||||
|
expect(extensionDialogCountdownText("2026-07-27T10:05:00.000Z", now)).toBe("Auto-cancels in 5m 0s");
|
||||||
|
expect(extensionDialogCountdownText("2026-07-27T11:02:00.000Z", now)).toBe("Auto-cancels in 1h 2m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays display-only once the deadline has passed", () => {
|
||||||
|
expect(extensionDialogCountdownText("2026-07-27T09:59:59.000Z", now)).toBe("Auto-cancel imminent");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extensionDialogCloseLabel and extensionDialogCloseSummary", () => {
|
||||||
|
it("labels every close reason", () => {
|
||||||
|
expect(extensionDialogCloseLabel("answered")).toBe("Answered");
|
||||||
|
expect(extensionDialogCloseLabel("cancelled")).toBe("Cancelled");
|
||||||
|
expect(extensionDialogCloseLabel("timeout")).toBe("Timed out");
|
||||||
|
expect(extensionDialogCloseLabel("aborted")).toBe("Aborted");
|
||||||
|
expect(extensionDialogCloseLabel("session-ended")).toBe("Session ended");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes answers by kind", () => {
|
||||||
|
expect(extensionDialogCloseSummary(closedDialog("answered", false))).toBe("Answered: No");
|
||||||
|
expect(extensionDialogCloseSummary(closedDialog("answered", "Staging"))).toBe("Answered: Staging");
|
||||||
|
expect(extensionDialogCloseSummary(closedDialog("answered", ""))).toBe("Answered with an empty response.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes closes without an answer", () => {
|
||||||
|
expect(extensionDialogCloseSummary(closedDialog("cancelled"))).toBe("Dismissed without an answer.");
|
||||||
|
expect(extensionDialogCloseSummary(closedDialog("timeout"))).toContain("timed out");
|
||||||
|
expect(extensionDialogCloseSummary(closedDialog("aborted"))).toContain("run ended");
|
||||||
|
expect(extensionDialogCloseSummary(closedDialog("session-ended"))).toContain("session ended");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mountOpenDialog(
|
||||||
|
dialog: PendingExtensionDialog,
|
||||||
|
callbacks: { onAnswer?: ExtensionDialogAnswerCallback; onCancel?: ExtensionDialogCancelCallback } = {},
|
||||||
|
): Promise<ExtensionDialogCard> {
|
||||||
|
const card = new ExtensionDialogCard();
|
||||||
|
card.dialog = dialog;
|
||||||
|
if (callbacks.onAnswer !== undefined) card.onAnswer = callbacks.onAnswer;
|
||||||
|
if (callbacks.onCancel !== undefined) card.onCancel = callbacks.onCancel;
|
||||||
|
document.body.append(card);
|
||||||
|
await card.updateComplete;
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoot(card: ExtensionDialogCard): ShadowRoot {
|
||||||
|
return requiredElement(card.shadowRoot, "extension-dialog-card shadow root");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonWithText(root: ShadowRoot, text: string): HTMLButtonElement {
|
||||||
|
const matches = buttonsWithText(root, text);
|
||||||
|
if (matches.length !== 1) throw new Error(`Expected exactly one button named ${text}, found ${String(matches.length)}`);
|
||||||
|
const match = matches[0];
|
||||||
|
return requiredElement(match, `button named ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonsWithText(root: ShadowRoot, text: string): HTMLButtonElement[] {
|
||||||
|
return [...root.querySelectorAll("button")].filter((candidate) => candidate.textContent.trim() === text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushClose(card: ExtensionDialogCard): Promise<void> {
|
||||||
|
// The card's close promise chain settles over several microtasks; a macrotask
|
||||||
|
// flush waits for all of them plus the state change they schedule.
|
||||||
|
await new Promise((resolve) => { setTimeout(resolve, 0); });
|
||||||
|
await card.updateComplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredElement<T>(value: T | null | undefined, label: string): T {
|
||||||
|
if (value === null || value === undefined) throw new Error(`Expected ${label}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDialog(overrides: Partial<PendingExtensionDialog> = {}): PendingExtensionDialog {
|
||||||
|
return {
|
||||||
|
dialogId: "dlg-1",
|
||||||
|
kind: "confirm",
|
||||||
|
title: "Allow file writes?",
|
||||||
|
askedAt: "2026-07-27T10:00:00.000Z",
|
||||||
|
runScoped: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function closedDialog(reason: ClosedExtensionDialog["reason"], answer?: ClosedExtensionDialog["answer"]): ClosedExtensionDialog {
|
||||||
|
return {
|
||||||
|
dialog: openDialog(),
|
||||||
|
reason,
|
||||||
|
...(answer === undefined ? {} : { answer }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import { LitElement, css, html, type PropertyValues, type TemplateResult } from "lit";
|
||||||
|
import { customElement, property, state } from "lit/decorators.js";
|
||||||
|
import { ifDefined } from "lit/directives/if-defined.js";
|
||||||
|
import {
|
||||||
|
EXTENSION_DIALOG_INPUT_MAX_LENGTH,
|
||||||
|
type ExtensionDialogAnswer,
|
||||||
|
type ExtensionDialogCloseReason,
|
||||||
|
type PendingExtensionDialog,
|
||||||
|
} from "../../../shared/apiTypes";
|
||||||
|
import type { ClosedExtensionDialog } from "../appState";
|
||||||
|
|
||||||
|
export type ExtensionDialogAnswerCallback = (dialogId: string, value: ExtensionDialogAnswer) => void | Promise<void>;
|
||||||
|
export type ExtensionDialogCancelCallback = (dialogId: string) => void | Promise<void>;
|
||||||
|
export type ExtensionDialogDismissCallback = (dialogId: string) => void;
|
||||||
|
|
||||||
|
const COUNTDOWN_TICK_MS = 1_000;
|
||||||
|
|
||||||
|
/** Header status label for a closed extension dialog. */
|
||||||
|
export function extensionDialogCloseLabel(reason: ExtensionDialogCloseReason): string {
|
||||||
|
switch (reason) {
|
||||||
|
case "answered": return "Answered";
|
||||||
|
case "cancelled": return "Cancelled";
|
||||||
|
case "timeout": return "Timed out";
|
||||||
|
case "aborted": return "Aborted";
|
||||||
|
case "session-ended": return "Session ended";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-line summary of what a closed dialog resolved to, for the outcome card. */
|
||||||
|
export function extensionDialogCloseSummary(closed: ClosedExtensionDialog): string {
|
||||||
|
switch (closed.reason) {
|
||||||
|
case "answered": {
|
||||||
|
const answer = closed.answer;
|
||||||
|
// An answered close without an answer value breaks the wire contract;
|
||||||
|
// the card still renders rather than crashing the transcript.
|
||||||
|
if (answer === undefined) return "Closed without an answer.";
|
||||||
|
if (typeof answer === "boolean") return `Answered: ${answer ? "Yes" : "No"}`;
|
||||||
|
return answer === "" ? "Answered with an empty response." : `Answered: ${answer}`;
|
||||||
|
}
|
||||||
|
case "cancelled": return "Dismissed without an answer.";
|
||||||
|
case "timeout": return "No answer was given before the dialog timed out.";
|
||||||
|
case "aborted": return "The run ended before this dialog was answered.";
|
||||||
|
case "session-ended": return "The session ended before this dialog was answered.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remaining-time label for an open dialog's auto-cancel deadline. Display
|
||||||
|
* only: the daemon owns the real timeout and publishes `dialog.closed`, so a
|
||||||
|
* card whose countdown reaches zero simply waits for that event.
|
||||||
|
*/
|
||||||
|
export function extensionDialogCountdownText(timeoutAt: string | undefined, nowMs: number): string | undefined {
|
||||||
|
if (timeoutAt === undefined) return undefined;
|
||||||
|
const deadline = Date.parse(timeoutAt);
|
||||||
|
if (!Number.isFinite(deadline)) return undefined;
|
||||||
|
const remainingMs = deadline - nowMs;
|
||||||
|
if (remainingMs <= 0) return "Auto-cancel imminent";
|
||||||
|
const seconds = Math.ceil(remainingMs / 1000);
|
||||||
|
if (seconds >= 3600) {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.round((seconds % 3600) / 60);
|
||||||
|
return `Auto-cancels in ${String(hours)}h ${String(minutes)}m`;
|
||||||
|
}
|
||||||
|
if (seconds >= 60) {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
return `Auto-cancels in ${String(minutes)}m ${String(seconds % 60)}s`;
|
||||||
|
}
|
||||||
|
return `Auto-cancels in ${String(seconds)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One extension dialog opened by `ctx.ui.confirm()`, `ctx.ui.select()`, or
|
||||||
|
* `ctx.ui.input()`.
|
||||||
|
*
|
||||||
|
* The card owns only browser-local form state (the half-typed input, the
|
||||||
|
* in-flight close flag, the display-only countdown); the daemon remains the
|
||||||
|
* source of truth for whether the dialog is open. Closed mode renders the
|
||||||
|
* transient outcome for a browser that saw the dialog open.
|
||||||
|
*/
|
||||||
|
@customElement("extension-dialog-card")
|
||||||
|
export class ExtensionDialogCard extends LitElement {
|
||||||
|
@property({ attribute: false }) dialog?: PendingExtensionDialog;
|
||||||
|
@property({ attribute: false }) outcome?: ClosedExtensionDialog;
|
||||||
|
@property({ attribute: false }) onAnswer?: ExtensionDialogAnswerCallback;
|
||||||
|
@property({ attribute: false }) onCancel?: ExtensionDialogCancelCallback;
|
||||||
|
@property({ attribute: false }) onDismiss?: ExtensionDialogDismissCallback;
|
||||||
|
|
||||||
|
@state() private inputValue = "";
|
||||||
|
@state() private closing = false;
|
||||||
|
@state() private countdownNow = 0;
|
||||||
|
private dialogIdentity: string | undefined;
|
||||||
|
private countdownTimer: number | undefined;
|
||||||
|
|
||||||
|
override connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.syncCountdownTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback(): void {
|
||||||
|
this.stopCountdownTimer();
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override willUpdate(changed: PropertyValues<this>): void {
|
||||||
|
if (!changed.has("dialog") && !changed.has("outcome")) return;
|
||||||
|
// Identity is keyed by dialogId, not object identity: status refreshes
|
||||||
|
// re-project the same open dialog as a new object and must not wipe a
|
||||||
|
// half-typed answer or an in-flight close.
|
||||||
|
const identity = this.currentIdentity();
|
||||||
|
if (identity !== this.dialogIdentity) {
|
||||||
|
this.dialogIdentity = identity;
|
||||||
|
this.inputValue = "";
|
||||||
|
this.closing = false;
|
||||||
|
}
|
||||||
|
this.syncCountdownTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
override render(): TemplateResult | null {
|
||||||
|
if (this.outcome !== undefined) return this.renderClosed(this.outcome);
|
||||||
|
if (this.dialog !== undefined) return this.renderOpen(this.dialog);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderOpen(dialog: PendingExtensionDialog): TemplateResult {
|
||||||
|
const countdown = extensionDialogCountdownText(dialog.timeoutAt, this.countdownNow === 0 ? Date.now() : this.countdownNow);
|
||||||
|
return html`
|
||||||
|
<article class="card open-card" aria-labelledby="extension-dialog-heading">
|
||||||
|
<header class="card-header">
|
||||||
|
<h2 id="extension-dialog-heading">${dialog.title}</h2>
|
||||||
|
${countdown === undefined
|
||||||
|
? null
|
||||||
|
: html`<span class="header-status countdown" role="status" aria-live="polite" aria-atomic="true">${countdown}</span>`}
|
||||||
|
</header>
|
||||||
|
${this.renderOpenBody(dialog)}
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderOpenBody(dialog: PendingExtensionDialog): TemplateResult {
|
||||||
|
if (dialog.kind === "select") return this.renderSelectBody(dialog);
|
||||||
|
if (dialog.kind === "input") return this.renderInputBody(dialog);
|
||||||
|
return this.renderConfirmBody(dialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderConfirmBody(dialog: PendingExtensionDialog): TemplateResult {
|
||||||
|
return html`
|
||||||
|
${dialog.message === undefined ? null : html`<p class="dialog-message">${dialog.message}</p>`}
|
||||||
|
<footer class="dialog-footer">
|
||||||
|
<button class="secondary-action" type="button" ?disabled=${this.closing} @click=${() => { this.cancelDialog(dialog); }}>Cancel</button>
|
||||||
|
<button class="secondary-action" type="button" ?disabled=${this.closing} @click=${() => { this.answerDialog(dialog, false); }}>No</button>
|
||||||
|
<button class="primary-action" type="button" ?disabled=${this.closing} @click=${() => { this.answerDialog(dialog, true); }}>Yes</button>
|
||||||
|
</footer>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSelectBody(dialog: PendingExtensionDialog): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="dialog-options" role="group" aria-label="Choices">
|
||||||
|
${(dialog.options ?? []).map((option) => html`
|
||||||
|
<button class="option-button" type="button" ?disabled=${this.closing} @click=${() => { this.answerDialog(dialog, option); }}>${option}</button>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
<footer class="dialog-footer">
|
||||||
|
<button class="secondary-action" type="button" ?disabled=${this.closing} @click=${() => { this.cancelDialog(dialog); }}>Cancel</button>
|
||||||
|
</footer>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderInputBody(dialog: PendingExtensionDialog): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<form class="dialog-input-form" @submit=${(event: SubmitEvent) => { this.submitInput(event, dialog); }}>
|
||||||
|
<input
|
||||||
|
class="dialog-input"
|
||||||
|
type="text"
|
||||||
|
name="dialog-answer"
|
||||||
|
aria-label="Your answer"
|
||||||
|
placeholder=${ifDefined(dialog.placeholder)}
|
||||||
|
maxlength=${String(EXTENSION_DIALOG_INPUT_MAX_LENGTH)}
|
||||||
|
.value=${this.inputValue}
|
||||||
|
?disabled=${this.closing}
|
||||||
|
@input=${(event: Event) => { this.changeInput(event); }}
|
||||||
|
/>
|
||||||
|
<footer class="dialog-footer">
|
||||||
|
<button class="secondary-action" type="button" ?disabled=${this.closing} @click=${() => { this.cancelDialog(dialog); }}>Cancel</button>
|
||||||
|
<button class="primary-action" type="submit" ?disabled=${this.closing}>${this.closing ? "Sending…" : "Send"}</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderClosed(closed: ClosedExtensionDialog): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<article class="card closed-card" aria-labelledby="extension-dialog-closed-heading">
|
||||||
|
<header class="card-header">
|
||||||
|
<h2 id="extension-dialog-closed-heading">${closed.dialog.title}</h2>
|
||||||
|
<span class=${`header-status ${closed.reason}`}>${extensionDialogCloseLabel(closed.reason)}</span>
|
||||||
|
</header>
|
||||||
|
<p class="closed-summary">${extensionDialogCloseSummary(closed)}</p>
|
||||||
|
<footer class="dialog-footer">
|
||||||
|
<button class="secondary-action" type="button" @click=${() => { this.dismissClosed(closed); }}>Dismiss</button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private answerDialog(dialog: PendingExtensionDialog, value: ExtensionDialogAnswer): void {
|
||||||
|
this.closeWith(dialog, () => this.onAnswer?.(dialog.dialogId, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelDialog(dialog: PendingExtensionDialog): void {
|
||||||
|
this.closeWith(dialog, () => this.onCancel?.(dialog.dialogId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private submitInput(event: SubmitEvent, dialog: PendingExtensionDialog): void {
|
||||||
|
event.preventDefault();
|
||||||
|
// An empty string is a valid input answer, so Send stays enabled.
|
||||||
|
this.answerDialog(dialog, this.inputValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeWith(dialog: PendingExtensionDialog, close: () => void | Promise<void>): void {
|
||||||
|
if (this.closing) return;
|
||||||
|
this.closing = true;
|
||||||
|
const dialogId = dialog.dialogId;
|
||||||
|
void Promise.resolve()
|
||||||
|
.then(close)
|
||||||
|
.catch(() => {
|
||||||
|
// The parent controller owns the visible transport error. Keeping this
|
||||||
|
// card usable is the only recovery needed at this boundary.
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (this.dialog?.dialogId === dialogId) this.closing = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private changeInput(event: Event): void {
|
||||||
|
const input = event.currentTarget;
|
||||||
|
if (!(input instanceof HTMLInputElement)) return;
|
||||||
|
this.inputValue = input.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private dismissClosed(closed: ClosedExtensionDialog): void {
|
||||||
|
this.onDismiss?.(closed.dialog.dialogId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentIdentity(): string | undefined {
|
||||||
|
if (this.outcome !== undefined) return `closed:${this.outcome.dialog.dialogId}`;
|
||||||
|
if (this.dialog !== undefined) return `open:${this.dialog.dialogId}`;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncCountdownTimer(): void {
|
||||||
|
const needsTick = this.isConnected && this.outcome === undefined && this.dialog?.timeoutAt !== undefined;
|
||||||
|
if (needsTick && this.countdownTimer === undefined) {
|
||||||
|
this.countdownNow = Date.now();
|
||||||
|
this.countdownTimer = window.setInterval(() => { this.countdownNow = Date.now(); }, COUNTDOWN_TICK_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!needsTick) this.stopCountdownTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopCountdownTimer(): void {
|
||||||
|
if (this.countdownTimer === undefined) return;
|
||||||
|
window.clearInterval(this.countdownTimer);
|
||||||
|
this.countdownTimer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
color: var(--pi-text);
|
||||||
|
font: 14px system-ui, sans-serif;
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--pi-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--pi-surface);
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
position: sticky;
|
||||||
|
top: var(--pi-chat-sticky-top, 0px);
|
||||||
|
z-index: 6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 8px 16px 7px;
|
||||||
|
border-bottom: 1px solid var(--pi-border-muted);
|
||||||
|
border-radius: 9px 9px 0 0;
|
||||||
|
background: var(--pi-surface);
|
||||||
|
box-shadow: 0 8px 18px var(--pi-shadow-soft);
|
||||||
|
}
|
||||||
|
h2, p { margin-top: 0; }
|
||||||
|
h2 {
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.header-status { flex: 0 0 auto; color: var(--pi-muted); font-size: 11px; text-align: end; }
|
||||||
|
.header-status.answered { color: var(--pi-success); }
|
||||||
|
.header-status.timeout, .header-status.aborted, .header-status.session-ended { color: var(--pi-warning); }
|
||||||
|
.dialog-message {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.dialog-options { display: grid; gap: 7px; padding: 12px 16px; }
|
||||||
|
.option-button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: start;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.option-button:hover:not(:disabled) { border-color: var(--pi-accent); background: var(--pi-surface-hover); }
|
||||||
|
.dialog-input-form { display: grid; }
|
||||||
|
.dialog-input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
margin: 12px 16px 0;
|
||||||
|
border: 1px solid var(--pi-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--pi-bg);
|
||||||
|
color: var(--pi-text);
|
||||||
|
padding: 8px;
|
||||||
|
font: var(--pi-control-font-size, 16px)/1.4 var(--pi-control-font-family, system-ui, sans-serif);
|
||||||
|
}
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
border-top: 1px solid var(--pi-border-muted);
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.dialog-message + .dialog-footer, .dialog-options + .dialog-footer { border-top: 0; }
|
||||||
|
button {
|
||||||
|
border: 1px solid var(--pi-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--pi-surface);
|
||||||
|
color: var(--pi-text);
|
||||||
|
padding: 7px 12px;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover:not(:disabled) { background: var(--pi-surface-hover); }
|
||||||
|
button:disabled { cursor: wait; opacity: .65; }
|
||||||
|
button:focus-visible, .dialog-input:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; }
|
||||||
|
.primary-action { border-color: var(--pi-accent); background: var(--pi-accent); color: var(--pi-accent-contrast, white); font-weight: 650; }
|
||||||
|
.primary-action:hover:not(:disabled) { background: color-mix(in srgb, var(--pi-accent) 86%, white); }
|
||||||
|
.closed-summary {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--pi-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
@container (max-width: 580px) {
|
||||||
|
.primary-action { min-height: 42px; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"extension-dialog-card": ExtensionDialogCard;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import type { TemplateResult } from "lit";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ExtensionDialogAnswer, PendingExtensionDialog, SessionInfo, SessionStatus } from "../api";
|
||||||
|
import { initialAppState, type AppState, type ClosedExtensionDialog } from "../appState";
|
||||||
|
import { SessionController } from "../controllers/sessionController";
|
||||||
|
// Template inspection here is the escape hatch for verifying the chat-view
|
||||||
|
// dialog callback wiring in a node environment (no DOM harness), mirroring
|
||||||
|
// PiWebApp.clearQueue.test.ts. See templateInspection.testSupport for the
|
||||||
|
// proportionality rationale.
|
||||||
|
import { templateValueAfterMarker } from "../templateInspection.testSupport";
|
||||||
|
import { PiWebApp } from "./PiWebApp";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PiWebApp extension-dialog wiring", () => {
|
||||||
|
it("passes dialog state and stable SessionController callbacks through to chat-view", () => {
|
||||||
|
const app = createApp();
|
||||||
|
const state = stateWithDialogs();
|
||||||
|
setAppState(app, state);
|
||||||
|
const controller = appSessionController(app);
|
||||||
|
const answerDialog = vi.spyOn(controller, "answerDialog").mockResolvedValue(undefined);
|
||||||
|
const cancelDialog = vi.spyOn(controller, "cancelDialog").mockResolvedValue(undefined);
|
||||||
|
const dismissClosedDialog = vi.spyOn(controller, "dismissClosedDialog").mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const firstRender = renderChatView(app, state);
|
||||||
|
const secondRender = renderChatView(app, state);
|
||||||
|
const onAnswer = templateDialogCallback(firstRender, ".onAnswerDialog=");
|
||||||
|
const onCancel = templateDialogCallback(firstRender, ".onCancelDialog=");
|
||||||
|
const onDismiss = templateDialogCallback(firstRender, ".onDismissClosedDialog=");
|
||||||
|
|
||||||
|
expect(templateValueAfterMarker(firstRender, ".pendingDialogs=")).toBe(state.pendingDialogs);
|
||||||
|
expect(templateValueAfterMarker(firstRender, ".closedDialogs=")).toBe(state.closedDialogs);
|
||||||
|
expect(templateDialogCallback(secondRender, ".onAnswerDialog=")).toBe(onAnswer);
|
||||||
|
expect(templateDialogCallback(secondRender, ".onCancelDialog=")).toBe(onCancel);
|
||||||
|
expect(templateDialogCallback(secondRender, ".onDismissClosedDialog=")).toBe(onDismiss);
|
||||||
|
|
||||||
|
onAnswer("dlg-1", true);
|
||||||
|
onCancel("dlg-2");
|
||||||
|
onDismiss("dlg-0");
|
||||||
|
expect(answerDialog).toHaveBeenCalledWith("dlg-1", true);
|
||||||
|
expect(cancelDialog).toHaveBeenCalledWith("dlg-2");
|
||||||
|
expect(dismissClosedDialog).toHaveBeenCalledWith("dlg-0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type RenderChatView = (this: PiWebApp, state: AppState, session: SessionInfo) => TemplateResult;
|
||||||
|
type DialogCallback = (dialogId: string, value?: ExtensionDialogAnswer) => void;
|
||||||
|
|
||||||
|
function createApp(): PiWebApp {
|
||||||
|
const storage = {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => undefined,
|
||||||
|
removeItem: () => undefined,
|
||||||
|
};
|
||||||
|
vi.stubGlobal("window", { location: { search: "" }, localStorage: storage });
|
||||||
|
return new PiWebApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateWithDialogs(): AppState {
|
||||||
|
const session: SessionInfo = {
|
||||||
|
id: "session-1",
|
||||||
|
cwd: "/repo",
|
||||||
|
path: "/repo/session-1.jsonl",
|
||||||
|
created: "2026-07-27T00:00:00.000Z",
|
||||||
|
modified: "2026-07-27T00:00:00.000Z",
|
||||||
|
messageCount: 1,
|
||||||
|
firstMessage: "hello",
|
||||||
|
};
|
||||||
|
const open: PendingExtensionDialog = {
|
||||||
|
dialogId: "dlg-1",
|
||||||
|
kind: "confirm",
|
||||||
|
title: "Allow file writes?",
|
||||||
|
askedAt: "2026-07-27T10:00:00.000Z",
|
||||||
|
runScoped: false,
|
||||||
|
};
|
||||||
|
const closed: ClosedExtensionDialog = {
|
||||||
|
dialog: { ...open, dialogId: "dlg-0", title: "Allow reads?" },
|
||||||
|
reason: "answered",
|
||||||
|
answer: true,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...initialAppState(),
|
||||||
|
selectedSession: session,
|
||||||
|
status: dialogStatus(),
|
||||||
|
pendingDialogs: [open],
|
||||||
|
closedDialogs: [closed],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dialogStatus(): SessionStatus {
|
||||||
|
return {
|
||||||
|
sessionId: "session-1",
|
||||||
|
isStreaming: false,
|
||||||
|
isCompacting: false,
|
||||||
|
isBashRunning: false,
|
||||||
|
pendingMessageCount: 0,
|
||||||
|
queuedMessages: [],
|
||||||
|
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
cost: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAppState(app: PiWebApp, state: AppState): void {
|
||||||
|
if (!Reflect.set(app, "state", state)) throw new Error("Could not set PiWebApp state");
|
||||||
|
}
|
||||||
|
|
||||||
|
function appSessionController(app: PiWebApp): SessionController {
|
||||||
|
const controller: unknown = Reflect.get(app, "sessions");
|
||||||
|
if (!(controller instanceof SessionController)) throw new Error("PiWebApp SessionController was unavailable");
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChatView(app: PiWebApp, state: AppState): TemplateResult {
|
||||||
|
const method: unknown = Reflect.get(app, "renderChatView");
|
||||||
|
if (!isRenderChatView(method)) throw new Error("PiWebApp.renderChatView is not callable");
|
||||||
|
const session = state.selectedSession;
|
||||||
|
if (session === undefined) throw new Error("Expected a selected session");
|
||||||
|
return method.call(app, state, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRenderChatView(value: unknown): value is RenderChatView {
|
||||||
|
return typeof value === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateDialogCallback(template: TemplateResult, marker: string): DialogCallback {
|
||||||
|
const value = templateValueAfterMarker(template, marker);
|
||||||
|
if (!isDialogCallback(value)) throw new Error(`Expected callback after ${marker}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDialogCallback(value: unknown): value is DialogCallback {
|
||||||
|
return typeof value === "function";
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { LitElement, html } from "lit";
|
import { LitElement, html } from "lit";
|
||||||
import { customElement, query, state } from "lit/decorators.js";
|
import { customElement, query, state } from "lit/decorators.js";
|
||||||
import { configApi, effectiveWorkspaceUploadFolder, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type AskUserSubmission, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type SessionTreeNavigateResult, type SessionTreeSummaryChoice, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
|
import { configApi, effectiveWorkspaceUploadFolder, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type AskUserSubmission, type ExtensionDialogAnswer, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type SessionTreeNavigateResult, type SessionTreeSummaryChoice, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
|
||||||
import type { AppAction } from "../actions";
|
import type { AppAction } from "../actions";
|
||||||
import { initialAppState, type AppState } from "../appState";
|
import { initialAppState, type AppState } from "../appState";
|
||||||
import { isSessionActive } from "../../../shared/activity";
|
import { isSessionActive } from "../../../shared/activity";
|
||||||
@@ -2147,6 +2147,14 @@ export class PiWebApp extends LitElement {
|
|||||||
|
|
||||||
private readonly handleSubmitAsk = (askId: string, submission: AskUserSubmission): Promise<void> => this.sessions.submitAsk(askId, submission);
|
private readonly handleSubmitAsk = (askId: string, submission: AskUserSubmission): Promise<void> => this.sessions.submitAsk(askId, submission);
|
||||||
|
|
||||||
|
private readonly handleAnswerDialog = (dialogId: string, value: ExtensionDialogAnswer): Promise<void> => this.sessions.answerDialog(dialogId, value);
|
||||||
|
|
||||||
|
private readonly handleCancelDialog = (dialogId: string): Promise<void> => this.sessions.cancelDialog(dialogId);
|
||||||
|
|
||||||
|
private readonly handleDismissClosedDialog = (dialogId: string): void => {
|
||||||
|
this.sessions.dismissClosedDialog(dialogId);
|
||||||
|
};
|
||||||
|
|
||||||
private readonly handleDismissNotification = (notificationId: string): void => {
|
private readonly handleDismissNotification = (notificationId: string): void => {
|
||||||
void this.notifications.dismissNotification(notificationId);
|
void this.notifications.dismissNotification(notificationId);
|
||||||
};
|
};
|
||||||
@@ -2172,7 +2180,7 @@ export class PiWebApp extends LitElement {
|
|||||||
|
|
||||||
private renderChatView(state: AppState, session: SessionInfo) {
|
private renderChatView(state: AppState, session: SessionInfo) {
|
||||||
return html`
|
return html`
|
||||||
<chat-view .sessionId=${session.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isSendingPrompt=${state.sendingPrompts[session.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[session.id] ?? []} .status=${state.status} .activity=${state.activity} .pendingAsk=${state.pendingAsk} .askDraftSessionId=${machineSessionKey(selectedMachineId(state), session.id)} .onSubmitAsk=${this.handleSubmitAsk} .notificationInbox=${selectedNotificationView(state.selectedNotificationInbox)} .canClearServerQueue=${this.canClearServerQueue()} .onClearServerQueue=${this.handleClearServerQueue} .onDismissWarning=${this.handleDismissWarning} .onDismissNotification=${this.handleDismissNotification} .onDismissAllNotifications=${this.handleDismissAllNotifications} .warningsVisible=${!this.sessionWarningVisibility.collapsed} .onToggleWarnings=${this.handleToggleWarnings} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
<chat-view .sessionId=${session.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isSendingPrompt=${state.sendingPrompts[session.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[session.id] ?? []} .status=${state.status} .activity=${state.activity} .pendingAsk=${state.pendingAsk} .pendingDialogs=${state.pendingDialogs} .closedDialogs=${state.closedDialogs} .onAnswerDialog=${this.handleAnswerDialog} .onCancelDialog=${this.handleCancelDialog} .onDismissClosedDialog=${this.handleDismissClosedDialog} .askDraftSessionId=${machineSessionKey(selectedMachineId(state), session.id)} .onSubmitAsk=${this.handleSubmitAsk} .notificationInbox=${selectedNotificationView(state.selectedNotificationInbox)} .canClearServerQueue=${this.canClearServerQueue()} .onClearServerQueue=${this.handleClearServerQueue} .onDismissWarning=${this.handleDismissWarning} .onDismissNotification=${this.handleDismissNotification} .onDismissAllNotifications=${this.handleDismissAllNotifications} .warningsVisible=${!this.sessionWarningVisibility.collapsed} .onToggleWarnings=${this.handleToggleWarnings} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -421,6 +421,7 @@ export const chatStyles = css`
|
|||||||
.queued-message { display: grid; gap: 4px; padding-top: 8px; border-top: 1px solid var(--pi-border); }
|
.queued-message { display: grid; gap: 4px; padding-top: 8px; border-top: 1px solid var(--pi-border); }
|
||||||
.queued-message:first-of-type { padding-top: 0; border-top: 0; }
|
.queued-message:first-of-type { padding-top: 0; border-top: 0; }
|
||||||
.queued-kind { color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
|
.queued-kind { color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
|
||||||
|
.queued-dialogs { margin: -8px 0 14px; padding: 0 4px; color: var(--pi-muted); font-size: 12px; text-align: center; }
|
||||||
.session-activity { max-width: 100%; min-width: 0; box-sizing: border-box; display: grid; gap: 4px; margin: 0 0 14px; padding: 12px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); color: var(--pi-text); overflow: hidden; }
|
.session-activity { max-width: 100%; min-width: 0; box-sizing: border-box; display: grid; gap: 4px; margin: 0 0 14px; padding: 12px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); color: var(--pi-text); overflow: hidden; }
|
||||||
.session-activity.compacting { border-color: var(--pi-purple-border); background: var(--pi-purple-surface); }
|
.session-activity.compacting { border-color: var(--pi-purple-border); background: var(--pi-purple-surface); }
|
||||||
.session-activity strong { color: var(--pi-purple); }
|
.session-activity strong { color: var(--pi-purple); }
|
||||||
|
|||||||
Reference in New Issue
Block a user