Skip to content
Merged
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
33 changes: 32 additions & 1 deletion opencode-ui/packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
147 changes: 146 additions & 1 deletion opencode-ui/packages/app/src/pages/session/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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)
})
})
46 changes: 46 additions & 0 deletions opencode-ui/packages/app/src/pages/session/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, readonly { type: string; text?: string; synthetic?: boolean; ignored?: boolean }[] | undefined>
}): 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
}
10 changes: 6 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5815,10 +5815,12 @@ fn cmd_opencode_status(pod_name: &str, json_output: bool) -> Result<()> {
fn check_agent_health(pod_name: &str) -> Option<bool> {
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();
Expand Down
Loading