refactor(ui): split chat-send.ts along ack/request/drain/lifecycle seams (#113052)

* refactor(ui): split chat-send.ts along ack/request/drain/lifecycle seams

chat-send.ts was ~2,330 lines under a grandfathered max-lines
suppression, mixing ack normalization, wire requests/routing, the
stored-outbox drain scheduler, retry/steer actions, and the send
lifecycle. Mechanical extraction into owned modules:

- chat-send-contract.ts absorbs ack normalization (it owns the ack shape)
- chat-send-request.ts: wire requests + session routing
- chat-outbox-drain.ts: drain lanes, retry timers, head reconciliation —
  colocated with the outbox ownership boundary from the composer split
- chat-send-actions.ts / chat-send-queue-state.ts / chat-send-submit.ts:
  retry/steer actions, queue-state helpers, submission routing
- chat-send.ts keeps the send lifecycle (719 lines); its max-lines
  suppression and baseline entry are removed

No compatibility re-exports; cycles broken by hoisting ChatHost into the
contract module and injecting the drain's two lifecycle callbacks.

Closes #112742.

* fix(ui): extract chat-send ack shapes into a leaf module to break the madge type cycle
This commit is contained in:
Peter Steinberger
2026-07-23 08:51:29 -07:00
committed by GitHub
parent 585eba56e3
commit dedf85a34d
14 changed files with 1903 additions and 1728 deletions
-1
View File
@@ -1093,7 +1093,6 @@ ui/src/pages/chat/chat-history.ts
ui/src/pages/chat/chat-pane.ts
ui/src/pages/chat/chat-responsive.browser.test.ts
ui/src/pages/chat/chat-send.test.ts
ui/src/pages/chat/chat-send.ts
ui/src/pages/chat/chat-state.test.ts
ui/src/pages/chat/chat-state.ts
ui/src/pages/chat/chat-thread.test.ts
+628
View File
@@ -0,0 +1,628 @@
import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts";
import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts";
import { isSessionRunActive } from "../../lib/session-run-state.ts";
import { visibleSessionMatches } from "../../lib/sessions/index.ts";
import { isUiGlobalSessionKey } from "../../lib/sessions/session-key.ts";
import { releaseChatAttachmentPayloads } from "./attachment-payload-store.ts";
import {
confirmConversationResetForCurrentSession,
dispatchChatSlashCommand,
type ChatCommandResetOptions,
} from "./chat-commands.ts";
import { loadChatHistory, type ChatHistoryResult, type ChatState } from "./chat-history.ts";
import {
excludeComposerAttachments,
removeQueuedMessageWithoutReleasing,
syncChatQueueFromStoredOutbox,
updateQueuedMessageForSession,
} from "./chat-queue.ts";
import type { ChatHost } from "./chat-send-contract.ts";
import {
listStoredChatOutboxes,
storedChatOutboxScopeKey,
type StoredChatOutbox,
type StoredChatOutboxScope,
} from "./composer-persistence.ts";
import { isChatBusy } from "./run-lifecycle.ts";
import {
chatMessagesContainQueuedSend,
OFFLINE_QUEUE_STORAGE_ERROR,
preserveQueuedUserTurn,
} from "./steer-lifecycle.ts";
export type QueuedChatSendResult = "sent" | "pending" | "failed";
export type QueuedChatStorageMode = "durable" | "memory";
export type QueuedChatSendOptions = {
previousAttachments?: ChatAttachment[];
previousDraft?: string;
routingSessionKey?: string;
storageMode?: QueuedChatStorageMode;
};
export type ChatOutboxDrainDependencies = {
sendQueuedChatMessage: (
host: ChatHost,
id: string,
opts?: QueuedChatSendOptions,
queuedSessionKey?: string,
) => Promise<QueuedChatSendResult>;
sendResetSlashCommand: (
host: ChatHost,
message: string,
opts: ChatCommandResetOptions,
) => Promise<void>;
setChatError: (
host: { lastError?: string | null; chatError?: string | null },
error: string | null,
) => void;
};
type StoredChatOutboxDrainResult = "blocked" | "empty";
type StoredChatOutboxDrainLane = {
freshAdmissions: Set<string>;
host: ChatHost;
pendingOptions: Map<string, QueuedChatSendOptions>;
promise: Promise<void>;
rerun: boolean;
};
const STORED_OUTBOX_RETRY_DEFAULT_MS = 500;
const STORED_OUTBOX_RETRY_MIN_MS = 100;
const STORED_OUTBOX_RETRY_MAX_MS = 30_000;
export const UNCONFIRMED_CHAT_SEND_ERROR =
"Delivery could not be confirmed after reconnect. Check the conversation before retrying.";
const UNCERTAIN_CLEAR_SUCCESSOR_ERROR =
"A preceding /clear may have completed. Review the current conversation before retrying.";
const storedChatOutboxDrainLanesByClient = new WeakMap<
GatewayBrowserClient,
Map<string, StoredChatOutboxDrainLane>
>();
const storedChatOutboxRetryTimersByClient = new WeakMap<
GatewayBrowserClient,
Map<string, ReturnType<typeof setTimeout>>
>();
function storedChatOutboxClientMap<T>(
store: WeakMap<GatewayBrowserClient, Map<string, T>>,
client: GatewayBrowserClient,
): Map<string, T> {
const existing = store.get(client);
if (existing) {
return existing;
}
const created = new Map<string, T>();
store.set(client, created);
return created;
}
export function retryableGatewayDelayMs(err: unknown): number | null {
if (!(err instanceof GatewayRequestError) || !err.retryable) {
return null;
}
const requested = err.retryAfterMs ?? STORED_OUTBOX_RETRY_DEFAULT_MS;
return Math.min(Math.max(requested, STORED_OUTBOX_RETRY_MIN_MS), STORED_OUTBOX_RETRY_MAX_MS);
}
export function scheduleStoredChatOutboxRetry(
host: ChatHost,
scope: StoredChatOutboxScope,
delayMs: number,
dependencies: ChatOutboxDrainDependencies,
) {
const client = host.client;
if (!host.connected || !client) {
return;
}
const connectionEpoch = host.connectionEpoch;
const timers = storedChatOutboxClientMap(storedChatOutboxRetryTimersByClient, client);
const key = storedChatOutboxScopeKey(scope);
if (timers.has(key)) {
return;
}
const timer = setTimeout(() => {
timers.delete(key);
if (host.connected && host.client === client && host.connectionEpoch === connectionEpoch) {
void scheduleStoredChatOutboxDrain(host, scope, dependencies);
}
}, delayMs);
timers.set(key, timer);
}
function readStoredChatOutbox(
host: ChatHost,
scope: StoredChatOutboxScope,
): StoredChatOutbox | undefined {
return listStoredChatOutboxes(host).find(
(outbox) => outbox.sessionKey === scope.sessionKey && outbox.agentId === scope.agentId,
);
}
function sameQueuedDeliveryVersion(left: ChatQueueItem, right: ChatQueueItem): boolean {
return (
left.id === right.id &&
left.sendRunId === right.sendRunId &&
left.sendAttempts === right.sendAttempts &&
left.sendState === right.sendState &&
left.agentId === right.agentId &&
left.sessionKey === right.sessionKey
);
}
async function readCurrentStoredChatHistory(
host: ChatHost,
outbox: StoredChatOutbox,
item: ChatQueueItem,
client: NonNullable<ChatHost["client"]>,
connectionEpoch: number | undefined,
dependencies: ChatOutboxDrainDependencies,
): Promise<ChatHistoryResult | "blocked" | "continue"> {
let history: ChatHistoryResult;
try {
history = await client.request<ChatHistoryResult>("chat.history", {
sessionKey: outbox.sessionKey,
...(isUiGlobalSessionKey(outbox.sessionKey) && outbox.agentId
? { agentId: outbox.agentId }
: {}),
limit: 1000,
});
} catch (err) {
const retryDelayMs = retryableGatewayDelayMs(err);
if (
retryDelayMs !== null &&
host.client === client &&
host.connectionEpoch === connectionEpoch &&
host.connected
) {
scheduleStoredChatOutboxRetry(host, outbox, retryDelayMs, dependencies);
}
return "blocked";
}
const currentOutbox = readStoredChatOutbox(host, outbox);
const currentItem = currentOutbox?.queue.find((entry) => entry.id === item.id);
if (host.client !== client || host.connectionEpoch !== connectionEpoch || !host.connected) {
return "blocked";
}
if (!currentOutbox || !currentItem || !sameQueuedDeliveryVersion(currentItem, item)) {
return "continue";
}
syncChatQueueFromStoredOutbox(host, currentOutbox);
if (chatMessagesContainQueuedSend(history.messages, item)) {
// Server history owns the turn, but the visible transcript may not have
// reloaded yet; materialize the turn locally before dropping the queue row
// or the bubble vanishes until loadChatHistory below resolves.
preserveQueuedUserTurn(host, item);
const removed = removeQueuedMessageWithoutReleasing(host, item.id, outbox.sessionKey);
if (!removed) {
return "blocked";
}
releaseChatAttachmentPayloads(excludeComposerAttachments(host, removed.attachments));
if (visibleSessionMatches(host, outbox.sessionKey, outbox.agentId)) {
void loadChatHistory(host as unknown as ChatState);
}
return "continue";
}
if (
!history.sessionInfo ||
history.sessionInfo.hasActiveRun === true ||
isSessionRunActive(history.sessionInfo)
) {
return "blocked";
}
return history;
}
async function reconcileStoredChatOutboxHead(
host: ChatHost,
outbox: StoredChatOutbox,
item: ChatQueueItem,
dependencies: ChatOutboxDrainDependencies,
): Promise<"blocked" | "continue" | "send"> {
const client = host.client;
const connectionEpoch = host.connectionEpoch;
if (!client || !host.connected) {
return "blocked";
}
const history = await readCurrentStoredChatHistory(
host,
outbox,
item,
client,
connectionEpoch,
dependencies,
);
if (history === "blocked" || history === "continue") {
return history;
}
if (visibleSessionMatches(host, outbox.sessionKey, outbox.agentId) && isChatBusy(host)) {
return "blocked";
}
if ((item.sendAttempts ?? 0) > 0) {
// History messages and active-run metadata are not captured atomically.
// Re-read after the first idle snapshot before classifying delivery as unknown.
const verifiedHistory = await readCurrentStoredChatHistory(
host,
outbox,
item,
client,
connectionEpoch,
dependencies,
);
if (verifiedHistory === "blocked" || verifiedHistory === "continue") {
return verifiedHistory;
}
const parked = updateQueuedMessageForSession(host, outbox.sessionKey, item.id, (entry) => ({
...entry,
sendError: UNCONFIRMED_CHAT_SEND_ERROR,
sendState: "unconfirmed",
}));
if (parked && visibleSessionMatches(host, outbox.sessionKey, outbox.agentId)) {
dependencies.setChatError(host, UNCONFIRMED_CHAT_SEND_ERROR);
}
return "blocked";
}
return "send";
}
async function drainStoredChatOutbox(
lane: StoredChatOutboxDrainLane,
scope: StoredChatOutboxScope,
dependencies: ChatOutboxDrainDependencies,
): Promise<StoredChatOutboxDrainResult> {
while (true) {
const host = lane.host;
if (!host.connected || !host.client) {
return "blocked";
}
const outbox = readStoredChatOutbox(host, scope);
if (!outbox) {
return "empty";
}
// Failed non-command rows are skipped; a failed command may have changed
// session state before reporting an error, so it blocks the drain and
// preserves FIFO until the user explicitly retries or removes it.
let item: ChatQueueItem | undefined;
for (const entry of outbox.queue) {
if (entry.sendState !== "failed") {
item = entry;
break;
}
if (entry.localCommandName) {
break;
}
}
if (!item) {
return "empty";
}
if (item.sendState === "unconfirmed" || item.sendState === "waiting-model") {
syncChatQueueFromStoredOutbox(host, outbox);
return "blocked";
}
const visible = visibleSessionMatches(host, outbox.sessionKey, outbox.agentId);
if (item.localCommandName) {
if (!visible || isChatBusy(host)) {
lane.freshAdmissions.delete(item.id);
lane.pendingOptions.delete(item.id);
return "blocked";
}
syncChatQueueFromStoredOutbox(host, outbox);
if (item.localCommandName === "reset") {
const resetText = item.localCommandArgs ? `/reset ${item.localCommandArgs}` : "/reset";
const convertResetToMessage = (sendState?: ChatQueueItem["sendState"]) =>
updateQueuedMessageForSession(host, outbox.sessionKey, item.id, (entry) => ({
...entry,
localCommandArgs: undefined,
localCommandName: undefined,
refreshSessions: true,
text: resetText,
...(sendState ? { sendState } : {}),
}));
const confirmation = await confirmConversationResetForCurrentSession(host, {
sessionKey: outbox.sessionKey,
...(outbox.agentId ? { agentId: outbox.agentId } : {}),
});
if (confirmation === "deferred") {
const approvedDuringRun =
visibleSessionMatches(host, outbox.sessionKey, outbox.agentId) && host.chatRunId;
const deferred = approvedDuringRun
? convertResetToMessage("waiting-idle")
: updateQueuedMessageForSession(host, outbox.sessionKey, item.id, (entry) => ({
...entry,
sendError: undefined,
sendState: "waiting-idle",
}));
if (!deferred) {
return "blocked";
}
return "blocked";
}
if (confirmation === "cancelled") {
if (!removeQueuedMessageWithoutReleasing(host, item.id, outbox.sessionKey)) {
return "blocked";
}
continue;
}
const converted = convertResetToMessage();
if (!converted) {
return "blocked";
}
continue;
}
// This token exists only in the live drain that admitted the row. Consume
// it before command execution so a manual retry cannot inherit it.
const freshAdmission = lane.freshAdmissions.delete(item.id);
lane.pendingOptions.delete(item.id);
if (!freshAdmission) {
const reconciled = await reconcileStoredChatOutboxHead(host, outbox, item, dependencies);
if (reconciled === "blocked") {
return "blocked";
}
if (reconciled === "continue") {
continue;
}
}
// Claim in place before executing. This preserves FIFO on command failure
// and leaves a manual-review marker if the page disappears mid-command.
const claimed = updateQueuedMessageForSession(host, outbox.sessionKey, item.id, (entry) => ({
...entry,
sendError: undefined,
sendState: "executing-command",
}));
if (!claimed) {
return "blocked";
}
const commandClient = host.client;
const commandConnectionEpoch = host.connectionEpoch;
const commandScopeIsCurrent = () =>
host.connected &&
host.client === commandClient &&
host.connectionEpoch === commandConnectionEpoch &&
visibleSessionMatches(host, outbox.sessionKey, outbox.agentId);
try {
const dispatchResult = await dispatchChatSlashCommand(
host,
claimed.localCommandName ?? item.localCommandName,
claimed.localCommandArgs ?? "",
{
sendResetMessage: (message, resetOpts) =>
dependencies.sendResetSlashCommand(host, message, resetOpts),
},
);
if (dispatchResult === "deferred") {
updateQueuedMessageForSession(host, outbox.sessionKey, item.id, (entry) => ({
...entry,
sendError: undefined,
sendState: "waiting-idle",
}));
return "blocked";
}
if (dispatchResult === "failed") {
const commandStillCurrent = commandScopeIsCurrent();
const error =
(commandStillCurrent ? host.lastError : null) ??
`Command /${item.localCommandName} failed.`;
if (
!updateQueuedMessageForSession(host, outbox.sessionKey, item.id, (entry) => ({
...entry,
sendError: error,
sendState: "failed",
}))
) {
if (commandStillCurrent) {
dependencies.setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
}
}
return "blocked";
}
if (dispatchResult === "uncertain") {
const currentOutbox = readStoredChatOutbox(host, outbox);
const currentIndex =
currentOutbox?.queue.findIndex((entry) => entry.id === item.id) ?? -1;
const successor = currentIndex >= 0 ? currentOutbox?.queue[currentIndex + 1] : undefined;
if (
successor &&
!updateQueuedMessageForSession(
host,
outbox.sessionKey,
successor.id,
(entry) => ({
...entry,
sendError: UNCERTAIN_CLEAR_SUCCESSOR_ERROR,
sendState: "unconfirmed",
}),
outbox.agentId,
)
) {
dependencies.setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
// If the successor barrier cannot be made durable, keep the
// claimed clear row. Its persisted executing-command projection
// is unconfirmed, which safely blocks this lane after reload.
return "blocked";
}
}
if (!removeQueuedMessageWithoutReleasing(host, item.id, outbox.sessionKey)) {
if (commandScopeIsCurrent()) {
dependencies.setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
}
return "blocked";
}
if (dispatchResult === "uncertain") {
// The destructive command itself is consumed. An unconfirmed
// successor is the durable manual-review barrier for this FIFO lane.
return "blocked";
}
if (commandScopeIsCurrent()) {
dependencies.setChatError(host, null);
}
} catch (err) {
const commandStillCurrent = commandScopeIsCurrent();
if (
!updateQueuedMessageForSession(host, outbox.sessionKey, item.id, (entry) => ({
...entry,
sendError: String(err),
sendState: "failed",
}))
) {
if (commandStillCurrent) {
dependencies.setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
}
return "blocked";
}
if (commandStillCurrent) {
dependencies.setChatError(host, String(err));
}
return "blocked";
}
continue;
}
if (isUiGlobalSessionKey(outbox.sessionKey) && !outbox.agentId) {
lane.freshAdmissions.delete(item.id);
lane.pendingOptions.delete(item.id);
return "blocked";
}
// Consume fresh provenance before any await. A restored or deferred row
// has no token and must reconcile Gateway history before transport.
const freshAdmission = lane.freshAdmissions.delete(item.id);
const pendingOptions = lane.pendingOptions.get(item.id);
lane.pendingOptions.delete(item.id);
const needsHistory = !freshAdmission;
if (needsHistory) {
const reconciled = await reconcileStoredChatOutboxHead(host, outbox, item, dependencies);
if (reconciled === "blocked") {
return "blocked";
}
if (reconciled === "continue") {
continue;
}
}
if (visible && isChatBusy(host)) {
syncChatQueueFromStoredOutbox(host, outbox);
updateQueuedMessageForSession(host, outbox.sessionKey, item.id, (entry) => ({
...entry,
sendState: host.connected && host.client ? "waiting-idle" : "waiting-reconnect",
}));
return "blocked";
}
const currentOutbox = readStoredChatOutbox(host, scope);
const currentItem = currentOutbox?.queue.find((entry) => entry.id === item.id);
if (!currentOutbox || !currentItem || !sameQueuedDeliveryVersion(currentItem, item)) {
continue;
}
syncChatQueueFromStoredOutbox(host, currentOutbox);
const result = await dependencies.sendQueuedChatMessage(
host,
item.id,
pendingOptions,
outbox.sessionKey,
);
if (result === "pending") {
// A pending ACK/reconnect state owns the next wakeup. Any rerun requested
// while this RPC was in flight is already reflected in the durable queue.
lane.rerun = false;
return "blocked";
}
if (result === "failed") {
continue;
}
}
}
export async function scheduleStoredChatOutboxDrain(
host: ChatHost,
scope: StoredChatOutboxScope,
dependencies: ChatOutboxDrainDependencies,
itemId?: string,
options?: QueuedChatSendOptions,
): Promise<void> {
const client = host.client;
if (!host.connected || !client) {
return;
}
const key = storedChatOutboxScopeKey(scope);
const retryTimers = storedChatOutboxRetryTimersByClient.get(client);
const retryTimer = retryTimers?.get(key);
if (retryTimer !== undefined) {
clearTimeout(retryTimer);
retryTimers?.delete(key);
}
// Drain ownership follows the live gateway client. A disconnected client can
// leave an RPC pending, but its lane must never capture a replacement client.
const lanes = storedChatOutboxClientMap(storedChatOutboxDrainLanesByClient, client);
const existing = lanes.get(key);
if (existing) {
const existingHostOwnsScope =
existing.host.connected &&
existing.host.client === client &&
visibleSessionMatches(existing.host, scope.sessionKey, scope.agentId);
const candidateOwnsScope = visibleSessionMatches(host, scope.sessionKey, scope.agentId);
// Local commands need the visible pane's session-bound UI state. Keep that
// owner while connected; an inactive split pane may still request a rerun.
if (!existingHostOwnsScope && candidateOwnsScope) {
existing.host = host;
} else if (!existing.host.connected || existing.host.client !== client) {
existing.host = host;
}
existing.rerun = true;
if (itemId && options) {
existing.pendingOptions.set(itemId, options);
}
if (itemId) {
existing.freshAdmissions.add(itemId);
}
await existing.promise;
return;
}
let resolveLane!: () => void;
let rejectLane!: (reason?: unknown) => void;
const promise = new Promise<void>((resolve, reject) => {
resolveLane = resolve;
rejectLane = reject;
});
const lane: StoredChatOutboxDrainLane = {
freshAdmissions: new Set(itemId ? [itemId] : []),
host,
pendingOptions: new Map(itemId && options ? [[itemId, options]] : []),
promise,
rerun: false,
};
lanes.set(key, lane);
void (async () => {
do {
lane.rerun = false;
await drainStoredChatOutbox(lane, scope, dependencies);
} while (lane.rerun);
})().then(resolveLane, rejectLane);
try {
await lane.promise;
} finally {
if (lanes.get(key) === lane) {
lanes.delete(key);
}
}
}
export async function resumeStoredChatOutboxes(
host: ChatHost,
dependencies: ChatOutboxDrainDependencies,
) {
if (!host.connected || !host.client) {
return;
}
await Promise.allSettled(
listStoredChatOutboxes(host).map((outbox) =>
scheduleStoredChatOutboxDrain(host, outbox, dependencies),
),
);
}
export async function flushStoredChatOutbox(
host: ChatHost,
dependencies: ChatOutboxDrainDependencies,
) {
const outbox = listStoredChatOutboxes(host).find((candidate) =>
visibleSessionMatches(host, candidate.sessionKey, candidate.agentId),
);
if (outbox) {
await scheduleStoredChatOutboxDrain(host, outbox, dependencies);
}
}
+1 -1
View File
@@ -169,7 +169,7 @@ import {
import { markQueuedChatSendsWaitingForReconnect } from "./chat-queue.ts";
import { dismissRealtimeTalkError } from "./chat-realtime.ts";
import { activeChatRunStartupStatus } from "./chat-run-startup.ts";
import { flushChatQueueForEvent, retryReconnectableQueuedChatSends } from "./chat-send.ts";
import { flushChatQueueForEvent, retryReconnectableQueuedChatSends } from "./chat-send-actions.ts";
import {
flushChatQueueAfterIdleSessionReconciliation,
switchChatFastMode,
+80
View File
@@ -0,0 +1,80 @@
// Leaf contract for chat.send acknowledgment shapes and timing records.
// Kept import-free of chat-page modules so lifecycle/steer/history layers
// can consume ack types without forming import cycles.
import type { ChatQueueItem } from "../../lib/chat/chat-types.ts";
type ChatSendAckStatus = "started" | "in_flight" | "ok" | "timeout" | "error";
type ChatSendAckServerTiming = {
receivedToAckMs?: number;
loadSessionMs?: number;
prepareAttachmentsMs?: number;
};
export type ChatSendAck = {
runId: string;
status: ChatSendAckStatus;
serverTiming?: ChatSendAckServerTiming;
};
function normalizeAckTimingValue(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
}
function normalizeChatSendAckServerTiming(value: unknown): ChatSendAckServerTiming | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const record = value as Record<string, unknown>;
const receivedToAckMs = normalizeAckTimingValue(record.receivedToAckMs);
const loadSessionMs = normalizeAckTimingValue(record.loadSessionMs);
const prepareAttachmentsMs = normalizeAckTimingValue(record.prepareAttachmentsMs);
const timing: ChatSendAckServerTiming = {
...(receivedToAckMs !== undefined ? { receivedToAckMs } : {}),
...(loadSessionMs !== undefined ? { loadSessionMs } : {}),
...(prepareAttachmentsMs !== undefined ? { prepareAttachmentsMs } : {}),
};
return Object.keys(timing).length > 0 ? timing : undefined;
}
export function normalizeChatSendAck(payload: unknown, fallbackRunId: string): ChatSendAck {
if (!payload || typeof payload !== "object") {
return { runId: fallbackRunId, status: "started" };
}
const record = payload as Record<string, unknown>;
const runId =
typeof record.runId === "string" && record.runId.trim() ? record.runId.trim() : fallbackRunId;
const status = record.status;
const serverTiming = normalizeChatSendAckServerTiming(record.serverTiming);
return {
runId,
status:
status === "in_flight" || status === "ok" || status === "timeout" || status === "error"
? status
: "started",
...(serverTiming ? { serverTiming } : {}),
};
}
export type TerminalFailureChatSendAck = ChatSendAck & { status: "timeout" | "error" };
// ChatSendAck's status is a union field, not a discriminant across object
// types; callers need this predicate to narrow the whole ack object.
export function isTerminalFailureChatSendAck(
ack: ChatSendAck | null,
): ack is TerminalFailureChatSendAck {
return ack?.status === "timeout" || ack?.status === "error";
}
export type ChatSendTimingEntry = {
runId: string;
sessionKey?: string;
agentId?: string;
sendAttempts: number;
sendState?: ChatQueueItem["sendState"];
submittedAtMs: number;
requestStartedAtMs?: number;
ackAtMs?: number;
ackStatus?: ChatSendAckStatus;
firstAssistantVisibleRecorded?: boolean;
};
+191
View File
@@ -0,0 +1,191 @@
import type { QueueMode } from "../../../../src/auto-reply/reply/queue/types.js";
import type { ChatAttachment } from "../../lib/chat/chat-types.ts";
import { generateUUID } from "../../lib/uuid.ts";
import { loadChatHistory, type ChatState } from "./chat-history.ts";
import {
flushStoredChatOutbox,
resumeStoredChatOutboxes as resumeStoredChatOutboxesDrain,
scheduleStoredChatOutboxDrain,
} from "./chat-outbox-drain.ts";
import {
admitQueuedMessageForSession,
isVolatileQueuedMessage,
updateQueuedMessage,
updateVolatileQueuedMessage,
} from "./chat-queue.ts";
import type { ChatSendAck } from "./chat-send-ack.ts";
import type { ChatHost } from "./chat-send-contract.ts";
import {
canSendVolatileQueueItem,
reconnectSafeQueuedSendState,
setChatError,
} from "./chat-send-queue-state.ts";
import { requestChatSend } from "./chat-send-request.ts";
import { chatOutboxDrainDependencies, sendQueuedChatMessage } from "./chat-send.ts";
import { listStoredChatOutboxes, storedChatOutboxScopeKey } from "./composer-persistence.ts";
import { formatConnectError } from "./connect-error.ts";
import {
OFFLINE_QUEUE_STORAGE_ERROR,
steerQueuedChatMessage as steerQueuedChatMessageLifecycle,
type SteerSendDependencies,
} from "./steer-lifecycle.ts";
import { isInflightSteer } from "./steered-chip.ts";
export async function sendChatMessageWithGeneratedRunId(
state: ChatState,
message: string,
attachments?: ChatAttachment[],
options: {
canApplyError?: () => boolean;
queueMode?: QueueMode;
runId?: string;
} = {},
): Promise<ChatSendAck | null> {
if (!state.client || !state.connected) {
return null;
}
const msg = message.trim();
const hasAttachments = attachments && attachments.length > 0;
if (!msg && !hasAttachments) {
return null;
}
const canApplyError = options.canApplyError ?? (() => true);
if (canApplyError()) {
setChatError(state, null);
}
const runId = options.runId ?? generateUUID();
try {
return await requestChatSend(state, {
message: msg,
attachments,
runId,
...(options.queueMode ? { queueMode: options.queueMode } : {}),
});
} catch (err) {
if (canApplyError()) {
setChatError(state, formatConnectError(err));
}
return null;
}
}
export const steerSendDependencies: SteerSendDependencies = {
loadChatHistory: (host) => void loadChatHistory(host as unknown as ChatState),
resumeRestoredOutbox: (host, itemId) => {
const restoredOutbox = listStoredChatOutboxes(host).find((outbox) =>
outbox.queue.some((item) => item.id === itemId),
);
if (!host.chatRunId && restoredOutbox) {
void scheduleStoredChatOutboxDrain(
host as ChatHost,
restoredOutbox,
chatOutboxDrainDependencies,
);
}
},
sendChatMessage: (host, message, attachments, options) =>
sendChatMessageWithGeneratedRunId(host as unknown as ChatState, message, attachments, options),
};
export function steerQueuedChatMessage(host: ChatHost, id: string) {
return steerQueuedChatMessageLifecycle(host, id, steerSendDependencies);
}
export async function resumeStoredChatOutboxes(host: ChatHost) {
await resumeStoredChatOutboxesDrain(host, chatOutboxDrainDependencies);
}
export async function flushChatQueueForEvent(host: ChatHost) {
await flushStoredChatOutbox(host, chatOutboxDrainDependencies);
}
export async function retryReconnectableQueuedChatSends(host: ChatHost) {
await resumeStoredChatOutboxes(host);
}
export async function retryQueuedChatMessage(host: ChatHost, id: string) {
const item = host.chatQueue.find((entry) => entry.id === id);
if (
!item ||
item.pendingRunId ||
item.sendState === "executing-command" ||
isInflightSteer(item) ||
item.sendState === "sending" ||
item.sendState === "waiting-model"
) {
return;
}
let outbox = listStoredChatOutboxes(host).find((candidate) =>
candidate.queue.some((entry) => entry.id === item.id),
);
if (!outbox) {
const wasVolatile = isVolatileQueuedMessage(host, item.id);
if (!admitQueuedMessageForSession(host, item.sessionKey ?? host.sessionKey, item)) {
if (
wasVolatile &&
!item.localCommandName &&
item.sendRunId &&
(item.sendState === "failed" || item.sendState === "unconfirmed") &&
canSendVolatileQueueItem(host, item)
) {
const retry = updateVolatileQueuedMessage(host, id, (entry) => ({
...entry,
sendAttempts: 0,
sendError: undefined,
sendRunId: entry.sendState === "failed" ? generateUUID() : entry.sendRunId,
sendState: undefined,
}));
if (!retry) {
setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
return;
}
await sendQueuedChatMessage(
host,
retry.id,
{
routingSessionKey: retry.sessionKey ?? host.sessionKey,
storageMode: "memory",
},
retry.sessionKey ?? host.sessionKey,
);
return;
}
setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
return;
}
outbox = listStoredChatOutboxes(host).find((candidate) =>
candidate.queue.some((entry) => entry.id === item.id),
);
}
if (!outbox) {
setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
return;
}
const retry = updateQueuedMessage(host, id, (entry) => ({
...entry,
sendAttempts: 0,
sendError: undefined,
sendRunId: entry.sendState === "failed" ? generateUUID() : entry.sendRunId,
sendState: reconnectSafeQueuedSendState(host),
}));
if (!retry) {
setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
return;
}
outbox = listStoredChatOutboxes(host).find((candidate) =>
candidate.queue.some((entry) => entry.id === retry.id),
);
if (!outbox) {
setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
return;
}
const drain = scheduleStoredChatOutboxDrain(host, outbox, chatOutboxDrainDependencies);
if (host.chatSending && host.chatSendingScopeKey === storedChatOutboxScopeKey(outbox)) {
void drain;
return;
}
await drain;
if (!host.chatRunId) {
void flushStoredChatOutbox(host, chatOutboxDrainDependencies);
}
}
+58 -35
View File
@@ -1,38 +1,61 @@
import type { ChatQueueItem } from "../../lib/chat/chat-types.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "../../api/gateway.ts";
import type { AgentsListResult } from "../../api/types.ts";
import type { ChatFollowUpMode } from "../../app/settings.ts";
import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts";
import type { ControlUiFollowUpMode } from "../../lib/chat/follow-up-mode.ts";
import type { ChatSideResultPending } from "../../lib/chat/side-result.ts";
import type { SessionCapability, SessionRefreshTarget } from "../../lib/sessions/index.ts";
import type { ChatCommandHost } from "./chat-commands.ts";
import type { ChatRunStartupState } from "./chat-run-startup.ts";
import type { ChatSendTimingEntry } from "./chat-send-ack.ts";
import type { ChatInputHistoryState } from "./input-history.ts";
import type { RenderLifecycle } from "./render-lifecycle.ts";
type ChatSendAckStatus = "started" | "in_flight" | "ok" | "timeout" | "error";
export type ChatSendAckServerTiming = {
receivedToAckMs?: number;
loadSessionMs?: number;
prepareAttachmentsMs?: number;
type ChatAgentsListSnapshot = Partial<Omit<AgentsListResult, "agents">> & {
agents?: AgentsListResult["agents"];
};
export type ChatSendAck = {
runId: string;
status: ChatSendAckStatus;
serverTiming?: ChatSendAckServerTiming;
};
export type TerminalFailureChatSendAck = ChatSendAck & { status: "timeout" | "error" };
// ChatSendAck's status is a union field, not a discriminant across object
// types; callers need this predicate to narrow the whole ack object.
export function isTerminalFailureChatSendAck(
ack: ChatSendAck | null,
): ack is TerminalFailureChatSendAck {
return ack?.status === "timeout" || ack?.status === "error";
}
export type ChatSendTimingEntry = {
runId: string;
sessionKey?: string;
agentId?: string;
sendAttempts: number;
sendState?: ChatQueueItem["sendState"];
submittedAtMs: number;
requestStartedAtMs?: number;
ackAtMs?: number;
ackStatus?: ChatSendAckStatus;
firstAssistantVisibleRecorded?: boolean;
};
export type ChatHost = ChatInputHistoryState &
ChatCommandHost & {
sessions: SessionCapability;
client: GatewayBrowserClient | null;
chatStream: string | null;
connected: boolean;
connectionEpoch?: number;
chatAttachments: ChatAttachment[];
chatQueue: ChatQueueItem[];
chatQueueByScope?: Record<string, ChatQueueItem[]>;
chatRunId: string | null;
chatRunStartup?: ChatRunStartupState | null;
chatRunUsageById?: Map<string, number>;
chatSending: boolean;
chatSendingScopeKey?: string | null;
chatRunError?: { summary: string } | null;
lastError?: string | null;
chatError?: string | null;
hello: GatewayHelloOk | null;
renderLifecycle?: RenderLifecycle;
requestUpdate?: () => void;
refreshSessionsAfterChat: Map<string, SessionRefreshTarget>;
chatSubmitGuards?: Map<string, Promise<void>>;
chatSendTimingsByRun?: Map<string, ChatSendTimingEntry>;
eventLogBuffer?: unknown[];
assistantAgentId?: string | null;
agentsList?: ChatAgentsListSnapshot | null;
settings?: { chatFollowUpMode?: ChatFollowUpMode };
/** Prepared from the browser override and current Gateway effective queue mode. */
chatFollowUpMode?: ControlUiFollowUpMode;
/** Selected message to reply to (right-click / keyboard shortcut). */
chatReplyTarget?: {
messageId: string;
text: string;
senderLabel?: string | null;
sourceMessageId?: string | null;
} | null;
/** Placeholder for an in-flight /btw side question awaiting chat.side_result. */
chatSideResultPending?: ChatSideResultPending | null;
/** Retired/handled BTW run ids whose late events must not reach the transcript. */
chatSideResultTerminalRuns?: Set<string>;
/** Side-chat panel closed via X/Escape; a new question reopens it. */
chatSideChatHidden?: boolean;
};
+124
View File
@@ -0,0 +1,124 @@
import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts";
import { resolveCurrentUserIdentity } from "../../lib/chat/current-user-identity.ts";
import { scopedAgentIdForSession, visibleSessionMatches } from "../../lib/sessions/index.ts";
import { generateUUID } from "../../lib/uuid.ts";
import type { QueuedChatStorageMode } from "./chat-outbox-drain.ts";
import { updateQueuedMessageForSession, updateVolatileQueuedMessage } from "./chat-queue.ts";
import type { ChatHost } from "./chat-send-contract.ts";
import { recordChatSendTiming, schedulePendingSendPaintTiming } from "./chat-send-timing.ts";
import { getPendingChatPickerPatch } from "./chat-session.ts";
import { storedChatOutboxScopeKey, type StoredChatOutboxScope } from "./composer-persistence.ts";
import { controlUiNowMs } from "./performance.ts";
import { isChatBusy } from "./run-lifecycle.ts";
import { scheduleChatScroll } from "./scroll.ts";
export function setChatError(
host: { lastError?: string | null; chatError?: string | null },
error: string | null,
) {
host.lastError = error;
host.chatError = error;
}
export function enqueuePendingSendMessage(
host: ChatHost,
text: string,
attachments?: ChatAttachment[],
refreshSessions?: boolean,
submittedAtMs = controlUiNowMs(),
sendState?: ChatQueueItem["sendState"],
skillWorkshopRevision?: ChatQueueItem["skillWorkshopRevision"],
replyToId?: string,
): ChatQueueItem | null {
const trimmed = text.trim();
const hasAttachments = Boolean(attachments && attachments.length > 0);
if (!trimmed && !hasAttachments) {
return null;
}
const sender = resolveCurrentUserIdentity(host.hello, host.client?.instanceId);
const pending: ChatQueueItem = {
id: generateUUID(),
text: trimmed,
createdAt: Date.now(),
attachments: hasAttachments ? attachments : undefined,
refreshSessions,
sendAttempts: 0,
sendRunId: generateUUID(),
sendState,
sendSubmittedAtMs: submittedAtMs,
sessionKey: host.sessionKey,
agentId: scopedAgentIdForSession(host, host.sessionKey),
...(sender ? { sender } : {}),
...(skillWorkshopRevision ? { skillWorkshopRevision } : {}),
...(replyToId ? { replyToId } : {}),
};
host.chatQueue = [...host.chatQueue, pending];
recordChatSendTiming(host, pending, "pending-visible", submittedAtMs);
if (sendState === "waiting-model" || sendState === "waiting-reconnect") {
recordChatSendTiming(host, pending, sendState, submittedAtMs);
}
schedulePendingSendPaintTiming(host, pending, submittedAtMs);
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0], true, false, {
source: "manual",
});
return pending;
}
export function reconnectSafeQueuedSendState(
host: Pick<ChatHost, "client" | "connected">,
): "waiting-idle" | "waiting-reconnect" {
return host.connected && host.client ? "waiting-idle" : "waiting-reconnect";
}
export function updateQueuedSendItem(
host: ChatHost,
storageMode: QueuedChatStorageMode,
sessionKey: string,
id: string,
update: (item: ChatQueueItem) => ChatQueueItem,
): ChatQueueItem | null {
return storageMode === "memory"
? updateVolatileQueuedMessage(host, id, update)
: updateQueuedMessageForSession(host, sessionKey, id, update);
}
export function canSendVolatileQueueItem(
host: ChatHost,
item: ChatQueueItem,
routingSessionKey = item.sessionKey ?? host.sessionKey,
): boolean {
return (
host.connected &&
Boolean(host.client) &&
!isChatBusy(host) &&
!getPendingChatPickerPatch(host, routingSessionKey, item.agentId) &&
host.sessionKey === routingSessionKey &&
visibleSessionMatches(host, routingSessionKey, item.agentId) &&
host.chatQueue[0]?.id === item.id
);
}
export function finishScopedChatSending(host: ChatHost, scope: StoredChatOutboxScope): void {
if (host.chatSendingScopeKey !== storedChatOutboxScopeKey(scope)) {
return;
}
host.chatSendingScopeKey = null;
host.chatSending = false;
}
export async function waitForPendingChatSettings(
host: ChatHost,
sessionKey: string,
initialPending: Promise<boolean>,
agentId?: string,
): Promise<boolean> {
let pending = initialPending;
while (await pending) {
const nextPending = getPendingChatPickerPatch(host, sessionKey, agentId);
if (!nextPending || nextPending === pending) {
return true;
}
pending = nextPending;
}
return false;
}
+101
View File
@@ -0,0 +1,101 @@
import type { QueueMode } from "../../../../src/auto-reply/reply/queue/types.js";
import type { ChatAttachment } from "../../lib/chat/chat-types.ts";
import {
isUiGlobalSessionKey,
normalizeAgentId,
resolveUiSelectedSessionAgentId,
} from "../../lib/sessions/session-key.ts";
import { buildChatApiAttachments } from "./attachment-api.ts";
import type { ChatState } from "./chat-history.ts";
import { normalizeChatSendAck, type ChatSendAck } from "./chat-send-ack.ts";
export async function requestChatSend(
state: ChatState,
params: {
message: string;
attachments?: ChatAttachment[];
runId: string;
sessionKey?: string;
agentId?: string;
queueMode?: QueueMode;
replyToId?: string;
},
): Promise<ChatSendAck> {
const routing = resolveChatSendRouting(state, params);
const controlUiReconnectResume = Boolean(
routing.sessionId && state.reconnectResumeSessionId === routing.sessionId,
);
const payload = await state.client!.request("chat.send", {
sessionKey: routing.sessionKey,
...(isUiGlobalSessionKey(routing.sessionKey) && routing.selectedAgentId
? { agentId: routing.selectedAgentId }
: {}),
...(routing.sessionId ? { sessionId: routing.sessionId } : {}),
...(controlUiReconnectResume ? { __controlUiReconnectResume: true } : {}),
message: params.message,
deliver: false,
...(params.replyToId ? { replyToId: params.replyToId } : {}),
...(params.queueMode ? { queueMode: params.queueMode } : {}),
idempotencyKey: params.runId,
attachments: buildChatApiAttachments(params.attachments),
});
if (controlUiReconnectResume) {
state.reconnectResumeSessionId = null;
}
return normalizeChatSendAck(payload, params.runId);
}
function resolveChatSendRouting(
state: ChatState,
params: {
sessionKey?: string;
agentId?: string;
},
): { selectedAgentId?: string; sessionId?: string; sessionKey: string } {
const sessionKey = params.sessionKey ?? state.sessionKey;
const selectedAgentId = params.agentId
? normalizeAgentId(params.agentId)
: resolveUiSelectedSessionAgentId(state);
const currentSessionId = state.currentSessionId;
const canReuseCurrentSessionId =
sessionKey === state.sessionKey &&
(!isUiGlobalSessionKey(sessionKey) ||
(selectedAgentId !== undefined &&
selectedAgentId === resolveUiSelectedSessionAgentId(state)));
const sessionId =
canReuseCurrentSessionId && typeof currentSessionId === "string" && currentSessionId.trim()
? currentSessionId.trim()
: undefined;
return {
sessionKey,
...(selectedAgentId ? { selectedAgentId } : {}),
...(sessionId ? { sessionId } : {}),
};
}
export async function requestSkillWorkshopRevisionChatSend(
state: ChatState,
params: {
proposalId: string;
instructions: string;
runId: string;
sessionKey?: string;
agentId?: string;
targetAgentId?: string;
},
): Promise<ChatSendAck> {
const routing = resolveChatSendRouting(state, {
sessionKey: params.sessionKey,
agentId: params.targetAgentId,
});
const payload = await state.client!.request("skills.proposals.requestRevision", {
...(params.agentId ? { agentId: normalizeAgentId(params.agentId) } : {}),
...(routing.selectedAgentId ? { targetAgentId: routing.selectedAgentId } : {}),
proposalId: params.proposalId,
instructions: params.instructions,
sessionKey: routing.sessionKey,
...(routing.sessionId ? { sessionId: routing.sessionId } : {}),
idempotencyKey: params.runId,
});
return normalizeChatSendAck(payload, params.runId);
}
+648
View File
@@ -0,0 +1,648 @@
import { shouldForwardModelCommandToServer } from "../../../../src/auto-reply/commands-registry.shared.js";
import { normalizeChatFollowUpModeOverride, setLastActiveSessionKey } from "../../app/settings.ts";
import type {
ChatAttachment,
ChatQueueItem,
ChatQueueSkillWorkshopRevision,
} from "../../lib/chat/chat-types.ts";
import { parseSlashCommand } from "../../lib/chat/commands.ts";
import { resolveCurrentUserIdentity } from "../../lib/chat/current-user-identity.ts";
import { extractSideQuestionDisplayText } from "../../lib/chat/side-question.ts";
import { retirePendingChatSideQuestion } from "../../lib/chat/side-result.ts";
import { visibleSessionMatches } from "../../lib/sessions/index.ts";
import { normalizeLowercaseStringOrEmpty } from "../../lib/string-coerce.ts";
import { generateUUID } from "../../lib/uuid.ts";
import {
getChatAttachmentDataUrl,
releaseChatAttachmentPayloads,
} from "./attachment-payload-store.ts";
import { dispatchChatSlashCommand, shouldQueueLocalSlashCommand } from "./chat-commands.ts";
import type { ChatState } from "./chat-history.ts";
import { scheduleStoredChatOutboxDrain } from "./chat-outbox-drain.ts";
import {
admitQueuedMessageForSession,
enqueueChatMessage,
excludeComposerAttachments,
removeQueuedMessageWithoutReleasing,
updateQueuedMessage,
updateQueuedMessageForSession,
} from "./chat-queue.ts";
import { isTerminalFailureChatSendAck } from "./chat-send-ack.ts";
import { sendChatMessageWithGeneratedRunId, steerSendDependencies } from "./chat-send-actions.ts";
import type { ChatHost } from "./chat-send-contract.ts";
import {
canSendVolatileQueueItem,
enqueuePendingSendMessage,
reconnectSafeQueuedSendState,
setChatError,
waitForPendingChatSettings,
} from "./chat-send-queue-state.ts";
import { recordChatSendTiming } from "./chat-send-timing.ts";
import {
cancelPendingSendBeforeRequest,
chatOutboxDrainDependencies,
pendingComposerRestorePlan,
sendChatMessageNow,
withChatSubmitGuard,
} from "./chat-send.ts";
import { getPendingChatPickerPatch } from "./chat-session.ts";
import { INTERRUPTED_SETTINGS_WAIT_ERROR, listStoredChatOutboxes } from "./composer-persistence.ts";
import {
recordNonTranscriptInputHistory,
resetChatInputHistoryNavigation,
} from "./input-history.ts";
import { controlUiNowMs } from "./performance.ts";
import {
handleAbortChat,
hasAbortableSessionRun,
isChatBusy,
isChatStopCommand,
} from "./run-lifecycle.ts";
import {
formatTerminalChatSendAckError,
OFFLINE_QUEUE_STORAGE_ERROR,
sendQueuedChatMessageWithQueueMode as sendQueuedChatMessageWithQueueModeLifecycle,
} from "./steer-lifecycle.ts";
type ChatSendOptions = {
confirmReset?: boolean;
restoreDraft?: boolean;
skillWorkshopRevision?: ChatQueueSkillWorkshopRevision;
/** Side-chat follow-ups embed prior-turn context in the /btw command; the
* pending turn must display the user's typed question instead. */
sideQuestionDisplayText?: string;
/** Lets the side-chat panel restore its typed follow-up when the detached
* send is not accepted (the panel input is not a managed draft). */
onSideQuestionSendRejected?: () => void;
/** Lets request-scoped UI actions recover when their local slash command
* fails before the Gateway accepts it. */
onLocalCommandSendRejected?: () => void;
};
function isChatResetCommand(text: string) {
const parsed = parseSlashCommand(text);
if (!parsed || (parsed.command.key !== "new" && parsed.command.key !== "reset")) {
return false;
}
if (parsed.command.key === "new") {
return true;
}
if (/^soft(?:\s|$)/.test(normalizeLowercaseStringOrEmpty(parsed.args))) {
return false;
}
return true;
}
function isBtwCommand(text: string) {
return /^\/(?:btw|side)(?::|\s|$)/i.test(text.trim());
}
function attachmentSubmitSignature(attachment: ChatAttachment): string {
const dataUrl = getChatAttachmentDataUrl(attachment);
return JSON.stringify([
attachment.id,
attachment.mimeType,
attachment.fileName ?? "",
attachment.sizeBytes ?? 0,
dataUrl?.length ?? 0,
dataUrl?.slice(0, 64) ?? "",
]);
}
function chatSubmitKey(
host: ChatHost,
kind: "detached" | "local" | "message",
message: string,
attachments: ChatAttachment[],
skillWorkshopRevision?: ChatQueueSkillWorkshopRevision,
): string {
return JSON.stringify([
kind,
host.sessionKey,
message.trim(),
skillWorkshopRevision?.proposalId ?? "",
skillWorkshopRevision?.agentId ?? "",
attachments.map(attachmentSubmitSignature),
]);
}
function clearSubmittedComposerState(
host: ChatHost,
submittedDraft: string,
submittedAttachments: ChatAttachment[],
): {
previousAttachments?: ChatAttachment[];
previousDraft?: string;
} {
const attachmentsUnchanged =
host.chatAttachments.length === submittedAttachments.length &&
host.chatAttachments.every((attachment, index) => {
const submitted = submittedAttachments[index];
return (
submitted !== undefined &&
attachmentSubmitSignature(attachment) === attachmentSubmitSignature(submitted)
);
});
const clearedDraft = host.chatMessage === submittedDraft && attachmentsUnchanged;
const clearedAttachments = clearedDraft;
if (clearedDraft) {
host.chatMessage = "";
}
if (clearedAttachments) {
host.chatAttachments = [];
}
if (clearedDraft || clearedAttachments) {
resetChatInputHistoryNavigation(host);
}
return {
previousAttachments: clearedAttachments ? submittedAttachments : undefined,
previousDraft: clearedDraft ? submittedDraft : undefined,
};
}
function snapshotChatAttachments(attachments: readonly ChatAttachment[]): ChatAttachment[] {
return attachments.map((attachment) => {
const dataUrl = getChatAttachmentDataUrl(attachment);
return {
...attachment,
...(dataUrl ? { dataUrl } : {}),
};
});
}
async function sendDetachedCommandMessage(
host: ChatHost,
message: string,
opts?: {
previousDraft?: string;
attachments?: ChatAttachment[];
previousAttachments?: ChatAttachment[];
runId?: string;
},
) {
const ack = await sendChatMessageWithGeneratedRunId(
host as unknown as ChatState,
message,
opts?.attachments,
{ runId: opts?.runId },
);
const ok = ack?.status === "ok" || ack?.status === "started" || ack?.status === "in_flight";
if (!ok && opts?.previousDraft != null) {
host.chatMessage = opts.previousDraft;
}
if (!ok && opts?.previousAttachments) {
host.chatAttachments = opts.previousAttachments;
}
if (isTerminalFailureChatSendAck(ack)) {
setChatError(host, formatTerminalChatSendAckError(ack, "detached"));
}
if (ok) {
setLastActiveSessionKey(
host as unknown as Parameters<typeof setLastActiveSessionKey>[0],
host.sessionKey,
);
releaseChatAttachmentPayloads(excludeComposerAttachments(host, opts?.attachments));
}
return ack;
}
export async function handleSendChat(
host: ChatHost,
messageOverride?: string,
opts?: ChatSendOptions,
) {
const previousDraft = host.chatMessage;
const message = (messageOverride ?? host.chatMessage).trim();
const submittedAtMs = controlUiNowMs();
const submittedSessionKey = host.sessionKey;
const attachments = host.chatAttachments ?? [];
const attachmentsToSend = messageOverride == null ? snapshotChatAttachments(attachments) : [];
const hasAttachments = attachmentsToSend.length > 0;
const skillWorkshopRevision = opts?.skillWorkshopRevision;
const shouldInterpretChatCommands = !skillWorkshopRevision;
if (!message && !hasAttachments) {
return;
}
if (
messageOverride != null &&
opts?.confirmReset &&
isChatResetCommand(message) &&
(typeof globalThis.confirm !== "function" ||
!globalThis.confirm("Start a new thread? This will reset the current chat."))
) {
return;
}
host.chatRunError = null;
if (shouldInterpretChatCommands) {
// Natural words such as "wait" and "exit" are stop aliases only while a
// run exists. Keep the explicit /stop command available at any time.
const shouldAbort =
isChatStopCommand(message) &&
(message.trim().startsWith("/") || hasAbortableSessionRun(host));
if (shouldAbort) {
if (messageOverride == null) {
recordNonTranscriptInputHistory(host, message);
}
await handleAbortChat(host);
return;
}
const parsed = parseSlashCommand(message);
// The backend resolves /approve before active-run admission. Send it now so
// the approval command cannot queue behind the run that is waiting for it.
const shouldSendDetachedCommand =
isBtwCommand(message) || (parsed?.command.key === "approve" && isChatBusy(host));
if (shouldSendDetachedCommand) {
const submitKey = chatSubmitKey(host, "detached", message, attachmentsToSend);
// Covers every non-accepted path — early exits, guard dedupe, and
// rejected acks — so the side-chat panel can restore its typed
// follow-up even when no request was sent.
let detachedSendAccepted = false;
await withChatSubmitGuard(host, submitKey, async () => {
const pendingSettings = getPendingChatPickerPatch(host, submittedSessionKey);
if (
pendingSettings &&
!(await waitForPendingChatSettings(host, submittedSessionKey, pendingSettings))
) {
return;
}
if (host.sessionKey !== submittedSessionKey) {
return;
}
const cleared =
messageOverride == null
? clearSubmittedComposerState(host, previousDraft, attachmentsToSend)
: {};
if (messageOverride == null) {
recordNonTranscriptInputHistory(host, message);
}
// BTW runs detached and delivers via chat.side_result only; show a
// pending turn immediately so the send has visible feedback. The run
// id is generated upfront so the turn is correlatable before the ack
// returns.
const btwPending = isBtwCommand(message)
? {
question: opts?.sideQuestionDisplayText ?? extractSideQuestionDisplayText(message),
ts: Date.now(),
runId: generateUUID(),
}
: null;
if (btwPending) {
// The superseded run loses its pending record; retire it so its
// late side_result/terminal events cannot reach the panel or the
// transcript. Completed turns stay: the panel is a conversation.
retirePendingChatSideQuestion(host);
host.chatSideResultPending = btwPending;
host.chatSideChatHidden = false;
host.requestUpdate?.();
}
const ack = await sendDetachedCommandMessage(host, message, {
previousDraft: cleared.previousDraft,
attachments: hasAttachments ? attachmentsToSend : undefined,
previousAttachments: cleared.previousAttachments,
runId: btwPending?.runId,
});
detachedSendAccepted =
ack?.status === "ok" || ack?.status === "started" || ack?.status === "in_flight";
// Touch only this send's card: a side_result (or a newer question)
// may already have replaced it while the ack was in flight.
if (btwPending && host.chatSideResultPending === btwPending && !detachedSendAccepted) {
host.chatSideResultPending = null;
host.requestUpdate?.();
}
});
if (!detachedSendAccepted) {
opts?.onSideQuestionSendRejected?.();
}
return;
}
// Intercept local slash commands (/status, /model, /compact, etc.)
const forwardModelCommand =
parsed?.command.key === "model" && shouldForwardModelCommandToServer(parsed.args);
if (parsed?.command.executeLocal && !forwardModelCommand) {
const shouldQueueCommand = shouldQueueLocalSlashCommand(parsed.command.key);
if (shouldQueueCommand) {
if (messageOverride == null) {
recordNonTranscriptInputHistory(host, message);
host.chatMessage = "";
resetChatInputHistoryNavigation(host);
}
const queued = enqueueChatMessage(
host,
message,
undefined,
isChatResetCommand(message),
{
args: parsed.args,
name: parsed.command.key,
},
resolveCurrentUserIdentity(host.hello, host.client?.instanceId) ?? undefined,
);
if (queued) {
queued.sendState = reconnectSafeQueuedSendState(host);
}
if (!queued) {
return;
}
if (!admitQueuedMessageForSession(host, host.sessionKey, queued)) {
removeQueuedMessageWithoutReleasing(host, queued.id);
if (messageOverride == null) {
host.chatMessage = previousDraft;
host.chatAttachments = attachmentsToSend;
}
setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
return;
}
if (host.connected && host.client && !isChatBusy(host)) {
const outbox = listStoredChatOutboxes(host).find((candidate) =>
candidate.queue.some((entry) => entry.id === queued.id),
);
if (outbox) {
await scheduleStoredChatOutboxDrain(
host,
outbox,
chatOutboxDrainDependencies,
queued.id,
{
routingSessionKey: host.sessionKey,
},
);
}
}
return;
}
const waitsForPicker = parsed.command.key === "redirect";
const dispatchLocalCommand = async () => {
if (waitsForPicker) {
const pendingSettings = getPendingChatPickerPatch(host, submittedSessionKey);
if (
pendingSettings &&
!(await waitForPendingChatSettings(host, submittedSessionKey, pendingSettings))
) {
return;
}
if (host.sessionKey !== submittedSessionKey) {
return;
}
}
let prevDraft = messageOverride == null ? previousDraft : undefined;
if (messageOverride == null) {
recordNonTranscriptInputHistory(host, message);
if (waitsForPicker) {
prevDraft = clearSubmittedComposerState(
host,
previousDraft,
attachmentsToSend,
).previousDraft;
} else {
host.chatMessage = "";
host.chatAttachments = [];
resetChatInputHistoryNavigation(host);
}
}
const dispatchResult = await dispatchChatSlashCommand(
host,
parsed.command.key,
parsed.args,
{
previousDraft: prevDraft,
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
sendResetMessage: (resetMessage, resetOpts) =>
chatOutboxDrainDependencies.sendResetSlashCommand(host, resetMessage, resetOpts),
},
);
if (dispatchResult === "failed") {
opts?.onLocalCommandSendRejected?.();
}
if (
(dispatchResult === "failed" || dispatchResult === "cancelled") &&
messageOverride == null
) {
const restorePlan = pendingComposerRestorePlan(host, {
previousAttachments: attachmentsToSend,
previousDraft,
});
if (restorePlan.willRestoreDraft) {
host.chatMessage = previousDraft;
}
if (restorePlan.willRestoreAttachments) {
host.chatAttachments = attachmentsToSend;
}
}
};
if (waitsForPicker) {
const submitKey = chatSubmitKey(host, "local", message, attachmentsToSend);
await withChatSubmitGuard(host, submitKey, dispatchLocalCommand);
} else {
await dispatchLocalCommand();
}
return;
}
}
const replyTarget = host.chatReplyTarget;
// Persisted transcript ids ride chat.send as replyToId so the Gateway can
// hydrate reply context like Discord; synthetic ids fall back to a quote.
const replyToId = replyTarget?.sourceMessageId?.trim() || undefined;
const effectiveMessage =
replyTarget && !replyToId ? prependReplyQuote(message, replyTarget) : message;
const refreshSessions = shouldInterpretChatCommands && isChatResetCommand(message);
const submitKey = chatSubmitKey(
host,
"message",
effectiveMessage,
attachmentsToSend,
skillWorkshopRevision,
);
await withChatSubmitGuard(host, submitKey, async () => {
if (host.sessionKey !== submittedSessionKey) {
return;
}
const cleared =
messageOverride == null
? clearSubmittedComposerState(host, previousDraft, attachmentsToSend)
: {};
if (messageOverride == null) {
recordNonTranscriptInputHistory(host, message);
}
const pendingSettings = getPendingChatPickerPatch(host, submittedSessionKey);
const waitingForSettings = pendingSettings !== undefined;
const initialSendState: ChatQueueItem["sendState"] = waitingForSettings
? "waiting-model"
: reconnectSafeQueuedSendState(host);
const queued = enqueuePendingSendMessage(
host,
effectiveMessage,
hasAttachments ? attachmentsToSend : undefined,
refreshSessions,
submittedAtMs,
initialSendState,
skillWorkshopRevision,
replyToId,
);
if (!queued) {
return;
}
const admittedDurably = admitQueuedMessageForSession(host, submittedSessionKey, queued);
const canSendFromMemory =
!admittedDurably &&
!waitingForSettings &&
canSendVolatileQueueItem(host, queued, submittedSessionKey);
if (!admittedDurably && !canSendFromMemory) {
cancelPendingSendBeforeRequest(host, queued, {
previousDraft: cleared.previousDraft,
previousAttachments: cleared.previousAttachments,
});
setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
return;
}
if (
pendingSettings &&
!(await waitForPendingChatSettings(host, submittedSessionKey, pendingSettings))
) {
const canRestoreComposer =
cleared.previousDraft !== undefined &&
!host.chatMessage.trim() &&
host.chatAttachments.length === 0;
const submittedScopeVisible =
host.sessionKey === submittedSessionKey &&
visibleSessionMatches(host, submittedSessionKey, queued.agentId);
if (canRestoreComposer && submittedScopeVisible) {
cancelPendingSendBeforeRequest(host, queued, {
previousDraft: cleared.previousDraft,
previousAttachments: cleared.previousAttachments,
});
} else {
updateQueuedMessageForSession(host, submittedSessionKey, queued.id, (item) => ({
...item,
sendError: INTERRUPTED_SETTINGS_WAIT_ERROR,
sendState: "failed",
}));
}
return;
}
if (waitingForSettings) {
const ready = updateQueuedMessageForSession(host, submittedSessionKey, queued.id, (item) => ({
...item,
sendError: undefined,
sendState: reconnectSafeQueuedSendState(host),
}));
if (!ready) {
setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
return;
}
}
if (
host.sessionKey !== submittedSessionKey ||
!visibleSessionMatches(host, submittedSessionKey, queued.agentId)
) {
const parked = updateQueuedMessageForSession(
host,
submittedSessionKey,
queued.id,
(item) => ({
...item,
sendError: undefined,
sendState: host.connected && host.client ? "waiting-idle" : "waiting-reconnect",
}),
);
if (!parked) {
setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
return;
}
const outbox = listStoredChatOutboxes(host).find((candidate) =>
candidate.queue.some((item) => item.id === queued.id),
);
if (outbox) {
await scheduleStoredChatOutboxDrain(host, outbox, chatOutboxDrainDependencies);
}
return;
}
let sendResult: "sent" | "pending" | "failed";
if (isChatBusy(host) || hasAbortableSessionRun(host)) {
const pending = updateQueuedMessage(host, queued.id, (item) => ({
...item,
sendError: undefined,
sendState: host.connected && host.client ? "waiting-idle" : "waiting-reconnect",
}));
if (!pending) {
setChatError(host, OFFLINE_QUEUE_STORAGE_ERROR);
sendResult = "failed";
} else {
recordChatSendTiming(host, pending, "queued-busy", submittedAtMs);
sendResult = "pending";
// Inherited policy belongs to the Gateway: preserve steer, followup,
// collect, and interrupt semantics. Browser-local queueing only applies
// to an explicit browser override.
const followUpMode =
host.chatFollowUpMode ??
normalizeChatFollowUpModeOverride(host.settings?.chatFollowUpMode);
if (
!skillWorkshopRevision &&
followUpMode !== "queue" &&
host.connected &&
hasAbortableSessionRun(host)
) {
void sendQueuedChatMessageWithQueueModeLifecycle(
host,
pending.id,
followUpMode,
steerSendDependencies,
);
}
}
} else {
sendResult = await sendChatMessageNow(host, effectiveMessage, {
queueItemId: queued.id,
previousDraft: cleared.previousDraft,
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
attachments: hasAttachments ? attachmentsToSend : undefined,
previousAttachments: cleared.previousAttachments,
restoreAttachments: Boolean(messageOverride && opts?.restoreDraft),
refreshSessions,
routingSessionKey: submittedSessionKey,
storageMode: canSendFromMemory ? "memory" : "durable",
submittedAtMs,
});
}
if (
sendResult !== "failed" &&
replyTarget &&
host.chatReplyTarget?.messageId === replyTarget.messageId &&
host.sessionKey === submittedSessionKey
) {
// A reconnect queue owns the quoted turn before the Gateway ACK. Consume
// its reply target so later offline turns cannot reuse stale context.
host.chatReplyTarget = null;
}
});
}
function prependReplyQuote(
message: string,
replyTarget: NonNullable<ChatHost["chatReplyTarget"]>,
): string {
const label = escapeMarkdownInline(replyTarget.senderLabel ?? "User");
const text = replyTarget.text.trim();
if (!text.includes("\n")) {
return `> **${label}:** ${text}\n\n${message}`;
}
const quoted = text
.split("\n")
.map((line) => `> ${line}`)
.join("\n");
return `> **${label}:**\n${quoted}\n\n${message}`;
}
function escapeMarkdownInline(value: string): string {
return value.replace(/([\\`*_{}[\]()#+\-.!|>])/g, "\\$1");
}
+1 -1
View File
@@ -1,7 +1,7 @@
import type { ChatQueueItem } from "../../lib/chat/chat-types.ts";
import { visibleSessionMatches, type SessionScopeHost } from "../../lib/sessions/index.ts";
import { readChatQueueForScope } from "./chat-queue.ts";
import type { ChatSendAck, ChatSendTimingEntry } from "./chat-send-contract.ts";
import type { ChatSendAck, ChatSendTimingEntry } from "./chat-send-ack.ts";
import {
controlUiNowMs,
recordControlUiPerformanceEvent,
+8 -8
View File
@@ -17,7 +17,7 @@ import {
import { refreshChatAvatar } from "./chat-avatar.ts";
import * as chatCommandExecutor from "./chat-command-executor.ts";
import type { executeSlashCommand } from "./chat-command-executor.ts";
import type { ChatHost } from "./chat-send.ts";
import type { ChatHost } from "./chat-send-contract.ts";
import {
getPendingChatPickerPatch,
switchChatFastMode,
@@ -138,8 +138,8 @@ afterEach(() => {
vi.restoreAllMocks();
});
let handleSendChat: typeof import("./chat-send.ts").handleSendChat;
let steerQueuedChatMessage: typeof import("./chat-send.ts").steerQueuedChatMessage;
let handleSendChat: typeof import("./chat-send-submit.ts").handleSendChat;
let steerQueuedChatMessage: typeof import("./chat-send-actions.ts").steerQueuedChatMessage;
let handleAbortChat: typeof import("./run-lifecycle.ts").handleAbortChat;
let hasAbortableSessionRun: typeof import("./run-lifecycle.ts").hasAbortableSessionRun;
let handlePageGatewayEvent: typeof import("./chat-state.ts").handlePageGatewayEvent;
@@ -152,20 +152,20 @@ let removeVisibleOrScopedQueuedMessageWithoutReleasing: typeof import("./chat-qu
let markQueuedChatSendsWaitingForReconnect: typeof import("./chat-queue.ts").markQueuedChatSendsWaitingForReconnect;
let subscribeChatOutboxProjection: typeof import("./chat-queue.ts").subscribeChatOutboxProjection;
let syncChatQueueFromStoredOutbox: typeof import("./chat-queue.ts").syncChatQueueFromStoredOutbox;
let flushChatQueueForEvent: typeof import("./chat-send.ts").flushChatQueueForEvent;
let retryReconnectableQueuedChatSends: typeof import("./chat-send.ts").retryReconnectableQueuedChatSends;
let retryQueuedChatMessage: typeof import("./chat-send.ts").retryQueuedChatMessage;
let flushChatQueueForEvent: typeof import("./chat-send-actions.ts").flushChatQueueForEvent;
let retryReconnectableQueuedChatSends: typeof import("./chat-send-actions.ts").retryReconnectableQueuedChatSends;
let retryQueuedChatMessage: typeof import("./chat-send-actions.ts").retryQueuedChatMessage;
let recordChatSendServerTiming: typeof import("./chat-send-timing.ts").recordChatSendServerTiming;
let refreshPageChat: typeof import("./chat-state.ts").refreshPageChat;
async function loadChatHelpers(): Promise<void> {
({
handleSendChat,
steerQueuedChatMessage,
flushChatQueueForEvent,
retryReconnectableQueuedChatSends,
retryQueuedChatMessage,
} = await import("./chat-send.ts"));
} = await import("./chat-send-actions.ts"));
({ handleSendChat } = await import("./chat-send-submit.ts"));
({ recordChatSendServerTiming } = await import("./chat-send-timing.ts"));
const chatState = await import("./chat-state.ts");
handlePageGatewayEvent = chatState.handlePageGatewayEvent;
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -77,16 +77,16 @@ import {
resetChatRealtimeConversation,
type ChatRealtimeState,
} from "./chat-realtime.ts";
import type { ChatSendTimingEntry } from "./chat-send-contract.ts";
import { recordChatSendServerTiming } from "./chat-send-timing.ts";
import type { ChatSendTimingEntry } from "./chat-send-ack.ts";
import {
flushChatQueueForEvent,
handleSendChat,
resumeStoredChatOutboxes,
retryQueuedChatMessage,
steerQueuedChatMessage,
type ChatHost,
} from "./chat-send.ts";
} from "./chat-send-actions.ts";
import type { ChatHost } from "./chat-send-contract.ts";
import { handleSendChat } from "./chat-send-submit.ts";
import { recordChatSendServerTiming } from "./chat-send-timing.ts";
import {
flushChatQueueAfterIdleSessionReconciliation,
refreshCurrentChatSessionList,
+1 -1
View File
@@ -22,7 +22,7 @@ import {
isTerminalFailureChatSendAck,
type ChatSendAck,
type TerminalFailureChatSendAck,
} from "./chat-send-contract.ts";
} from "./chat-send-ack.ts";
import { hasAbortableSessionRun } from "./run-lifecycle.ts";
import { scheduleChatScroll } from "./scroll.ts";
import {