feat(app): migrate session interactions (#38461)
This commit is contained in:
parent
090a26a301
commit
29af2e39ff
14 changed files with 189 additions and 104 deletions
|
|
@ -98,7 +98,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||||
.toEqual([
|
.toEqual([
|
||||||
{
|
{
|
||||||
origin: serverA,
|
origin: serverA,
|
||||||
directory: directoryA,
|
directory: undefined,
|
||||||
sessionID: sessionA.id,
|
sessionID: sessionA.id,
|
||||||
permissionID: "permission-background-a",
|
permissionID: "permission-background-a",
|
||||||
body: { response: "once" },
|
body: { response: "once" },
|
||||||
|
|
@ -126,14 +126,14 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||||
.toEqual([
|
.toEqual([
|
||||||
{
|
{
|
||||||
origin: serverA,
|
origin: serverA,
|
||||||
directory: directoryA,
|
directory: undefined,
|
||||||
sessionID: sessionA.id,
|
sessionID: sessionA.id,
|
||||||
permissionID: "permission-background-a",
|
permissionID: "permission-background-a",
|
||||||
body: { response: "once" },
|
body: { response: "once" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
origin: serverA,
|
origin: serverA,
|
||||||
directory: directoryA,
|
directory: undefined,
|
||||||
sessionID: childSessionA.id,
|
sessionID: childSessionA.id,
|
||||||
permissionID: "permission-background-a-child",
|
permissionID: "permission-background-a-child",
|
||||||
body: { response: "once" },
|
body: { response: "once" },
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,8 @@ test("shows a pending question dock", async ({ page }) => {
|
||||||
const rejectRequests: string[] = []
|
const rejectRequests: string[] = []
|
||||||
page.on("request", (request) => {
|
page.on("request", (request) => {
|
||||||
if (request.method() !== "POST") return
|
if (request.method() !== "POST") return
|
||||||
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
|
if (new URL(request.url()).pathname === "/question/question-request/reject")
|
||||||
|
rejectRequests.push(request.url())
|
||||||
})
|
})
|
||||||
|
|
||||||
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
||||||
|
|
@ -64,7 +65,9 @@ test("shows a pending question dock", async ({ page }) => {
|
||||||
|
|
||||||
await question.getByRole("radio", { name: /Minimal/ }).click()
|
await question.getByRole("radio", { name: /Minimal/ }).click()
|
||||||
const reply = page.waitForRequest(
|
const reply = page.waitForRequest(
|
||||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
|
(request) =>
|
||||||
|
request.method() === "POST" &&
|
||||||
|
new URL(request.url()).pathname === "/question/question-request/reply",
|
||||||
)
|
)
|
||||||
await question.getByRole("button", { name: "Submit" }).click()
|
await question.getByRole("button", { name: "Submit" }).click()
|
||||||
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { expect, test, type Page } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const directory = "C:/OpenCode/SubagentNavigation"
|
const directory = "C:/OpenCode/SubagentNavigation"
|
||||||
|
|
@ -72,16 +72,19 @@ async function setup(page: Page, events?: () => EventPayload[]) {
|
||||||
events,
|
events,
|
||||||
eventRetry: events ? 16 : undefined,
|
eventRetry: events ? 16 : undefined,
|
||||||
})
|
})
|
||||||
// The child session resolves via /session/:id but is absent from the /session list,
|
// The child session resolves by ID but is absent from the session list,
|
||||||
// matching a subagent session that has not been loaded into the list cache yet.
|
// matching a subagent session that has not been loaded into the list cache yet.
|
||||||
await page.route(
|
await page.route(
|
||||||
(url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
(url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
||||||
(route) =>
|
(route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
headers: { "access-control-allow-origin": "*" },
|
||||||
body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]),
|
body: JSON.stringify({
|
||||||
|
data: [currentSession(session(parentID, parentTitle, 1700000000000))],
|
||||||
|
cursor: {},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await configurePage(page)
|
await configurePage(page)
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
}
|
}
|
||||||
|
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
|
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
||||||
route.request().method() === "POST"
|
route.request().method() === "POST"
|
||||||
|
|
|
||||||
|
|
@ -69,15 +69,11 @@ export const DialogFork: Component = () => {
|
||||||
const dir = base64Encode(sdk().directory)
|
const dir = base64Encode(sdk().directory)
|
||||||
|
|
||||||
sdk()
|
sdk()
|
||||||
.client.session.fork({ sessionID, messageID: item.id })
|
.api.session.fork({ sessionID, messageID: item.id })
|
||||||
.then((forked) => {
|
.then((forked) => {
|
||||||
if (!forked.data) {
|
|
||||||
showToast({ title: language.t("common.requestFailed") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dialog.close()
|
dialog.close()
|
||||||
prompt.set(restored, undefined, { dir, id: forked.data.id })
|
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||||
navigate(`/${dir}/session/${forked.data.id}`)
|
navigate(`/${dir}/session/${forked.id}`)
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
||||||
|
|
||||||
const createdClients: string[] = []
|
const createdClients: string[] = []
|
||||||
const createdSessions: string[] = []
|
const createdSessions: string[] = []
|
||||||
|
const sessionCreateInputs: Array<{
|
||||||
|
agent?: string
|
||||||
|
model?: { id: string; providerID: string; variant?: string }
|
||||||
|
location?: { directory: string }
|
||||||
|
}> = []
|
||||||
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
||||||
const optimistic: Array<{
|
const optimistic: Array<{
|
||||||
directory?: string
|
directory?: string
|
||||||
|
|
@ -19,11 +24,15 @@ const optimistic: Array<{
|
||||||
}> = []
|
}> = []
|
||||||
const optimisticSeeded: boolean[] = []
|
const optimisticSeeded: boolean[] = []
|
||||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||||
const sessionDirectories: Record<string, string> = {}
|
|
||||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||||
const sentShell: string[] = []
|
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||||
const syncedDirectories: string[] = []
|
const syncedDirectories: string[] = []
|
||||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||||
|
const sentPrompts: string[] = []
|
||||||
|
const promptInputs: unknown[] = []
|
||||||
|
const sentCommands: unknown[] = []
|
||||||
|
const commands: Array<{ name: string }> = []
|
||||||
|
let serverSessionSyncs = 0
|
||||||
|
|
||||||
let params: { id?: string } = {}
|
let params: { id?: string } = {}
|
||||||
let search: { draftId?: string } = {}
|
let search: { draftId?: string } = {}
|
||||||
|
|
@ -32,7 +41,7 @@ let variant: string | undefined
|
||||||
let permissionServer = "server-a"
|
let permissionServer = "server-a"
|
||||||
let createSessionGate: Promise<void> | undefined
|
let createSessionGate: Promise<void> | undefined
|
||||||
|
|
||||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||||
prompt: promptValue,
|
prompt: promptValue,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
|
|
@ -64,23 +73,39 @@ const prompt = {
|
||||||
const clientFor = (directory: string) => {
|
const clientFor = (directory: string) => {
|
||||||
createdClients.push(directory)
|
createdClients.push(directory)
|
||||||
return {
|
return {
|
||||||
session: {
|
api: {
|
||||||
create: async () => {
|
session: {
|
||||||
await createSessionGate
|
create: async (input: (typeof sessionCreateInputs)[number]) => {
|
||||||
createdSessions.push(directory)
|
await createSessionGate
|
||||||
return {
|
const location = input.location?.directory ?? directory
|
||||||
data: {
|
createdSessions.push(location)
|
||||||
|
sessionCreateInputs.push(input)
|
||||||
|
return {
|
||||||
id: `session-${createdSessions.length}`,
|
id: `session-${createdSessions.length}`,
|
||||||
|
projectID: "project",
|
||||||
|
agent: input.agent,
|
||||||
|
model: input.model,
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
title: `New session ${createdSessions.length}`,
|
title: `New session ${createdSessions.length}`,
|
||||||
},
|
location: { directory: location },
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
prompt: async (input: unknown) => {
|
||||||
|
sentPrompts.push(directory)
|
||||||
|
promptInputs.push(input)
|
||||||
|
return { data: undefined }
|
||||||
|
},
|
||||||
|
command: async (input: unknown) => {
|
||||||
|
sentCommands.push(input)
|
||||||
|
},
|
||||||
|
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||||
|
sentShell.push(input)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
shell: async () => {
|
},
|
||||||
sentShell.push(directory)
|
session: {
|
||||||
return { data: undefined }
|
|
||||||
},
|
|
||||||
prompt: async () => ({ data: undefined }),
|
|
||||||
promptAsync: async () => ({ data: undefined }),
|
|
||||||
command: async () => ({ data: undefined }),
|
command: async () => ({ data: undefined }),
|
||||||
abort: async () => ({ data: undefined }),
|
abort: async () => ({ data: undefined }),
|
||||||
},
|
},
|
||||||
|
|
@ -90,27 +115,6 @@ const clientFor = (directory: string) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = {
|
|
||||||
session: {
|
|
||||||
async create(input: { location: { directory: string } }) {
|
|
||||||
await createSessionGate
|
|
||||||
createdSessions.push(input.location.directory)
|
|
||||||
const session = {
|
|
||||||
id: `session-${createdSessions.length}`,
|
|
||||||
title: `New session ${createdSessions.length}`,
|
|
||||||
}
|
|
||||||
sessionDirectories[session.id] = input.location.directory
|
|
||||||
return session
|
|
||||||
},
|
|
||||||
async shell(input: { sessionID: string }) {
|
|
||||||
sentShell.push(sessionDirectories[input.sessionID] ?? "/repo/main")
|
|
||||||
},
|
|
||||||
async prompt() {},
|
|
||||||
async command() {},
|
|
||||||
async interrupt() {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const rootClient = clientFor("/repo/main")
|
const rootClient = clientFor("/repo/main")
|
||||||
|
|
||||||
|
|
@ -193,8 +197,8 @@ beforeAll(async () => {
|
||||||
const sdk = {
|
const sdk = {
|
||||||
scope: "local",
|
scope: "local",
|
||||||
directory: "/repo/main",
|
directory: "/repo/main",
|
||||||
api,
|
|
||||||
client: rootClient,
|
client: rootClient,
|
||||||
|
api: rootClient.api,
|
||||||
url: "http://localhost:4096",
|
url: "http://localhost:4096",
|
||||||
createClient(opts: any) {
|
createClient(opts: any) {
|
||||||
return clientFor(opts.directory)
|
return clientFor(opts.directory)
|
||||||
|
|
@ -206,7 +210,7 @@ beforeAll(async () => {
|
||||||
|
|
||||||
mock.module("@/context/sync", () => ({
|
mock.module("@/context/sync", () => ({
|
||||||
useSync: () => () => ({
|
useSync: () => () => ({
|
||||||
data: { command: [] },
|
data: { command: commands },
|
||||||
session: {
|
session: {
|
||||||
optimistic: {
|
optimistic: {
|
||||||
add: (value: {
|
add: (value: {
|
||||||
|
|
@ -233,6 +237,9 @@ beforeAll(async () => {
|
||||||
session: {
|
session: {
|
||||||
remember: () => undefined,
|
remember: () => undefined,
|
||||||
set: () => undefined,
|
set: () => undefined,
|
||||||
|
sync: async () => {
|
||||||
|
serverSessionSyncs++
|
||||||
|
},
|
||||||
},
|
},
|
||||||
child: (directory: string) => {
|
child: (directory: string) => {
|
||||||
syncedDirectories.push(directory)
|
syncedDirectories.push(directory)
|
||||||
|
|
@ -274,11 +281,17 @@ beforeAll(async () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createdClients.length = 0
|
createdClients.length = 0
|
||||||
createdSessions.length = 0
|
createdSessions.length = 0
|
||||||
|
sessionCreateInputs.length = 0
|
||||||
enabledAutoAccept.length = 0
|
enabledAutoAccept.length = 0
|
||||||
optimistic.length = 0
|
optimistic.length = 0
|
||||||
optimisticSeeded.length = 0
|
optimisticSeeded.length = 0
|
||||||
promoted.length = 0
|
promoted.length = 0
|
||||||
promotedDrafts.length = 0
|
promotedDrafts.length = 0
|
||||||
|
sentPrompts.length = 0
|
||||||
|
promptInputs.length = 0
|
||||||
|
sentCommands.length = 0
|
||||||
|
commands.length = 0
|
||||||
|
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
params = {}
|
params = {}
|
||||||
search = {}
|
search = {}
|
||||||
sentShell.length = 0
|
sentShell.length = 0
|
||||||
|
|
@ -287,8 +300,8 @@ beforeEach(() => {
|
||||||
variant = undefined
|
variant = undefined
|
||||||
permissionServer = "server-a"
|
permissionServer = "server-a"
|
||||||
createSessionGate = undefined
|
createSessionGate = undefined
|
||||||
|
serverSessionSyncs = 0
|
||||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||||
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("prompt submit worktree selection", () => {
|
describe("prompt submit worktree selection", () => {
|
||||||
|
|
@ -321,8 +334,24 @@ describe("prompt submit worktree selection", () => {
|
||||||
|
|
||||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(sessionCreateInputs).toEqual([
|
||||||
|
{
|
||||||
|
agent: "agent",
|
||||||
|
model: { id: "model", providerID: "provider", variant: undefined },
|
||||||
|
location: { directory: "/repo/worktree-a" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agent: "agent",
|
||||||
|
model: { id: "model", providerID: "provider", variant: undefined },
|
||||||
|
location: { directory: "/repo/worktree-b" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(sentShell).toEqual([
|
||||||
|
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||||
|
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
|
||||||
|
])
|
||||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||||
|
expect(serverSessionSyncs).toBe(0)
|
||||||
expect(promoted).toEqual([
|
expect(promoted).toEqual([
|
||||||
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
||||||
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
||||||
|
|
@ -443,6 +472,7 @@ describe("prompt submit worktree selection", () => {
|
||||||
const event = { preventDefault: () => undefined } as unknown as Event
|
const event = { preventDefault: () => undefined } as unknown as Event
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
|
await Bun.sleep(0)
|
||||||
|
|
||||||
expect(optimistic).toHaveLength(1)
|
expect(optimistic).toHaveLength(1)
|
||||||
expect(optimistic[0]).toMatchObject({
|
expect(optimistic[0]).toMatchObject({
|
||||||
|
|
@ -451,6 +481,53 @@ describe("prompt submit worktree selection", () => {
|
||||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
expect(sentPrompts).toEqual(["/repo/main"])
|
||||||
|
expect(promptInputs[0]).toMatchObject({
|
||||||
|
sessionID: "session-1",
|
||||||
|
text: "ls",
|
||||||
|
files: [],
|
||||||
|
agents: [],
|
||||||
|
})
|
||||||
|
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("submits slash commands through the current session API", async () => {
|
||||||
|
params = { id: "session-1" }
|
||||||
|
variant = "high"
|
||||||
|
commands.push({ name: "review" })
|
||||||
|
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
|
||||||
|
|
||||||
|
const submit = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
|
info: () => ({ id: "session-1" }),
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
|
mode: () => "normal",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||||
|
|
||||||
|
expect(sentCommands).toEqual([
|
||||||
|
{
|
||||||
|
sessionID: "session-1",
|
||||||
|
id: expect.stringMatching(/^msg_/),
|
||||||
|
command: "review",
|
||||||
|
arguments: "staged changes",
|
||||||
|
agent: "agent",
|
||||||
|
model: { id: "model", providerID: "provider", variant: "high" },
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(serverSessionSyncs).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses an injected model selection", async () => {
|
test("uses an injected model selection", async () => {
|
||||||
|
|
@ -511,7 +588,8 @@ describe("prompt submit worktree selection", () => {
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
|
|
||||||
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
|
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
|
||||||
|
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
|
||||||
expect(optimisticSeeded).toEqual([true])
|
expect(optimisticSeeded).toEqual([true])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { type DraftTab, useTabs } from "./tabs"
|
||||||
import { useSettings } from "./settings"
|
import { useSettings } from "./settings"
|
||||||
import { requireServerKey } from "@/utils/session-route"
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
import { normalizePermissionRequest } from "./global-sync/utils"
|
||||||
import {
|
import {
|
||||||
acceptKey,
|
acceptKey,
|
||||||
directoryAcceptKey,
|
directoryAcceptKey,
|
||||||
|
|
@ -243,9 +244,20 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
||||||
|
|
||||||
const respond: PermissionRespondFn = (request) => {
|
const respond: PermissionRespondFn = (request) => {
|
||||||
if (meta.disposed) return
|
if (meta.disposed) return
|
||||||
input.sdk.client.permission.respond(request).catch(() => {
|
input.sdk.api.permission
|
||||||
responded.delete(request.permissionID)
|
.reply({ sessionID: request.sessionID, requestID: request.permissionID, reply: request.response })
|
||||||
})
|
.catch(() => {
|
||||||
|
responded.delete(request.permissionID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = async (directory: string) => {
|
||||||
|
if ((await input.sdk.protocol) === "v1") {
|
||||||
|
return (await input.sdk.client.permission.list({ directory })).data ?? []
|
||||||
|
}
|
||||||
|
return input.sdk.api.permission.request
|
||||||
|
.list({ location: { directory } })
|
||||||
|
.then((result) => result.data.map(normalizePermissionRequest))
|
||||||
}
|
}
|
||||||
|
|
||||||
function respondOnce(permission: PermissionRequest, directory?: string) {
|
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||||
|
|
@ -343,14 +355,12 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
input.sdk.client.permission
|
list(directory)
|
||||||
.list({ directory })
|
.then((permissions) => {
|
||||||
.then((x) => {
|
|
||||||
if (meta.disposed) return
|
if (meta.disposed) return
|
||||||
if (!isAutoAcceptingDirectory(directory)) return
|
if (!isAutoAcceptingDirectory(directory)) return
|
||||||
for (const perm of x.data ?? []) {
|
for (const permission of permissions) {
|
||||||
if (!perm?.id) continue
|
void respondPending(permission, directory, () => isAutoAcceptingDirectory(directory))
|
||||||
void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory))
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
|
|
@ -377,16 +387,14 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
input.sdk.client.permission
|
list(directory)
|
||||||
.list({ directory })
|
.then((permissions) => {
|
||||||
.then((x) => {
|
|
||||||
if (meta.disposed) return
|
if (meta.disposed) return
|
||||||
if (enableVersion.get(key) !== version) return
|
if (enableVersion.get(key) !== version) return
|
||||||
if (!isAutoAccepting(sessionID, directory)) return
|
if (!isAutoAccepting(sessionID, directory)) return
|
||||||
for (const perm of x.data ?? []) {
|
for (const permission of permissions) {
|
||||||
if (!perm?.id) continue
|
|
||||||
void respondPending(
|
void respondPending(
|
||||||
perm,
|
permission,
|
||||||
directory,
|
directory,
|
||||||
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
|
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ test("archiving a Home session removes its open titlebar tab", async () => {
|
||||||
await archiveHomeSession({
|
await archiveHomeSession({
|
||||||
server: remote,
|
server: remote,
|
||||||
session: { id: "ses_1", directory: "/workspace" },
|
session: { id: "ses_1", directory: "/workspace" },
|
||||||
update: async () => undefined,
|
archive: async () => undefined,
|
||||||
remove: () => {
|
remove: () => {
|
||||||
removed = true
|
removed = true
|
||||||
},
|
},
|
||||||
|
|
@ -37,7 +37,7 @@ test("reports archive failures without removing the session", async () => {
|
||||||
await archiveHomeSession({
|
await archiveHomeSession({
|
||||||
server: remote,
|
server: remote,
|
||||||
session: { id: "ses_1", directory: "/workspace" },
|
session: { id: "ses_1", directory: "/workspace" },
|
||||||
update: async () => Promise.reject(failure),
|
archive: async () => Promise.reject(failure),
|
||||||
remove: () => {
|
remove: () => {
|
||||||
removed = true
|
removed = true
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,15 @@ type HomeSession = {
|
||||||
directory: string
|
directory: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionUpdate = {
|
|
||||||
directory: string
|
|
||||||
sessionID: string
|
|
||||||
time: { archived: number }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function archiveHomeSession(input: {
|
export async function archiveHomeSession(input: {
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
session: HomeSession
|
session: HomeSession
|
||||||
update: (value: SessionUpdate) => Promise<unknown>
|
archive: (sessionID: string) => Promise<unknown>
|
||||||
remove: () => void
|
remove: () => void
|
||||||
onError?: (error: unknown) => void
|
onError?: (error: unknown) => void
|
||||||
}) {
|
}) {
|
||||||
await input
|
await input
|
||||||
.update({
|
.archive(input.session.id)
|
||||||
directory: input.session.directory,
|
|
||||||
sessionID: input.session.id,
|
|
||||||
time: { archived: Date.now() },
|
|
||||||
})
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
input.remove()
|
input.remove()
|
||||||
notifySessionTabsRemoved({
|
notifySessionTabsRemoved({
|
||||||
|
|
|
||||||
|
|
@ -606,7 +606,7 @@ export function NewHome() {
|
||||||
await archiveHomeSession({
|
await archiveHomeSession({
|
||||||
server: ServerConnection.key(conn),
|
server: ServerConnection.key(conn),
|
||||||
session,
|
session,
|
||||||
update: (value) => ctx.sdk.client.session.update(value),
|
archive: (sessionID) => ctx.sdk.api.session.archive({ sessionID, directory: session.directory }),
|
||||||
remove: () =>
|
remove: () =>
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,8 @@ export function createPromptInputController(input: {
|
||||||
model: {
|
model: {
|
||||||
selection: input.model ?? local.model,
|
selection: input.model ?? local.model,
|
||||||
paid: providers.paid().length > 0,
|
paid: providers.paid().length > 0,
|
||||||
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
loading:
|
||||||
|
(local.agent.visible() && agentsQuery.isLoading) || providersQuery.isLoading || globalProvidersQuery.isLoading,
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
id: input.sessionID(),
|
id: input.sessionID(),
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||||
|
|
||||||
setStore("responding", perm.id)
|
setStore("responding", perm.id)
|
||||||
sdk()
|
sdk()
|
||||||
.client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
|
.api.permission.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response })
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const description = err instanceof Error ? err.message : String(err)
|
const description = err instanceof Error ? err.message : String(err)
|
||||||
showToast({ title: language.t("common.requestFailed"), description })
|
showToast({ title: language.t("common.requestFailed"), description })
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
}
|
}
|
||||||
|
|
||||||
const replyMutation = useMutation(() => ({
|
const replyMutation = useMutation(() => ({
|
||||||
mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }),
|
mutationFn: (answers: QuestionAnswer[]) =>
|
||||||
|
sdk().api.question.reply({ sessionID: props.request.sessionID, requestID: props.request.id, answers }),
|
||||||
onMutate: () => {
|
onMutate: () => {
|
||||||
props.onSubmit()
|
props.onSubmit()
|
||||||
},
|
},
|
||||||
|
|
@ -235,7 +236,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const rejectMutation = useMutation(() => ({
|
const rejectMutation = useMutation(() => ({
|
||||||
mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }),
|
mutationFn: () => sdk().api.question.reject({ sessionID: props.request.sessionID, requestID: props.request.id }),
|
||||||
onMutate: () => {
|
onMutate: () => {
|
||||||
props.onSubmit()
|
props.onSubmit()
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b
|
||||||
import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file"
|
import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal } from "@/context/local"
|
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
|
|
@ -19,6 +18,7 @@ import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionOwnership } from "./session-ownership"
|
import { createSessionOwnership } from "./session-ownership"
|
||||||
|
import { useLocal } from "@/context/local"
|
||||||
|
|
||||||
export type SessionCommandContext = {
|
export type SessionCommandContext = {
|
||||||
navigateMessageByOffset: (offset: number) => void
|
navigateMessageByOffset: (offset: number) => void
|
||||||
|
|
@ -40,7 +40,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const local = useLocal()
|
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
|
@ -48,6 +47,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
const local = useLocal()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||||
const sessionOwnership = createSessionOwnership(sessionKey)
|
const sessionOwnership = createSessionOwnership(sessionKey)
|
||||||
|
|
@ -306,7 +306,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
const owner = sessionOwnership.capture()
|
const owner = sessionOwnership.capture()
|
||||||
const client = sdk().client
|
const session = sdk().api.session
|
||||||
const directory = sdk().directory
|
const directory = sdk().directory
|
||||||
const promptSession = prompt.capture()
|
const promptSession = prompt.capture()
|
||||||
const revert = info()?.revert?.messageID
|
const revert = info()?.revert?.messageID
|
||||||
|
|
@ -316,13 +316,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
const parts = sync().data.part[message.id]
|
const parts = sync().data.part[message.id]
|
||||||
|
|
||||||
if (sync().data.session_working(sessionID)) {
|
if (sync().data.session_working(sessionID)) {
|
||||||
await client.session.abort({ sessionID }).catch(() => {})
|
await session.interrupt({ sessionID }).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
await runCommand({
|
await runCommand({
|
||||||
owner,
|
owner,
|
||||||
prompt: promptSession,
|
prompt: promptSession,
|
||||||
request: () => client.session.revert({ sessionID, messageID: message.id }),
|
request: () => session.revert.stage({ sessionID, messageID: message.id }),
|
||||||
updatePrompt: (promptSession) => {
|
updatePrompt: (promptSession) => {
|
||||||
if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
|
if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
|
||||||
},
|
},
|
||||||
|
|
@ -334,7 +334,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
const owner = sessionOwnership.capture()
|
const owner = sessionOwnership.capture()
|
||||||
const client = sdk().client
|
const session = sdk().api.session
|
||||||
const messages = userMessages()
|
const messages = userMessages()
|
||||||
const promptSession = prompt.capture()
|
const promptSession = prompt.capture()
|
||||||
|
|
||||||
|
|
@ -346,7 +346,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
await runCommand({
|
await runCommand({
|
||||||
owner,
|
owner,
|
||||||
prompt: promptSession,
|
prompt: promptSession,
|
||||||
request: () => client.session.unrevert({ sessionID }),
|
request: () => session.revert.clear({ sessionID }),
|
||||||
updatePrompt: (promptSession) => promptSession.reset(),
|
updatePrompt: (promptSession) => promptSession.reset(),
|
||||||
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)),
|
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)),
|
||||||
})
|
})
|
||||||
|
|
@ -356,7 +356,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
await runCommand({
|
await runCommand({
|
||||||
owner,
|
owner,
|
||||||
prompt: promptSession,
|
prompt: promptSession,
|
||||||
request: () => client.session.revert({ sessionID, messageID: next.id }),
|
request: () => session.revert.stage({ sessionID, messageID: next.id }),
|
||||||
updatePrompt: () => undefined,
|
updatePrompt: () => undefined,
|
||||||
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)),
|
updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)),
|
||||||
})
|
})
|
||||||
|
|
@ -375,10 +375,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await sdk().client.session.summarize({
|
await sdk().api.session.compact({
|
||||||
sessionID,
|
sessionID,
|
||||||
modelID: model.id,
|
model: { providerID: model.provider.id, modelID: model.id },
|
||||||
providerID: model.provider.id,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue