diff --git a/opencode-ui/packages/app/src/pages/session.tsx b/opencode-ui/packages/app/src/pages/session.tsx index 5ceaf60..963d097 100644 --- a/opencode-ui/packages/app/src/pages/session.tsx +++ b/opencode-ui/packages/app/src/pages/session.tsx @@ -40,7 +40,7 @@ import { showToast } from "@opencode-ai/ui/toast" import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session" import { navMark, navParams } from "@/utils/perf" import { same } from "@/utils/same" -import { createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "@/pages/session/helpers" +import { createOpenReviewFile, focusTerminalById, getTabReorderIndex, isStaleSubmittedPrompt } from "@/pages/session/helpers" import { createScrollSpy } from "@/pages/session/scroll-spy" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" @@ -680,6 +680,37 @@ export default function Page() { sync.session.sync(id) }) + // Clear stale submitted prompts (devaipod iframe workaround). + // + // In devaipod, switching between pods destroys and recreates the iframe + // hosting the OpenCode web UI. If the user navigates away while the + // agent is still processing, the per-session prompt in localStorage may + // retain the already-submitted text. On reload the stale text reappears + // in the input box. Detect and clear it on session mount. + { + let cleared = false + createEffect(() => { + if (cleared) return + const id = params.id + if (!id) return + if (!prompt.ready()) return + + const msgs = sync.data.message[id] + if (!msgs) return + + if ( + isStaleSubmittedPrompt({ + promptParts: prompt.current(), + messages: msgs, + parts: sync.data.part, + }) + ) { + cleared = true + prompt.reset() + } + }) + } + createEffect(() => { if (!view().terminal.opened()) { setUi("autoCreated", false) diff --git a/opencode-ui/packages/app/src/pages/session/helpers.test.ts b/opencode-ui/packages/app/src/pages/session/helpers.test.ts index d877d5b..ee0285c 100644 --- a/opencode-ui/packages/app/src/pages/session/helpers.test.ts +++ b/opencode-ui/packages/app/src/pages/session/helpers.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test" -import { combineCommandSections, createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "./helpers" +import { + combineCommandSections, + createOpenReviewFile, + focusTerminalById, + getTabReorderIndex, + isStaleSubmittedPrompt, +} from "./helpers" describe("createOpenReviewFile", () => { test("opens and loads selected review file", () => { @@ -69,3 +75,142 @@ describe("getTabReorderIndex", () => { expect(getTabReorderIndex(["a", "b", "c"], "a", "missing")).toBeUndefined() }) }) + +describe("isStaleSubmittedPrompt", () => { + const userMsg = (id: string) => ({ role: "user", id }) + const assistantMsg = (id: string) => ({ role: "assistant", id }) + const textPart = (text: string) => ({ type: "text", text }) + + test("detects prompt matching last user message", () => { + expect( + isStaleSubmittedPrompt({ + promptParts: [{ type: "text", content: "fix the bug" }], + messages: [userMsg("m1"), assistantMsg("m2")], + parts: { m1: [textPart("fix the bug")] }, + }), + ).toBe(true) + }) + + test("matches after trimming whitespace", () => { + expect( + isStaleSubmittedPrompt({ + promptParts: [{ type: "text", content: " fix the bug " }], + messages: [userMsg("m1")], + parts: { m1: [textPart("fix the bug")] }, + }), + ).toBe(true) + }) + + test("returns false for empty prompt", () => { + expect( + isStaleSubmittedPrompt({ + promptParts: [{ type: "text", content: "" }], + messages: [userMsg("m1")], + parts: { m1: [textPart("fix the bug")] }, + }), + ).toBe(false) + }) + + test("returns false when prompt differs from last message", () => { + expect( + isStaleSubmittedPrompt({ + promptParts: [{ type: "text", content: "something new" }], + messages: [userMsg("m1")], + parts: { m1: [textPart("fix the bug")] }, + }), + ).toBe(false) + }) + + test("returns false with no user messages", () => { + expect( + isStaleSubmittedPrompt({ + promptParts: [{ type: "text", content: "fix the bug" }], + messages: [assistantMsg("m1")], + parts: {}, + }), + ).toBe(false) + }) + + test("returns false when message parts are not loaded yet", () => { + expect( + isStaleSubmittedPrompt({ + promptParts: [{ type: "text", content: "fix the bug" }], + messages: [userMsg("m1")], + parts: {}, + }), + ).toBe(false) + }) + + test("ignores synthetic and ignored parts", () => { + expect( + isStaleSubmittedPrompt({ + promptParts: [{ type: "text", content: "fix the bug" }], + messages: [userMsg("m1")], + parts: { + m1: [ + { type: "text", text: "system prompt", synthetic: true }, + { type: "text", text: "fix the bug" }, + ], + }, + }), + ).toBe(true) + + expect( + isStaleSubmittedPrompt({ + promptParts: [{ type: "text", content: "fix the bug" }], + messages: [userMsg("m1")], + parts: { + m1: [{ type: "text", text: "fix the bug", synthetic: true }], + }, + }), + ).toBe(false) + }) + + test("uses last user message, not first", () => { + expect( + isStaleSubmittedPrompt({ + promptParts: [{ type: "text", content: "second message" }], + messages: [userMsg("m1"), assistantMsg("m2"), userMsg("m3")], + parts: { + m1: [textPart("first message")], + m3: [textPart("second message")], + }, + }), + ).toBe(true) + + expect( + isStaleSubmittedPrompt({ + promptParts: [{ type: "text", content: "first message" }], + messages: [userMsg("m1"), assistantMsg("m2"), userMsg("m3")], + parts: { + m1: [textPart("first message")], + m3: [textPart("second message")], + }, + }), + ).toBe(false) + }) + + test("handles multi-part prompt text", () => { + expect( + isStaleSubmittedPrompt({ + promptParts: [ + { type: "text", content: "fix " }, + { type: "file", content: "@src/main.ts" }, + { type: "text", content: " please" }, + ], + messages: [userMsg("m1")], + parts: { m1: [textPart("fix @src/main.ts please")] }, + }), + ).toBe(true) + }) + + test("returns false for whitespace-only prompt", () => { + expect( + isStaleSubmittedPrompt({ + promptParts: [{ type: "text", content: " " }], + messages: [userMsg("m1")], + parts: { m1: [textPart("fix the bug")] }, + }), + ).toBe(false) + }) +}) diff --git a/opencode-ui/packages/app/src/pages/session/helpers.ts b/opencode-ui/packages/app/src/pages/session/helpers.ts index 6ead7a7..1f33897 100644 --- a/opencode-ui/packages/app/src/pages/session/helpers.ts +++ b/opencode-ui/packages/app/src/pages/session/helpers.ts @@ -46,3 +46,49 @@ export const getTabReorderIndex = (tabs: readonly string[], from: string, to: st if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return undefined return toIndex } + +/** + * Detect whether the current prompt input is a stale copy of an + * already-submitted message. + * + * In devaipod's iframe architecture, switching pods destroys and + * recreates the iframe hosting the OpenCode web UI. If the user + * navigates away while the agent is still processing, the per-session + * prompt state in localStorage may not reflect the `clearInput()` that + * ran on submit. When the iframe is recreated the stale prompt is + * restored. + * + * This function returns `true` when the prompt text matches the last + * user message, indicating it should be cleared. + */ +export function isStaleSubmittedPrompt(input: { + promptParts: readonly { type: string; content?: string }[] + messages: readonly { role: string; id: string }[] + parts: Record +}): boolean { + const text = input.promptParts + .map((p) => ("content" in p && p.content ? p.content : "")) + .join("") + .trim() + if (!text) return false + + const lastUser = findLastUserMessage(input.messages) + if (!lastUser) return false + + const messageParts = input.parts[lastUser.id] + if (!messageParts) return false + + const textPart = messageParts.find( + (p) => p.type === "text" && !p.synthetic && !p.ignored, + ) + if (!textPart || !textPart.text) return false + + return textPart.text.trim() === text +} + +function findLastUserMessage(messages: readonly { role: string; id: string }[]) { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") return messages[i] + } + return undefined +} diff --git a/src/main.rs b/src/main.rs index b976853..f85afa8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5815,10 +5815,12 @@ fn cmd_opencode_status(pod_name: &str, json_output: bool) -> Result<()> { fn check_agent_health(pod_name: &str) -> Option { let workspace_container = format!("{}-workspace", pod_name); - // Use nc to check if the port is accepting connections. - // This is more reliable than HTTP health checks since opencode's - // endpoints may return errors during/after initialization. - let check_cmd = format!("nc -z localhost {} 2>/dev/null", pod::OPENCODE_PORT); + // Try nc first (fast port check), fall back to curl (more widely available). + // Custom/minimal container images may not have nc installed. + let check_cmd = format!( + "nc -z localhost {port} 2>/dev/null || curl -sf -o /dev/null http://localhost:{port}/session 2>/dev/null", + port = pod::OPENCODE_PORT, + ); let result = podman_command() .args(["exec", &workspace_container, "/bin/sh", "-c", &check_cmd]) .status();