diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index b908887c39f8..6757894c0c33 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -68,8 +68,9 @@ export const AttachCommand = cmd({ const headers = ServerAuth.headers({ password: args.password, username: args.username }) const config = await TuiConfig.get() + let resumed: { projectID: string } | undefined try { - await validateSession({ + resumed = await validateSession({ url: args.url, sessionID: args.session, directory, @@ -90,6 +91,7 @@ export const AttachCommand = cmd({ args: { continue: args.continue, sessionID: args.session, + sessionProjectID: resumed?.projectID, fork: args.fork, }, directory, diff --git a/packages/opencode/src/cli/cmd/tui/context/args.tsx b/packages/opencode/src/cli/cmd/tui/context/args.tsx index 8a229ffaba69..9895b1cb3642 100644 --- a/packages/opencode/src/cli/cmd/tui/context/args.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/args.tsx @@ -6,6 +6,7 @@ export interface Args { prompt?: string continue?: boolean sessionID?: string + sessionProjectID?: string fork?: boolean } diff --git a/packages/opencode/src/cli/cmd/tui/context/event.ts b/packages/opencode/src/cli/cmd/tui/context/event.ts index 5d814ecdcaab..fec4bd40acde 100644 --- a/packages/opencode/src/cli/cmd/tui/context/event.ts +++ b/packages/opencode/src/cli/cmd/tui/context/event.ts @@ -1,4 +1,5 @@ import type { Event } from "@opencode-ai/sdk/v2" +import { useArgs } from "./args" import { useProject } from "./project" import { useSDK } from "./sdk" @@ -7,6 +8,7 @@ type EventMetadata = { } export function useEvent() { + const args = useArgs() const project = useProject() const sdk = useSDK() @@ -16,7 +18,14 @@ export function useEvent() { return } - if (event.directory === "global" || event.project === project.project()) { + // A session resumed via `-s` from a different directory belongs to a + // different project than the launch cwd. Deliver its events too so the + // TUI live-renders without chdir-ing the process. See #28581. + if ( + event.directory === "global" || + event.project === project.project() || + (args.sessionProjectID !== undefined && event.project === args.sessionProjectID) + ) { handler(event.payload, { workspace: event.workspace }) } }) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 382147d918f5..9ebe9379d809 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -210,8 +210,9 @@ export const TuiThreadCommand = cmd({ events: createEventSource(client), } + let resumed: { projectID: string } | undefined try { - await validateSession({ + resumed = await validateSession({ url: transport.url, sessionID: args.session, directory: cwd, @@ -245,6 +246,7 @@ export const TuiThreadCommand = cmd({ args: { continue: args.continue, sessionID: args.session, + sessionProjectID: resumed?.projectID, agent: args.agent, model: args.model, prompt, diff --git a/packages/opencode/src/cli/cmd/tui/validate-session.ts b/packages/opencode/src/cli/cmd/tui/validate-session.ts index 31329a6533fe..de02f0e46187 100644 --- a/packages/opencode/src/cli/cmd/tui/validate-session.ts +++ b/packages/opencode/src/cli/cmd/tui/validate-session.ts @@ -10,7 +10,7 @@ export async function validateSession(input: { directory?: string fetch?: typeof fetch headers?: RequestInit["headers"] -}) { +}): Promise<{ projectID: string } | undefined> { if (!input.sessionID) return let sessionID: SessionID @@ -20,10 +20,15 @@ export async function validateSession(input: { throw new Error(`Invalid session ID: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error }) } - await createOpencodeClient({ + const session = await createOpencodeClient({ baseUrl: input.url, directory: input.directory, fetch: input.fetch, headers: input.headers, }).session.get({ sessionID }, { throwOnError: true }) + // The resumed session may belong to a different project than the launch + // directory (e.g. `opencode -s ` run from elsewhere). Returning its + // project lets the TUI event filter deliver the session's live events + // without chdir-ing the process. See #28581. + return session.data ? { projectID: session.data.projectID } : undefined } diff --git a/packages/opencode/test/cli/tui/use-event.test.tsx b/packages/opencode/test/cli/tui/use-event.test.tsx index 2aa3e978128c..1d02cdd2b388 100644 --- a/packages/opencode/test/cli/tui/use-event.test.tsx +++ b/packages/opencode/test/cli/tui/use-event.test.tsx @@ -6,6 +6,7 @@ import { onMount } from "solid-js" import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project" import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk" import { useEvent } from "../../../src/cli/cmd/tui/context/event" +import { ArgsProvider, type Args } from "../../../src/cli/cmd/tui/context/args" import { createEventSource, createFetch, directory } from "../../fixture/tui-sdk" const projectID = "proj_test" @@ -47,7 +48,7 @@ function update(version: string): Event { } } -async function mount() { +async function mount(args: Args = {}) { const events = createEventSource() const calls = createFetch() const seen: Event[] = [] @@ -59,19 +60,21 @@ async function mount() { }) const app = await testRender(() => ( - - - { - project = ctx.project - await project.sync() - done() - }} - seen={seen} - workspaces={workspaces} - /> - - + + + + { + project = ctx.project + await project.sync() + done() + }} + seen={seen} + workspaces={workspaces} + /> + + + )) await ready @@ -155,4 +158,33 @@ describe("useEvent", () => { app.renderer.destroy() } }) + + test("delivers events for a resumed session's project from another directory", async () => { + const { app, emit, seen } = await mount({ sessionProjectID: "proj_resumed" }) + + try { + // `opencode -s ` launched elsewhere: event project matches the + // resumed session, not the launch project. + emit(event(vcs("resumed"), { directory: "/tmp/other", project: "proj_resumed" })) + + await wait(() => seen.length === 1) + + expect(seen).toEqual([vcs("resumed")]) + } finally { + app.renderer.destroy() + } + }) + + test("still ignores unrelated projects when a session is resumed", async () => { + const { app, emit, seen } = await mount({ sessionProjectID: "proj_resumed" }) + + try { + emit(event(vcs("other"), { directory: "/tmp/other", project: "proj_unrelated" })) + await Bun.sleep(30) + + expect(seen).toHaveLength(0) + } finally { + app.renderer.destroy() + } + }) })