Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,438 changes: 1,227 additions & 1,211 deletions crates/ironclaw_webui_v2_static/static/dist/app.js

Large diffs are not rendered by default.

36 changes: 32 additions & 4 deletions crates/ironclaw_webui_v2_static/static/js/pages/chat/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function Chat({
messages,
isProcessing,
pendingGate,
busyGateNotice,
channelConnectAction,
suggestions,
sseStatus,
Expand Down Expand Up @@ -85,10 +86,14 @@ export function Chat({
// error banner instead so the user is not misled into thinking the thread
// is empty.
const showLanding = !historyLoading && !hasMessages && !historyLoadError;
const approvalSubmitWarning = pendingGate
? "Resolve the approval request before sending another message."
: "";
const composerSendDisabled =
(isProcessing && !pendingGate) || cooldownSeconds > 0;
Boolean(pendingGate) || (isProcessing && !pendingGate) || cooldownSeconds > 0;
const composerStatusText =
cooldownSeconds > 0 ? `Retry in ${cooldownSeconds}s` : undefined;
approvalSubmitWarning ||
(cooldownSeconds > 0 ? `Retry in ${cooldownSeconds}s` : undefined);
// Scope the persisted composer draft to the open thread (or the
// shared new-conversation slot when there's no active thread yet).
const composerDraftKey = activeThreadId || NEW_DRAFT_KEY;
Expand All @@ -110,6 +115,10 @@ export function Chat({
: null;
const handleSend = React.useCallback(
async (content, { images = [], attachments = [] } = {}) => {
if (pendingGate) {
throw new Error(approvalSubmitWarning);
}
if (composerSendDisabled) return null;
const response = await send(content, {
images,
attachments,
Expand All @@ -121,15 +130,23 @@ export function Chat({
}
return response;
},
[activeThreadId, onSelectThread, send]
[
activeThreadId,
approvalSubmitWarning,
composerSendDisabled,
onSelectThread,
pendingGate,
send,
]
);

const handleSuggestion = React.useCallback(
async (text) => {
if (composerSendDisabled) return;
setSuggestions([]);
await handleSend(text);
},
[handleSend, setSuggestions]
[composerSendDisabled, handleSend, setSuggestions]
);

const handleCancelRun = React.useCallback(
Expand Down Expand Up @@ -308,11 +325,22 @@ export function Chat({
approve(pendingGate.requestId, "always", pendingGate.kind)}
/>
`)}
${busyGateNotice &&
html`
<div
data-testid="busy-gate-notice"
role="status"
className="mx-auto mt-3 max-w-lg rounded-lg border border-copper/25 bg-copper/10 px-4 py-3 text-center text-sm leading-6 text-copper"
>
${busyGateNotice.content}
</div>
`}
<//>

<${SuggestionChips}
suggestions=${suggestions}
onSelect=${handleSuggestion}
disabled=${composerSendDisabled}
/>

<${ChatInput}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ export function ApprovalCard({ gate, onApprove, onDeny, onAlways }) {
}, [always, allowAlways, onAlways, onApprove]);

return html`
<div className="mx-auto max-w-lg rounded-xl border border-copper/30 bg-copper/10 p-4">
<div
data-testid="approval-card"
className="mx-auto max-w-lg rounded-xl border border-copper/30 bg-copper/10 p-4"
>
<div className="mb-3 flex items-center gap-2">
<span className="grid h-8 w-8 place-items-center rounded-md border border-copper/25 bg-copper/10 text-copper">
<${Icon} name="lock" className="h-4 w-4" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { html } from "../../../lib/html.js";

export function SuggestionChips({ suggestions, onSelect }) {
export function SuggestionChips({ suggestions, onSelect, disabled = false }) {
if (!suggestions || suggestions.length === 0) return null;

return html`
Expand All @@ -10,8 +10,11 @@ export function SuggestionChips({ suggestions, onSelect }) {
(text) => html`
<button
key=${text}
onClick=${() => onSelect(text)}
className="v2-button rounded-full border border-white/10 bg-white/[0.035] px-3 py-1.5 text-xs text-iron-100 hover:border-signal/40 hover:text-signal"
onClick=${() => {
if (!disabled) onSelect(text);
}}
disabled=${disabled}
className="v2-button rounded-full border border-white/10 bg-white/[0.035] px-3 py-1.5 text-xs text-iron-100 hover:border-signal/40 hover:text-signal disabled:cursor-not-allowed disabled:opacity-50"
>
${text}
</button>
Expand Down
107 changes: 88 additions & 19 deletions crates/ironclaw_webui_v2_static/static/js/pages/chat/hooks/useChat.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { useSSE } from "./useSSE.js";
const AUTH_TOKEN_FLOW_TIMEOUT_MS = 30000;
const AUTH_GATE_CREDENTIAL_STORED_ERROR =
"credential_stored_gate_resolution_failed";
const APPROVAL_GATE_PENDING_SEND_ERROR = "approval_gate_pending_send_blocked";
const OAUTH_CALLBACK_CHANNEL = "ironclaw-product-auth";
const OAUTH_CALLBACK_STORAGE_KEY = "ironclaw:product-auth:oauth-complete";
const OAUTH_CALLBACK_MESSAGE_TYPE = "ironclaw:product-auth:oauth-complete";
Expand All @@ -52,6 +53,14 @@ function credentialStoredGateResolutionError(cause) {
return error;
}

function approvalGatePendingSendError() {
const error = new Error(
"Resolve the approval request before sending another message.",
);
error.safeErrorCode = APPROVAL_GATE_PENDING_SEND_ERROR;
return error;
}

function threadNeedsSidebarRefresh(threadId) {
const cached = queryClient.getQueryData?.(["threads"]);
const threads = cached?.threads;
Expand All @@ -60,6 +69,11 @@ function threadNeedsSidebarRefresh(threadId) {
return !thread?.title;
}

function busyNoticeKey(threadId, gate) {
if (!threadId || !gate?.runId || !gate?.gateRef) return null;
return `${threadId}\n${gate.runId}\n${gate.gateRef}`;
}

function submitResponseResumedTurnGate(response) {
return response?.continuation?.type === "turn_gate_resume";
}
Expand Down Expand Up @@ -125,6 +139,7 @@ async function resolveConnectAction(content) {
// a v1-style `requestId`.
// - cancelRun is a first-class action and posts to the v2 cancel route.
export function useChat(threadId) {
const threadIdRef = React.useRef(threadId);
const pendingMessagesRef = React.useRef(new Map());
const pendingSeqRef = React.useRef(1);
const [cooldownUntil, setCooldownUntil] = React.useState(0);
Expand Down Expand Up @@ -175,7 +190,17 @@ export function useChat(threadId) {
} = useHistory(threadId, { getPendingMessages, setPendingMessages });

const [isProcessing, setIsProcessing] = React.useState(false);
const [pendingGate, setPendingGate] = React.useState(null);
const [pendingGate, setPendingGateState] = React.useState(null);
const pendingGateRef = React.useRef(pendingGate);
const [busyGateNotice, setBusyGateNotice] = React.useState(null);
const setPendingGate = React.useCallback((next) => {
const current = pendingGateRef.current;
const value =
typeof next === "function" ? next(current) : next;
if (Object.is(value, current)) return;
pendingGateRef.current = value;
setPendingGateState(value);
}, []);
const [stateThreadId, setStateThreadId] = React.useState(threadId);
const toolActivityStateRef = React.useRef(createToolActivityState());
const locallyResolvedGatesRef = React.useRef(new Map());
Expand Down Expand Up @@ -215,11 +240,27 @@ export function useChat(threadId) {
if (stateThreadId !== threadId) {
setStateThreadId(threadId);
setIsProcessing(false);
setPendingGate(null);
setPendingGateState(null);
setBusyGateNotice(null);
setActiveRunState(null);
setChannelConnectAction(null);
}

React.useEffect(() => {
threadIdRef.current = threadId;
}, [threadId]);

React.useEffect(() => {
pendingGateRef.current = pendingGate;
}, [pendingGate]);
Comment thread
think-in-universe marked this conversation as resolved.

React.useEffect(() => {
const currentKey = busyNoticeKey(threadId, pendingGate);
setBusyGateNotice((current) =>
current && current.gateKey !== currentKey ? null : current,
);
}, [pendingGate, threadId]);

React.useEffect(() => {
resetToolActivityState(toolActivityStateRef);
locallyResolvedGatesRef.current.clear();
Expand Down Expand Up @@ -344,6 +385,10 @@ export function useChat(threadId) {
const wireAttachments = stagedAttachments.map(toWireAttachment);
const renderAttachments = stagedAttachments.map(toRenderAttachment);

if (pendingGate) {
throw approvalGatePendingSendError();
}

// Channel-connect slash commands ("/connect telegram") never carry
// attachments; skip that detection when files are staged so an
// upload is never misread as a command and dropped.
Expand All @@ -367,6 +412,10 @@ export function useChat(threadId) {
}
}

if (pendingGateRef.current) {
throw approvalGatePendingSendError();
}

const pendingKey = sendThreadId;
const pendingRecord = {
id: `pending-${pendingSeqRef.current++}`,
Expand Down Expand Up @@ -399,13 +448,13 @@ export function useChat(threadId) {
};

updateCurrentThread((prev) => [...prev, pendingRenderMessage]);
if (sendThreadId !== threadId) {
seedThreadMessages(sendThreadId, (prev) => [...prev, pendingRenderMessage]);
}
updateSeededTarget((prev) => [...prev, pendingRenderMessage]);

updateCurrentRunState(() => {
setIsProcessing(true);
setPendingGate(null);
if (!pendingGateRef.current) {
setPendingGate(null);
}
});

try {
Expand Down Expand Up @@ -457,19 +506,38 @@ export function useChat(threadId) {
updateCurrentThread(markRejected);
updateSeededTarget(markRejected);
if (response?.notice) {
const noticeMessage = {
id: `system-rejected-${pendingSeqRef.current++}`,
role: "system",
content: response.notice,
timestamp: new Date().toISOString(),
isOptimistic: false,
const appendSystemNotice = (renderCurrent = shouldRenderInCurrentThread) => {
const noticeMessage = {
id: `system-rejected-${pendingSeqRef.current++}`,
role: "system",
content: response.notice,
timestamp: new Date().toISOString(),
isOptimistic: false,
};
const appendNotice = (prev) => [
...prev,
noticeMessage,
];
if (renderCurrent) setMessages(appendNotice);
if (!renderCurrent || sendThreadId !== threadId) {
seedThreadMessages(sendThreadId, appendNotice);
}
};
const appendNotice = (prev) => [
...prev,
noticeMessage,
];
updateCurrentThread(appendNotice);
updateSeededTarget(appendNotice);
const liveShouldRenderInCurrentThread =
!threadIdRef.current || threadIdRef.current === sendThreadId;
if (liveShouldRenderInCurrentThread) {
const currentNoticeKey = busyNoticeKey(sendThreadId, pendingGateRef.current);
if (currentNoticeKey) {
setBusyGateNotice({
gateKey: currentNoticeKey,
content: response.notice,
});
} else {
appendSystemNotice();
}
} else {
appendSystemNotice(false);
}
}
updateCurrentRunState(() => setIsProcessing(false));
}
Expand Down Expand Up @@ -505,7 +573,7 @@ export function useChat(threadId) {
removePending(pendingMessagesRef.current, pendingKey, optimisticId);
}
},
[threadId, setMessages, seedThreadMessages],
[threadId, pendingGate, setMessages, seedThreadMessages],
);

// v2 resolveGate signature: `(resolution, { always?, credentialRef? })`.
Expand Down Expand Up @@ -680,6 +748,7 @@ export function useChat(threadId) {
messages,
isProcessing,
pendingGate,
busyGateNotice,
channelConnectAction,
activeRun,
sseStatus,
Expand Down
Loading
Loading