refactor(app): introduce attachment storage layer and draft lifecycle management

Replace inline data-URL image handling with a persistent attachment storage service, add draft lifecycle states (active/abandoned/sent) with generation tracking, and update fetchAgent to return project placement alongside agent snapshots.
This commit is contained in:
Mohamed Boudra
2026-02-22 16:22:21 +07:00
parent 2652bdda2e
commit 0c710b09f5
55 changed files with 2381 additions and 350 deletions
@@ -77,7 +77,6 @@ import {
derivePendingPermissionKey,
normalizeAgentSnapshot,
} from "@/utils/agent-snapshots";
import { resolveProjectPlacement } from "@/utils/project-placement";
import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
import type { DaemonClient } from "@server/client/daemon-client";
@@ -424,9 +423,13 @@ function AgentScreenContent({
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
const pendingCreate = useCreateFlowStore((state) => state.pending);
const markPendingCreateLifecycle = useCreateFlowStore(
(state) => state.markLifecycle
);
const clearPendingCreate = useCreateFlowStore((state) => state.clear);
const isPendingCreateForRoute =
Boolean(pendingCreate) &&
pendingCreate?.lifecycle === "active" &&
pendingCreate?.serverId === serverId &&
pendingCreate?.agentId === resolvedAgentId;
@@ -624,7 +627,7 @@ function AgentScreenContent({
return [
{
kind: "user_message",
id: pendingCreate.messageId,
id: pendingCreate.clientMessageId,
text: pendingCreate.text,
timestamp: new Date(pendingCreate.timestamp),
...(pendingCreate.images && pendingCreate.images.length > 0
@@ -725,7 +728,7 @@ function AgentScreenContent({
const hasUserMessage = streamItems.some(
(item) =>
item.kind === "user_message" &&
(item.id === pendingCreate.messageId || item.text === pendingCreate.text)
item.id === pendingCreate.clientMessageId
);
if (agent && hasUserMessage) {
if (
@@ -741,8 +744,7 @@ function AgentScreenContent({
const merged = mergePendingCreateImages({
streamItems: current,
messageId: pendingCreate.messageId,
text: pendingCreate.text,
clientMessageId: pendingCreate.clientMessageId,
images: pendingCreate.images,
});
if (merged === current) {
@@ -754,12 +756,14 @@ function AgentScreenContent({
return next;
});
}
markPendingCreateLifecycle("sent");
clearPendingCreate();
}
}, [
agent,
clearPendingCreate,
isPendingCreateForRoute,
markPendingCreateLifecycle,
pendingCreate,
resolvedAgentId,
serverId,
@@ -827,24 +831,21 @@ function AgentScreenContent({
.sessions[serverId]
?.agents.get(resolvedAgentId);
if (!currentAgent && client) {
const snapshot = await client.fetchAgent(resolvedAgentId);
const result = await client.fetchAgent(resolvedAgentId);
if (attemptToken !== initAttemptTokenRef.current) {
return;
}
if (!snapshot) {
if (!result) {
setMissingAgentState({
kind: "not_found",
message: `Agent not found: ${resolvedAgentId}`,
});
return;
}
const normalized = normalizeAgentSnapshot(snapshot, serverId);
const normalized = normalizeAgentSnapshot(result.agent, serverId);
const hydrated = {
...normalized,
projectPlacement: resolveProjectPlacement({
projectPlacement: null,
cwd: normalized.cwd,
}),
projectPlacement: result.project,
};
setAgents(serverId, (prev) => {
const next = new Map(prev);
@@ -34,6 +34,7 @@ import { buildWorkingDirectorySuggestions } from '@/utils/working-directory-sugg
import { useExplorerOpenGesture } from '@/hooks/use-explorer-open-gesture'
import { useSessionStore } from '@/stores/session-store'
import { useCreateFlowStore } from '@/stores/create-flow-store'
import { generateDraftId } from '@/stores/draft-keys'
import { getHostRuntimeStore, useHostRuntimeSession } from '@/runtime/host-runtime'
import { ExplorerSidebarAnimationProvider } from '@/contexts/explorer-sidebar-animation-context'
import { usePanelStore, type ExplorerCheckoutContext } from '@/stores/panel-store'
@@ -244,6 +245,7 @@ function DraftAgentScreenContent({
)
const tauriDragHandlers = useTauriDragHandlers()
const isExplorerOpen = isMobile ? mobileView === 'file-explorer' : desktopFileExplorerOpen
const draftIdRef = useRef(generateDraftId())
const [worktreeMode, setWorktreeMode] = useState<'none' | 'create' | 'attach'>('none')
const [baseBranch, setBaseBranch] = useState('')
@@ -262,6 +264,7 @@ function DraftAgentScreenContent({
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null)
const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending)
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId)
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle)
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear)
useEffect(() => {
@@ -277,7 +280,7 @@ function DraftAgentScreenContent({
}, [workingDirSearchQuery])
type CreateAttempt = {
messageId: string
clientMessageId: string
text: string
timestamp: Date
images?: UserMessageImageAttachment[]
@@ -832,7 +835,7 @@ function DraftAgentScreenContent({
return [
{
kind: 'user_message',
id: machine.attempt.messageId,
id: machine.attempt.clientMessageId,
text: machine.attempt.text,
timestamp: machine.attempt.timestamp,
...(machine.attempt.images && machine.attempt.images.length > 0
@@ -972,15 +975,16 @@ function DraftAgentScreenContent({
throw new Error('Host is not connected')
}
const attempt = {
messageId: generateMessageId(),
clientMessageId: generateMessageId(),
text: trimmedPrompt,
timestamp: new Date(),
...(images && images.length > 0 ? { images } : {}),
}
setPendingCreateAttempt({
draftId: draftIdRef.current,
serverId: selectedServerId,
agentId: null,
messageId: attempt.messageId,
clientMessageId: attempt.clientMessageId,
text: attempt.text,
timestamp: attempt.timestamp.getTime(),
...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}),
@@ -1026,6 +1030,7 @@ function DraftAgentScreenContent({
config,
labels: { ui: 'true' },
initialPrompt: trimmedPrompt,
clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
git: gitOptions,
})
@@ -1042,12 +1047,14 @@ function DraftAgentScreenContent({
message: 'Failed to create agent',
})
onCreateFlowActiveChange?.(false)
markPendingCreateLifecycle('abandoned')
clearPendingCreateAttempt()
throw new Error('Failed to create agent')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create agent'
dispatch({ type: 'CREATE_FAILED', message })
onCreateFlowActiveChange?.(false)
markPendingCreateLifecycle('abandoned')
clearPendingCreateAttempt()
throw error // Re-throw so AgentInputArea knows it failed
}
@@ -1073,6 +1080,7 @@ function DraftAgentScreenContent({
createAgentClient,
setPendingCreateAttempt,
updatePendingAgentId,
markPendingCreateLifecycle,
clearPendingCreateAttempt,
workingDir,
isAttachWorktree,
@@ -1324,6 +1332,7 @@ function DraftAgentScreenContent({
autoFocus={machine.tag === 'draft'}
onAddImages={handleAddImagesCallback}
commandDraftConfig={draftCommandConfig}
draftId={draftIdRef.current}
/>
</View>
</View>