Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
bd51c51
fix(app): keep session timeline stable across worktree exit
Astro-Han May 4, 2026
af87f30
fix(app): address timeline stability review
Astro-Han May 4, 2026
0d35251
test(app): cover timeline stability review cases
Astro-Han May 4, 2026
a26ab8e
fix(app): refresh session actions after directory changes
Astro-Han May 4, 2026
9139287
test(app): tighten timeline cache diagnostics
Astro-Han May 4, 2026
280aa03
fix(app): document followup queue migration boundary
Astro-Han May 4, 2026
1dd3fa5
fix(app): keep timeline cache identity stable
Astro-Han May 4, 2026
90bc76a
fix(app): bind review artifacts to source directory
Astro-Han May 5, 2026
12b2e11
fix(app): scope local model cache by directory
Astro-Han May 5, 2026
3bffa2e
fix(app): restore model selection on directory change
Astro-Han May 5, 2026
cb6d2f0
test(app): cover local model selection boundaries
Astro-Han May 5, 2026
01b6f96
fix(app): gate session actions during cache hydration
Astro-Han May 5, 2026
800d800
fix(app): gate session actions on hydrated state
Astro-Han May 5, 2026
f5608a5
fix(app): track session status hydration separately
Astro-Han May 5, 2026
a4003de
fix(app): harden session action readiness
Astro-Han May 5, 2026
b110195
fix(app): degrade session status readiness on failure
Astro-Han May 5, 2026
33edf35
fix(app): preserve active status during degraded bootstrap
Astro-Han May 5, 2026
5b0b9ec
fix(app): wait for local model cache hydration
Astro-Han May 5, 2026
9d2245f
fix(app): resync session model after local cache ready
Astro-Han May 5, 2026
471d3d9
fix(app): isolate review and status hydration races
Astro-Han May 5, 2026
b8949b5
fix(app): handle directory status and store pins
Astro-Han May 5, 2026
7b106b7
fix: gate session actions on directory readiness
Astro-Han May 5, 2026
13f8b52
fix: avoid permanent session action locks
Astro-Han May 5, 2026
94e799d
fix: separate submit and session action readiness
Astro-Han May 5, 2026
c8dcadc
fix: gate submit readiness separately from stop actions
Astro-Han May 5, 2026
ee84e79
fix: keep disabled prompt keys blocked
Astro-Han May 5, 2026
3531bfa
fix: handle local and sync readiness edge cases
Astro-Han May 5, 2026
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
107 changes: 77 additions & 30 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { PromptContextItems } from "./prompt-input/context-items"
import { PromptImageAttachments } from "./prompt-input/image-attachments"
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
import { promptPlaceholder } from "./prompt-input/placeholder"
import { promptKeyActionReady, promptSendDisabled } from "./prompt-input/readiness"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import type { PawworkSkillName } from "@/components/session/pawwork-skill-meta"

Expand All @@ -76,6 +77,8 @@ interface PromptInputProps {
onModeChange?: (mode: "normal" | "shell") => void
sessionID?: string
sessionIDControlled?: boolean
actionReady?: () => boolean
abortReady?: () => boolean
selectedSkill?: () => PawworkSkillName | undefined
}

Expand Down Expand Up @@ -256,6 +259,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const imageAttachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
const actionReady = createMemo(() => props.actionReady?.() ?? true)
const abortReady = createMemo(() => props.abortReady?.() ?? actionReady())

