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>
);