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
4 changes: 3 additions & 1 deletion packages/opencode/src/cli/cmd/tui/attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -90,6 +91,7 @@ export const AttachCommand = cmd({
args: {
continue: args.continue,
sessionID: args.session,
sessionProjectID: resumed?.projectID,
fork: args.fork,
},
directory,
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/cli/cmd/tui/context/args.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface Args {
prompt?: string
continue?: boolean
sessionID?: string
sessionProjectID?: string
fork?: boolean
}

Expand Down
11 changes: 10 additions & 1 deletion packages/opencode/src/cli/cmd/tui/context/event.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Event } from "@opencode-ai/sdk/v2"
import { useArgs } from "./args"
import { useProject } from "./project"
import { useSDK } from "./sdk"

Expand All @@ -7,6 +8,7 @@ type EventMetadata = {
}

export function useEvent() {
const args = useArgs()
const project = useProject()
const sdk = useSDK()

Expand All @@ -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 })
}
})
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions packages/opencode/src/cli/cmd/tui/validate-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <id>` 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
}
60 changes: 46 additions & 14 deletions packages/opencode/test/cli/tui/use-event.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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[] = []
Expand All @@ -59,19 +60,21 @@ async function mount() {
})

const app = await testRender(() => (
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<Probe
onReady={async (ctx) => {
project = ctx.project
await project.sync()
done()
}}
seen={seen}
workspaces={workspaces}
/>
</ProjectProvider>
</SDKProvider>
<ArgsProvider {...args}>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<Probe
onReady={async (ctx) => {
project = ctx.project
await project.sync()
done()
}}
seen={seen}
workspaces={workspaces}
/>
</ProjectProvider>
</SDKProvider>
</ArgsProvider>
))

await ready
Expand Down Expand Up @@ -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 <id>` 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()
}
})
})
Loading