const [store, setStore] = createStore<{
popover: "at" | "slash" | null
Expand Down Expand Up @@ -297,7 +302,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const stopping = createMemo(() => working() && blank())
const tip = () => {
if (stopping()) {
if (stopping() && abortReady()) {
return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
Expand All @@ -306,6 +311,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)
}

if (!actionReady()) return language.t("prompt.loading")

return (
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
Expand Down Expand Up @@ -356,14 +363,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)

const placeholder = createMemo(() =>
promptPlaceholder({
mode: store.mode,
commentCount: commentCount(),
example: suggest() ? language.t(EXAMPLES[store.placeholder]) : "",
suggest: suggest(),
selectedSkill: props.selectedSkill?.(),
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}),
actionReady()
? promptPlaceholder({
mode: store.mode,
commentCount: commentCount(),
example: suggest() ? language.t(EXAMPLES[store.placeholder]) : "",
suggest: suggest(),
selectedSkill: props.selectedSkill?.(),
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
})
: language.t("prompt.loading"),
)

const historyComments = () => {
Expand Down Expand Up @@ -455,6 +464,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"

const pick = () => {
if (!actionReady()) return
const openFilePickerDialog = platform.openFilePickerDialog
void pickAttachments({
openFilePickerDialog: canUseNativeFilePicker(platform) ? openFilePickerDialog : undefined,
Expand All @@ -464,6 +474,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}

const setMode = (mode: "normal" | "shell") => {
if (!actionReady()) return
setStore("mode", mode)
setStore("popover", null)
requestAnimationFrame(() => editorRef?.focus())
Expand All @@ -478,23 +489,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
title: language.t("prompt.action.attachFile"),
category: language.t("command.category.file"),
keybind: "mod+u",
disabled: store.mode !== "normal",
disabled: store.mode !== "normal" || !actionReady(),
onSelect: pick,
},
{
id: "prompt.mode.shell",
title: language.t("command.prompt.mode.shell"),
category: language.t("command.category.session"),
keybind: shellModeKey,
disabled: store.mode === "shell",
disabled: store.mode === "shell" || !actionReady(),
onSelect: () => setMode("shell"),
},
{
id: "prompt.mode.normal",
title: language.t("command.prompt.mode.normal"),
category: language.t("command.category.session"),
keybind: normalModeKey,
disabled: store.mode === "normal",
disabled: store.mode === "normal" || !actionReady(),
onSelect: () => setMode("normal"),
},
])
Expand Down Expand Up @@ -580,6 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}

const handleAtSelect = (option: AtOption | undefined) => {
if (!actionReady()) return
if (!option) return
addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
}
Expand Down Expand Up @@ -636,6 +648,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})

