improve ai alert copilot ux

This commit is contained in:
dirtydishes 2026-05-20 19:38:17 -04:00
parent ebdc4ab8e6
commit 32e965d782
15 changed files with 931 additions and 15 deletions

View file

@ -6,3 +6,4 @@ export const DESKTOP_AI_CANCEL_LOGIN = "islandflow:desktop-ai:cancel-login";
export const DESKTOP_AI_LOGOUT = "islandflow:desktop-ai:logout";
export const DESKTOP_AI_UPDATE_PREFERENCES = "islandflow:desktop-ai:update-preferences";
export const DESKTOP_AI_RUN_TASK = "islandflow:desktop-ai:run-task";
export const DESKTOP_AI_CANCEL_TASK = "islandflow:desktop-ai:cancel-task";

View file

@ -171,4 +171,65 @@ describe("desktop ai usage and state tracking", () => {
expect(service.getState().account.planType).toBeNull();
expect(service.getState().account.login).toEqual({ status: "idle", message: "Logged out." });
});
it("marks active tasks cancelled and ignores later deltas or completion", async () => {
const dir = await makeTempDir();
const service = new IslandflowDesktopAiService(dir, async () => {}, () => {});
const internal = service as any;
const requests: Array<{ method: string; params: unknown }> = [];
internal.client = {
request: async (method: string, params: unknown) => {
requests.push({ method, params });
return {};
}
};
internal.state.tasks = [
{
taskId: "task-1",
kind: "smart-money-explain",
title: "Explain smart money event",
subtitle: "AAPL",
status: "running",
createdAt: Date.now(),
updatedAt: Date.now(),
threadId: "thread-1",
turnId: "turn-1",
model: "gpt-5.4",
reasoningEffort: "high",
text: "partial",
error: null,
compiledScreen: null
}
];
internal.activeTasksByThreadId.set("thread-1", {
taskId: "task-1",
taskKind: "smart-money-explain",
taskTitle: "Explain smart money event",
profileId: "managed-chatgpt"
});
await service.cancelTask("task-1");
await internal.handleNotification("item/agentMessage/delta", {
threadId: "thread-1",
delta: " late delta"
});
await internal.handleNotification("turn/completed", {
threadId: "thread-1",
turn: {
id: "turn-1",
status: "completed",
error: null
}
});
expect(requests).toEqual([
{
method: "turn/cancel",
params: { threadId: "thread-1", turnId: "turn-1" }
}
]);
expect(service.getState().tasks[0]?.status).toBe("cancelled");
expect(service.getState().tasks[0]?.text).toBe("partial");
});
});

View file

