Refactor agent input draft persistence
This commit is contained in:
@@ -1,48 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { shouldSkipDraftPersist } from "./agent-input-area.draft-persist-guard";
|
|
||||||
|
|
||||||
describe("shouldSkipDraftPersist", () => {
|
|
||||||
it("blocks persist while hydrate for current uncontrolled generation is incomplete", () => {
|
|
||||||
expect(
|
|
||||||
shouldSkipDraftPersist({
|
|
||||||
isControlled: false,
|
|
||||||
currentGeneration: 2,
|
|
||||||
hydratedGeneration: 1,
|
|
||||||
isCurrentGeneration: true,
|
|
||||||
})
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("allows persist after hydrate completes for current generation", () => {
|
|
||||||
expect(
|
|
||||||
shouldSkipDraftPersist({
|
|
||||||
isControlled: false,
|
|
||||||
currentGeneration: 3,
|
|
||||||
hydratedGeneration: 3,
|
|
||||||
isCurrentGeneration: true,
|
|
||||||
})
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks persist for stale generations", () => {
|
|
||||||
expect(
|
|
||||||
shouldSkipDraftPersist({
|
|
||||||
isControlled: false,
|
|
||||||
currentGeneration: 4,
|
|
||||||
hydratedGeneration: 4,
|
|
||||||
isCurrentGeneration: false,
|
|
||||||
})
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not block controlled draft persistence", () => {
|
|
||||||
expect(
|
|
||||||
shouldSkipDraftPersist({
|
|
||||||
isControlled: true,
|
|
||||||
currentGeneration: 0,
|
|
||||||
hydratedGeneration: 0,
|
|
||||||
isCurrentGeneration: true,
|
|
||||||
})
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
export function shouldSkipDraftPersist(input: {
|
|
||||||
isControlled: boolean;
|
|
||||||
currentGeneration: number;
|
|
||||||
hydratedGeneration: number;
|
|
||||||
isCurrentGeneration: boolean;
|
|
||||||
}): boolean {
|
|
||||||
if (input.isControlled) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.currentGeneration <= 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!input.isCurrentGeneration) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return input.hydratedGeneration !== input.currentGeneration;
|
|
||||||
}
|
|
||||||
@@ -10,8 +10,6 @@ import { generateMessageId, type StreamItem } from '@/types/stream'
|
|||||||
import { AgentStatusBar, DraftAgentStatusBar, type DraftAgentStatusBarProps } from './agent-status-bar'
|
import { AgentStatusBar, DraftAgentStatusBar, type DraftAgentStatusBarProps } from './agent-status-bar'
|
||||||
import { useImageAttachmentPicker } from '@/hooks/use-image-attachment-picker'
|
import { useImageAttachmentPicker } from '@/hooks/use-image-attachment-picker'
|
||||||
import { useSessionStore } from '@/stores/session-store'
|
import { useSessionStore } from '@/stores/session-store'
|
||||||
import { useDraftStore } from '@/stores/draft-store'
|
|
||||||
import { buildDraftStoreKey } from '@/stores/draft-keys'
|
|
||||||
import {
|
import {
|
||||||
MessageInput,
|
MessageInput,
|
||||||
type MessagePayload,
|
type MessagePayload,
|
||||||
@@ -35,7 +33,6 @@ import {
|
|||||||
persistAttachmentFromFileUri,
|
persistAttachmentFromFileUri,
|
||||||
} from '@/attachments/service'
|
} from '@/attachments/service'
|
||||||
import { resolveStatusControlMode } from '@/components/agent-input-area.status-controls'
|
import { resolveStatusControlMode } from '@/components/agent-input-area.status-controls'
|
||||||
import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard'
|
|
||||||
import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation'
|
import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation'
|
||||||
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
||||||
import { useKeyboardActionHandler } from '@/hooks/use-keyboard-action-handler'
|
import { useKeyboardActionHandler } from '@/hooks/use-keyboard-action-handler'
|
||||||
@@ -47,17 +44,21 @@ type QueuedMessage = {
|
|||||||
images?: ImageAttachment[]
|
images?: ImageAttachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[])
|
||||||
|
|
||||||
interface AgentInputAreaProps {
|
interface AgentInputAreaProps {
|
||||||
agentId: string
|
agentId: string
|
||||||
serverId: string
|
serverId: string
|
||||||
draftId?: string
|
|
||||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>
|
onSubmitMessage?: (payload: MessagePayload) => Promise<void>
|
||||||
/** Externally controlled loading state. When true, disables the submit button. */
|
/** Externally controlled loading state. When true, disables the submit button. */
|
||||||
isSubmitLoading?: boolean
|
isSubmitLoading?: boolean
|
||||||
/** When true, blurs the input immediately when submitting. */
|
/** When true, blurs the input immediately when submitting. */
|
||||||
blurOnSubmit?: boolean
|
blurOnSubmit?: boolean
|
||||||
value?: string
|
value: string
|
||||||
onChangeText?: (text: string) => void
|
onChangeText: (text: string) => void
|
||||||
|
images: ImageAttachment[]
|
||||||
|
onChangeImages: (updater: ImageListUpdater) => void
|
||||||
|
clearDraft: (lifecycle: 'sent' | 'abandoned') => void
|
||||||
/** When true, auto-focuses the text input on web. */
|
/** When true, auto-focuses the text input on web. */
|
||||||
autoFocus?: boolean
|
autoFocus?: boolean
|
||||||
/** Callback to expose the addImages function to parent components */
|
/** Callback to expose the addImages function to parent components */
|
||||||
@@ -80,12 +81,14 @@ const MOBILE_MESSAGE_PLACEHOLDER = 'Message, @files, /commands'
|
|||||||
export function AgentInputArea({
|
export function AgentInputArea({
|
||||||
agentId,
|
agentId,
|
||||||
serverId,
|
serverId,
|
||||||
draftId,
|
|
||||||
onSubmitMessage,
|
onSubmitMessage,
|
||||||
isSubmitLoading = false,
|
isSubmitLoading = false,
|
||||||
blurOnSubmit = false,
|
blurOnSubmit = false,
|
||||||
value,
|
value,
|
||||||
onChangeText,
|
onChangeText,
|
||||||
|
images,
|
||||||
|
onChangeImages,
|
||||||
|
clearDraft,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
onAddImages,
|
onAddImages,
|
||||||
commandDraftConfig,
|
commandDraftConfig,
|
||||||
@@ -114,14 +117,6 @@ export function AgentInputArea({
|
|||||||
|
|
||||||
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId))
|
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId))
|
||||||
|
|
||||||
const draftStoreKey = buildDraftStoreKey({ serverId, agentId, draftId })
|
|
||||||
const getDraftInput = useDraftStore((state) => state.getDraftInput)
|
|
||||||
const hydrateDraftInput = useDraftStore((state) => state.hydrateDraftInput)
|
|
||||||
const saveDraftInput = useDraftStore((state) => state.saveDraftInput)
|
|
||||||
const clearDraftInput = useDraftStore((state) => state.clearDraftInput)
|
|
||||||
const beginDraftGeneration = useDraftStore((state) => state.beginDraftGeneration)
|
|
||||||
const isDraftGenerationCurrent = useDraftStore((state) => state.isDraftGenerationCurrent)
|
|
||||||
|
|
||||||
const queuedMessagesRaw = useSessionStore((state) =>
|
const queuedMessagesRaw = useSessionStore((state) =>
|
||||||
state.sessions[serverId]?.queuedMessages?.get(agentId)
|
state.sessions[serverId]?.queuedMessages?.get(agentId)
|
||||||
)
|
)
|
||||||
@@ -131,7 +126,6 @@ export function AgentInputArea({
|
|||||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail)
|
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail)
|
||||||
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead)
|
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead)
|
||||||
|
|
||||||
const [internalInput, setInternalInput] = useState('')
|
|
||||||
const isDesktopWebBreakpoint =
|
const isDesktopWebBreakpoint =
|
||||||
Platform.OS === 'web' &&
|
Platform.OS === 'web' &&
|
||||||
UnistylesRuntime.breakpoint !== 'xs' &&
|
UnistylesRuntime.breakpoint !== 'xs' &&
|
||||||
@@ -139,17 +133,16 @@ export function AgentInputArea({
|
|||||||
const messagePlaceholder = isDesktopWebBreakpoint
|
const messagePlaceholder = isDesktopWebBreakpoint
|
||||||
? DESKTOP_MESSAGE_PLACEHOLDER
|
? DESKTOP_MESSAGE_PLACEHOLDER
|
||||||
: MOBILE_MESSAGE_PLACEHOLDER
|
: MOBILE_MESSAGE_PLACEHOLDER
|
||||||
const userInput = value ?? internalInput
|
const userInput = value
|
||||||
const setUserInput = onChangeText ?? setInternalInput
|
const setUserInput = onChangeText
|
||||||
|
const selectedImages = images
|
||||||
|
const setSelectedImages = onChangeImages
|
||||||
const [cursorIndex, setCursorIndex] = useState(0)
|
const [cursorIndex, setCursorIndex] = useState(0)
|
||||||
const [isProcessing, setIsProcessing] = useState(false)
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([])
|
|
||||||
const [isCancellingAgent, setIsCancellingAgent] = useState(false)
|
const [isCancellingAgent, setIsCancellingAgent] = useState(false)
|
||||||
const [sendError, setSendError] = useState<string | null>(null)
|
const [sendError, setSendError] = useState<string | null>(null)
|
||||||
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false)
|
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false)
|
||||||
const messageInputRef = useRef<MessageInputRef>(null)
|
const messageInputRef = useRef<MessageInputRef>(null)
|
||||||
const draftGenerationRef = useRef(0)
|
|
||||||
const hydratedGenerationRef = useRef(0)
|
|
||||||
const keyboardHandlerIdRef = useRef(
|
const keyboardHandlerIdRef = useRef(
|
||||||
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`
|
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`
|
||||||
)
|
)
|
||||||
@@ -187,7 +180,7 @@ export function AgentInputArea({
|
|||||||
// Expose addImages function to parent for drag-and-drop support
|
// Expose addImages function to parent for drag-and-drop support
|
||||||
const addImages = useCallback((images: ImageAttachment[]) => {
|
const addImages = useCallback((images: ImageAttachment[]) => {
|
||||||
setSelectedImages((prev) => [...prev, ...images])
|
setSelectedImages((prev) => [...prev, ...images])
|
||||||
}, [])
|
}, [setSelectedImages])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onAddImages?.(addImages)
|
onAddImages?.(addImages)
|
||||||
@@ -326,10 +319,7 @@ export function AgentInputArea({
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
const isControlled = value !== undefined
|
setUserInput('')
|
||||||
if (!isControlled) {
|
|
||||||
setUserInput('')
|
|
||||||
}
|
|
||||||
setSelectedImages([])
|
setSelectedImages([])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,28 +344,20 @@ export function AgentInputArea({
|
|||||||
// Save values so we can restore on error.
|
// Save values so we can restore on error.
|
||||||
const savedImages = imageAttachments
|
const savedImages = imageAttachments
|
||||||
if (!onSubmitMessageRef.current) {
|
if (!onSubmitMessageRef.current) {
|
||||||
if (value !== undefined) {
|
setUserInput('')
|
||||||
onChangeText?.('')
|
setSelectedImages([])
|
||||||
} else {
|
|
||||||
setUserInput('')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setSelectedImages([])
|
|
||||||
setSendError(null)
|
setSendError(null)
|
||||||
setIsProcessing(true)
|
setIsProcessing(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await submitMessage(trimmedMessage, imageAttachments)
|
await submitMessage(trimmedMessage, imageAttachments)
|
||||||
clearDraftInput({ draftKey: draftStoreKey, lifecycle: 'sent' })
|
clearDraft('sent')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AgentInput] Failed to send message:', error)
|
console.error('[AgentInput] Failed to send message:', error)
|
||||||
// Restore input so the user never loses their message
|
// Restore input so the user never loses their message
|
||||||
if (!onSubmitMessageRef.current) {
|
if (!onSubmitMessageRef.current) {
|
||||||
if (value !== undefined) {
|
setUserInput(trimmedMessage)
|
||||||
onChangeText?.(trimmedMessage)
|
|
||||||
} else {
|
|
||||||
setUserInput(trimmedMessage)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (savedImages) {
|
if (savedImages) {
|
||||||
setSelectedImages(savedImages)
|
setSelectedImages(savedImages)
|
||||||
@@ -434,109 +416,6 @@ export function AgentInputArea({
|
|||||||
}
|
}
|
||||||
}, [isAgentRunning, isConnected])
|
}, [isAgentRunning, isConnected])
|
||||||
|
|
||||||
// Hydrate draft only when switching agents (uncontrolled mode only)
|
|
||||||
const isControlled = value !== undefined
|
|
||||||
useEffect(() => {
|
|
||||||
// Skip draft hydration for controlled inputs - parent manages state
|
|
||||||
if (isControlled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const generation = beginDraftGeneration(draftStoreKey)
|
|
||||||
draftGenerationRef.current = generation
|
|
||||||
hydratedGenerationRef.current = 0
|
|
||||||
setUserInput('')
|
|
||||||
setSelectedImages([])
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
const draft = await hydrateDraftInput(draftStoreKey)
|
|
||||||
if (cancelled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!isDraftGenerationCurrent({ draftKey: draftStoreKey, generation })) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!draft) {
|
|
||||||
hydratedGenerationRef.current = generation
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setUserInput(draft.text)
|
|
||||||
setSelectedImages(draft.images)
|
|
||||||
hydratedGenerationRef.current = generation
|
|
||||||
})()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
beginDraftGeneration,
|
|
||||||
draftStoreKey,
|
|
||||||
hydrateDraftInput,
|
|
||||||
isControlled,
|
|
||||||
isDraftGenerationCurrent,
|
|
||||||
setUserInput,
|
|
||||||
])
|
|
||||||
|
|
||||||
// Persist drafts into the shared session store with change detection to avoid redundant work
|
|
||||||
useEffect(() => {
|
|
||||||
const currentGeneration = draftGenerationRef.current
|
|
||||||
const isCurrentGeneration =
|
|
||||||
currentGeneration > 0
|
|
||||||
? isDraftGenerationCurrent({ draftKey: draftStoreKey, generation: currentGeneration })
|
|
||||||
: true
|
|
||||||
if (
|
|
||||||
shouldSkipDraftPersist({
|
|
||||||
isControlled,
|
|
||||||
currentGeneration,
|
|
||||||
hydratedGeneration: hydratedGenerationRef.current,
|
|
||||||
isCurrentGeneration,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = getDraftInput(draftStoreKey)
|
|
||||||
const isSameText = existing?.text === userInput
|
|
||||||
const existingImages: ImageAttachment[] = existing?.images ?? []
|
|
||||||
const isSameImages =
|
|
||||||
existingImages.length === selectedImages.length &&
|
|
||||||
existingImages.every((img, idx) => {
|
|
||||||
return (
|
|
||||||
img.id === selectedImages[idx]?.id &&
|
|
||||||
img.mimeType === selectedImages[idx]?.mimeType &&
|
|
||||||
img.storageType === selectedImages[idx]?.storageType &&
|
|
||||||
img.storageKey === selectedImages[idx]?.storageKey
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isSameText && isSameImages) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasContent = userInput.trim().length > 0 || selectedImages.length > 0
|
|
||||||
if (!hasContent) {
|
|
||||||
if (existing) {
|
|
||||||
clearDraftInput({ draftKey: draftStoreKey, lifecycle: 'abandoned' })
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
saveDraftInput({
|
|
||||||
draftKey: draftStoreKey,
|
|
||||||
draft: { text: userInput, images: selectedImages },
|
|
||||||
})
|
|
||||||
}, [
|
|
||||||
clearDraftInput,
|
|
||||||
draftStoreKey,
|
|
||||||
getDraftInput,
|
|
||||||
isControlled,
|
|
||||||
isDraftGenerationCurrent,
|
|
||||||
saveDraftInput,
|
|
||||||
selectedImages,
|
|
||||||
userInput,
|
|
||||||
])
|
|
||||||
|
|
||||||
const handleKeyboardAction = useCallback((action: KeyboardActionDefinition): boolean => {
|
const handleKeyboardAction = useCallback((action: KeyboardActionDefinition): boolean => {
|
||||||
if (!isScreenFocused) {
|
if (!isScreenFocused) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import type { AttachmentMetadata } from '@/attachments/types'
|
||||||
|
import { useDraftStore } from '@/stores/draft-store'
|
||||||
|
|
||||||
|
type ImageUpdater =
|
||||||
|
| AttachmentMetadata[]
|
||||||
|
| ((prev: AttachmentMetadata[]) => AttachmentMetadata[])
|
||||||
|
|
||||||
|
interface AgentInputDraft {
|
||||||
|
text: string
|
||||||
|
setText: (text: string) => void
|
||||||
|
images: AttachmentMetadata[]
|
||||||
|
setImages: (updater: ImageUpdater) => void
|
||||||
|
clear: (lifecycle: 'sent' | 'abandoned') => void
|
||||||
|
isHydrated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDraftContent(input: { text: string; images: AttachmentMetadata[] }): boolean {
|
||||||
|
return input.text.trim().length > 0 || input.images.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function areImagesEqual(input: {
|
||||||
|
left: AttachmentMetadata[]
|
||||||
|
right: AttachmentMetadata[]
|
||||||
|
}): boolean {
|
||||||
|
if (input.left.length !== input.right.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.left.every((image, index) => {
|
||||||
|
const other = input.right[index]
|
||||||
|
return (
|
||||||
|
image.id === other?.id &&
|
||||||
|
image.mimeType === other?.mimeType &&
|
||||||
|
image.storageType === other?.storageType &&
|
||||||
|
image.storageKey === other?.storageKey
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentInputDraft(draftKey: string): AgentInputDraft {
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [images, setImagesState] = useState<AttachmentMetadata[]>([])
|
||||||
|
const [isHydrated, setIsHydrated] = useState(false)
|
||||||
|
const draftGenerationRef = useRef(0)
|
||||||
|
const hydratedGenerationRef = useRef(0)
|
||||||
|
|
||||||
|
const setImages = useCallback((updater: ImageUpdater) => {
|
||||||
|
setImagesState((previousImages) => {
|
||||||
|
if (typeof updater === 'function') {
|
||||||
|
return updater(previousImages)
|
||||||
|
}
|
||||||
|
return updater
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clear = useCallback(
|
||||||
|
(lifecycle: 'sent' | 'abandoned') => {
|
||||||
|
const store = useDraftStore.getState()
|
||||||
|
store.clearDraftInput({ draftKey, lifecycle })
|
||||||
|
|
||||||
|
const generation = store.beginDraftGeneration(draftKey)
|
||||||
|
draftGenerationRef.current = generation
|
||||||
|
hydratedGenerationRef.current = generation
|
||||||
|
|
||||||
|
setText('')
|
||||||
|
setImagesState([])
|
||||||
|
setIsHydrated(true)
|
||||||
|
},
|
||||||
|
[draftKey]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const store = useDraftStore.getState()
|
||||||
|
const generation = store.beginDraftGeneration(draftKey)
|
||||||
|
draftGenerationRef.current = generation
|
||||||
|
hydratedGenerationRef.current = 0
|
||||||
|
|
||||||
|
setText('')
|
||||||
|
setImagesState([])
|
||||||
|
setIsHydrated(false)
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const draft = await store.hydrateDraftInput(draftKey)
|
||||||
|
if (cancelled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!useDraftStore.getState().isDraftGenerationCurrent({ draftKey, generation })) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft) {
|
||||||
|
setText(draft.text)
|
||||||
|
setImagesState(draft.images)
|
||||||
|
}
|
||||||
|
|
||||||
|
hydratedGenerationRef.current = generation
|
||||||
|
setIsHydrated(true)
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [draftKey])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentGeneration = draftGenerationRef.current
|
||||||
|
if (currentGeneration <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = useDraftStore.getState()
|
||||||
|
const isCurrentGeneration = store.isDraftGenerationCurrent({
|
||||||
|
draftKey,
|
||||||
|
generation: currentGeneration,
|
||||||
|
})
|
||||||
|
if (!isCurrentGeneration) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (hydratedGenerationRef.current !== currentGeneration) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = store.getDraftInput(draftKey)
|
||||||
|
const isSameDraft =
|
||||||
|
existing?.text === text &&
|
||||||
|
areImagesEqual({
|
||||||
|
left: existing?.images ?? [],
|
||||||
|
right: images,
|
||||||
|
})
|
||||||
|
if (isSameDraft) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasDraftContent({ text, images })) {
|
||||||
|
if (existing) {
|
||||||
|
store.clearDraftInput({ draftKey, lifecycle: 'abandoned' })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store.saveDraftInput({
|
||||||
|
draftKey,
|
||||||
|
draft: {
|
||||||
|
text,
|
||||||
|
images,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [draftKey, images, text])
|
||||||
|
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
setText,
|
||||||
|
images,
|
||||||
|
setImages,
|
||||||
|
clear,
|
||||||
|
isHydrated,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,11 +12,10 @@ type CreateAttempt = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DraftAgentMachineState =
|
type DraftAgentMachineState =
|
||||||
| { tag: 'draft'; promptText: string; errorMessage: string }
|
| { tag: 'draft'; errorMessage: string }
|
||||||
| { tag: 'creating'; attempt: CreateAttempt }
|
| { tag: 'creating'; attempt: CreateAttempt }
|
||||||
|
|
||||||
type DraftAgentMachineEvent =
|
type DraftAgentMachineEvent =
|
||||||
| { type: 'DRAFT_SET_PROMPT'; text: string }
|
|
||||||
| { type: 'DRAFT_SET_ERROR'; message: string }
|
| { type: 'DRAFT_SET_ERROR'; message: string }
|
||||||
| { type: 'SUBMIT'; attempt: CreateAttempt }
|
| { type: 'SUBMIT'; attempt: CreateAttempt }
|
||||||
| { type: 'CREATE_FAILED'; message: string }
|
| { type: 'CREATE_FAILED'; message: string }
|
||||||
@@ -27,12 +26,6 @@ function assertNever(value: never): never {
|
|||||||
|
|
||||||
function reducer(state: DraftAgentMachineState, event: DraftAgentMachineEvent): DraftAgentMachineState {
|
function reducer(state: DraftAgentMachineState, event: DraftAgentMachineEvent): DraftAgentMachineState {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'DRAFT_SET_PROMPT': {
|
|
||||||
if (state.tag !== 'draft') {
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
return { ...state, promptText: event.text }
|
|
||||||
}
|
|
||||||
case 'DRAFT_SET_ERROR': {
|
case 'DRAFT_SET_ERROR': {
|
||||||
if (state.tag !== 'draft') {
|
if (state.tag !== 'draft') {
|
||||||
return state
|
return state
|
||||||
@@ -46,7 +39,7 @@ function reducer(state: DraftAgentMachineState, event: DraftAgentMachineEvent):
|
|||||||
if (state.tag !== 'creating') {
|
if (state.tag !== 'creating') {
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
return { tag: 'draft', promptText: state.attempt.text, errorMessage: event.message }
|
return { tag: 'draft', errorMessage: event.message }
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return assertNever(event)
|
return assertNever(event)
|
||||||
@@ -94,7 +87,6 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
|
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
|
||||||
const [machine, dispatch] = useReducer(reducer, {
|
const [machine, dispatch] = useReducer(reducer, {
|
||||||
tag: 'draft',
|
tag: 'draft',
|
||||||
promptText: '',
|
|
||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
} as DraftAgentMachineState)
|
} as DraftAgentMachineState)
|
||||||
|
|
||||||
@@ -103,7 +95,6 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle)
|
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle)
|
||||||
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear)
|
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear)
|
||||||
|
|
||||||
const promptValue = machine.tag === 'draft' ? machine.promptText : ''
|
|
||||||
const formErrorMessage = machine.tag === 'draft' ? machine.errorMessage : ''
|
const formErrorMessage = machine.tag === 'draft' ? machine.errorMessage : ''
|
||||||
const isSubmitting = machine.tag === 'creating'
|
const isSubmitting = machine.tag === 'creating'
|
||||||
|
|
||||||
@@ -132,10 +123,6 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
return buildDraftAgent(machine.attempt)
|
return buildDraftAgent(machine.attempt)
|
||||||
}, [buildDraftAgent, machine])
|
}, [buildDraftAgent, machine])
|
||||||
|
|
||||||
const setPromptText = useCallback((text: string) => {
|
|
||||||
dispatch({ type: 'DRAFT_SET_PROMPT', text })
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleCreateFromInput = useCallback(
|
const handleCreateFromInput = useCallback(
|
||||||
async ({ text, images }: SubmitContext) => {
|
async ({ text, images }: SubmitContext) => {
|
||||||
if (isSubmitting) {
|
if (isSubmitting) {
|
||||||
@@ -226,12 +213,10 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
machine,
|
machine,
|
||||||
promptValue,
|
|
||||||
formErrorMessage,
|
formErrorMessage,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
optimisticStreamItems,
|
optimisticStreamItems,
|
||||||
draftAgent,
|
draftAgent,
|
||||||
setPromptText,
|
|
||||||
handleCreateFromInput,
|
handleCreateFromInput,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from "@/hooks/use-agent-screen-state-machine";
|
} from "@/hooks/use-agent-screen-state-machine";
|
||||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||||
import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast";
|
import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast";
|
||||||
|
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { usePaneContext } from "@/panels/pane-context";
|
import { usePaneContext } from "@/panels/pane-context";
|
||||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||||
@@ -42,6 +43,7 @@ import {
|
|||||||
import { mergePendingCreateImages } from "@/utils/pending-create-images";
|
import { mergePendingCreateImages } from "@/utils/pending-create-images";
|
||||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
|
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
@@ -234,6 +236,12 @@ function AgentPanelBody({
|
|||||||
routeKey: string;
|
routeKey: string;
|
||||||
reason: "initial-entry" | "resume";
|
reason: "initial-entry" | "resume";
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const agentInputDraft = useAgentInputDraft(
|
||||||
|
buildDraftStoreKey({
|
||||||
|
serverId,
|
||||||
|
agentId: agentId ?? "__pending__",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
||||||
addImagesRef.current?.(files);
|
addImagesRef.current?.(files);
|
||||||
@@ -851,6 +859,11 @@ function AgentPanelBody({
|
|||||||
<AgentInputArea
|
<AgentInputArea
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
value={agentInputDraft.text}
|
||||||
|
onChangeText={agentInputDraft.setText}
|
||||||
|
images={agentInputDraft.images}
|
||||||
|
onChangeImages={agentInputDraft.setImages}
|
||||||
|
clearDraft={agentInputDraft.clear}
|
||||||
autoFocus={isPaneFocused}
|
autoFocus={isPaneFocused}
|
||||||
isSubmitLoading={showPendingCreateSubmitLoading}
|
isSubmitLoading={showPendingCreateSubmitLoading}
|
||||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import { collectAgentWorkingDirectorySuggestions } from '@/utils/agent-working-d
|
|||||||
import { buildWorkingDirectorySuggestions } from '@/utils/working-directory-suggestions'
|
import { buildWorkingDirectorySuggestions } from '@/utils/working-directory-suggestions'
|
||||||
import { useExplorerOpenGesture } from '@/hooks/use-explorer-open-gesture'
|
import { useExplorerOpenGesture } from '@/hooks/use-explorer-open-gesture'
|
||||||
import { useSessionStore } from '@/stores/session-store'
|
import { useSessionStore } from '@/stores/session-store'
|
||||||
import { generateDraftId } from '@/stores/draft-keys'
|
import { buildDraftStoreKey, generateDraftId } from '@/stores/draft-keys'
|
||||||
import { getHostRuntimeStore, useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime'
|
import { getHostRuntimeStore, useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime'
|
||||||
import { ExplorerSidebarAnimationProvider } from '@/contexts/explorer-sidebar-animation-context'
|
import { ExplorerSidebarAnimationProvider } from '@/contexts/explorer-sidebar-animation-context'
|
||||||
import { usePanelStore, type ExplorerCheckoutContext } from '@/stores/panel-store'
|
import { usePanelStore, type ExplorerCheckoutContext } from '@/stores/panel-store'
|
||||||
@@ -50,6 +50,7 @@ import { prepareWorkspaceTab } from '@/utils/workspace-navigation'
|
|||||||
import { useDesktopDragHandlers } from '@/utils/desktop-window'
|
import { useDesktopDragHandlers } from '@/utils/desktop-window'
|
||||||
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
||||||
import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
|
import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
|
||||||
|
import { useAgentInputDraft } from '@/hooks/use-agent-input-draft'
|
||||||
import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow'
|
import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow'
|
||||||
|
|
||||||
const EMPTY_PENDING_PERMISSIONS = new Map()
|
const EMPTY_PENDING_PERMISSIONS = new Map()
|
||||||
@@ -242,6 +243,13 @@ function DraftAgentScreenContent({
|
|||||||
const isExplorerOpen = isMobile ? mobileView === 'file-explorer' : desktopFileExplorerOpen
|
const isExplorerOpen = isMobile ? mobileView === 'file-explorer' : desktopFileExplorerOpen
|
||||||
const draftIdRef = useRef(generateDraftId())
|
const draftIdRef = useRef(generateDraftId())
|
||||||
const draftAgentIdRef = useRef(generateDraftId())
|
const draftAgentIdRef = useRef(generateDraftId())
|
||||||
|
const draftInput = useAgentInputDraft(
|
||||||
|
buildDraftStoreKey({
|
||||||
|
serverId: selectedServerId ?? '',
|
||||||
|
agentId: draftAgentIdRef.current,
|
||||||
|
draftId: draftIdRef.current,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const [worktreeMode, setWorktreeMode] = useState<'none' | 'create' | 'attach'>(
|
const [worktreeMode, setWorktreeMode] = useState<'none' | 'create' | 'attach'>(
|
||||||
initialWorktreeMode
|
initialWorktreeMode
|
||||||
@@ -766,12 +774,10 @@ function DraftAgentScreenContent({
|
|||||||
])
|
])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
promptValue,
|
|
||||||
formErrorMessage,
|
formErrorMessage,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
optimisticStreamItems,
|
optimisticStreamItems,
|
||||||
draftAgent,
|
draftAgent,
|
||||||
setPromptText,
|
|
||||||
handleCreateFromInput,
|
handleCreateFromInput,
|
||||||
} = useDraftAgentCreateFlow<Agent, { id: string; cwd: string }>({
|
} = useDraftAgentCreateFlow<Agent, { id: string; cwd: string }>({
|
||||||
draftId: draftIdRef.current,
|
draftId: draftIdRef.current,
|
||||||
@@ -1184,12 +1190,14 @@ function DraftAgentScreenContent({
|
|||||||
onSubmitMessage={handleCreateFromInput}
|
onSubmitMessage={handleCreateFromInput}
|
||||||
isSubmitLoading={isSubmitting}
|
isSubmitLoading={isSubmitting}
|
||||||
blurOnSubmit={true}
|
blurOnSubmit={true}
|
||||||
value={promptValue}
|
value={draftInput.text}
|
||||||
onChangeText={setPromptText}
|
onChangeText={draftInput.setText}
|
||||||
|
images={draftInput.images}
|
||||||
|
onChangeImages={draftInput.setImages}
|
||||||
|
clearDraft={draftInput.clear}
|
||||||
autoFocus={!isSubmitting}
|
autoFocus={!isSubmitting}
|
||||||
onAddImages={handleAddImagesCallback}
|
onAddImages={handleAddImagesCallback}
|
||||||
commandDraftConfig={draftCommandConfig}
|
commandDraftConfig={draftCommandConfig}
|
||||||
draftId={draftIdRef.current}
|
|
||||||
statusControls={{
|
statusControls={{
|
||||||
providerDefinitions,
|
providerDefinitions,
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { FileDropZone } from '@/components/file-drop-zone'
|
|||||||
import { AgentStreamView } from '@/components/agent-stream-view'
|
import { AgentStreamView } from '@/components/agent-stream-view'
|
||||||
import type { ImageAttachment } from '@/components/message-input'
|
import type { ImageAttachment } from '@/components/message-input'
|
||||||
import { useAgentFormState } from '@/hooks/use-agent-form-state'
|
import { useAgentFormState } from '@/hooks/use-agent-form-state'
|
||||||
|
import { useAgentInputDraft } from '@/hooks/use-agent-input-draft'
|
||||||
import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow'
|
import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow'
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime'
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime'
|
||||||
|
import { buildDraftStoreKey } from '@/stores/draft-keys'
|
||||||
import type { Agent } from '@/stores/session-store'
|
import type { Agent } from '@/stores/session-store'
|
||||||
import { encodeImages } from '@/utils/encode-images'
|
import { encodeImages } from '@/utils/encode-images'
|
||||||
import type { AgentCapabilityFlags, AgentSessionConfig } from '@server/server/agent/agent-sdk-types'
|
import type { AgentCapabilityFlags, AgentSessionConfig } from '@server/server/agent/agent-sdk-types'
|
||||||
@@ -43,6 +45,13 @@ export function WorkspaceDraftAgentTab({
|
|||||||
const client = useHostRuntimeClient(serverId)
|
const client = useHostRuntimeClient(serverId)
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId)
|
const isConnected = useHostRuntimeIsConnected(serverId)
|
||||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null)
|
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null)
|
||||||
|
const draftInput = useAgentInputDraft(
|
||||||
|
buildDraftStoreKey({
|
||||||
|
serverId,
|
||||||
|
agentId: tabId,
|
||||||
|
draftId,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
@@ -81,12 +90,10 @@ export function WorkspaceDraftAgentTab({
|
|||||||
}, [setWorkingDir, workingDir, workspaceId])
|
}, [setWorkingDir, workingDir, workspaceId])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
promptValue,
|
|
||||||
formErrorMessage,
|
formErrorMessage,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
optimisticStreamItems,
|
optimisticStreamItems,
|
||||||
draftAgent,
|
draftAgent,
|
||||||
setPromptText,
|
|
||||||
handleCreateFromInput,
|
handleCreateFromInput,
|
||||||
} = useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
|
} = useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
|
||||||
draftId,
|
draftId,
|
||||||
@@ -233,12 +240,14 @@ export function WorkspaceDraftAgentTab({
|
|||||||
onSubmitMessage={handleCreateFromInput}
|
onSubmitMessage={handleCreateFromInput}
|
||||||
isSubmitLoading={isSubmitting}
|
isSubmitLoading={isSubmitting}
|
||||||
blurOnSubmit={true}
|
blurOnSubmit={true}
|
||||||
value={promptValue}
|
value={draftInput.text}
|
||||||
onChangeText={setPromptText}
|
onChangeText={draftInput.setText}
|
||||||
|
images={draftInput.images}
|
||||||
|
onChangeImages={draftInput.setImages}
|
||||||
|
clearDraft={draftInput.clear}
|
||||||
autoFocus={!isSubmitting}
|
autoFocus={!isSubmitting}
|
||||||
onAddImages={handleAddImagesCallback}
|
onAddImages={handleAddImagesCallback}
|
||||||
commandDraftConfig={draftCommandConfig}
|
commandDraftConfig={draftCommandConfig}
|
||||||
draftId={draftId}
|
|
||||||
statusControls={{
|
statusControls={{
|
||||||
providerDefinitions,
|
providerDefinitions,
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
|
|||||||
Reference in New Issue
Block a user