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

@ -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",