improve ai alert copilot ux
This commit is contained in:
parent
ebdc4ab8e6
commit
32e965d782
15 changed files with 931 additions and 15 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue