feat: add git branch/worktree setup, message queueing, file explorer improvements, and server restart

This commit is contained in:
Mohamed Boudra
2025-11-22 23:20:42 +01:00
parent 9feec81047
commit 95bacf8fc7
40 changed files with 3759 additions and 644 deletions
+179 -34
View File
@@ -9,6 +9,7 @@ import type {
AgentStreamEventPayload,
SessionInboundMessage,
WSInboundMessage,
GitSetupOptions,
} from "@server/server/messages";
import type {
AgentLifecycleStatus,
@@ -83,6 +84,7 @@ export interface Agent {
status: AgentLifecycleStatus;
createdAt: Date;
updatedAt: Date;
lastUserMessageAt: Date | null;
lastActivityAt: Date;
sessionId: string | null;
capabilities: AgentCapabilityFlags;
@@ -144,6 +146,8 @@ export interface AgentFileExplorerState {
lastError: string | null;
pendingRequest: ExplorerRequestState | null;
currentPath: string;
history: string[];
lastVisitedPath: string;
}
const createExplorerState = (): AgentFileExplorerState => ({
@@ -153,17 +157,30 @@ const createExplorerState = (): AgentFileExplorerState => ({
lastError: null,
pendingRequest: null,
currentPath: ".",
history: ["."],
lastVisitedPath: ".",
});
const pushHistory = (history: string[], path: string): string[] => {
const normalizedHistory = history.length === 0 ? ["."] : history;
const last = normalizedHistory[normalizedHistory.length - 1];
if (last === path) {
return normalizedHistory;
}
return [...normalizedHistory, path];
};
function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload): Agent {
const createdAt = new Date(snapshot.createdAt);
const updatedAt = new Date(snapshot.updatedAt);
const lastUserMessageAt = snapshot.lastUserMessageAt ? new Date(snapshot.lastUserMessageAt) : null;
return {
id: snapshot.id,
provider: snapshot.provider,
status: snapshot.status as AgentLifecycleStatus,
createdAt,
updatedAt,
lastUserMessageAt,
lastActivityAt: updatedAt,
sessionId: snapshot.sessionId,
capabilities: snapshot.capabilities,
@@ -202,6 +219,12 @@ interface SessionContextValue {
setAgentStreamState: (state: Map<string, StreamItem[]> | ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>)) => void;
initializingAgents: Map<string, boolean>;
// Queued messages and draft input per agent
draftInputs: Map<string, { text: string; images: Array<{ uri: string; mimeType: string }> }>;
setDraftInputs: (value: Map<string, { text: string; images: Array<{ uri: string; mimeType: string }> }> | ((prev: Map<string, { text: string; images: Array<{ uri: string; mimeType: string }> }>) => Map<string, { text: string; images: Array<{ uri: string; mimeType: string }> }>)) => void;
queuedMessages: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>;
setQueuedMessages: (value: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>> | ((prev: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>) => Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>)) => void;
// Agents and commands
agents: Map<string, Agent>;
setAgents: (agents: Map<string, Agent> | ((prev: Map<string, Agent>) => Map<string, Agent>)) => void;
@@ -218,17 +241,25 @@ interface SessionContextValue {
// File explorer
fileExplorer: Map<string, AgentFileExplorerState>;
requestDirectoryListing: (agentId: string, path: string) => void;
requestDirectoryListing: (agentId: string, path: string, options?: { recordHistory?: boolean }) => void;
requestFilePreview: (agentId: string, path: string) => void;
navigateExplorerBack: (agentId: string) => string | null;
// Helpers
restartServer: (reason?: string) => void;
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
refreshAgent: (params: { agentId: string; requestId?: string }) => void;
cancelAgentRun: (agentId: string) => void;
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
sendAgentAudio: (agentId: string, audioBlob: Blob, requestId?: string) => Promise<void>;
deleteAgent: (agentId: string) => void;
createAgent: (options: { config: AgentSessionConfig; worktreeName?: string; requestId?: string }) => void;
createAgent: (options: {
config: AgentSessionConfig;
initialPrompt: string;
git?: GitSetupOptions;
worktreeName?: string;
requestId?: string;
}) => void;
resumeAgent: (options: { handle: AgentPersistenceHandle; overrides?: Partial<AgentSessionConfig>; requestId?: string }) => void;
setAgentMode: (agentId: string, modeId: string) => void;
respondToPermission: (agentId: string, requestId: string, response: AgentPermissionResponse) => void;
@@ -274,7 +305,10 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
const [pendingPermissions, setPendingPermissions] = useState<Map<string, PendingPermission>>(new Map());
const [gitDiffs, setGitDiffs] = useState<Map<string, string>>(new Map());
const [fileExplorer, setFileExplorer] = useState<Map<string, AgentFileExplorerState>>(new Map());
const [draftInputs, setDraftInputs] = useState<SessionContextValue["draftInputs"]>(new Map());
const [queuedMessages, setQueuedMessages] = useState<SessionContextValue["queuedMessages"]>(new Map());
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
// Buffer for streaming audio chunks
interface AudioChunk {
@@ -354,14 +388,42 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
setAgents(nextAgents);
setPendingPermissions(nextPermissions);
setCommands(new Map(commandsList.map((c) => [c.id, c as Command])));
setAgentStreamState(() => {
const next = new Map<string, StreamItem[]>();
for (const snapshot of agentsList) {
next.set(snapshot.id, []);
setAgentStreamState((prev) => {
if (prev.size === 0) {
return prev;
}
return next;
const validAgentIds = new Set(agentsList.map((snapshot) => snapshot.id));
let changed = false;
const next = new Map(prev);
for (const agentId of prev.keys()) {
if (!validAgentIds.has(agentId)) {
next.delete(agentId);
changed = true;
}
}
return changed ? next : prev;
});
setInitializingAgents((prev) => {
if (prev.size === 0) {
return prev;
}
const validAgentIds = new Set(agentsList.map((snapshot) => snapshot.id));
let changed = false;
const next = new Map(prev);
for (const agentId of prev.keys()) {
if (!validAgentIds.has(agentId)) {
next.delete(agentId);
changed = true;
}
}
return changed ? next : prev;
});
setInitializingAgents(new Map());
});
const unsubAgentState = ws.on("agent_state", (message) => {
@@ -948,28 +1010,6 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
ws.send(msg);
}, [ws]);
const cancelAgentRun = useCallback((agentId: string) => {
const msg: WSInboundMessage = {
type: "session",
message: {
type: "cancel_agent_request",
agentId,
},
};
ws.send(msg);
}, [ws]);
const deleteAgent = useCallback((agentId: string) => {
const msg: WSInboundMessage = {
type: "session",
message: {
type: "delete_agent_request",
agentId,
},
};
ws.send(msg);
}, [ws]);
const sendAgentMessage = useCallback(async (agentId: string, message: string, imageUris?: string[]) => {
// Generate unique message ID for deduplication
const messageId = generateMessageId();
@@ -997,10 +1037,10 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
// Use FileSystem.File to read the image as base64
const file = new FileSystem.File(imageUri);
const base64 = file.base64Sync();
// Get MIME type from the file
const mimeType = file.type || 'image/jpeg';
imagesData.push({
data: base64,
mimeType,
@@ -1025,6 +1065,60 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
ws.send(msg);
}, [ws]);
// Auto-flush queued messages when an agent transitions from running -> not running
useEffect(() => {
for (const [agentId, agent] of agents.entries()) {
const prevStatus = previousAgentStatusRef.current.get(agentId);
if (prevStatus === "running" && agent.status !== "running") {
const queue = queuedMessages.get(agentId);
if (queue && queue.length > 0) {
const [next, ...rest] = queue;
void sendAgentMessage(agentId, next.text, next.images?.map((img) => img.uri));
setQueuedMessages((prev) => {
const updated = new Map(prev);
updated.set(agentId, rest);
return updated;
});
}
}
previousAgentStatusRef.current.set(agentId, agent.status);
}
}, [agents, queuedMessages, sendAgentMessage]);
const cancelAgentRun = useCallback((agentId: string) => {
const msg: WSInboundMessage = {
type: "session",
message: {
type: "cancel_agent_request",
agentId,
},
};
ws.send(msg);
}, [ws]);
const deleteAgent = useCallback((agentId: string) => {
const msg: WSInboundMessage = {
type: "session",
message: {
type: "delete_agent_request",
agentId,
},
};
ws.send(msg);
}, [ws]);
const restartServer = useCallback((reason?: string) => {
const msg: WSInboundMessage = {
type: "session",
message: {
type: "restart_server_request",
...(reason && reason.trim().length > 0 ? { reason } : {}),
},
};
ws.send(msg);
}, [ws]);
const sendAgentAudio = useCallback(async (agentId: string, audioBlob: Blob, requestId?: string) => {
try {
// Convert blob to base64
@@ -1060,12 +1154,15 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
}
}, [ws]);
const createAgent = useCallback(({ config, worktreeName, requestId }: { config: AgentSessionConfig; worktreeName?: string; requestId?: string }) => {
const createAgent = useCallback(({ config, initialPrompt, git, worktreeName, requestId }: { config: AgentSessionConfig; initialPrompt: string; git?: GitSetupOptions; worktreeName?: string; requestId?: string }) => {
const trimmedPrompt = initialPrompt.trim();
const msg: WSInboundMessage = {
type: "session",
message: {
type: "create_agent_request",
config,
...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}),
...(git ? { git } : {}),
...(worktreeName ? { worktreeName } : {}),
...(requestId ? { requestId } : {}),
},
@@ -1127,14 +1224,18 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
ws.send(msg);
}, [ws]);
const requestDirectoryListing = useCallback((agentId: string, path: string) => {
const requestDirectoryListing = useCallback((agentId: string, path: string, options?: { recordHistory?: boolean }) => {
const normalizedPath = path && path.length > 0 ? path : ".";
const shouldRecordHistory = options?.recordHistory ?? true;
updateExplorerState(agentId, (state) => ({
...state,
isLoading: true,
lastError: null,
pendingRequest: { path: normalizedPath, mode: "list" },
currentPath: normalizedPath,
history: shouldRecordHistory ? pushHistory(state.history, normalizedPath) : state.history,
lastVisitedPath: normalizedPath,
}));
const msg: WSInboundMessage = {
@@ -1170,6 +1271,44 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
ws.send(msg);
}, [updateExplorerState, ws]);
const navigateExplorerBack = useCallback((agentId: string) => {
let targetPath: string | null = null;
updateExplorerState(agentId, (state) => {
if (!state.history || state.history.length <= 1) {
return state;
}
const nextHistory = state.history.slice(0, -1);
targetPath = nextHistory[nextHistory.length - 1] ?? ".";
return {
...state,
isLoading: true,
lastError: null,
pendingRequest: { path: targetPath, mode: "list" },
currentPath: targetPath,
history: nextHistory,
};
});
if (!targetPath) {
return null;
}
const msg: WSInboundMessage = {
type: "session",
message: {
type: "file_explorer_request",
agentId,
path: targetPath,
mode: "list",
},
};
ws.send(msg);
return targetPath;
}, [updateExplorerState, ws]);
const value: SessionContextValue = {
ws,
audioPlayer,
@@ -1194,8 +1333,14 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
gitDiffs,
requestGitDiff,
fileExplorer,
draftInputs,
setDraftInputs,
queuedMessages,
setQueuedMessages,
requestDirectoryListing,
requestFilePreview,
navigateExplorerBack,
restartServer,
initializeAgent,
refreshAgent,
cancelAgentRun,