Add runtime guidance input queue

This commit is contained in:
wuyang6
2026-06-12 18:15:52 +08:00
parent 00a83ba4c3
commit 4ba8761db2
14 changed files with 1580 additions and 69 deletions
+272 -54
View File
@@ -91,12 +91,14 @@ import {
writeActiveSessionId,
writePendingWorkspaceSessionId,
} from "@/lib/claw-active-session";
import { fetchSessionReplaySnapshot } from "@/lib/claw-session-cache";
import { useClawSessionReplay } from "@/lib/claw-session-replay";
import { readSessionIdFromUrl } from "@/lib/claw-session-url";
import { dispatchSkillToggled } from "@/lib/use-training-mode";
import { cn } from "@/lib/utils";
type ComposerInsertEvent = CustomEvent<{ text: string }>;
const INPUT_QUEUE_CHANGED_EVENT = "claw-input-queue-changed";
type SkillSummary = {
name: string;
@@ -129,11 +131,23 @@ type ClawState = {
active_session_id?: string | null;
};
type ClawStoredSession = {
session_id?: string;
type ClawStoredSession = Parameters<typeof toReplayRepository>[0] & {
model?: string;
};
type SessionInputQueueItem = {
id: number;
session_id: string;
run_id?: string;
kind: "next_turn" | "guidance";
status: "pending" | "consumed" | "cancelled";
content: string;
created_at?: number;
updated_at?: number;
consumed_at?: number | null;
metadata?: Record<string, unknown>;
};
type ModelOption = {
id: string;
provider?: string;
@@ -220,32 +234,27 @@ function useRefreshReplayedRun() {
if (!replayedRun.active || !replayedRun.sessionId) return;
const activeSessionId = replayedRun.sessionId;
let cancelled = false;
let lastSignature: string | null = null;
async function refreshIfFinished() {
const runStatus = await fetchLatestRunStatus(activeSessionId);
if (cancelled) return;
if (runStatus?.status === "queued" || runStatus?.status === "running") {
const response = await fetch(`/api/claw/sessions/${activeSessionId}`, {
cache: "no-store",
});
if (!response.ok || cancelled) return;
const payload = await response.json();
replaySession(
activeSessionId,
toReplayRepository(payload, activeSessionId, runStatus),
);
return;
}
const response = await fetch(`/api/claw/sessions/${activeSessionId}`, {
cache: "no-store",
});
if (!response.ok || cancelled) return;
const payload = await response.json();
const snapshot = await fetchSessionReplaySnapshot<
ClawStoredSession,
ClawRunStatus
>(activeSessionId);
if (cancelled || !snapshot) return;
if (snapshot.signature === lastSignature) return;
lastSignature = snapshot.signature;
replaySession(
activeSessionId,
toReplayRepository(payload, activeSessionId, runStatus),
toReplayRepository(
snapshot.session,
activeSessionId,
snapshot.runStatus,
),
);
window.dispatchEvent(new Event("claw-sessions-changed"));
if (!isActiveRunStatus(snapshot.runStatus)) {
window.dispatchEvent(new Event("claw-sessions-changed"));
}
}
refreshIfFinished();
@@ -279,7 +288,11 @@ function useRefreshCurrentRun() {
async function refreshIfSettled() {
if (!sessionId) return;
const runStatus = await fetchLatestRunStatus(sessionId);
const snapshot = await fetchSessionReplaySnapshot<
ClawStoredSession,
ClawRunStatus
>(sessionId);
const runStatus = snapshot?.runStatus ?? null;
if (cancelled) return;
if (runStatus?.status === "queued" || runStatus?.status === "running") {
const runKey = runStatus.run_id ?? "active";
@@ -291,14 +304,10 @@ function useRefreshCurrentRun() {
// useRefreshReplayedRun will take over polling once replayedRun.active
// becomes true.
if (!runtimeRunning && previousRunKey !== runKey) {
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
cache: "no-store",
});
if (!response.ok || cancelled) return;
const payload = await response.json();
if (!snapshot || cancelled) return;
replaySession(
sessionId,
toReplayRepository(payload, sessionId, runStatus),
toReplayRepository(snapshot.session, sessionId, runStatus),
);
}
return;
@@ -312,14 +321,10 @@ function useRefreshCurrentRun() {
runStatus?.elapsed_ms ?? "",
].join(":");
if (appliedTerminalRefMap.current.get(sessionId) === terminalKey) return;
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
cache: "no-store",
});
if (!response.ok || cancelled) return;
const payload = await response.json();
if (!snapshot || cancelled) return;
replaySession(
sessionId,
toReplayRepository(payload, sessionId, runStatus),
toReplayRepository(snapshot.session, sessionId, runStatus),
);
activeRunRefMap.current.delete(sessionId);
appliedTerminalRefMap.current.set(sessionId, terminalKey);
@@ -893,15 +898,10 @@ const ThreadSuggestionItem: FC = () => {
};
const Composer: FC = () => {
const replayedRun = useReplayedRunState();
const workspaceSessionId = useCurrentThreadSessionId({
includePending: true,
includeActive: true,
});
const replayedRunIsCurrent =
replayedRun.active &&
Boolean(workspaceSessionId) &&
replayedRun.sessionId === workspaceSessionId;
return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
<ComposerPrimitive.AttachmentDropzone asChild>
@@ -911,13 +911,13 @@ const Composer: FC = () => {
>
<ComposerAttachments />
<ComposerWorkspaceStatus sessionId={workspaceSessionId} />
<ComposerPendingInputQueue sessionId={workspaceSessionId} />
<ImeComposerInput
placeholder="Send a message..."
className="aui-composer-input max-h-32 min-h-10 w-full resize-none bg-transparent px-1.75 py-1 text-sm outline-none placeholder:text-muted-foreground/80"
rows={1}
autoFocus
aria-label="Message input"
disabled={replayedRunIsCurrent}
/>
<ComposerAction />
</div>
@@ -926,6 +926,92 @@ const Composer: FC = () => {
);
};
const ComposerPendingInputQueue: FC<{ sessionId: string | null }> = ({
sessionId,
}) => {
const backendRunStatus = useBackendActiveRunStatus(sessionId);
const items = useSessionInputQueue(sessionId);
if (!sessionId || items.length === 0) return null;
return (
<div className="flex flex-col gap-1 px-1">
{items.map((item) => {
const isGuidance = item.kind === "guidance";
const canGuide = Boolean(backendRunStatus?.run_id);
return (
<div
key={item.id}
className="flex items-center gap-2 rounded-lg border bg-muted/40 px-2 py-1 text-xs text-muted-foreground"
>
<span className="min-w-0 flex-1 truncate">
<span className="mr-1 font-medium text-foreground">
{isGuidance ? "待引导" : "待发送"}
</span>
{item.content}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
void updateSessionInputQueueItem(sessionId, item.id, {
status: "cancelled",
})
.catch(() => undefined)
.finally(() => {
window.dispatchEvent(
new CustomEvent("claw-composer-restore-draft", {
detail: { text: item.content },
}),
);
dispatchInputQueueChanged();
});
}}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
disabled={!canGuide || isGuidance}
onClick={() => {
if (!backendRunStatus?.run_id) return;
void updateSessionInputQueueItem(sessionId, item.id, {
kind: "guidance",
run_id: backendRunStatus.run_id,
status: "pending",
})
.catch(() => undefined)
.finally(dispatchInputQueueChanged);
}}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
void updateSessionInputQueueItem(sessionId, item.id, {
status: "cancelled",
})
.catch(() => undefined)
.finally(dispatchInputQueueChanged);
}}
>
</Button>
</div>
);
})}
</div>
);
};
const ComposerWorkspaceStatus: FC<{ sessionId: string | null }> = ({
sessionId,
}) => {
@@ -1071,6 +1157,100 @@ function isActiveRunStatus(status?: ClawRunStatus | null) {
return status?.status === "queued" || status?.status === "running";
}
function dispatchInputQueueChanged() {
window.dispatchEvent(new Event(INPUT_QUEUE_CHANGED_EVENT));
}
function useSessionInputQueue(sessionId: string | null) {
const [items, setItems] = useState<SessionInputQueueItem[]>([]);
useEffect(() => {
if (!sessionId) {
setItems([]);
return;
}
let cancelled = false;
const refresh = async () => {
const nextItems = await fetchSessionInputQueue(sessionId);
if (!cancelled) setItems(nextItems);
};
const handleQueueChanged = () => {
void refresh();
};
void refresh();
const interval = window.setInterval(refresh, 1000);
window.addEventListener(INPUT_QUEUE_CHANGED_EVENT, handleQueueChanged);
return () => {
cancelled = true;
window.clearInterval(interval);
window.removeEventListener(INPUT_QUEUE_CHANGED_EVENT, handleQueueChanged);
};
}, [sessionId]);
return items;
}
async function fetchSessionInputQueue(sessionId: string) {
try {
const response = await fetch(
`/api/claw/sessions/${encodeURIComponent(sessionId)}/input-queue`,
{ cache: "no-store" },
);
if (!response.ok) return [];
const payload = (await response.json()) as {
items?: SessionInputQueueItem[];
};
return Array.isArray(payload.items) ? payload.items : [];
} catch {
return [];
}
}
async function createSessionInputQueueItem(
sessionId: string,
payload: {
content: string;
kind?: "next_turn" | "guidance";
run_id?: string;
},
) {
const response = await fetch(
`/api/claw/sessions/${encodeURIComponent(sessionId)}/input-queue`,
{
method: "POST",
cache: "no-store",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!response.ok) {
throw new Error(`Failed to queue input: ${response.status}`);
}
return (await response.json()) as { item?: SessionInputQueueItem };
}
async function updateSessionInputQueueItem(
sessionId: string,
itemId: number,
payload: Partial<
Pick<SessionInputQueueItem, "content" | "kind" | "status" | "run_id">
>,
) {
const response = await fetch(
`/api/claw/sessions/${encodeURIComponent(sessionId)}/input-queue/${itemId}`,
{
method: "PATCH",
cache: "no-store",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!response.ok) {
throw new Error(`Failed to update queued input: ${response.status}`);
}
return (await response.json()) as { item?: SessionInputQueueItem };
}
const ReplayedRunCancelButton: FC<{
sessionId: string | null;
runId: string | null;
@@ -1090,15 +1270,18 @@ const ReplayedRunCancelButton: FC<{
setCancelling(true);
try {
await cancelLatestRun(sessionId, runId);
const runStatus = await fetchLatestRunStatus(sessionId);
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
cache: "no-store",
});
if (response.ok) {
const payload = await response.json();
const snapshot = await fetchSessionReplaySnapshot<
ClawStoredSession,
ClawRunStatus
>(sessionId);
if (snapshot) {
replaySession(
sessionId,
toReplayRepository(payload, sessionId, runStatus),
toReplayRepository(
snapshot.session,
sessionId,
snapshot.runStatus,
),
);
}
window.dispatchEvent(new Event("claw-sessions-changed"));
@@ -2099,10 +2282,7 @@ const ActivityChainSummary: FC<{
liveStartedAtRef.current = authoritativeStartedAt;
} else if (liveStartedAtRef.current === null && storedElapsedMs !== null) {
liveStartedAtRef.current = Date.now() - storedElapsedMs;
} else if (
liveStartedAtRef.current !== null &&
storedElapsedMs !== null
) {
} else if (liveStartedAtRef.current !== null && storedElapsedMs !== null) {
liveStartedAtRef.current = Math.min(
liveStartedAtRef.current,
Date.now() - storedElapsedMs,
@@ -2422,14 +2602,25 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
const storeText = useAuiState((s) =>
s.composer.isEditing ? s.composer.text : "",
);
const isEditingComposer = useAuiState((s) => s.composer.isEditing);
const runtimeRunning = useAuiState((s) => s.thread.isRunning);
const runtimeDisabled = useAuiState(
(s) => s.thread.isDisabled || s.composer.dictation?.inputDisabled,
);
const activeSessionId = useCurrentThreadSessionId({
includePending: true,
includeActive: true,
});
const backendRunStatus = useBackendActiveRunStatus(activeSessionId);
const [localText, setLocalText] = useState(storeText);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isComposingRef = useRef(false);
const justSubmittedRef = useRef(false);
const isDisabled = runtimeDisabled || disabled;
const canQueueWhileRunning =
!isEditingComposer &&
Boolean(activeSessionId) &&
(runtimeRunning || isActiveRunStatus(backendRunStatus));
const isDisabled = (runtimeDisabled && !canQueueWhileRunning) || disabled;
// Pull store→local on external store changes (session switch, paste-from-attachment,
// submit-clear, etc). The reverse direction (local→store) is pushed eagerly by
@@ -2475,6 +2666,12 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
const text = detail?.text ?? "";
justSubmittedRef.current = false;
setLocalText(text);
requestAnimationFrame(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.focus({ preventScroll: true });
textarea.setSelectionRange(text.length, text.length);
});
};
window.addEventListener("claw-composer-restore-draft", handler);
return () =>
@@ -2523,6 +2720,27 @@ const ImeComposerInput: FC<ComponentProps<typeof TextareaAutosize>> = ({
}
const threadState = aui.thread().getState();
const content = localText.trim();
if (
(threadState.isRunning || isActiveRunStatus(backendRunStatus)) &&
activeSessionId
) {
event.preventDefault();
if (!content) return;
justSubmittedRef.current = true;
setLocalText("");
syncComposerText("");
void createSessionInputQueueItem(activeSessionId, {
content,
kind: "next_turn",
})
.catch(() => {
justSubmittedRef.current = false;
setLocalText(content);
})
.finally(dispatchInputQueueChanged);
return;
}
if (threadState.isRunning && !threadState.capabilities.queue) return;
event.preventDefault();