Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/app/src/components/settings-v2/general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,29 @@ export const SettingsGeneralV2: Component<{
</div>
</SettingsRowV2>
</Show>

<SettingsRowV2
title={language.t("settings.general.row.followup.title")}
description={language.t("settings.general.row.followup.description")}
>
<SelectV2
appearance="inline"
data-action="settings-followup"
options={[
{ value: "steer", label: language.t("settings.general.row.followup.option.steer") },
{ value: "queue", label: language.t("settings.general.row.followup.option.queue") },
]}
current={[
{ value: "steer", label: language.t("settings.general.row.followup.option.steer") },
{ value: "queue", label: language.t("settings.general.row.followup.option.queue") },
].find((o) => o.value === settings.general.followup())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && settings.general.setFollowup(option.value as "queue" | "steer")}
placement="bottom-end"
gutter={6}
/>
</SettingsRowV2>
</SettingsListV2>
</div>
)
Expand Down
9 changes: 2 additions & 7 deletions packages/app/src/context/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,11 +170,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
})

createEffect(() => {
if (store.general?.followup !== "queue") return
setStore("general", "followup", "steer")
})

return {
ready,
get current() {
Expand All @@ -190,11 +185,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("general", "releaseNotes", value)
},
followup: withFallback(
() => (store.general?.followup === "queue" ? "steer" : store.general?.followup),
() => store.general?.followup,
defaultSettings.general.followup,
),
setFollowup(value: "queue" | "steer") {
setStore("general", "followup", value === "queue" ? "steer" : value)
setStore("general", "followup", value)
},
showFileTree,
setShowFileTree(value: boolean) {
Expand Down
195 changes: 181 additions & 14 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import {
Switch,
createMemo,
createEffect,
createSignal,
createComputed,
on,
onMount,
type ParentProps,
type JSX,
untrack,
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
Expand Down Expand Up @@ -92,7 +94,7 @@ import { createSessionOwnership } from "./session/session-ownership"
import { createSessionLineage } from "./session/session-lineage"

type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context" | "sessionDirectory" | "agent" | "model"> & { index?: number; variant?: string }
const emptyFollowups: FollowupItem[] = []

type ChangeMode = "git" | "branch" | "turn"
Expand Down Expand Up @@ -1660,10 +1662,69 @@ export default function Page() {
return followupMutation.variables?.id
})

const [perMessageMode, setPerMessageMode] = createSignal<"steer" | "queue" | undefined>()
const [drainProgress, setDrainProgress] = createSignal<{ current: number; total: number }>()
const [countdown, setCountdown] = createSignal<{ remaining: number }>()
const effectiveQueueMode = () => perMessageMode() ?? settings.general.followup()

const queueEnabled = createMemo(() => {
const id = params.id
if (!id) return false
return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession()
// Force queue when editing a queued item — edits always re-queue
if (followup.edit[id]) return true
return effectiveQueueMode() === "queue" && busy(id) && !composer.blocked() && !isChildSession()
})

const queueModeToolbar = createMemo((): JSX.Element | undefined => {
const id = params.id
if (!id || isChildSession()) return undefined

// Show edit indicator when editing a queued message
const editEntry = editingFollowup()
if (editEntry) {
const preview = editEntry.prompt
.map((p) => ("content" in p ? p.content : ""))
.join("")
.split("\n")[0]
.trim()
.substring(0, 50)
return (
<span class="flex items-center gap-3 text-[13px] font-[440] leading-none">
<span style="color:var(--v2-text-text-faint,#808080)">
Editing: <span style="color:var(--v2-text-text-muted,#aeaeae)">"{preview}"</span>
</span>
<span
role="button"
tabIndex={0}
data-action="prompt-cancel-edit"
class="cursor-pointer transition-colors duration-85"
style="color:var(--v2-text-text-muted,#aeaeae)"
onClick={() => restoreFollowupEdit()}
onKeyDown={(e) => { if (e.key === "Enter") restoreFollowupEdit() }}
>
← Cancel
</span>
</span>
)
}

if (!busy(id)) return undefined
const isSteer = effectiveQueueMode() === "steer"
const label = language.t(isSteer ? "settings.general.row.followup.option.steer" : "settings.general.row.followup.option.queue")
return (
<span
role="button"
tabIndex={0}
data-action="prompt-queue-mode"
class="cursor-pointer text-[13px] font-[440] leading-none transition-colors duration-85"
style={{ color: isSteer ? "var(--v2-state-fg-success, #4ade80)" : "var(--v2-text-text-accent, #a2bcff)" }}
onClick={() => setPerMessageMode(isSteer ? "queue" : "steer")}
onKeyDown={(e) => { if (e.key === "Enter") setPerMessageMode(isSteer ? "queue" : "steer") }}
title={`Switch to ${language.t(isSteer ? "settings.general.row.followup.option.queue" : "settings.general.row.followup.option.steer")}`}
>
{isSteer ? "→ Steer" : "📥 Queue"}
</span>
)
})

const followupText = (item: FollowupDraft) => {
Expand All @@ -1684,15 +1745,59 @@ export default function Page() {
}

const queueFollowup = (draft: FollowupDraft) => {
setFollowup("items", draft.sessionID, (items) => [
...(items ?? []),
{ id: Identifier.ascending("message"), ...draft },
])
const itemId = Identifier.ascending("message")
setFollowup("items", draft.sessionID, (items) => {
const list = items ?? []
const editEntry = followup.edit[draft.sessionID]
if (editEntry?.index !== undefined && editEntry.index >= 0 && editEntry.index <= list.length) {
const before = list.slice(0, editEntry.index)
const after = list.slice(editEntry.index)
return [...before, { id: itemId, ...draft }, ...after]
}
return [...list, { id: itemId, ...draft }]
})
setFollowup("failed", draft.sessionID, undefined)
setFollowup("paused", draft.sessionID, undefined)
setFollowup("edit", draft.sessionID, undefined)
setPerMessageMode(undefined)
const pendingCount = (followup.items[draft.sessionID]?.length ?? 0) + 1
showToast({
title: `📥 Queued (${pendingCount})`,
description: "Will auto-send when model finishes",
actions: [{ label: "Undo", onClick: () => removeFollowup(itemId) }],
})
}

// Escape during edit restores the item to the queue
createEffect(() => {
if (!editingFollowup()) return
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault()
restoreFollowupEdit()
}
}
document.addEventListener("keydown", handler)
onCleanup(() => document.removeEventListener("keydown", handler))
})

const removeFollowup = (id: string) => {
const sessionID = params.id
if (!sessionID) return
setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
}

const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
const followupDock = createMemo(() => {
const items = queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) }))
const editEntry = editingFollowup()
if (editEntry?.index !== undefined) {
const placeholder = { id: "editing-placeholder", text: "Editing…" }
const before = items.slice(0, editEntry.index)
const after = items.slice(editEntry.index)
return [...before, placeholder, ...after]
}
return items
})