@ -650,6 +650,7 @@ export class IslandflowDesktopAiService {
private readonly sandboxCwd: string;
private readonly client: CodexAppServerClient;
private readonly activeTasksByThreadId = new Map<string, ActiveTaskContext>();
private readonly locallyCancelledTaskIds = new Set<string>();
private usageStore: PersistedUsageStore = createUsageStore();
private state: IslandflowAiState = createInitialState();
private serviceTier: string | null = null;
@ -851,6 +852,32 @@ export class IslandflowDesktopAiService {
}
}
async cancelTask(taskId: string): Promise<void> {
const task = this.state.tasks.find((candidate) => candidate.taskId === taskId);
if (!task || (task.status !== "queued" && task.status !== "running")) {
return;
}
this.locallyCancelledTaskIds.add(taskId);
this.patchTask(taskId, {
status: "cancelled",
error: null
});
const threadId = task.threadId;
if (!threadId) {
return;
}
this.activeTasksByThreadId.delete(threadId);
const params = {
threadId,
turnId: task.turnId ?? undefined
};
await this.client.request("turn/cancel", params).catch(() => undefined);
}
private async ensureClientReady(): Promise<void> {
const selectedProfile = this.resolveSelectedProfileMode();
this.state.transportStatus = this.state.transportStatus === "restarting" ? "restarting" : "starting";
@ -979,6 +1006,9 @@ export class IslandflowDesktopAiService {
if (!activeTask) {
return;
}
if (this.locallyCancelledTaskIds.has(activeTask.taskId)) {
return;
}
const current = this.state.tasks.find((task) => task.taskId === activeTask.taskId);
if (!current) {
return;
@ -1000,6 +1030,9 @@ export class IslandflowDesktopAiService {
if (!activeTask) {
return;
}
if (this.locallyCancelledTaskIds.has(activeTask.taskId)) {
return;
}
if (typeof payload.item.text === "string") {
this.patchTask(activeTask.taskId, {
text: payload.item.text
@ -1032,6 +1065,10 @@ export class IslandflowDesktopAiService {
if (!activeTask) {
return;
}
if (this.locallyCancelledTaskIds.has(activeTask.taskId)) {
this.activeTasksByThreadId.delete(payload.threadId);
return;
}
const current = this.state.tasks.find((task) => task.taskId === activeTask.taskId);
if (!current) {
return;

View file

@ -11,6 +11,7 @@ import {
import { IslandflowDesktopAiService } from "./desktop-ai.js";
import {
DESKTOP_AI_CANCEL_LOGIN,
DESKTOP_AI_CANCEL_TASK,
DESKTOP_AI_GET_STATE,
DESKTOP_AI_LOGIN_BROWSER,
DESKTOP_AI_LOGIN_DEVICE,
@ -168,6 +169,11 @@ const registerDesktopAiIpc = (service: IslandflowDesktopAiService): void => {
guard(event);
return service.runTask(request);
});
ipcMain.handle(DESKTOP_AI_CANCEL_TASK, async (event, taskId) => {
guard(event);
await service.cancelTask(String(taskId));
});
};
const ensureMainWindow = (): void => {

View file

@ -8,6 +8,7 @@ const DESKTOP_AI_CANCEL_LOGIN = "islandflow:desktop-ai:cancel-login";
const DESKTOP_AI_LOGOUT = "islandflow:desktop-ai:logout";
const DESKTOP_AI_UPDATE_PREFERENCES = "islandflow:desktop-ai:update-preferences";
const DESKTOP_AI_RUN_TASK = "islandflow:desktop-ai:run-task";
const DESKTOP_AI_CANCEL_TASK = "islandflow:desktop-ai:cancel-task";
type DesktopAiState = any;
type DesktopAiTaskRequest = any;
@ -27,6 +28,8 @@ const bridge = {
ipcRenderer.invoke(DESKTOP_AI_UPDATE_PREFERENCES, next),
runTask: (request: DesktopAiTaskRequest): Promise<{ taskId: string }> =>
ipcRenderer.invoke(DESKTOP_AI_RUN_TASK, request),
cancelTask: (taskId: string): Promise<void> =>
ipcRenderer.invoke(DESKTOP_AI_CANCEL_TASK, taskId),
subscribe: (listener: (state: DesktopAiState) => void): (() => void) => {
const handler = (_event: Electron.IpcRendererEvent, state: DesktopAiState) => {
listener(state);

View file

@ -2,6 +2,7 @@
import Link from "next/link";
import { useMemo, useState, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import type {
AlertEvent,
ClassifierHitEvent,
@ -11,6 +12,8 @@ import type {
IslandflowAiRateLimitSnapshot,
IslandflowAiReasoningEffort,
IslandflowAiTaskKind,
IslandflowAiTaskSnapshot,
IslandflowAiTaskStatus,
OptionFlowFilters,
OptionPrint,
SmartMoneyEvent,
@ -53,6 +56,55 @@ const formatPercent = (value: number): string => `${Math.round(value)}%`;
const getTaskStatusLabel = (value: string): string => humanizeValue(value);
type CopilotTaskCache = Record<string, string>;
const RUNNING_TASK_STATUSES: IslandflowAiTaskStatus[] = ["queued", "running"];
export const createCopilotTaskCacheKey = (
kind: IslandflowAiTaskKind,
contextKey: string,
): string => `${kind}:${contextKey}`;
export const getCachedCopilotTaskId = (
cache: CopilotTaskCache,
kind: IslandflowAiTaskKind,
contextKey: string,
): string | null => cache[createCopilotTaskCacheKey(kind, contextKey)] ?? null;
export const shouldShowCopilotRegenerate = (
task: Pick<IslandflowAiTaskSnapshot, "status" | "text" | "compiledScreen"> | null,
): boolean =>
Boolean(
task &&
task.status === "completed" &&
(task.text.trim().length > 0 || task.compiledScreen),
);
export const isCopilotTaskRunning = (
task: Pick<IslandflowAiTaskSnapshot, "status"> | null,
): boolean => Boolean(task && RUNNING_TASK_STATUSES.includes(task.status));
export const getCopilotTaskSurfaceState = (
task: Pick<IslandflowAiTaskSnapshot, "status" | "text" | "compiledScreen" | "error"> | null,
): "empty" | "running" | "completed" | "failed" | "cancelled" => {
if (!task) {
return "empty";
}
if (RUNNING_TASK_STATUSES.includes(task.status)) {
return "running";
}
if (task.status === "completed") {
return "completed";
}
if (task.status === "failed") {
return "failed";
}
return "cancelled";
};
const getSmartMoneyContextKey = (event: SmartMoneyEvent): string =>
[event.underlying_id, event.source_ts, event.event_id].join(":");
type DesktopAiSettingsNotice = {
title: string;
body: string;
@ -324,19 +376,26 @@ const RateLimitBoard = ({
const TaskOutput = ({
taskId,
emptyMessage,
onCancel,
}: {
taskId: string | null;
emptyMessage: string;
onCancel?: (taskId: string) => void;
}) => {
const { state } = useDesktopAi();
const task = findTask(state.tasks, taskId);
const surfaceState = getCopilotTaskSurfaceState(task);
if (!task) {
return <p className="copilot-empty">{emptyMessage}</p>;
return (
<div className="copilot-task-output copilot-task-output-empty">
<p className="copilot-empty">{emptyMessage}</p>
</div>
);
}
return (
<div className="copilot-task-output" aria-live="polite">
<div className={`copilot-task-output state-${surfaceState}`} aria-live="polite">
<div className="copilot-task-head">
<div>
<strong>{task.title}</strong>
@ -348,8 +407,34 @@ const TaskOutput = ({
{getTaskStatusLabel(task.status)}
</span>
</div>
{surfaceState === "running" ? (
<div className="copilot-task-running-row">
<span className="copilot-spinner" aria-hidden="true" />
<p className="copilot-note">
{task.status === "queued"
? "Queued with the desktop Copilot bridge."
: "Generating analysis as deltas arrive."}
</p>
{onCancel ? (
<button
className="terminal-button"
type="button"
onClick={() => onCancel(task.taskId)}
>
Cancel
</button>
) : null}
</div>
) : null}
{task.error ? <p className="copilot-error">{task.error}</p> : null}
{task.text ? <pre className="copilot-task-text">{task.text}</pre> : null}
{task.status === "cancelled" && !task.text ? (
<p className="copilot-note">This Copilot task was cancelled locally.</p>
) : null}
{task.text ? (
<div className="copilot-markdown">
<ReactMarkdown>{task.text}</ReactMarkdown>
</div>
) : null}
{task.compiledScreen ? (
<CompiledScreenResult compiled={task.compiledScreen} />
) : null}
@ -880,6 +965,8 @@ const SmartMoneyTaskButton = ({
symbol,
disabled,
busyKind,
selected,
cached,
onRun,
}: {
label: string;
@ -887,17 +974,19 @@ const SmartMoneyTaskButton = ({
symbol: string;
disabled: boolean;
busyKind: IslandflowAiTaskKind | null;
selected: boolean;
cached: boolean;
onRun: (kind: IslandflowAiTaskKind) => void;
}) => {
return (
<button
className={`terminal-button${kind === "smart-money-explain" ? " terminal-button-primary" : ""}`}
className={`terminal-button${kind === "smart-money-explain" ? " terminal-button-primary" : ""}${selected ? " is-active" : ""}`}
type="button"
onClick={() => onRun(kind)}
disabled={busyKind !== null || disabled}
title={`${label} for ${symbol}`}
>
{busyKind === kind ? "Running" : label}
{busyKind === kind ? "Running" : cached ? `${label} cached` : label}
</button>
);
};
@ -913,10 +1002,17 @@ export function SmartMoneyCopilotPanel({
evidencePrints: OptionPrint[];
relatedPackets: FlowPacket[];
}) {
const { bridgeAvailable, shellAvailable, state, runTask } = useDesktopAi();
const { bridgeAvailable, shellAvailable, state, runTask, cancelTask } = useDesktopAi();
const [busyKind, setBusyKind] = useState<IslandflowAiTaskKind | null>(null);
const [selectedKind, setSelectedKind] = useState<IslandflowAiTaskKind>("smart-money-explain");
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [taskCache, setTaskCache] = useState<CopilotTaskCache>({});
const [taskError, setTaskError] = useState<string | null>(null);
const contextKey = useMemo(() => getSmartMoneyContextKey(event), [event]);
const activeTask = useMemo(
() => findTask(state.tasks, activeTaskId),
[state.tasks, activeTaskId],
);
const disabledCopy = requireDesktopActionCopy(
shellAvailable,
bridgeAvailable,
@ -924,7 +1020,16 @@ export function SmartMoneyCopilotPanel({
);
const actionsDisabled = !bridgeAvailable || !state.account.loggedIn;
const handleRun = async (kind: IslandflowAiTaskKind) => {
const handleRun = async (kind: IslandflowAiTaskKind, options?: { regenerate?: boolean }) => {
setSelectedKind(kind);
const cachedTaskId = getCachedCopilotTaskId(taskCache, kind, contextKey);
const cachedTask = findTask(state.tasks, cachedTaskId);
if (!options?.regenerate && cachedTask) {
setActiveTaskId(cachedTask.taskId);
setTaskError(null);
return;
}
setBusyKind(kind);
setTaskError(null);
try {
@ -942,6 +1047,10 @@ export function SmartMoneyCopilotPanel({
},
});
setActiveTaskId(result.taskId);
setTaskCache((current) => ({
...current,
[createCopilotTaskCacheKey(kind, contextKey)]: result.taskId,
}));
} catch (error) {
setTaskError(error instanceof Error ? error.message : String(error));
} finally {
@ -949,6 +1058,17 @@ export function SmartMoneyCopilotPanel({
}
};
const handleCancel = async (taskId: string) => {
setTaskError(null);
try {
await cancelTask(taskId);
} catch (error) {
setTaskError(error instanceof Error ? error.message : String(error));
}
};
const canRegenerate = shouldShowCopilotRegenerate(activeTask);
return (
<div className="copilot-inline-panel">
<div className="copilot-inline-head">
@ -970,6 +1090,8 @@ export function SmartMoneyCopilotPanel({
symbol={event.underlying_id}
disabled={actionsDisabled}
busyKind={busyKind}
selected={selectedKind === "smart-money-explain"}
cached={Boolean(getCachedCopilotTaskId(taskCache, "smart-money-explain", contextKey))}
onRun={(kind) => void handleRun(kind)}
/>
<SmartMoneyTaskButton
@ -978,6 +1100,8 @@ export function SmartMoneyCopilotPanel({
symbol={event.underlying_id}
disabled={actionsDisabled}
busyKind={busyKind}
selected={selectedKind === "smart-money-skeptic"}
cached={Boolean(getCachedCopilotTaskId(taskCache, "smart-money-skeptic", contextKey))}
onRun={(kind) => void handleRun(kind)}
/>
<SmartMoneyTaskButton
@ -986,6 +1110,8 @@ export function SmartMoneyCopilotPanel({
symbol={event.underlying_id}
disabled={actionsDisabled}
busyKind={busyKind}
selected={selectedKind === "smart-money-burst-summary"}
cached={Boolean(getCachedCopilotTaskId(taskCache, "smart-money-burst-summary", contextKey))}
onRun={(kind) => void handleRun(kind)}
/>
<SmartMoneyTaskButton
@ -994,14 +1120,29 @@ export function SmartMoneyCopilotPanel({
symbol={event.underlying_id}
disabled={actionsDisabled}
busyKind={busyKind}
selected={selectedKind === "watchlist-synthesis"}
cached={Boolean(getCachedCopilotTaskId(taskCache, "watchlist-synthesis", contextKey))}
onRun={(kind) => void handleRun(kind)}
/>
</div>
{canRegenerate ? (
<div className="copilot-apply-row">
<button
className="terminal-button"
type="button"
onClick={() => void handleRun(selectedKind, { regenerate: true })}
disabled={actionsDisabled || busyKind !== null}
>
Regenerate
</button>
</div>
) : null}
{disabledCopy ? <p className="copilot-note">{disabledCopy}</p> : null}
{taskError ? <p className="copilot-error">{taskError}</p> : null}
<TaskOutput
taskId={activeTaskId}
emptyMessage="Run an explanation, skepticism pass, burst summary, or watchlist synthesis to see the result here."
onCancel={(taskId) => void handleCancel(taskId)}
/>
</div>
);
@ -1024,10 +1165,14 @@ export function ReplayCopilotPanel({
flowPackets: FlowPacket[];
optionPrints: OptionPrint[];
}) {
const { bridgeAvailable, shellAvailable, state, runTask } = useDesktopAi();
const { bridgeAvailable, shellAvailable, state, runTask, cancelTask } = useDesktopAi();
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [taskError, setTaskError] = useState<string | null>(null);
const activeTask = useMemo(
() => findTask(state.tasks, activeTaskId),
[state.tasks, activeTaskId],
);
const disabledCopy = requireDesktopActionCopy(
shellAvailable,
bridgeAvailable,
@ -1059,6 +1204,17 @@ export function ReplayCopilotPanel({
}
};
const handleCancel = async (taskId: string) => {
setTaskError(null);
try {
await cancelTask(taskId);
} catch (error) {
setTaskError(error instanceof Error ? error.message : String(error));
}
};
const canRegenerate = shouldShowCopilotRegenerate(activeTask);
return (
<CopilotPane
title="Replay postmortem"
@ -1074,7 +1230,7 @@ export function ReplayCopilotPanel({
onClick={() => void handleRun()}
disabled={actionsDisabled}
>
{busy ? "Running" : "Generate postmortem"}
{busy ? "Running" : canRegenerate ? "Regenerate" : "Generate postmortem"}
</button>
</>
}
@ -1088,6 +1244,7 @@ export function ReplayCopilotPanel({
<TaskOutput
taskId={activeTaskId}
emptyMessage="Generate a replay postmortem to capture the cleanest read from the current session slice."
onCancel={(taskId) => void handleCancel(taskId)}
/>
</CopilotPane>
);
@ -1100,7 +1257,7 @@ export function ScreenCompilerPanel({
currentFilters: OptionFlowFilters;
onApplyFilters: (next: OptionFlowFilters) => void;
}) {
const { bridgeAvailable, shellAvailable, state, runTask } = useDesktopAi();
const { bridgeAvailable, shellAvailable, state, runTask, cancelTask } = useDesktopAi();
const [prompt, setPrompt] = useState("");
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@ -1141,7 +1298,17 @@ export function ScreenCompilerPanel({
}
};
const handleCancel = async (taskId: string) => {
setTaskError(null);
try {
await cancelTask(taskId);
} catch (error) {
setTaskError(error instanceof Error ? error.message : String(error));
}
};
const compiledFilters = activeTask?.compiledScreen?.compiledFilters ?? null;
const canRegenerate = shouldShowCopilotRegenerate(activeTask);
return (
<CopilotPane
@ -1158,7 +1325,7 @@ export function ScreenCompilerPanel({
onClick={() => void handleCompile()}
disabled={actionsDisabled}
>
{busy ? "Compiling" : "Compile screen"}
{busy ? "Compiling" : canRegenerate ? "Regenerate" : "Compile screen"}
</button>
</>
}
@ -1197,6 +1364,7 @@ export function ScreenCompilerPanel({
<TaskOutput
taskId={activeTaskId}
emptyMessage="Compile a natural-language screen to preview the translated filter set and rationale."
onCancel={(taskId) => void handleCancel(taskId)}
/>
</CopilotPane>
);

View file

@ -10,7 +10,11 @@ import {
getDesktopAiModelSelectLabel,
getDesktopAiProfileBadgeLabel,
getDesktopAiSettingsBridgeNotice,
createCopilotTaskCacheKey,
getCachedCopilotTaskId,
getCopilotTaskSurfaceState,
requireDesktopActionCopy,
shouldShowCopilotRegenerate,
} from "./desktop-ai-panels";
describe("desktop ai runtime detection", () => {
@ -41,6 +45,7 @@ describe("desktop ai runtime detection", () => {
logout: async () => {},
updatePreferences: async () => {},
runTask: async () => ({ taskId: "task-1" }),
cancelTask: async () => {},
subscribe: () => () => {},
},
},
@ -146,3 +151,49 @@ describe("desktop ai settings copy", () => {
).toBe("Selected");
});
});
describe("desktop ai panel task helpers", () => {
it("looks up cached task ids by action kind and context key", () => {
const key = createCopilotTaskCacheKey("smart-money-explain", "AAPL:1:event-1");
expect(key).toBe("smart-money-explain:AAPL:1:event-1");
expect(
getCachedCopilotTaskId(
{ [key]: "task-1" },
"smart-money-explain",
"AAPL:1:event-1",
),
).toBe("task-1");
});
it("only shows regenerate once a completed result has output", () => {
expect(
shouldShowCopilotRegenerate({
status: "completed",
text: "## Thesis",
compiledScreen: null,
}),
).toBe(true);
expect(
shouldShowCopilotRegenerate({
status: "running",
text: "partial",
compiledScreen: null,
}),
).toBe(false);
expect(shouldShowCopilotRegenerate(null)).toBe(false);
});
it("maps queued, running, failed, and cancelled tasks to result surface states", () => {
expect(getCopilotTaskSurfaceState(null)).toBe("empty");
expect(getCopilotTaskSurfaceState({ status: "queued", text: "", compiledScreen: null, error: null })).toBe(
"running",
);
expect(getCopilotTaskSurfaceState({ status: "running", text: "", compiledScreen: null, error: null })).toBe(
"running",
);
expect(getCopilotTaskSurfaceState({ status: "cancelled", text: "", compiledScreen: null, error: null })).toBe(
"cancelled",
);
});
});

View file

@ -25,6 +25,7 @@ type DesktopAiBridge = {
next: Partial<{ model: string | null; reasoningEffort: IslandflowAiReasoningEffort | null }>
) => Promise<void>;
runTask: (request: IslandflowAiTaskRequest) => Promise<{ taskId: string }>;
cancelTask: (taskId: string) => Promise<void>;
subscribe: (listener: (state: IslandflowAiState) => void) => () => void;
};
};
@ -53,6 +54,7 @@ type DesktopAiContextValue = {
next: Partial<{ model: string | null; reasoningEffort: IslandflowAiReasoningEffort | null }>
) => Promise<void>;
runTask: (request: IslandflowAiTaskRequest) => Promise<{ taskId: string }>;
cancelTask: (taskId: string) => Promise<void>;
};
const BRIDGE_POLL_INTERVAL_MS = 250;
@ -261,7 +263,8 @@ export function DesktopAiProvider({ children }: { children: ReactNode }) {
cancelLogin: bridge?.ai.cancelLogin ?? rejectDesktopOnly,
logout: bridge?.ai.logout ?? rejectDesktopOnly,
updatePreferences: bridge?.ai.updatePreferences ?? rejectDesktopOnly,
runTask: bridge?.ai.runTask ?? rejectDesktopOnly
runTask: bridge?.ai.runTask ?? rejectDesktopOnly,
cancelTask: bridge?.ai.cancelTask ?? rejectDesktopOnly
}),
[bridge, shellAvailable, state]
);

View file

@ -1182,6 +1182,7 @@ h3 {
.copilot-empty,
.copilot-device-code,
.copilot-task-text,
.copilot-markdown,
.copilot-json-block {
margin: 0;
}
@ -1317,6 +1318,45 @@ h3 {
background: oklch(0.12 0.01 250 / 0.72);
}
.copilot-task-output-empty {
color: var(--text-dim);
}
.copilot-task-running-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid oklch(0.78 0.12 74 / 0.24);
border-radius: 10px;
background: oklch(0.78 0.12 74 / 0.06);
}
.copilot-task-running-row .terminal-button {
margin-left: auto;
}
.copilot-spinner {
width: 16px;
height: 16px;
flex: 0 0 auto;
border-radius: 999px;
border: 2px solid oklch(0.88 0.06 76 / 0.3);
border-top-color: oklch(0.88 0.06 76);
animation: copilot-spin 0.8s linear infinite;
}
@keyframes copilot-spin {
to {
transform: rotate(360deg);
}
}
.terminal-button.is-active {
border-color: var(--border-strong);
box-shadow: 0 0 0 1px oklch(0.78 0.12 74 / 0.22);
}
.copilot-task-text,
.copilot-json-block,
.copilot-device-code {
@ -1329,6 +1369,78 @@ h3 {
line-height: 1.55;
}
.copilot-markdown {
padding: 14px;
border-radius: 10px;
border: 1px solid var(--border);
background: oklch(0.1 0.009 250 / 0.92);
color: var(--text);
font-family: var(--font-sans), sans-serif;
line-height: 1.6;
}
.copilot-markdown > :first-child {
margin-top: 0;
}
.copilot-markdown > :last-child {
margin-bottom: 0;
}
.copilot-markdown h1,
.copilot-markdown h2,
.copilot-markdown h3,
.copilot-markdown h4 {
margin: 1.1em 0 0.45em;
color: var(--text);
font-family: var(--font-display), sans-serif;
line-height: 1.18;
}
.copilot-markdown p,
.copilot-markdown ul,
.copilot-markdown ol,
.copilot-markdown blockquote,
.copilot-markdown pre {
margin: 0.75em 0;
}
.copilot-markdown ul,
.copilot-markdown ol {
padding-left: 1.25rem;
}
.copilot-markdown li + li {
margin-top: 0.35em;
}
.copilot-markdown code {
font-family: var(--font-mono), monospace;
font-size: 0.92em;
color: oklch(0.88 0.06 76);
}
.copilot-markdown :not(pre) > code {
padding: 0.1rem 0.32rem;
border-radius: 6px;
background: oklch(0.97 0.008 250 / 0.08);
border: 1px solid oklch(0.72 0.012 250 / 0.14);
}
.copilot-markdown pre {
overflow-x: auto;
padding: 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: oklch(0.07 0.008 250 / 0.9);
}
.copilot-markdown blockquote {
padding-left: 12px;
border-left: 3px solid var(--accent);
color: var(--text-dim);
}
.copilot-device-code {
font-size: clamp(1.3rem, 2vw, 1.7rem);
letter-spacing: 0.18em;

View file

@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -13,7 +13,8 @@
"lightweight-charts": "^4.2.0",
"next": "^16.2.6",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0"
},
"devDependencies": {
"@types/node": "^20.14.10",