const handleSlashSelect = (cmd: SlashCommand | undefined) => {
if (!actionReady()) return
if (!cmd) return
promptProbe.select(cmd.id)
closePopover()
Expand Down Expand Up @@ -1094,6 +1107,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
autoAccept: () => accepting(),
mode: () => store.mode,
working,
actionReady,
abortReady,
editor: () => editorRef,
queueScroll,
promptLength,
Expand Down Expand Up @@ -1131,6 +1146,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="h-[28px]! min-w-0 px-1.5 justify-start! text-13-regular! text-text-base group rounded-xl! transition-colors hover:bg-surface-base-hover"
style={triggerStyle()}
onClick={() => {
if (!actionReady()) return
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
Expand Down Expand Up @@ -1170,6 +1186,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class:
"h-[28px]! min-w-0 px-1.5 justify-start! text-13-regular! text-text-base group rounded-xl! transition-colors hover:bg-surface-base-hover",
"data-action": "prompt-model",
disabled: !actionReady(),
}}
onClose={restoreFocus}
>
Expand All @@ -1195,6 +1212,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {

const [variantOpen, setVariantOpen] = createSignal(false)

createEffect(() => {
if (actionReady()) return
closePopover()
setVariantOpen(false)
setStore("draggingType", null)
})

const renderVariantControl = (triggerStyle: () => Record<string, string | number | undefined>) => (
<div data-component="prompt-variant-control">
<TooltipKeybind
Expand All @@ -1213,16 +1237,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type: "button",
"data-action": "prompt-model-variant",
"aria-haspopup": "menu",
disabled: !actionReady(),
style: triggerStyle(),
class:
"h-[28px] px-2 max-w-[160px] @max-[20rem]/composer:max-w-[80px] inline-flex items-center gap-1.5 rounded-xl text-13-regular text-text-base transition-[max-width,colors] duration-200 ease-out hover:bg-surface-base-hover",
} as any
}
trigger={
<>
<span class="truncate">
{translateVariant(language.t, local.model.variant.current() ?? "default")}
</span>
<span class="truncate">{translateVariant(language.t, local.model.variant.current() ?? "default")}</span>
<Icon name="chevron-down" size="small" class="text-text-weak" />
</>
}
Expand All @@ -1242,6 +1265,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
aria-checked={active()}
class="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-13-regular text-text-strong outline-none hover:bg-surface-raised-base-hover focus-visible:bg-surface-raised-base-hover"
onClick={() => {
if (!actionReady()) return
local.model.variant.set(variant === "default" ? undefined : variant)
setVariantOpen(false)
restoreFocus()
Expand All @@ -1262,6 +1286,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)

const handleKeyDown = (event: KeyboardEvent) => {
if (
!promptKeyActionReady({
key: event.key,
working: working(),
stopping: stopping(),
actionReady: actionReady(),
abortReady: abortReady(),
})
) {
if (event.key === "Enter" || event.key === "Escape") {
event.preventDefault()
event.stopPropagation()
}
return
}

if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
event.preventDefault()
if (store.mode !== "normal") return
Expand Down Expand Up @@ -1407,16 +1447,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
if (event.repeat) return
if (
working() &&
prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
.trim().length === 0 &&
imageAttachments().length === 0 &&
commentCount() === 0
) {
if (stopping()) {
handleSubmit(event)
return
}
handleSubmit(event)
Expand Down Expand Up @@ -1445,6 +1477,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
classList={{
"group/prompt-input @container/composer": true,
"border-icon-info-active border-dashed": store.draggingType !== null,
"opacity-75": !actionReady(),
[props.class ?? ""]: !!props.class,
}}
>
Expand Down Expand Up @@ -1500,15 +1533,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
role="textbox"
aria-multiline="true"
aria-label={placeholder()}
contenteditable="true"
aria-disabled={!actionReady()}
contenteditable={actionReady() ? "true" : "false"}
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"}
inputMode="text"
// @ts-expect-error
autocomplete="off"
onInput={handleInput}
onPaste={handlePaste}
onPaste={(event) => {
if (!actionReady()) {
event.preventDefault()
return
}
handlePaste(event)
}}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onBlur={handleBlur}
Expand All @@ -1519,6 +1559,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
"cursor-wait text-text-weak": !actionReady(),
}}
style={{ "padding-bottom": space }}
/>
Expand Down Expand Up @@ -1577,7 +1618,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="size-7 shrink-0 p-0 rounded-xl!"
style={buttons()}
onClick={pick}
disabled={store.mode !== "normal"}
disabled={store.mode !== "normal" || !actionReady()}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
>
Expand All @@ -1595,10 +1636,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {

<div class="flex items-center gap-2 pointer-events-auto">
<SessionContextUsage placement="top" />
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<Tooltip placement="top" inactive={(working() ? abortReady() : actionReady()) && !working() && blank()} value={tip()}>
<SendButton
stopping={stopping()}
disabled={!working() && blank() && !props.selectedSkill?.()}
disabled={promptSendDisabled({
stopping: stopping(),
actionReady: actionReady(),
abortReady: abortReady(),
blank: blank(),
selectedSkill: !!props.selectedSkill?.(),
})}
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
Expand Down
76 changes: 76 additions & 0 deletions packages/app/src/components/prompt-input/readiness.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, test } from "bun:test"
import { promptKeyActionReady, promptSendDisabled } from "./readiness"

describe("promptKeyActionReady", () => {
test("allows keyboard stop when submit is blocked but abort is ready", () => {
expect(
promptKeyActionReady({
key: "Escape",
working: true,
stopping: false,
actionReady: false,
abortReady: true,
}),
).toBe(true)

expect(
promptKeyActionReady({
key: "Enter",
working: true,
stopping: true,
actionReady: false,
abortReady: true,
}),
).toBe(true)
})

test("keeps submit keys blocked when neither submit nor abort is ready", () => {
expect(
promptKeyActionReady({
key: "Enter",
working: true,
stopping: true,
actionReady: false,
abortReady: false,
}),
).toBe(false)
})

test("blocks non-stop keys while submit is blocked", () => {
expect(
promptKeyActionReady({
key: "ArrowUp",
working: false,
stopping: false,
actionReady: false,
abortReady: true,
}),
).toBe(false)
})
})

describe("promptSendDisabled", () => {
test("uses abort readiness only for the stop state", () => {
expect(
promptSendDisabled({
stopping: true,
actionReady: false,
abortReady: true,
blank: true,
selectedSkill: false,
}),
).toBe(false)
})

test("keeps nonblank send disabled when submit readiness is blocked", () => {
expect(
promptSendDisabled({
stopping: false,
actionReady: false,
abortReady: true,
blank: false,
selectedSkill: false,
}),
).toBe(true)
})
})
Loading
Loading