Skip to content

Commit 42a5af6

Browse files
authored
feat(app): follow-up behavior (anomalyco#17233)
1 parent f0542fa commit 42a5af6

45 files changed

Lines changed: 1164 additions & 183 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/app/src/components/prompt-input.tsx

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ import {
4848
type PromptHistoryStoredEntry,
4949
promptLength,
5050
} from "./prompt-input/history"
51-
import { createPromptSubmit } from "./prompt-input/submit"
51+
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
5252
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
5353
import { PromptContextItems } from "./prompt-input/context-items"
5454
import { PromptImageAttachments } from "./prompt-input/image-attachments"
@@ -61,6 +61,11 @@ interface PromptInputProps {
6161
ref?: (el: HTMLDivElement) => void
6262
newSessionWorktree?: string
6363
onNewSessionWorktreeReset?: () => void
64+
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
65+
onEditLoaded?: () => void
66+
shouldQueue?: () => boolean
67+
onQueue?: (draft: FollowupDraft) => void
68+
onAbort?: () => void
6469
onSubmit?: () => void
6570
}
6671

@@ -947,6 +952,45 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
947952
setCurrentHistory("entries", next)
948953
}
949954

955+
createEffect(
956+
on(
957+
() => props.edit?.id,
958+
(id) => {
959+
const edit = props.edit
960+
if (!id || !edit) return
961+
962+
for (const item of prompt.context.items()) {
963+
prompt.context.remove(item.key)
964+
}
965+
966+
for (const item of edit.context) {
967+
prompt.context.add({
968+
type: item.type,
969+
path: item.path,
970+
selection: item.selection,
971+
comment: item.comment,
972+
commentID: item.commentID,
973+
commentOrigin: item.commentOrigin,
974+
preview: item.preview,
975+
})
976+
}
977+
978+
setStore("mode", "normal")
979+
setStore("popover", null)
980+
setStore("historyIndex", -1)
981+
setStore("savedPrompt", null)
982+
prompt.set(edit.prompt, promptLength(edit.prompt))
983+
requestAnimationFrame(() => {
984+
editorRef.focus()
985+
setCursorPosition(editorRef, promptLength(edit.prompt))
986+
queueScroll()
987+
})
988+
props.onEditLoaded?.()
989+
},
990+
{ defer: true },
991+
),
992+
)
993+
950994
const navigateHistory = (direction: "up" | "down") => {
951995
const result = navigatePromptHistory({
952996
direction,
@@ -1001,6 +1045,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
10011045
setPopover: (popover) => setStore("popover", popover),
10021046
newSessionWorktree: () => props.newSessionWorktree,
10031047
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
1048+
shouldQueue: props.shouldQueue,
1049+
onQueue: props.onQueue,
1050+
onAbort: props.onAbort,
10041051
onSubmit: props.onSubmit,
10051052
})
10061053

packages/app/src/components/prompt-input/submit.ts

Lines changed: 181 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language"
99
import { useLayout } from "@/context/layout"
1010
import { useLocal } from "@/context/local"
1111
import { usePermission } from "@/context/permission"
12-
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
12+
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
1313
import { useSDK } from "@/context/sdk"
1414
import { useSync } from "@/context/sync"
1515
import { Identifier } from "@/utils/id"
@@ -25,6 +25,145 @@ type PendingPrompt = {
2525

2626
const pending = new Map<string, PendingPrompt>()
2727

28+
export type FollowupDraft = {
29+
sessionID: string
30+
sessionDirectory: string
31+
prompt: Prompt
32+
context: (ContextItem & { key: string })[]
33+
agent: string
34+
model: { providerID: string; modelID: string }
35+
variant?: string
36+
}
37+
38+
type FollowupSendInput = {
39+
client: ReturnType<typeof useSDK>["client"]
40+
globalSync: ReturnType<typeof useGlobalSync>
41+
sync: ReturnType<typeof useSync>
42+
draft: FollowupDraft
43+
messageID?: string
44+
optimisticBusy?: boolean
45+
before?: () => Promise<boolean> | boolean
46+
}
47+
48+
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
49+
50+
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
51+
52+
export async function sendFollowupDraft(input: FollowupSendInput) {
53+
const text = draftText(input.draft.prompt)
54+
const images = draftImages(input.draft.prompt)
55+
const [, setStore] = input.globalSync.child(input.draft.sessionDirectory)
56+
57+
const setBusy = () => {
58+
if (!input.optimisticBusy) return
59+
setStore("session_status", input.draft.sessionID, { type: "busy" })
60+
}
61+
62+
const setIdle = () => {
63+
if (!input.optimisticBusy) return
64+
setStore("session_status", input.draft.sessionID, { type: "idle" })
65+
}
66+
67+
const wait = async () => {
68+
const ok = await input.before?.()
69+
if (ok === false) return false
70+
return true
71+
}
72+
73+
const [head, ...tail] = text.split(" ")
74+
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
75+
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
76+
setBusy()
77+
try {
78+
if (!(await wait())) {
79+
setIdle()
80+
return false
81+
}
82+
83+
await input.client.session.command({
84+
sessionID: input.draft.sessionID,
85+
command: cmd,
86+
arguments: tail.join(" "),
87+
agent: input.draft.agent,
88+
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
89+
variant: input.draft.variant,
90+
parts: images.map((attachment) => ({
91+
id: Identifier.ascending("part"),
92+
type: "file" as const,
93+
mime: attachment.mime,
94+
url: attachment.dataUrl,
95+
filename: attachment.filename,
96+
})),
97+
})
98+
return true
99+
} catch (err) {
100+
setIdle()
101+
throw err
102+
}
103+
}
104+
105+
const messageID = input.messageID ?? Identifier.ascending("message")
106+
const { requestParts, optimisticParts } = buildRequestParts({
107+
prompt: input.draft.prompt,
108+
context: input.draft.context,
109+
images,
110+
text,
111+
sessionID: input.draft.sessionID,
112+
messageID,
113+
sessionDirectory: input.draft.sessionDirectory,
114+
})
115+
116+
const message: Message = {
117+
id: messageID,
118+
sessionID: input.draft.sessionID,
119+
role: "user",
120+
time: { created: Date.now() },
121+
agent: input.draft.agent,
122+
model: input.draft.model,
123+
variant: input.draft.variant,
124+
}
125+
126+
const add = () =>
127+
input.sync.session.optimistic.add({
128+
directory: input.draft.sessionDirectory,
129+
sessionID: input.draft.sessionID,
130+
message,
131+
parts: optimisticParts,
132+
})
133+
134+
const remove = () =>
135+
input.sync.session.optimistic.remove({
136+
directory: input.draft.sessionDirectory,
137+
sessionID: input.draft.sessionID,
138+
messageID,
139+
})
140+
141+
setBusy()
142+
add()
143+
144+
try {
145+
if (!(await wait())) {
146+
setIdle()
147+
remove()
148+
return false
149+
}
150+
151+
await input.client.session.promptAsync({
152+
sessionID: input.draft.sessionID,
153+
agent: input.draft.agent,
154+
model: input.draft.model,
155+
messageID,
156+
parts: requestParts,
157+
variant: input.draft.variant,
158+
})
159+
return true
160+
} catch (err) {
161+
setIdle()
162+
remove()
163+
throw err
164+
}
165+
}
166+
28167
type PromptSubmitInput = {
29168
info: Accessor<{ id: string } | undefined>
30169
imageAttachments: Accessor<ImageAttachmentPart[]>
@@ -41,6 +180,9 @@ type PromptSubmitInput = {
41180
setPopover: (popover: "at" | "slash" | null) => void
42181
newSessionWorktree?: Accessor<string | undefined>
43182
onNewSessionWorktreeReset?: () => void
183+
shouldQueue?: Accessor<boolean>
184+
onQueue?: (draft: FollowupDraft) => void
185+
onAbort?: () => void
44186
onSubmit?: () => void
45187
}
46188

@@ -82,6 +224,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
82224
const [, setStore] = globalSync.child(sdk.directory)
83225
setStore("todo", sessionID, [])
84226

227+
input.onAbort?.()
228+
85229
const queued = pending.get(sessionID)
86230
if (queued) {
87231
queued.abort.abort()
@@ -116,6 +260,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
116260
}
117261
}
118262

263+
const clearContext = () => {
264+
for (const item of prompt.context.items()) {
265+
prompt.context.remove(item.key)
266+
}
267+
}
268+
119269
const handleSubmit = async (event: Event) => {
120270
event.preventDefault()
121271

@@ -215,14 +365,22 @@ export function createPromptSubmit(input: PromptSubmitInput) {
215365
return
216366
}
217367

218-
input.onSubmit?.()
219-
220368
const model = {
221369
modelID: currentModel.id,
222370
providerID: currentModel.provider.id,
223371
}
224372
const agent = currentAgent.name
225373
const variant = local.model.variant.current()
374+
const context = prompt.context.items().slice()
375+
const draft: FollowupDraft = {
376+
sessionID: session.id,
377+
sessionDirectory,
378+
prompt: currentPrompt,
379+
context,
380+
agent,
381+
model,
382+
variant,
383+
}
226384

227385
const clearInput = () => {
228386
prompt.reset()
@@ -243,6 +401,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
243401
})
244402
}
245403

