Files
claw3d/src/features/agents/state/store.tsx
T
Luke The Dev c9789c2148 Add office agent management wizard (#56)
* Add agents

* Agent

* Added agents management

* Polish agent wizard and release blockers.

Finalize the office agent management flow by aligning the gateway fallback behavior, cleaning up UI semantics, and updating tests so the branch is ready to ship.

Made-with: Cursor

---------

Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
Co-authored-by: iamlukethedev <lucas.guilherme@smartwayslfl.com>
2026-03-23 18:04:37 -05:00

602 lines
22 KiB
TypeScript

"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
useReducer,
type ReactNode,
} from "react";
import type { AgentAvatarProfile } from "@/lib/avatars/profile";
import {
areTranscriptEntriesEqual,
buildOutputLinesFromTranscriptEntries,
buildTranscriptEntriesFromLines,
createTranscriptEntryFromLine,
sortTranscriptEntries,
TRANSCRIPT_V2_ENABLED,
type TranscriptAppendMeta,
type TranscriptEntry,
} from "@/features/agents/state/transcript";
export type AgentStatus = "idle" | "running" | "error";
export type FocusFilter = "all" | "running" | "approvals";
export type AgentStoreSeed = {
agentId: string;
name: string;
sessionKey: string;
avatarSeed?: string | null;
avatarProfile?: AgentAvatarProfile | null;
avatarUrl?: string | null;
model?: string | null;
thinkingLevel?: string | null;
sessionExecHost?: "sandbox" | "gateway" | "node";
sessionExecSecurity?: "deny" | "allowlist" | "full";
sessionExecAsk?: "off" | "on-miss" | "always";
toolCallingEnabled?: boolean;
showThinkingTraces?: boolean;
};
export type AgentState = AgentStoreSeed & {
status: AgentStatus;
sessionCreated: boolean;
awaitingUserInput: boolean;
hasUnseenActivity: boolean;
outputLines: string[];
lastResult: string | null;
lastDiff: string | null;
runId: string | null;
runStartedAt: number | null;
streamText: string | null;
thinkingTrace: string | null;
latestOverride: string | null;
latestOverrideKind: "heartbeat" | "cron" | null;
lastAssistantMessageAt: number | null;
lastActivityAt: number | null;
latestPreview: string | null;
lastUserMessage: string | null;
draft: string;
queuedMessages?: string[];
sessionSettingsSynced: boolean;
historyLoadedAt: number | null;
historyFetchLimit: number | null;
historyFetchedCount: number | null;
historyMaybeTruncated: boolean;
toolCallingEnabled: boolean;
showThinkingTraces: boolean;
transcriptEntries?: TranscriptEntry[];
transcriptRevision?: number;
transcriptSequenceCounter?: number;
sessionEpoch?: number;
lastHistoryRequestRevision?: number | null;
lastAppliedHistoryRequestId?: string | null;
};
export const buildNewSessionAgentPatch = (agent: AgentState): Partial<AgentState> => {
return {
sessionKey: agent.sessionKey,
status: "idle",
runId: null,
runStartedAt: null,
streamText: null,
thinkingTrace: null,
outputLines: [],
lastResult: null,
lastDiff: null,
latestOverride: null,
latestOverrideKind: null,
lastAssistantMessageAt: null,
lastActivityAt: null,
latestPreview: null,
lastUserMessage: null,
draft: "",
queuedMessages: [],
historyLoadedAt: null,
historyFetchLimit: null,
historyFetchedCount: null,
historyMaybeTruncated: false,
awaitingUserInput: false,
hasUnseenActivity: false,
sessionCreated: true,
sessionSettingsSynced: true,
transcriptEntries: [],
transcriptRevision: (agent.transcriptRevision ?? 0) + 1,
transcriptSequenceCounter: 0,
sessionEpoch: (agent.sessionEpoch ?? 0) + 1,
lastHistoryRequestRevision: null,
lastAppliedHistoryRequestId: null,
};
};
export type AgentStoreState = {
agents: AgentState[];
selectedAgentId: string | null;
loading: boolean;
error: string | null;
};
type Action =
| { type: "hydrateAgents"; agents: AgentStoreSeed[]; selectedAgentId?: string }
| { type: "setError"; error: string | null }
| { type: "setLoading"; loading: boolean }
| { type: "updateAgent"; agentId: string; patch: Partial<AgentState> }
| { type: "removeAgent"; agentId: string }
| { type: "appendOutput"; agentId: string; line: string; transcript?: TranscriptAppendMeta }
| { type: "enqueueQueuedMessage"; agentId: string; message: string }
| { type: "removeQueuedMessage"; agentId: string; index: number }
| { type: "shiftQueuedMessage"; agentId: string; expectedMessage?: string }
| { type: "markActivity"; agentId: string; at?: number }
| { type: "selectAgent"; agentId: string | null };
const initialState: AgentStoreState = {
agents: [],
selectedAgentId: null,
loading: false,
error: null,
};
const areStringArraysEqual = (left: string[], right: string[]): boolean => {
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i += 1) {
if (left[i] !== right[i]) return false;
}
return true;
};
const ensureTranscriptEntries = (agent: AgentState): TranscriptEntry[] => {
if (Array.isArray(agent.transcriptEntries)) {
return agent.transcriptEntries;
}
return buildTranscriptEntriesFromLines({
lines: agent.outputLines,
sessionKey: agent.sessionKey,
source: "legacy",
startSequence: 0,
confirmed: true,
});
};
const nextTranscriptSequenceCounter = (
currentCounter: number | undefined,
entries: TranscriptEntry[]
): number => {
const derived = entries.reduce((max, entry) => Math.max(max, entry.sequenceKey + 1), 0);
return Math.max(currentCounter ?? 0, derived);
};
const createRuntimeAgentState = (
seed: AgentStoreSeed,
existing?: AgentState | null
): AgentState => {
const sameSessionKey = existing?.sessionKey === seed.sessionKey;
const outputLines = sameSessionKey ? (existing?.outputLines ?? []) : [];
const queuedMessages = sameSessionKey ? [...(existing?.queuedMessages ?? [])] : [];
const transcriptEntries = sameSessionKey
? Array.isArray(existing?.transcriptEntries)
? existing.transcriptEntries
: buildTranscriptEntriesFromLines({
lines: outputLines,
sessionKey: seed.sessionKey,
source: "legacy",
startSequence: 0,
confirmed: true,
})
: [];
return {
...seed,
avatarSeed: seed.avatarSeed ?? existing?.avatarSeed ?? seed.agentId,
avatarProfile: seed.avatarProfile ?? existing?.avatarProfile ?? null,
avatarUrl: seed.avatarUrl ?? existing?.avatarUrl ?? null,
model: seed.model ?? existing?.model ?? null,
thinkingLevel: seed.thinkingLevel ?? existing?.thinkingLevel ?? "high",
sessionExecHost: seed.sessionExecHost ?? existing?.sessionExecHost,
sessionExecSecurity: seed.sessionExecSecurity ?? existing?.sessionExecSecurity,
sessionExecAsk: seed.sessionExecAsk ?? existing?.sessionExecAsk,
status: sameSessionKey ? (existing?.status ?? "idle") : "idle",
sessionCreated: sameSessionKey ? (existing?.sessionCreated ?? false) : false,
awaitingUserInput: sameSessionKey ? (existing?.awaitingUserInput ?? false) : false,
hasUnseenActivity: sameSessionKey ? (existing?.hasUnseenActivity ?? false) : false,
outputLines,
lastResult: sameSessionKey ? (existing?.lastResult ?? null) : null,
lastDiff: sameSessionKey ? (existing?.lastDiff ?? null) : null,
runId: sameSessionKey ? (existing?.runId ?? null) : null,
runStartedAt: sameSessionKey ? (existing?.runStartedAt ?? null) : null,
streamText: sameSessionKey ? (existing?.streamText ?? null) : null,
thinkingTrace: sameSessionKey ? (existing?.thinkingTrace ?? null) : null,
latestOverride: sameSessionKey ? (existing?.latestOverride ?? null) : null,
latestOverrideKind: sameSessionKey ? (existing?.latestOverrideKind ?? null) : null,
lastAssistantMessageAt: sameSessionKey ? (existing?.lastAssistantMessageAt ?? null) : null,
lastActivityAt: sameSessionKey ? (existing?.lastActivityAt ?? null) : null,
latestPreview: sameSessionKey ? (existing?.latestPreview ?? null) : null,
lastUserMessage: sameSessionKey ? (existing?.lastUserMessage ?? null) : null,
draft: sameSessionKey ? (existing?.draft ?? "") : "",
queuedMessages,
sessionSettingsSynced: sameSessionKey ? (existing?.sessionSettingsSynced ?? false) : false,
historyLoadedAt: sameSessionKey ? (existing?.historyLoadedAt ?? null) : null,
historyFetchLimit: sameSessionKey ? (existing?.historyFetchLimit ?? null) : null,
historyFetchedCount: sameSessionKey ? (existing?.historyFetchedCount ?? null) : null,
historyMaybeTruncated: sameSessionKey ? (existing?.historyMaybeTruncated ?? false) : false,
toolCallingEnabled: seed.toolCallingEnabled ?? existing?.toolCallingEnabled ?? false,
showThinkingTraces: seed.showThinkingTraces ?? existing?.showThinkingTraces ?? true,
transcriptEntries,
transcriptRevision: sameSessionKey
? (existing?.transcriptRevision ?? outputLines.length)
: 0,
transcriptSequenceCounter: sameSessionKey
? (existing?.transcriptSequenceCounter ??
nextTranscriptSequenceCounter(existing?.transcriptSequenceCounter, transcriptEntries))
: 0,
sessionEpoch: sameSessionKey
? (existing?.sessionEpoch ?? 0)
: (existing?.sessionEpoch ?? 0) + 1,
lastHistoryRequestRevision: sameSessionKey
? (existing?.lastHistoryRequestRevision ?? null)
: null,
lastAppliedHistoryRequestId: sameSessionKey
? (existing?.lastAppliedHistoryRequestId ?? null)
: null,
};
};
const reducer = (state: AgentStoreState, action: Action): AgentStoreState => {
switch (action.type) {
case "hydrateAgents": {
const byId = new Map(state.agents.map((agent) => [agent.agentId, agent]));
const agents = action.agents.map((seed) =>
createRuntimeAgentState(seed, byId.get(seed.agentId))
);
const requestedSelectedAgentId = action.selectedAgentId?.trim() ?? "";
const selectedAgentId =
requestedSelectedAgentId &&
agents.some((agent) => agent.agentId === requestedSelectedAgentId)
? requestedSelectedAgentId
: state.selectedAgentId &&
agents.some((agent) => agent.agentId === state.selectedAgentId)
? state.selectedAgentId
: agents[0]?.agentId ?? null;
return {
...state,
agents,
selectedAgentId,
loading: false,
error: null,
};
}
case "setError":
return { ...state, error: action.error, loading: false };
case "setLoading":
return { ...state, loading: action.loading };
case "updateAgent":
return {
...state,
agents: state.agents.map((agent) => {
if (agent.agentId !== action.agentId) return agent;
const patch = action.patch;
const nextSessionKey = (patch.sessionKey ?? agent.sessionKey).trim();
const sessionKeyChanged = nextSessionKey !== agent.sessionKey.trim();
const patchHasTranscriptEntries = Array.isArray(patch.transcriptEntries);
const patchHasOutputLines = Array.isArray(patch.outputLines);
const patchMutatesTranscript = patchHasTranscriptEntries || patchHasOutputLines;
const existingEntries = ensureTranscriptEntries(agent);
const base: AgentState = { ...agent, ...patch };
let nextEntries: TranscriptEntry[] = existingEntries;
if (Array.isArray(base.transcriptEntries)) {
nextEntries = base.transcriptEntries as TranscriptEntry[];
}
let nextOutputLines: string[] = agent.outputLines;
if (Array.isArray(base.outputLines)) {
nextOutputLines = base.outputLines as string[];
}
let transcriptMutated = false;
if (patchHasTranscriptEntries) {
const patchedTranscriptEntries = patch.transcriptEntries as TranscriptEntry[];
const normalized = TRANSCRIPT_V2_ENABLED
? sortTranscriptEntries(patchedTranscriptEntries)
: [...patchedTranscriptEntries];
transcriptMutated = !areTranscriptEntriesEqual(existingEntries, normalized);
nextEntries = normalized;
nextOutputLines = buildOutputLinesFromTranscriptEntries(normalized);
} else if (patchHasOutputLines) {
const patchedOutputLines = patch.outputLines as string[];
const rebuilt = buildTranscriptEntriesFromLines({
lines: patchedOutputLines,
sessionKey: nextSessionKey || agent.sessionKey,
source: "legacy",
startSequence: 0,
confirmed: true,
});
const normalized = TRANSCRIPT_V2_ENABLED ? sortTranscriptEntries(rebuilt) : rebuilt;
transcriptMutated = !areStringArraysEqual(agent.outputLines, patchedOutputLines);
nextEntries = normalized;
nextOutputLines = TRANSCRIPT_V2_ENABLED
? buildOutputLinesFromTranscriptEntries(normalized)
: [...patchedOutputLines];
}
const revision = transcriptMutated
? (agent.transcriptRevision ?? 0) + 1
: (patch.transcriptRevision ?? agent.transcriptRevision ?? 0);
const nextCounter = patchMutatesTranscript
? nextTranscriptSequenceCounter(base.transcriptSequenceCounter, nextEntries)
: (base.transcriptSequenceCounter ?? agent.transcriptSequenceCounter ?? 0);
return {
...base,
outputLines: nextOutputLines,
transcriptEntries: nextEntries,
transcriptRevision: revision,
transcriptSequenceCounter: nextCounter,
sessionEpoch:
patch.sessionEpoch !== undefined
? patch.sessionEpoch
: sessionKeyChanged
? (agent.sessionEpoch ?? 0) + 1
: (agent.sessionEpoch ?? 0),
};
}),
};
case "removeAgent": {
const nextAgents = state.agents.filter((agent) => agent.agentId !== action.agentId);
const selectedAgentId =
state.selectedAgentId === action.agentId
? nextAgents[0]?.agentId ?? null
: state.selectedAgentId;
return {
...state,
agents: nextAgents,
selectedAgentId,
};
}
case "appendOutput":
return {
...state,
agents: state.agents.map((agent) => {
if (agent.agentId !== action.agentId) return agent;
const existingEntries = ensureTranscriptEntries(agent);
const nextSequence = nextTranscriptSequenceCounter(
agent.transcriptSequenceCounter,
existingEntries
);
const nextEntry = createTranscriptEntryFromLine({
line: action.line,
sessionKey: action.transcript?.sessionKey ?? agent.sessionKey,
source: action.transcript?.source ?? "legacy",
runId: action.transcript?.runId ?? agent.runId,
timestampMs: action.transcript?.timestampMs,
fallbackTimestampMs: action.transcript?.timestampMs ?? Date.now(),
role: action.transcript?.role,
kind: action.transcript?.kind,
entryId: action.transcript?.entryId,
confirmed: action.transcript?.confirmed,
sequenceKey: nextSequence,
});
if (!nextEntry) {
return { ...agent, outputLines: [...agent.outputLines, action.line] };
}
const nextEntryId = nextEntry.entryId.trim();
const existingIndex =
nextEntryId.length > 0
? existingEntries.findIndex((entry) => entry.entryId === nextEntryId)
: -1;
const hasReplacement = existingIndex >= 0;
let nextEntries: TranscriptEntry[];
if (hasReplacement) {
let replacedOne = false;
const replaced = existingEntries.reduce<TranscriptEntry[]>((acc, entry) => {
if (entry.entryId !== nextEntryId) {
acc.push(entry);
return acc;
}
if (replacedOne) {
return acc;
}
replacedOne = true;
acc.push({
...nextEntry,
sequenceKey: entry.sequenceKey,
});
return acc;
}, []);
nextEntries = TRANSCRIPT_V2_ENABLED ? sortTranscriptEntries(replaced) : replaced;
} else {
const appended = [...existingEntries, nextEntry];
nextEntries = TRANSCRIPT_V2_ENABLED ? sortTranscriptEntries(appended) : appended;
}
return {
...agent,
outputLines:
TRANSCRIPT_V2_ENABLED || hasReplacement
? buildOutputLinesFromTranscriptEntries(nextEntries)
: [...agent.outputLines, action.line],
transcriptEntries: nextEntries,
transcriptRevision: (agent.transcriptRevision ?? 0) + 1,
transcriptSequenceCounter: Math.max(
agent.transcriptSequenceCounter ?? 0,
nextEntry.sequenceKey + 1
),
};
}),
};
case "enqueueQueuedMessage":
return {
...state,
agents: state.agents.map((agent) => {
if (agent.agentId !== action.agentId) return agent;
const message = action.message.trim();
if (!message) return agent;
const queuedMessages = [...(agent.queuedMessages ?? []), message];
return { ...agent, queuedMessages };
}),
};
case "removeQueuedMessage":
return {
...state,
agents: state.agents.map((agent) => {
if (agent.agentId !== action.agentId) return agent;
if (!Number.isInteger(action.index) || action.index < 0) return agent;
const queuedMessages = agent.queuedMessages ?? [];
if (action.index >= queuedMessages.length) return agent;
return {
...agent,
queuedMessages: queuedMessages.filter((_, index) => index !== action.index),
};
}),
};
case "shiftQueuedMessage":
return {
...state,
agents: state.agents.map((agent) => {
if (agent.agentId !== action.agentId) return agent;
const queuedMessages = agent.queuedMessages ?? [];
if (queuedMessages.length === 0) return agent;
if (
action.expectedMessage !== undefined &&
action.expectedMessage.trim() !== queuedMessages[0]
) {
return agent;
}
return { ...agent, queuedMessages: queuedMessages.slice(1) };
}),
};
case "markActivity": {
const at = action.at ?? Date.now();
return {
...state,
agents: state.agents.map((agent) => {
if (agent.agentId !== action.agentId) return agent;
const isSelected = state.selectedAgentId === action.agentId;
return {
...agent,
lastActivityAt: at,
hasUnseenActivity: isSelected ? false : true,
};
}),
};
}
case "selectAgent": {
if (action.agentId === state.selectedAgentId) {
if (action.agentId === null) {
return state;
}
const selected = state.agents.find((agent) => agent.agentId === action.agentId) ?? null;
if (!selected || !selected.hasUnseenActivity) {
return state;
}
}
return {
...state,
selectedAgentId: action.agentId,
agents:
action.agentId === null
? state.agents
: state.agents.map((agent) =>
agent.agentId === action.agentId
? { ...agent, hasUnseenActivity: false }
: agent
),
};
}
default:
return state;
}
};
export const agentStoreReducer = reducer;
export const initialAgentStoreState = initialState;
type AgentStoreContextValue = {
state: AgentStoreState;
dispatch: React.Dispatch<Action>;
hydrateAgents: (agents: AgentStoreSeed[], selectedAgentId?: string) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
};
const AgentStoreContext = createContext<AgentStoreContextValue | null>(null);
export const AgentStoreProvider = ({ children }: { children: ReactNode }) => {
const [state, dispatch] = useReducer(reducer, initialState);
const hydrateAgents = useCallback(
(agents: AgentStoreSeed[], selectedAgentId?: string) => {
dispatch({ type: "hydrateAgents", agents, selectedAgentId });
},
[dispatch]
);
const setLoading = useCallback(
(loading: boolean) => dispatch({ type: "setLoading", loading }),
[dispatch]
);
const setError = useCallback(
(error: string | null) => dispatch({ type: "setError", error }),
[dispatch]
);
const value = useMemo(
() => ({ state, dispatch, hydrateAgents, setLoading, setError }),
[dispatch, hydrateAgents, setError, setLoading, state]
);
return (
<AgentStoreContext.Provider value={value}>{children}</AgentStoreContext.Provider>
);
};
export const useAgentStore = () => {
const ctx = useContext(AgentStoreContext);
if (!ctx) {
throw new Error("AgentStoreProvider is missing.");
}
return ctx;
};
export const getSelectedAgent = (state: AgentStoreState): AgentState | null => {
if (!state.selectedAgentId) return null;
return state.agents.find((agent) => agent.agentId === state.selectedAgentId) ?? null;
};
export const getFilteredAgents = (state: AgentStoreState, filter: FocusFilter): AgentState[] => {
const statusPriority: Record<AgentStatus, number> = {
running: 0,
idle: 1,
error: 2,
};
const getActivityTimestamp = (agent: AgentState) =>
Math.max(agent.lastActivityAt ?? 0, agent.runStartedAt ?? 0, agent.lastAssistantMessageAt ?? 0);
const sortAgents = (agents: AgentState[], prioritizeStatus: boolean) =>
agents
.map((agent, index) => ({ agent, index }))
.sort((left, right) => {
if (prioritizeStatus) {
const statusDelta =
statusPriority[left.agent.status] - statusPriority[right.agent.status];
if (statusDelta !== 0) return statusDelta;
}
const timeDelta = getActivityTimestamp(right.agent) - getActivityTimestamp(left.agent);
if (timeDelta !== 0) return timeDelta;
return left.index - right.index;
})
.map(({ agent }) => agent);
switch (filter) {
case "all":
return sortAgents(state.agents, true);
case "running":
return sortAgents(state.agents.filter((agent) => agent.status === "running"), false);
case "approvals":
return sortAgents(state.agents.filter((agent) => agent.awaitingUserInput), false);
default: {
const _exhaustive: never = filter;
void _exhaustive;
return sortAgents(state.agents, true);
}
}
};