feat(runtime): add user-controlled turn recovery
This commit is contained in:
@@ -11,6 +11,7 @@ import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactElement } from
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
FolderTree,
|
||||
@@ -260,6 +261,7 @@ interface ChatListProps {
|
||||
collapsedGroups?: Record<string, boolean>;
|
||||
runningChatIds?: string[];
|
||||
updatedChatIds?: string[];
|
||||
recoveryChatIds?: string[];
|
||||
density?: SidebarDensity;
|
||||
showPreviews?: boolean;
|
||||
showTimestamps?: boolean;
|
||||
@@ -302,6 +304,7 @@ export const ChatList = memo(function ChatList({
|
||||
collapsedGroups = {},
|
||||
runningChatIds = [],
|
||||
updatedChatIds = [],
|
||||
recoveryChatIds = [],
|
||||
density = "comfortable",
|
||||
showPreviews = false,
|
||||
showTimestamps = false,
|
||||
@@ -558,6 +561,7 @@ export const ChatList = memo(function ChatList({
|
||||
|
||||
const running = new Set(runningChatIds);
|
||||
const updated = new Set(updatedChatIds);
|
||||
const recovery = new Set(recoveryChatIds);
|
||||
const compact = density === "compact";
|
||||
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
|
||||
const selectableDeleteKeys = Array.from(new Set(limitedGroups.flatMap((group) => (
|
||||
@@ -881,6 +885,7 @@ export const ChatList = memo(function ChatList({
|
||||
compact={compact}
|
||||
running={running}
|
||||
updated={updated}
|
||||
recovery={recovery}
|
||||
onSelectPane={onSelectPane}
|
||||
onRequestDelete={onRequestDelete}
|
||||
onRequestRename={onRequestRename}
|
||||
@@ -915,9 +920,11 @@ export const ChatList = memo(function ChatList({
|
||||
: "";
|
||||
const activityState = running.has(s.chatId)
|
||||
? "running"
|
||||
: updated.has(s.chatId) && !topicActive
|
||||
? "updated"
|
||||
: null;
|
||||
: recovery.has(s.chatId)
|
||||
? "recovery"
|
||||
: updated.has(s.chatId) && !topicActive
|
||||
? "updated"
|
||||
: null;
|
||||
const hasPaneMoveTarget = Boolean(onAttachPane)
|
||||
&& paneGroupTargets.some((target) => (
|
||||
target.key !== paneGroup?.tabKey && !target.atCapacity
|
||||
@@ -1330,6 +1337,7 @@ function ActivePaneRows({
|
||||
compact,
|
||||
running,
|
||||
updated,
|
||||
recovery,
|
||||
onSelectPane,
|
||||
onRequestDelete,
|
||||
onRequestRename,
|
||||
@@ -1354,6 +1362,7 @@ function ActivePaneRows({
|
||||
compact: boolean;
|
||||
running: ReadonlySet<string>;
|
||||
updated: ReadonlySet<string>;
|
||||
recovery: ReadonlySet<string>;
|
||||
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
||||
onRequestDelete: (key: string, label: string) => void;
|
||||
onRequestRename: (key: string, label: string) => void;
|
||||
@@ -1390,9 +1399,11 @@ function ActivePaneRows({
|
||||
const active = tabActive && pane.key === group.activePaneKey;
|
||||
const activityState = running.has(pane.chatId)
|
||||
? "running"
|
||||
: updated.has(pane.chatId) && !active
|
||||
? "updated"
|
||||
: null;
|
||||
: recovery.has(pane.chatId)
|
||||
? "recovery"
|
||||
: updated.has(pane.chatId) && !active
|
||||
? "updated"
|
||||
: null;
|
||||
const paneActionsLabel = t("workbench.paneActions", { title: pane.title });
|
||||
const selected = selectedDeleteKeys.has(pane.key);
|
||||
const isPinned = pinned.has(pane.key);
|
||||
@@ -1852,10 +1863,27 @@ function ChatsFoldFooter({
|
||||
function SessionActivityIndicator({
|
||||
state,
|
||||
}: {
|
||||
state: "running" | "updated" | null;
|
||||
state: "running" | "updated" | "recovery" | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (state === "recovery") {
|
||||
const label = t("chat.activity.recovery", {
|
||||
defaultValue: "This conversation needs your attention",
|
||||
});
|
||||
return (
|
||||
<SidebarItemTooltip label={label}>
|
||||
<span
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className="grid h-4 w-4 shrink-0 place-items-center text-[#ff8a3d]"
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5" strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
</SidebarItemTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "running") {
|
||||
const label = t("chat.activity.running");
|
||||
return (
|
||||
|
||||
@@ -82,6 +82,7 @@ interface SidebarProps {
|
||||
collapsedGroups?: Record<string, boolean>;
|
||||
runningChatIds?: string[];
|
||||
updatedChatIds?: string[];
|
||||
recoveryChatIds?: string[];
|
||||
viewState?: SidebarViewState;
|
||||
showArchived?: boolean;
|
||||
archivedCount?: number;
|
||||
@@ -270,6 +271,7 @@ export function Sidebar(props: SidebarProps) {
|
||||
collapsedGroups={props.collapsedGroups}
|
||||
runningChatIds={props.runningChatIds}
|
||||
updatedChatIds={props.updatedChatIds}
|
||||
recoveryChatIds={props.recoveryChatIds}
|
||||
density={props.viewState?.density}
|
||||
showPreviews={props.viewState?.show_previews}
|
||||
showTimestamps={props.viewState?.show_timestamps}
|
||||
|
||||
@@ -421,6 +421,37 @@ export function AppearanceSettings({
|
||||
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
title={tx("settings.rows.browserNotifications", "Task notifications")}
|
||||
description={tx(
|
||||
"settings.help.browserNotifications",
|
||||
"Notify only when this page is in the background. Off by default.",
|
||||
)}
|
||||
>
|
||||
<ToggleButton
|
||||
checked={localPrefs.browserNotifications}
|
||||
onChange={(enabled) => {
|
||||
if (!enabled) {
|
||||
onChangeLocalPrefs((prev) => ({ ...prev, browserNotifications: false }));
|
||||
return;
|
||||
}
|
||||
if (typeof Notification === "undefined") return;
|
||||
if (Notification.permission === "granted") {
|
||||
onChangeLocalPrefs((prev) => ({ ...prev, browserNotifications: true }));
|
||||
return;
|
||||
}
|
||||
void Notification.requestPermission().then((permission) => {
|
||||
if (permission === "granted") {
|
||||
onChangeLocalPrefs((prev) => ({ ...prev, browserNotifications: true }));
|
||||
}
|
||||
});
|
||||
}}
|
||||
ariaLabel={tx("settings.rows.browserNotifications", "Task notifications")}
|
||||
label={localPrefs.browserNotifications
|
||||
? tx("settings.values.on", "On")
|
||||
: tx("settings.values.off", "Off")}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AlertTriangle, LoaderCircle, RotateCcw, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RecoveryState } from "@/lib/types";
|
||||
|
||||
interface RecoveryNoticeProps {
|
||||
state: RecoveryState;
|
||||
onContinue: () => Promise<void>;
|
||||
onDismiss: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function RecoveryNotice({ state, onContinue, onDismiss }: RecoveryNoticeProps) {
|
||||
const { t } = useTranslation();
|
||||
const [pending, setPending] = useState<"continue" | "dismiss" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hiddenRecoveryId, setHiddenRecoveryId] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
// A continuation can be interrupted again with the same recovery ID.
|
||||
// Reveal the decision surface when the server returns to a waiting state.
|
||||
if (state.status === "awaiting_user" || state.status === "failed") {
|
||||
setHiddenRecoveryId(null);
|
||||
}
|
||||
}, [state.recovery_id, state.status]);
|
||||
if (state.status === "recovered" || hiddenRecoveryId === state.recovery_id) return null;
|
||||
|
||||
const waiting = state.status === "awaiting_user" || state.status === "failed";
|
||||
const contextUnavailable = state.can_continue === false;
|
||||
const title = state.status === "failed"
|
||||
? t("recovery.failed", { defaultValue: "Task recovery failed" })
|
||||
: waiting
|
||||
? t("recovery.interrupted", { defaultValue: "Task interrupted" })
|
||||
: t("recovery.resuming", { defaultValue: "Restoring interrupted task…" });
|
||||
const detail = state.status === "failed" || contextUnavailable
|
||||
? t("recovery.failedHelp", {
|
||||
defaultValue: "The saved task could not be restored safely. Review it before continuing.",
|
||||
})
|
||||
: waiting
|
||||
? t("recovery.review", { defaultValue: "Review the task before continuing. Tools will not be replayed automatically." })
|
||||
: t("recovery.safeResume", { defaultValue: "Continuing from saved conversation context." });
|
||||
const run = (action: "continue" | "dismiss") => {
|
||||
setPending(action);
|
||||
setError(null);
|
||||
// ``resuming`` is an internal transition, not another task for the user
|
||||
// to monitor. Hide the notice optimistically and only bring it back if
|
||||
// the explicit action is rejected.
|
||||
if (action === "continue") setHiddenRecoveryId(state.recovery_id);
|
||||
const operation = action === "continue" ? onContinue() : onDismiss();
|
||||
void operation.catch(() => {
|
||||
if (action === "continue") setHiddenRecoveryId(null);
|
||||
setError(t("recovery.actionFailed", { defaultValue: "Recovery action failed. Try again." }));
|
||||
}).finally(() => setPending(null));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role={waiting ? "alert" : "status"}
|
||||
aria-live={waiting ? "assertive" : "polite"}
|
||||
aria-busy={state.status === "resuming"}
|
||||
data-recovery-status={state.status}
|
||||
className="mx-auto mb-2 flex w-full max-w-[49.5rem] items-center gap-3 rounded-control border border-border/70 bg-muted/35 px-3 py-2 text-sm transition-[background-color,border-color,opacity,transform] duration-200 ease-out motion-reduce:transition-none animate-in fade-in-0 slide-in-from-bottom-1 duration-200 motion-reduce:animate-none"
|
||||
>
|
||||
{waiting ? (
|
||||
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden />
|
||||
) : (
|
||||
<LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-primary motion-reduce:animate-none" aria-hidden />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">
|
||||
{title}
|
||||
</p>
|
||||
<p className={cn(
|
||||
"mt-0.5 text-xs",
|
||||
error ? "text-destructive" : "text-muted-foreground",
|
||||
)}>
|
||||
{error ?? detail}
|
||||
</p>
|
||||
</div>
|
||||
{waiting ? (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={pending !== null}
|
||||
onClick={() => run("dismiss")}
|
||||
>
|
||||
<X className="mr-1 h-3.5 w-3.5" aria-hidden />
|
||||
{t("recovery.dismiss", { defaultValue: "Dismiss" })}
|
||||
</Button>
|
||||
{!contextUnavailable ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={pending !== null}
|
||||
onClick={() => run("continue")}
|
||||
>
|
||||
<RotateCcw className="mr-1 h-3.5 w-3.5" aria-hidden />
|
||||
{t("recovery.continue", { defaultValue: "Continue" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailab
|
||||
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
||||
import { SessionHandleLabel } from "@/components/SessionHandleLabel";
|
||||
import { PromptNavigator } from "@/components/thread/PromptNavigator";
|
||||
import { RecoveryNotice } from "@/components/thread/RecoveryNotice";
|
||||
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
|
||||
import {
|
||||
ThreadComposer,
|
||||
@@ -763,6 +764,9 @@ export function ThreadShell({
|
||||
isStreaming,
|
||||
runStartedAt,
|
||||
goalState,
|
||||
recoveryState,
|
||||
continueRecovery,
|
||||
dismissRecovery,
|
||||
send,
|
||||
transcribeAudio,
|
||||
stop,
|
||||
@@ -835,8 +839,15 @@ export function ThreadShell({
|
||||
[displayMessages],
|
||||
);
|
||||
const currentGoalState = messagesReady ? goalState : undefined;
|
||||
const currentRunStartedAt = messagesReady ? runStartedAt : null;
|
||||
const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null);
|
||||
// Decision states freeze the interrupted turn and hand the next action to
|
||||
// the recovery notice. ``resuming`` remains active; ``recovered`` is only
|
||||
// historical metadata and must not suppress a later normal turn.
|
||||
const recoveryNeedsDecision = recoveryState?.status === "awaiting_user"
|
||||
|| recoveryState?.status === "failed";
|
||||
const currentRunStartedAt = messagesReady && !recoveryNeedsDecision ? runStartedAt : null;
|
||||
const turnActive = messagesReady
|
||||
&& !recoveryNeedsDecision
|
||||
&& (isStreaming || currentRunStartedAt !== null);
|
||||
const restoredViewportTurnId = useMemo(
|
||||
() => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null,
|
||||
[currentRunStartedAt, displayMessages, turnActive],
|
||||
@@ -1472,6 +1483,13 @@ export function ThreadShell({
|
||||
|
||||
const composer = (
|
||||
<>
|
||||
{recoveryState ? (
|
||||
<RecoveryNotice
|
||||
state={recoveryState}
|
||||
onContinue={continueRecovery}
|
||||
onDismiss={dismissRecovery}
|
||||
/>
|
||||
) : null}
|
||||
{streamError && !hasInlineDeliveryError(messages, streamError) ? (
|
||||
<StreamErrorNotice
|
||||
error={streamError}
|
||||
|
||||
Reference in New Issue
Block a user