const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
if (sync().session.get(sessionID)?.parentID) return Promise.resolve()
Expand All @@ -1708,24 +1813,58 @@ export default function Page() {
if (!sessionID) return
if (followupBusy(sessionID)) return

const item = queuedFollowups().find((entry) => entry.id === id)
if (!item) return
const items = queuedFollowups()
const index = items.findIndex((entry) => entry.id === id)
if (index < 0) return
const item = items[index]

setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
setFollowup("failed", sessionID, (value) => (value === id ? undefined : value))
setFollowup("edit", sessionID, {
id: item.id,
prompt: item.prompt,
context: item.context,
sessionDirectory: item.sessionDirectory,
agent: item.agent,
model: item.model,
variant: item.variant,
index,
})
}

const restoreFollowupEdit = () => {
const sessionID = params.id
if (!sessionID) return
const editEntry = followup.edit[sessionID]
if (!editEntry) return
const draft: FollowupDraft = {
sessionID,
sessionDirectory: editEntry.sessionDirectory,
prompt: editEntry.prompt,
context: editEntry.context,
agent: editEntry.agent,
model: editEntry.model,
variant: editEntry.variant,
}
queueFollowup(draft)
}

const clearFollowupEdit = () => {
const id = params.id
if (!id) return
setFollowup("edit", id, undefined)
}

// Clear stale edit state when session changes
createEffect(() => {
const id = params.id
const editEntry = followup.edit[id ?? ""]
if (!id && editEntry) return
if (id && editEntry && editEntry.sessionID !== id) {
setFollowup("edit", id, undefined)
}
})

const halt = (sessionID: string) =>
busy(sessionID)
? sdk()
Expand Down Expand Up @@ -1814,17 +1953,39 @@ export default function Page() {
createEffect(() => {
const sessionID = params.id
if (!sessionID) return

const item = queuedFollowups()[0]
if (!item) return
if (followupBusy(sessionID)) return
if (followup.failed[sessionID] === item.id) return
if (followup.paused[sessionID]) return
if (isChildSession()) return
if (composer.blocked()) return
if (busy(sessionID)) return

void sendFollowup(sessionID, item.id)
const item = queuedFollowups()[0]
if (!item) return
if (followup.failed[sessionID] === item.id) return

// Initialize drain progress if not set
if (!drainProgress()) {
const total = queuedFollowups().length
setDrainProgress({ current: 0, total })
}

// Start countdown
const remaining = 2
setCountdown({ remaining })

const timer = window.setTimeout(() => {
setCountdown(undefined)
void sendFollowup(sessionID, item.id).then(() => {
setDrainProgress((prev) => {
if (!prev) return undefined
const next = { current: prev.current + 1, total: prev.total }
if (next.current >= next.total) return undefined
return next
})
})
}, remaining * 1000)

onCleanup(() => window.clearTimeout(timer))
})

createResizeObserver(
Expand Down Expand Up @@ -1919,6 +2080,10 @@ export default function Page() {
sending: sendingFollowup(),
onSend: (id) => void sendFollowup(params.id!, id, { manual: true }),
onEdit: editFollowup,
onRemove: removeFollowup,
drainProgress: drainProgress(),
countdown: countdown(),
editingId: editingFollowup()?.id,
}
: undefined,
revert: () =>
Expand Down Expand Up @@ -1961,9 +2126,11 @@ export default function Page() {
onSubmit={() => {
comments.clear()
resumeScroll()
setPerMessageMode(undefined)
}}
edit={editingFollowup()}
onEditLoaded={clearFollowupEdit}
toolbar={queueModeToolbar()}
shouldQueue={queueEnabled}
onQueue={queueFollowup}
onAbort={() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export type SessionComposerFollowupDock = {
sending?: string
onSend: (id: string) => void
onEdit: (id: string) => void
onRemove?: (id: string) => void
drainProgress?: { current: number; total: number }
countdown?: { remaining: number }
editingId?: string
}

export type SessionComposerRevertDock = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ export function SessionComposerRegion(props: {
sending={controller.followup()!.sending}
onSend={controller.followup()!.onSend}
onEdit={controller.followup()!.onEdit}
onRemove={controller.followup()!.onRemove}
drainProgress={controller.followup()!.drainProgress}
countdown={controller.followup()!.countdown}
editingId={controller.followup()!.editingId}
/>
</Show>
<Show
Expand Down
Loading
Loading