404+
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
405+
input.onQueue?.(draft)
406+
clearContext()
407+
clearInput()
408+
return
409+
}
410+
411+
input.onSubmit?.()
412+
246413
if (mode === "shell") {
247414
clearInput()
248415
client.session
@@ -295,48 +462,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
295462
}
296463
}
297464

298-
const context = prompt.context.items().slice()
299465
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
300-
301466
const messageID = Identifier.ascending("message")
302-
const { requestParts, optimisticParts } = buildRequestParts({
303-
prompt: currentPrompt,
304-
context,
305-
images,
306-
text,
307-
sessionID: session.id,
308-
messageID,
309-
sessionDirectory,
310-
})
311-
312-
const optimisticMessage: Message = {
313-
id: messageID,
314-
sessionID: session.id,
315-
role: "user",
316-
time: { created: Date.now() },
317-
agent,
318-
model,
319-
variant,
320-
}
321-
322-
const addOptimisticMessage = () =>
323-
sync.session.optimistic.add({
324-
directory: sessionDirectory,
325-
sessionID: session.id,
326-
message: optimisticMessage,
327-
parts: optimisticParts,
328-
})
329467

330-
const removeOptimisticMessage = () =>
468+
const removeOptimisticMessage = () => {
331469
sync.session.optimistic.remove({
332470
directory: sessionDirectory,
333471
sessionID: session.id,
334472
messageID,
335473
})
474+
}
336475

337476
removeCommentItems(commentItems)
338477
clearInput()
339-
addOptimisticMessage()
340478

341479
const waitForWorktree = async () => {
342480
const worktree = WorktreeState.get(sessionDirectory)
@@ -393,20 +531,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
393531
return true
394532
}
395533

396-
const send = async () => {
397-
const ok = await waitForWorktree()
398-
if (!ok) return
399-
await client.session.promptAsync({
400-
sessionID: session.id,
401-
agent,
402-
model,
403-
messageID,
404-
parts: requestParts,
405-
variant,
406-
})
407-
}
408-
409-
void send().catch((err) => {
534+
void sendFollowupDraft({
535+
client,
536+
sync,
537+
globalSync,
538+
draft,
539+
messageID,
540+
optimisticBusy: sessionDirectory === projectDirectory,
541+
before: waitForWorktree,
542+
}).catch((err) => {
410543
pending.delete(session.id)
411544
if (sessionDirectory === projectDirectory) {
412545
sync.set("session_status", session.id, { type: "idle" })

0 commit comments

Comments
 (0)