Compare commits

..

18 Commits

Author SHA1 Message Date
rekram1-node 104b483bb9 fix(tui): default to configured agent variants 2026-09-03 14:47:55 +00:00
Aiden Cline f98a6286da refactor(ai): drop responses replay tombstones (#46965) 2026-09-03 09:39:47 -05:00
usrnk1 de365ecbaa feat(desktop): reflect saved project colors (#46787) 2026-09-03 16:37:57 +02:00
usrnk1 b3b08c9a04 feat(desktop): update blue accent styling (#47009) 2026-09-03 16:31:15 +02:00
usrnk1 4fcb59e4c7 feat(desktop): polish session activity controls (#47033) 2026-09-03 16:23:54 +02:00
OpeOginni c7263309d4 fix(desktop): wait for session export (#46435) 2026-09-03 14:04:23 +00:00
opencode-agent[bot] 5d8a01dedc feat(tui): add copy session ID command (#47064)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
2026-09-03 13:50:21 +00:00
OpeOginni 0f6393dab1 feat(app): add desktop session import (#46416)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-09-03 13:14:40 +00:00
Shoubhit Dash 59b29de409 fix(core): detect new ecosystem config roots (#47026) 2026-09-03 16:09:58 +05:30
opencode-agent[bot] 887f319769 fix(desktop): restore compact Windows channel badge (#47016)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-03 20:19:03 +10:00
opencode-agent[bot] 24f6cb51c8 fix(core): watch new config files and directories (#46925)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-09-03 15:38:28 +05:30
opencode-agent[bot] d2bec894bf fix(app): identify desktop in Console device auth (#47001)
Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
2026-09-03 11:50:45 +02:00
opencode-agent[bot] d7009156a6 chore: update nix node_modules hashes 2026-09-03 08:56:58 +00:00
opencode-agent[bot] 4cfca7ab68 fix(app): restore terminal application mouse handling (#45140)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-09-03 16:39:27 +08:00
Luke Parker ae7d9f029d fix(session-ui): preserve configured groups on tool failure (#46998) 2026-09-03 07:30:50 +00:00
Luke Parker 9acdb0be18 fix(session-ui): use standard Used labels for thoughts (#46995) 2026-09-03 07:10:56 +00:00
opencode-agent[bot] b42555cf01 fix: reuse current location for directory browsing (#46970)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-03 16:45:17 +10:00
Luke Parker cfe9f13963 fix(ui): capitalize notices in used group (#46992) 2026-09-03 06:43:01 +00:00
184 changed files with 2967 additions and 1735 deletions
+1
View File
@@ -1046,6 +1046,7 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-j3Vtfd5sx+hHnV3qHBq7Ae96E08meNqTqtYN9kWmCqE=",
"aarch64-linux": "sha256-LCvHEYoDgvkXtFFs0u7XBEzyo8fGGX25N8iuLrHSu8I=",
"aarch64-darwin": "sha256-wBRx1e8x6toEHV1sFMDDs26+s8IHyPAqCg6TcBgsxxA=",
"x86_64-darwin": "sha256-ClVls/CTpT76W5SQ/cij2YFmBqv9FGLeVxQh8iStnEk="
"x86_64-linux": "sha256-I2sHU46Bdp1DmW/ygNKKxF6U53oNIeVeAS8TmMdtvzw=",
"aarch64-linux": "sha256-aQUEBmfHhaR/YdsRyw0u6hsEo0TEmXHBMyiGnOFRCeI=",
"aarch64-darwin": "sha256-40Y3HYSjs8S0M3SFNUrU8lMYaHy7vpmiW+7b6Y9pKzs=",
"x86_64-darwin": "sha256-7iGegI/1pXcR5W0UBX0wAWAga+F34fhj9lbAHotVwEo="
}
}
+1
View File
@@ -177,6 +177,7 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"vite@8.2.2": "patches/vite@8.2.2.patch"
}
}
+4 -23
View File
@@ -423,14 +423,10 @@ export interface ParserState {
readonly name: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedTools: ReadonlySet<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
readonly completedMessages: ReadonlySet<string>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
@@ -1042,16 +1038,12 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
const item = event.item
if (!item) return [state, NO_EVENTS]
if (item.type === "message") {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS]
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== item.id) completedMessages.add(state.message.id)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== item.id)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
return Lifecycle.textEnd(
lifecycle,
@@ -1064,7 +1056,6 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
{
...state,
lifecycle,
completedMessages,
message: {
id: item.id,
phase: phase === undefined && state.message?.id === item.id ? state.message.phase : phase,
@@ -1094,7 +1085,7 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
]
}
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
if (state.tools[item.id] !== undefined || state.completedTools.has(item.id)) return [state, NO_EVENTS]
if (state.tools[item.id] !== undefined) return [state, NO_EVENTS]
const metadata = providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
@@ -1198,14 +1189,9 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
}
if (item.type === "message") {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
if (state.message !== undefined && state.message.id !== item.id)
return [{ ...state, completedMessages }, NO_EVENTS] satisfies StepResult
const message = state.message
const active = state.message?.id === item.id
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? message?.phase : itemPhase
const phase = itemPhase === undefined && active ? state.message?.phase : itemPhase
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
const content: string[] = []
for (const part of parts) {
@@ -1221,8 +1207,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
{
...state,
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
completedMessages,
message: undefined,
message: active ? undefined : state.message,
},
events,
] satisfies StepResult
@@ -1230,7 +1215,6 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
if (state.completedTools.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const metadata = providerMetadata(state, { itemId: item.id })
const registered = state.tools[item.id] !== undefined
const tools = registered
@@ -1257,7 +1241,6 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
tools: result.tools,
completedTools: new Set([...state.completedTools, item.id]),
},
events,
] satisfies StepResult
@@ -1518,11 +1501,9 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
providerMetadataKey: metadataKey(request.model),
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
completedTools: new Set<string>(),
lifecycle: Lifecycle.initial(),
outputItems: {},
message: undefined,
completedMessages: new Set<string>(),
reasoningItems: {},
})
@@ -82,32 +82,6 @@ describe("Open Responses completed item text", () => {
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
}),
)
it.effect("assembles a done-only message once across replayed item events", () =>
Effect.gen(function* () {
const item = {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Recovered" }],
}
const response = yield* generate(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Ignored after resume" },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.output_item.done", item },
completed,
)
expect(response.text).toBe("Recovered")
expect(response.message.content).toEqual([
{
type: "text",
text: "Recovered",
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
},
])
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
}),
)
})
describe("Open Responses completed item reasoning", () => {
@@ -217,7 +217,7 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("preserves non-empty done-only message content without replaying duplicates", () =>
it.effect("preserves non-empty done-only message content", () =>
Effect.gen(function* () {
const text = {
type: "message",
@@ -230,17 +230,11 @@ describe("Open Responses basic-item lifecycles", () => {
content: [{ type: "refusal", refusal: "Done-only refusal." }],
}
const events = yield* collect(
{ type: "response.output_item.done", item: text },
{ type: "response.output_item.done", item: text },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "Late" }] },
},
{ type: "response.output_item.done", item: refusal },
{ type: "response.output_item.done", item: refusal },
completed,
)
@@ -272,63 +266,6 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("treats a repeated message lifecycle as replay", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Second" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
completed,
)
expect(events.filter(LLMEvent.is.textEnd)).toEqual([
{
type: "text-end",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First"])
}),
)
it.effect("ignores a stale done-only message while another message is active", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Recovered" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Final" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Late" }] },
},
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-delta", id: "msg_1", text: "Draft" },
{
type: "text-end",
id: "msg_1",
text: "Final",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
])
}),
)
// Captured from Bedrock Mantle (openai.gpt-oss-120b): the terminal function_call
// items rename `id` to `item_id` and carry a stray `output_index`.
it.effect("recovers a terminal function_call id from its output slot", () =>
@@ -419,7 +356,7 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("opens and closes a done-only tool once", () =>
it.effect("opens and closes a done-only tool", () =>
Effect.gen(function* () {
const item = {
type: "function_call",
@@ -428,12 +365,7 @@ describe("Open Responses basic-item lifecycles", () => {
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
completed,
)
const events = yield* collect({ type: "response.output_item.done", item }, completed)
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
@@ -2850,7 +2850,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("ignores duplicate item boundary events", () =>
it.effect("ignores duplicate item start events", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
@@ -2881,21 +2881,6 @@ describe("OpenAI Responses route", () => {
arguments: '{"query":"weather"}',
},
},
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
// A completed item that is re-added stays closed.
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
@@ -93,71 +93,6 @@ test("keeps review visibility per tab and the pane mounted across tab switches",
await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible()
})
test("compact shell headers preserve equal outer gaps and aligned controls", async ({ page }) => {
await setup(page)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
})
await page.goto(sessionHref(sessionA))
await expectSessionTitle(page, titleA)
// Exercise the desktop's compact header metric with the real shell and panel components.
await page.locator('[data-slot="shell-layout"]').evaluate((element) => {
element.style.setProperty("--shell-header-height", "40px")
})
await page.getByRole("button", { name: "Toggle review", exact: true }).click()
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
const toggle = page.getByRole("button", { name: "Toggle vertical tabs", exact: true })
const review = page.getByRole("button", { name: "Toggle review", exact: true })
const file = page.locator('#review-panel button[aria-label="Open file"]')
await expect(file).toBeVisible()
const frame = await page.locator('[data-slot="shell-layout"]').boundingBox()
if (!frame) throw new Error("shell has no bounding box")
const bounds = await toggle.boundingBox()
expect(bounds).toMatchObject({ y: frame.y + 14, height: 28 })
for (const opened of [true, false]) {
if (!opened) await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", String(opened))
await expect.poll(() => toggle.boundingBox()).toEqual(bounds)
await expect
.poll(async () => {
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
const button = await review.boundingBox()
if (!panel || !button) return undefined
return { top: button.y - panel.y, right: panel.x + panel.width - button.x - button.width }
})
.toEqual({ top: 6, right: 6 })
await expect
.poll(async () => {
const button = await review.boundingBox()
return button ? button.y + button.height / 2 : undefined
})
.toBe(frame.y + 28)
await expect
.poll(async () => {
const button = await file.boundingBox()
return button ? button.y + button.height / 2 : undefined
})
.toBe(frame.y + 28)
await expect
.poll(() =>
page.locator('[data-slot="session-chat-panel"], #review-panel').evaluateAll(
(panels, frame) =>
panels.map((panel) => {
const bounds = panel.getBoundingClientRect()
return { top: bounds.top - frame.y, bottom: frame.y + frame.height - bounds.bottom }
}),
frame,
),
)
.toEqual([
{ top: 8, bottom: 8 },
{ top: 8, bottom: 8 },
])
}
})
type Probed = HTMLElement & { __e2eProbe?: string }
async function switchTab(page: Page, title: string) {
@@ -67,66 +67,3 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(mcp).toBeHidden()
})
}
for (const direction of ["ltr", "rtl"] as const) {
test(`collapsed vertical navigation stays in the populated timeline header in ${direction}`, async ({ page }) => {
await mockOpenCodeServer(page, {
directory: fixture.directory,
project: fixture.project,
sessions: fixture.sessions,
provider: fixture.provider,
pageMessages,
})
await installStressSessionTabs(page)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
})
await page.goto(stressSessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await page.locator("html").evaluate((element, direction) => element.setAttribute("dir", direction), direction)
await page.locator('[data-slot="shell-layout"]').evaluate((element) => {
element.style.setProperty("--shell-header-height", "40px")
element.style.setProperty("--tabs-native-inset", "70px")
})
const toggle = page.getByRole("button", { name: "Toggle vertical tabs", exact: true })
const host = header.locator('[data-slot="titlebar-navigation-start"]')
await expect(host).toBeAttached()
await host.evaluate((element) => element.setAttribute("data-retained-host", "true"))
for (const width of [1440, 900]) {
await page.setViewportSize({ width, height: 900 })
await toggle.click()
await expect(header.locator(toggle)).toHaveAttribute("aria-expanded", "false")
await expect(host).toHaveAttribute("data-retained-host", "true")
await expect(page.locator('[data-slot="collapsed-tabs-toolbar"]')).toBeHidden()
await expect
.poll(async () => {
const boxes = await Promise.all([
toggle.boundingBox(),
header.getByRole("button", { name: "Home", exact: true }).boundingBox(),
header.getByRole("button", { name: "New session", exact: true }).boundingBox(),
header.getByRole("heading").boundingBox(),
header.getByRole("button", { name: "Toggle review", exact: true }).boundingBox(),
])
if (boxes.some((box) => !box)) return Infinity
const centers = boxes.map((box) => box!.y + box!.height / 2)
return Math.max(...centers) - Math.min(...centers)
})
.toBeLessThanOrEqual(1)
await expect
.poll(async () => {
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
const button = await header.getByRole("button", { name: "Toggle review", exact: true }).boundingBox()
if (!panel || !button) return undefined
return {
top: button.y - panel.y,
end: direction === "ltr" ? panel.x + panel.width - button.x - button.width : button.x - panel.x,
}
})
.toEqual({ top: 6, end: direction === "ltr" ? 6 : 76 })
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(host).toHaveAttribute("data-retained-host", "true")
}
})
}
@@ -42,7 +42,7 @@ test("changes timeline presets and saves custom thinking details", async ({ page
.toEqual({ placement: "grouped", details: "collapsed" })
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await page.getByRole("button", { name: "Reasoning", exact: true }).click()
await page.getByRole("button", { name: "Used 1 Thought", exact: true }).click()
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await part.getByRole("button").click()
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
@@ -138,7 +138,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
})
test("keeps failed search calls and their error cards outside the collapsed stack", async ({ page }) => {
test("keeps failed search calls and their error cards inside the collapsed stack", async ({ page }) => {
const parts = [
toolPart(
"prt_error_glob",
@@ -161,9 +161,14 @@ test("keeps failed search calls and their error cards outside the collapsed stac
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(page.locator('[data-component="collapsed-tool-group"]')).toHaveCount(0)
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
const glob = page.locator('[data-timeline-part-id="prt_error_glob"]')
const group = page.locator('[data-component="collapsed-tool-group"]')
const summary = group.getByRole("button", { name: "Used 1 Glob, 1 Grep", exact: true })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await summary.click()
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
await expect(glob.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await glob.getByRole("button").click()
await expect(glob).toContainText("Invalid tool input")
await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible()
await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute(
@@ -177,7 +182,8 @@ test("keeps failed search calls and their error cards outside the collapsed stac
.evaluate((element) => getComputedStyle(element, "::before").display),
)
.toBe("none")
await expect(page.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
await group.locator('[data-timeline-part-id="prt_error_grep"]').getByRole("button").click()
await expect(group.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
"Search timed out after 30 seconds",
)
})
@@ -8,13 +8,11 @@ import {
userMessage,
} from "../performance/timeline-stability/fixture"
test("transitions shell and question through running error outcomes", async ({ page }) => {
test("keeps shell and question failures in their Used group", async ({ page }) => {
const shellID = "prt_transition_error_shell"
const questionID = "prt_transition_error_question"
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "expanded" } },
},
settings: { timelineDetail: timelinePresets[2].value },
messages: [
userMessage(),
assistantMessage(
@@ -26,13 +24,24 @@ test("transitions shell and question through running error outcomes", async ({ p
),
],
})
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await used.click()
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })))
await expect(page.locator(`[data-timeline-part-id="${shellID}"]`)).toContainText("exit 1")
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())))
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(
partUpdated(toolPart(shellID, "shell", "error", { command: "exit 1" }, { error: "Command exited 1" })),
partUpdated(
toolPart(
shellID,
"shell",
"completed",
{ command: "exit 1" },
{ output: "Command exited 1", metadata: { exit: 1 } },
),
),
)
await timeline.send(
partUpdated(
@@ -40,17 +49,21 @@ test("transitions shell and question through running error outcomes", async ({ p
),
)
await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
await expect(group).toHaveAttribute("data-timeline-part-ids", `${shellID},${questionID}`)
await expect(used).toHaveAttribute("aria-expanded", "true")
const shell = group.locator(`[data-timeline-part-id="${shellID}"]`)
await expect(shell.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await shell.locator('[data-slot="collapsible-trigger"]').click()
await expect(shell).toContainText("Command exited 1")
const question = group.locator(`[data-timeline-part-id="${questionID}"]`)
await expect(question).toContainText(/dismissed/i)
})
test("preserves surviving grouped patch state when its first patch fails", async ({ page }) => {
test("keeps a failed patch in Used without losing the surviving file choice", async ({ page }) => {
const failed = "prt_grouped_patch_failed"
const surviving = "prt_grouped_patch_surviving"
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { timelineDetail: timelinePresets[2].value },
messages: [
userMessage(),
assistantMessage(
@@ -81,7 +94,9 @@ test("preserves surviving grouped patch state when its first patch fails", async
],
})
const group = page.locator(`[data-timeline-part-ids="${failed},${surviving}"]`)
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await used.click()
const file = group.locator('[data-scope="apply-patch"] button')
await expect(file).toBeVisible()
await expect(file).toHaveAttribute("aria-expanded", "false")
@@ -104,18 +119,14 @@ test("preserves surviving grouped patch state when its first patch fails", async
const survivingRow = page.locator("[data-timeline-key]", {
has: page.locator(`[data-timeline-part-id="${surviving}"]`),
})
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:part:/)
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:file:/)
await expect(group).toHaveAttribute("data-timeline-part-ids", `${failed},${surviving}`)
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
await group.locator(`[data-timeline-part-id="${failed}"] [data-slot="collapsible-trigger"]`).click()
await expect(failedRow.getByText("Patch failed visibly")).toBeVisible()
await expect(survivingRow).toHaveAttribute("data-group-identity", "preserved")
await expect(survivingRow.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
await expect
.poll(async () => {
const previous = await failedRow.boundingBox()
const next = await survivingRow.boundingBox()
return previous && next ? next.y - (previous.y + previous.height) : Number.NEGATIVE_INFINITY
})
.toBeGreaterThanOrEqual(-0.5)
})
test("groups instruction files loaded by the same read", async ({ page }) => {
@@ -231,127 +231,6 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await expect(tabB).toBeVisible()
})
for (const direction of ["ltr", "rtl"] as const) {
test(`vertical tabs toggle stays anchored and exposes header navigation (${direction})`, async ({
page,
}, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionID }) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({ appearance: { tabLayout: "vertical" }, general: { showStatus: true } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ server, sessionID: sessionA.id },
)
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.goto(href)
const header = page.locator("[data-session-title]")
await expect(header.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
await page.locator("html").evaluate((element, dir) => element.setAttribute("dir", dir), direction)
const toggle = page.getByRole("button", { name: "Toggle vertical tabs", exact: true })
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
await expect(sidebar.locator(toggle)).toHaveAttribute("aria-expanded", "true")
const badge = page.locator('[data-slot="channel-indicator"]')
await expect(badge).toHaveCount(1)
await expect(badge).toHaveText((process.env.OPENCODE_CHANNEL ?? "dev").toUpperCase())
await expect(sidebar.locator('[data-slot="vertical-tabs-controls"]').locator(badge)).toBeVisible()
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]').locator(badge)).toHaveCount(0)
const badgeBounds = await badge.boundingBox()
const bounds = await toggle.boundingBox()
expect(bounds).not.toBeNull()
if (!badgeBounds || !bounds) throw new Error("tab controls have no bounding box")
expect(
direction === "ltr" ? bounds.x - badgeBounds.x - badgeBounds.width : badgeBounds.x - bounds.x - bounds.width,
).toBe(12)
expect(badgeBounds.y + badgeBounds.height / 2).toBe(bounds.y + bounds.height / 2)
await toggle.click()
await expect(sidebar).toHaveCount(0)
await expect(header.locator(toggle)).toHaveAttribute("aria-expanded", "false")
await expect(header.locator('[data-slot="vertical-tabs-controls"]').locator(badge)).toBeVisible()
await expect.poll(() => badge.boundingBox()).toEqual(badgeBounds)
await expect(toggle).toBeFocused()
await expect.poll(() => toggle.boundingBox()).toEqual(bounds)
await expect(header.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect(header.getByRole("button", { name: "New session", exact: true })).toBeVisible()
await expect(header.getByRole("button", { name: "Status", exact: true })).toBeVisible()
await expect(header.locator('[data-slot="titlebar-navigation-start"] button')).toHaveCount(3)
expect(
await header
.locator('[data-slot="titlebar-navigation-start"] button')
.evaluateAll((buttons) => buttons.map((button) => button.getAttribute("aria-label"))),
).toEqual(["Toggle vertical tabs", "Home", "New session"])
await testInfo.attach(`collapsed-${direction}`, { body: await page.screenshot(), contentType: "image/png" })
await toggle.press("Enter")
await expect(sidebar.locator(toggle)).toHaveAttribute("aria-expanded", "true")
await expect(toggle).toBeFocused()
await expect.poll(() => toggle.boundingBox()).toEqual(bounds)
await expect(sidebar).toHaveCSS("width", "260px")
await expect(header.getByRole("button", { name: "Home", exact: true })).toHaveCount(0)
await page.setViewportSize({ width: 800, height: 720 })
const narrow = await toggle.boundingBox()
await toggle.click()
await expect(header.locator(toggle)).toHaveAttribute("aria-expanded", "false")
await expect.poll(() => toggle.boundingBox()).toEqual(narrow)
await expect(header.getByRole("button", { name: "New session", exact: true })).toBeInViewport()
await page.setViewportSize({ width: 1280, height: 720 })
await header.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL(/\/$/)
const toolbar = page.locator('[data-slot="collapsed-tabs-toolbar"]')
const home = page.locator('[data-slot="home-panel"]')
await expect(home.locator(toggle)).toBeVisible()
await expect(toolbar).toBeHidden()
await expect.poll(() => toggle.boundingBox()).toEqual(bounds)
await expect
.poll(() =>
home.evaluate((panel) => {
const shell = panel.closest('[data-slot="shell-layout"]')!.getBoundingClientRect()
const bounds = panel.getBoundingClientRect()
return {
top: bounds.top - shell.top,
bottom: shell.bottom - bounds.bottom,
left: bounds.left - shell.left,
right: shell.right - bounds.right,
}
}),
)
.toEqual({ top: 8, bottom: 8, left: 8, right: 8 })
await home.getByRole("button", { name: "Home", exact: true }).click()
await expect(page).toHaveURL(new RegExp(`${href}$`))
await expect(header.locator(toggle)).toBeVisible()
await header.getByRole("button", { name: "New session", exact: true }).click()
await expect(page).toHaveURL(/\/new-session\?draftId=.+$/)
const draft = page.locator('[data-component="new-session"]')
await expect(draft.locator(toggle)).toBeVisible()
await expect(toolbar).toBeHidden()
await expect.poll(() => toggle.boundingBox()).toEqual(bounds)
await expect
.poll(() =>
draft.evaluate((panel) => {
const shell = panel.closest('[data-slot="shell-layout"]')!.getBoundingClientRect()
const bounds = panel.getBoundingClientRect()
return {
top: bounds.top - shell.top,
bottom: shell.bottom - bounds.bottom,
left: bounds.left - shell.left,
right: shell.right - bounds.right,
}
}),
)
.toEqual({ top: 8, bottom: 8, left: 8, right: 8 })
await toggle.click()
await expect(sidebar.getByRole("button", { name: "New session", exact: true })).toBeVisible()
})
}
test("appearance experimental settings control vertical tab details", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
@@ -435,239 +314,6 @@ test("appearance experimental settings control vertical tab details", async ({ p
await expect(layout).toContainText("Vertical")
})
for (const direction of ["ltr", "rtl"] as const) {
test(`collapsed tabs toggle previews every open tab on hover (${direction})`, async ({ page }, testInfo) => {
await mockServer(page)
await page.route(`${server}/api/session/active*`, (route) =>
json(route, { data: { [sessionC.id]: { type: "running" } } }),
)
await page.route(`${server}/api/event*`, (route) =>
route.fulfill({
status: 200,
contentType: "text/event-stream",
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
}),
)
await page.addInitScript(
({ server, sessionA, sessionB, sessionC, directory }) => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
{ type: "draft", server, directory, draftID: "hover-draft" },
{ type: "session", server, sessionId: sessionB },
{ type: "session", server, sessionId: sessionC },
]),
)
localStorage.setItem(
"opencode.global.dat:notification",
JSON.stringify({
list: [{ type: "turn-complete", session: sessionB, directory, time: Date.now(), viewed: false }],
}),
)
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id, sessionC: sessionC.id, directory: sessionA.directory },
)
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}`
await page.goto(hrefA)
await expect(page.locator("[data-session-title] h1")).toHaveText(sessionA.title)
await page.locator("html").evaluate((element, direction) => element.setAttribute("dir", direction), direction)
const toggle = page.getByRole("button", { name: "Toggle vertical tabs", exact: true })
const popup = page.getByRole("navigation", { name: "Open tabs", exact: true })
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
await expect(sidebar.locator("[data-titlebar-tab-title]")).toHaveText([
sessionA.title,
"Session",
sessionB.title,
sessionC.title,
])
const appearance = await sidebar.locator("[data-titlebar-tab]").evaluateAll((rows) =>
rows.map((row) => {
const style = getComputedStyle(row)
return { height: style.height, radius: style.borderRadius, background: style.backgroundImage }
}),
)
await page.clock.install()
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await expect(toggle).toHaveAttribute("data-hover-blocked", "true")
await toggle.hover()
await page.clock.runFor(1000)
await expect(popup).toHaveCount(0)
await page.locator("[data-session-title] h1").hover()
await expect(toggle).toHaveAttribute("data-hover-blocked", "false")
await toggle.hover()
await expect(popup.locator("[data-titlebar-tab-title]")).toHaveText([
sessionA.title,
"Session",
sessionB.title,
sessionC.title,
])
await expect(popup.getByRole("button", { name: "New session", exact: true })).toHaveCount(0)
await expect(popup.getByRole("button", { name: "Close tab", exact: true })).toHaveCount(4)
await expect(popup.locator("[data-titlebar-tab-list]")).toHaveCSS("gap", "4px")
await expect(popup.locator('[data-slot="project-avatar-slot"]')).toHaveCount(3)
await expect(popup).toHaveCSS("padding", "4px")
await expect
.poll(async () => {
const button = await toggle.boundingBox()
if (!button) return Infinity
const centers = await popup.locator('[data-slot="project-avatar-slot"]').evaluateAll((icons) =>
icons.map((icon) => {
const bounds = icon.getBoundingClientRect()
return bounds.x + bounds.width / 2
}),
)
return Math.max(...centers.map((center) => Math.abs(center - button.x - button.width / 2)))
})
.toBeLessThanOrEqual(1)
await expect(
popup.locator(`[data-titlebar-tab-link][href="${hrefB}"] [data-slot="project-avatar-unread-dot"]`),
).toBeVisible()
await expect(
popup.locator(`[data-titlebar-tab-link][href="${hrefC}"] [data-component="session-progress-indicator-v2"]`),
).toBeVisible()
expect(
await popup.locator("[data-titlebar-tab]").evaluateAll((rows) =>
rows.map((row) => {
const style = getComputedStyle(row)
return { height: style.height, radius: style.borderRadius, background: style.backgroundImage }
}),
),
).toEqual(appearance)
await popup.screenshot({ path: testInfo.outputPath("open-tabs.png") })
await expect(popup.locator('[data-titlebar-tab-slot][data-active="true"] [data-titlebar-tab-title]')).toHaveText(
sessionA.title,
)
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
const bounds = await toggle.boundingBox()
await popup.locator(`[data-titlebar-tab-link][href="${hrefB}"]`).hover()
await expect(popup).toBeVisible()
await expect(toggle).toHaveAttribute("data-state", "hover")
await popup.locator(`[data-titlebar-tab-link][href="${hrefB}"]`).click()
await expect(page).toHaveURL(new RegExp(`${hrefB}$`))
await expect(page.locator("[data-session-title] h1")).toHaveText(sessionB.title)
await expect(popup).toBeVisible()
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await expect.poll(() => toggle.boundingBox()).toEqual(bounds)
await expect(popup.locator('[data-titlebar-tab-slot][data-active="true"] [data-titlebar-tab-title]')).toHaveText(
sessionB.title,
)
const closing = popup
.locator("[data-titlebar-tab-slot]")
.filter({ has: page.locator(`[data-titlebar-tab-link][href="${hrefC}"]`) })
await closing.hover()
await closing.getByRole("button", { name: "Close tab", exact: true }).click()
await expect(popup.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "Session", sessionB.title])
await expect(page).toHaveURL(new RegExp(`${hrefB}$`))
await page.keyboard.press("Escape")
await expect(popup).toBeVisible()
await page.locator("[data-session-title] h1").hover()
await expect(popup).toBeHidden()
await expect(toggle).not.toHaveAttribute("data-state", "hover")
await toggle.click()
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toBeVisible()
await page.locator("[data-session-title] h1").hover()
await toggle.hover()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(popup).toHaveCount(0)
})
}
test("closing the active and final dropdown tabs keeps it open", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB }) => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
{ type: "session", server, sessionId: sessionB },
]),
)
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id },
)
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
await page.goto(hrefA)
await expect(page.locator("[data-session-title] h1")).toHaveText(sessionA.title)
const toggle = page.getByRole("button", { name: "Toggle vertical tabs", exact: true })
await toggle.click()
await page.locator("[data-session-title] h1").hover()
await toggle.hover()
const popup = page.getByRole("navigation", { name: "Open tabs", exact: true })
const active = popup.locator('[data-titlebar-tab-slot][data-active="true"]')
await expect(active.locator("[data-titlebar-tab-title]")).toHaveText(sessionA.title)
await active.getByRole("button", { name: "Close tab", exact: true }).click()
await expect(page).toHaveURL(new RegExp(`${hrefB}$`))
await expect(popup.locator("[data-titlebar-tab-title]")).toHaveText([sessionB.title])
await expect(active.locator("[data-titlebar-tab-title]")).toHaveText(sessionB.title)
await active.getByRole("button", { name: "Close tab", exact: true }).click()
await expect(page).toHaveURL(/\/$/)
await expect(popup).toHaveText("No open tabs")
await expect(toggle).toHaveAttribute("data-state", "hover")
await page.locator('[data-slot="home-panel"]').getByRole("button", { name: "Home", exact: true }).hover()
await expect(popup).toBeHidden()
})
test("collapsed tabs hover list handles an empty tab list", async ({ page }) => {
await mockServer(page)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
})
await page.goto("/")
const toggle = page.getByRole("button", { name: "Toggle vertical tabs", exact: true })
await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await page.locator('[data-slot="home-panel"]').getByRole("button", { name: "Home", exact: true }).hover()
await toggle.hover()
const popup = page.getByRole("navigation", { name: "Open tabs", exact: true })
await expect(popup).toHaveText("No open tabs")
await expect(popup.getByRole("button")).toHaveCount(0)
})
test("collapsed tabs dropdown scrolls the sidebar rows", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, directory }) => {
localStorage.setItem("settings.v3", JSON.stringify({ appearance: { tabLayout: "vertical" } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(
Array.from({ length: 20 }, (_, index) => ({
type: "draft",
server,
directory,
draftID: `hover-${index}`,
})),
),
)
},
{ server, directory: sessionA.directory },
)
await page.goto("/")
const toggle = page.getByRole("button", { name: "Toggle vertical tabs", exact: true })
await toggle.click()
await page.locator('[data-slot="home-panel"]').getByRole("button", { name: "Home", exact: true }).hover()
await toggle.hover()
const popup = page.getByRole("navigation", { name: "Open tabs", exact: true })
await expect(popup.locator("[data-titlebar-tab-slot]")).toHaveCount(20)
const scroll = popup.locator('[data-slot="vertical-tabs-scroll"]')
await expect.poll(() => scroll.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
const tab = popup.locator('[data-tab-key="draft:hover-19"] [data-titlebar-tab-link]')
await tab.scrollIntoViewIfNeeded()
await expect(tab).toBeInViewport()
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
})
test("vertical tab preference uses the drawer on mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 720 })
await mockServer(page)
@@ -746,11 +392,7 @@ async function mockServer(page: Page) {
url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory },
)
}
if (url.pathname === "/api/location")
return json(route, {
directory: sessionA.directory,
project: { id: sessionA.projectID, directory: sessionA.directory, canonical: sessionA.directory },
})
if (url.pathname === "/api/location") return json(route, { directory: sessionA.directory })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: sessionA.directory },
@@ -78,9 +78,13 @@ for (const theme of ["light", "dark"] as const) {
await expectToken(
message,
"background-color",
scenario.accent ? "--v2-background-bg-accent" : "--v2-state-bg-info",
scenario.accent ? "--v2-background-bg-accent" : theme === "light" ? "--v2-blue-100" : "--v2-blue-1200",
)
await expectToken(
message,
"color",
scenario.accent ? "--v2-text-text-contrast" : theme === "light" ? "--v2-blue-700" : "--v2-blue-300",
)
await expectToken(message, "color", scenario.accent ? "--v2-text-text-contrast" : "--v2-text-text-accent")
})
}
@@ -53,9 +53,8 @@ test("creates a session in a new project and selects its model", async ({ page }
}),
sessions: [],
pageMessages: () => ({ items: [] }),
fileList: (path) =>
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
findFiles: () => ["NewProject"],
// Listings are requested by absolute path and returned relative to the stable Location.
fileList: (path) => (path === "C:/OpenCode" ? [{ path: "./", type: "directory", ignored: false }] : []),
})
await page.addInitScript(() => {
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
@@ -76,12 +75,23 @@ test("creates a session in a new project and selects its model", async ({ page }
const addProject = page.locator('[data-action="home-add-project-row"]')
await expectAppVisible(addProject)
await addProject.click()
const directoryItem = page.getByRole("treeitem", { name: "NewProject" })
const picker = page.getByRole("dialog", { name: "Open project", exact: true })
await expect(picker.getByRole("combobox")).toHaveValue("C:\\OpenCode\\NewProject")
const listing = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/fs/list" && url.searchParams.get("path") === "C:/OpenCode"
})
await picker.getByRole("button", { name: "Parent", exact: true }).click()
expect(new URL((await listing).url()).searchParams.get("location[directory]")).toBe(directory)
const directoryItem = picker.getByRole("treeitem", { name: "NewProject", exact: true })
await expect(directoryItem).toBeVisible()
await directoryItem.click()
const selectFolder = page.getByRole("button", { name: "Select folder" })
await expect(directoryItem).toHaveAttribute("aria-selected", "true")
await expect(picker.getByText("C:\\OpenCode\\NewProject", { exact: true })).toBeVisible()
const selectFolder = picker.getByRole("button", { name: "Select folder", exact: true })
await expect(selectFolder).toBeEnabled()
await selectFolder.click()
await expect(picker).toBeHidden()
await page.locator('[data-action="home-new-session"]').click()
await expectAppVisible(page.locator('[data-component="composer"]'))
+2 -1
View File
@@ -26,7 +26,8 @@ export function Composer(props: { class?: string; model: ComposerModel; borderUn
modelControlsVisible={!props.model.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
alternateKeybind={[formatKeybind("mod", language.t), formatKeybind("enter", language.t)]}
alternateKeybind={[formatKeybind("mod", language.t), "↵"]}
exitShellKeybind={[formatKeybind("esc", language.t)]}
modelControl={
<ComposerModelControl
loading={props.model.model.loading}
@@ -6,3 +6,7 @@
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
background: var(--v2-background-bg-layer-01);
}
[data-color-scheme="dark"] [data-component="composer-suggestions"] [data-active] {
background: var(--v2-alpha-light-10);
}
+23 -2
View File
@@ -47,6 +47,7 @@ export type ComposerEditorProps = {
attachKeybind?: string[]
attachShortcut?: string
alternateKeybind?: string[]
exitShellKeybind?: string[]
}
export function ComposerEditor(props: ComposerEditorProps) {
@@ -281,6 +282,24 @@ export function ComposerEditor(props: ComposerEditorProps) {
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
/>
</Show>
<Show when={state.mode === "shell"}>
<Button
data-action="composer-exit-shell"
type="button"
variant="ghost-faint"
size="small"
class="me-3 gap-1.5 px-1.5"
onClick={() => {
props.controller.dispatch({ type: "mode.normal" })
props.controller.restoreFocus()
}}
>
{i18n.t("ui.promptInput.exitShell")}
<span class="hidden sm:block">
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
</span>
</Button>
</Show>
<ComposerEditorSubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
@@ -673,6 +692,7 @@ export function ComposerEditorPopover(props: {
}) {
return (
<div
data-component="composer-suggestions"
class="absolute inset-x-0 -top-2 z-40 flex max-h-80 -translate-y-full flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
onMouseDown={(event) => event.preventDefault()}
>
@@ -701,6 +721,7 @@ export function ComposerEditorPopover(props: {
<button
type="button"
data-suggestion-id={item.id}
data-active={props.activeID === item.id ? "" : undefined}
class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
onPointerMove={() => props.onActiveChange(item)}
@@ -749,9 +770,9 @@ function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorMode
ref={setButton}
data-action="composer-alternate-delivery"
type="button"
variant="ghost-muted"
variant="ghost-faint"
size="small"
class="me-3 gap-1.5 px-1.5 text-v2-text-text-muted ![font-weight:530] duration-150 motion-reduce:animate-none"
class="me-3 gap-1.5 px-1.5 ![font-weight:530] duration-150 motion-reduce:animate-none"
classList={{
"animate-in fade-in": presence.animate() && presence.show(),
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
+15 -1
View File
@@ -3,7 +3,8 @@ import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
import { createEffect, createMemo } from "solid-js"
import { createEffect, createMemo, startTransition } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client/promise"
export function createHomeController() {
const layout = useLayout()
@@ -45,6 +46,18 @@ export function createHomeController() {
void tabs.newDraft({ server: ServerConnection.key(conn), directory })
}
function openProjectSession(conn: ServerConnection.Any, directory: string, session: SessionInfo) {
const ctx = global.ensureServerCtx(conn)
void ctx.data.session.message.sync(session.id).catch(() => undefined)
void startTransition(() => {
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
tabs.select(tab)
ctx.data.session.remember(session)
ctx.projects.open(directory)
ctx.projects.touch(directory)
})
}
return {
selection: {
value: selection,
@@ -105,6 +118,7 @@ export function createHomeController() {
openProjectNewSession(conn, project.worktree)
},
openProjectNewSession,
openProjectSession,
},
}
}
@@ -14,6 +14,7 @@ import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import type { HomeController } from "../model"
import { useGlobal } from "@/runtime/server/runtime"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
export const HomeServersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
@@ -79,6 +80,35 @@ export function createHomeProjectsController(home: HomeController) {
select: home.project.select,
add: home.project.add,
openNewSession: home.project.openProjectNewSession,
canImportSession: !!platform.openAttachmentPickerDialog,
importSession: (conn: ServerConnection.Any, project: LocalProject) => {
if (!platform.openAttachmentPickerDialog) return
void platform
.openAttachmentPickerDialog(
{
title: language.t("command.session.import"),
accept: ["application/json"],
extensions: ["json"],
},
async (file) => {
const data = await Schema.decodeUnknownPromise(Schema.fromJsonString(SessionTransfer.Data))(
await file.text(),
)
const api = home.server.context(conn).sdk.api.session
const imported = await api.import({
...Schema.encodeSync(SessionTransfer.Data)(data),
location: { directory: project.worktree },
} as Parameters<typeof api.import>[0])
home.project.openProjectSession(conn, project.worktree, imported)
},
)
.catch((cause: unknown) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
})
})
},
edit: (conn: ServerConnection.Any, project: LocalProject) => {
void import("@/settings/workspaces/project-dialog").then(({ DialogEditProject }) => {
void dialog.show(() => <DialogEditProject server={conn} project={project} />)
@@ -37,6 +37,8 @@ export function HomeProjects(props: {
onSelectProject={props.projects.project.select}
onAddProjects={props.projects.project.add}
onOpenProjectNewSession={props.projects.project.openNewSession}
canImportSession={props.projects.project.canImportSession}
onImportSession={props.projects.project.importSession}
onEditProject={props.projects.project.edit}
onRevealProject={props.projects.project.reveal}
onClearNotifications={props.projects.project.clearNotifications}
+7
View File
@@ -57,6 +57,8 @@ export type HomeProjectsViewProps = {
onSelectProject: (server: ServerConnection.Any, directory: string) => void
onAddProjects: (server: ServerConnection.Any, directories: string[]) => void
onOpenProjectNewSession: (server: ServerConnection.Any, directory: string) => void
canImportSession: boolean
onImportSession: (server: ServerConnection.Any, project: LocalProject) => void
onEditProject: (server: ServerConnection.Any, project: LocalProject) => void
onRevealProject: (server: ServerConnection.Any, project: LocalProject) => void
onClearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
@@ -670,6 +672,11 @@ function HomeProjectRow(
<Menu.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
{props.language.t("command.session.new")}
</Menu.Item>
<Show when={props.canImportSession}>
<Menu.Item onSelect={() => props.onImportSession(props.server, props.project)}>
{props.language.t("command.session.import")}
</Menu.Item>
</Show>
<Menu.Item onSelect={() => props.onEditProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</Menu.Item>
-3
View File
@@ -9,7 +9,6 @@ import { createHomeScrollController } from "./scroll"
import { createHomeSessionSearchController } from "./sessions/search"
import { createHomeSessionsController } from "./sessions/controller"
import { HomeSessions } from "./sessions/region"
import { TitlebarNavigationHeader } from "@/shell/titlebar/navigation-slot"
export function Home() {
const mobile = createMediaQuery("(max-width: 767px)")
@@ -20,13 +19,11 @@ export function Home() {
const scroll = createHomeScrollController(sessions.data.groups)
return (
<div
data-slot="home-panel"
class={`
mx-2 mb-[var(--shell-bottom-inset,8px)] mt-[var(--shell-top-inset,8px)] flex min-h-0 flex-1 flex-col self-stretch overflow-hidden rounded-[10px]
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
`}
>
<TitlebarNavigationHeader />
<Show when={mobile()}>
<div class="relative z-40 -mb-3 shrink-0 px-3 pt-3">
<HomeProjects projects={projects} scroll={scroll} dropdown />
@@ -21,7 +21,8 @@ import { errorMessage } from "@/shell/layout/helpers"
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
import { removedSessionIDs } from "@/session/session-domain"
import { pathKey } from "@/workspaces/path-key"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
import { usePlatform } from "@/runtime/platform/platform"
import { sessionLabel, sessionTitle } from "@/session/title"
import { showToast } from "@/shell/notifications/toast"
import { archiveHomeSession } from "./archive"
@@ -45,6 +46,7 @@ export function createHomeSessionsController(home: HomeController) {
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
const queryClient = useQueryClient()
const projectDirectories = createMemo(() => {
const selected = home.selection.value().directory
@@ -172,7 +174,7 @@ export function createHomeSessionsController(home: HomeController) {
try {
const data = await fetchSessionExport({ sessionID: session.id, api: ctx.sdk.api })
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
if (!(await saveSessionExport(filename, data, platform))) return
showToast({
variant: "success",
icon: "circle-check",
@@ -50,6 +50,7 @@ export function createComposerProjectControls(props: { draftId: string; worktree
if (!connection) return
pickDirectory({
server: connection,
location: ServerConnection.key(connection) === ServerConnection.key(projectServer()) ? location().ref : undefined,
title,
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
-2
View File
@@ -15,7 +15,6 @@ import {
} from "@/new-session/project/selector"
import { StatusPopover } from "@/shell/status/status-popover"
import { TitlebarRight } from "@/shell/titlebar/right-slot"
import { TitlebarNavigationHeader } from "@/shell/titlebar/navigation-slot"
import { useLanguage } from "@/runtime/i18n/language"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useProviders } from "@/providers/catalog/providers"
@@ -55,7 +54,6 @@ export function NewSessionView(props: {
data-component="new-session"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
>
<TitlebarNavigationHeader />
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
<Wordmark class="h-auto w-full text-v2-background-bg-inverse" />
@@ -1,5 +1,6 @@
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
@@ -15,6 +16,7 @@ export function createProviderConnectionController(options: {
pollInterval?: number
}) {
const language = useLanguage()
const platform = usePlatform()
const serverSDK = useServerSDK()
const data = useData()
const location = () => {
@@ -184,7 +186,14 @@ export function createProviderConnectionController(options: {
...(answer ? { answer } : {}),
location: location(),
})
.then((response) => ({ ok: true as const, authorization: response.data }))
.then((response) => {
if (options.provider() === "opencode" && platform.platform === "desktop") {
const url = new URL(response.data.url)
url.searchParams.set("client_id", "opencode-desktop")
response.data.url = url.href
}
return { ok: true as const, authorization: response.data }
})
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "ሳይክል ቋንቋ",
"command.language.set": "ቋንቋን ተጠቀም፡ {{language}}",
"command.session.new": "አዲስ ክፍለ ጊዜ",
"command.session.import": "ክፍለ ጊዜ ማስመጣት",
"command.file.open": "ክፍት ፋይል",
"command.tab.close": "ትርፉን ዝጋ",
"command.tab.reopenClosed": "የተዘጋውን ትር እንደገና ክፈት",
+1
View File
@@ -139,6 +139,7 @@ export const dict = {
"command.language.cycle": "تغيير اللغة",
"command.language.set": "استخدام اللغة: {{language}}",
"command.session.new": "جلسة جديدة",
"command.session.import": "استيراد جلسة",
"command.file.open": "فتح ملف",
"command.tab.close": "إغلاق علامة التبويب",
"command.tab.reopenClosed": "إعادة فتح علامة التبويب المغلقة",
+1
View File
@@ -135,6 +135,7 @@ export const dict = {
"command.language.cycle": "Dili dəyiş",
"command.language.set": "Dildən istifadə et: {{language}}",
"command.session.new": "Yeni sessiya",
"command.session.import": "Sessiyanı idxal et",
"command.file.open": "Faylı aç",
"command.tab.close": "Tabı bağla",
"command.tab.reopenClosed": "Bağlanmış tabı yenidən aç",
+1
View File
@@ -135,6 +135,7 @@ export const dict = {
"command.language.cycle": "Цикличен език",
"command.language.set": "Използвайте език: {{language}}",
"command.session.new": "Нова сесия",
"command.session.import": "Импортиране на сесия",
"command.file.open": "Отворете файла",
"command.tab.close": "Затваряне на раздела",
"command.tab.reopenClosed": "Повторно отваряне на затворен раздел",
+1
View File
@@ -134,6 +134,7 @@ export const dict: Record<string, string> = {
"command.language.cycle": "সাইকেল ভাষা",
"command.language.set": "ভাষা ব্যবহার করুন: {{language}}",
"command.session.new": "নতুন সেশন",
"command.session.import": "সেশন আমদানি করুন",
"command.file.open": "ফাইল খুলুন",
"command.tab.close": "ট্যাব বন্ধ করুন",
"command.tab.reopenClosed": "বন্ধ ট্যাব আবার খুলুন",
+1
View File
@@ -141,6 +141,7 @@ export const dict = {
"command.language.cycle": "Alternar idioma",
"command.language.set": "Usar idioma: {{language}}",
"command.session.new": "Nova sessão",
"command.session.import": "Importar sessão",
"command.file.open": "Abrir arquivo",
"command.tab.close": "Fechar aba",
"command.tab.reopenClosed": "Reabrir aba fechada",
+1
View File
@@ -147,6 +147,7 @@ export const dict = {
"command.language.set": "Koristi jezik: {{language}}",
"command.session.new": "Nova sesija",
"command.session.import": "Uvezi sesiju",
"command.file.open": "Otvori datoteku",
"command.tab.close": "Zatvori karticu",
"command.tab.reopenClosed": "Ponovo otvori zatvorenu karticu",
+1
View File
@@ -135,6 +135,7 @@ export const dict = {
"command.language.cycle": "Llenguatge de cicle",
"command.language.set": "Utilitza l'idioma: {{language}}",
"command.session.new": "Nova sessió",
"command.session.import": "Importa la sessió",
"command.file.open": "Obre el fitxer",
"command.tab.close": "Tanca la pestanya",
"command.tab.reopenClosed": "Torneu a obrir la pestanya tancada",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Jazyk cyklu",
"command.language.set": "Použít jazyk: {{language}}",
"command.session.new": "Nová relace",
"command.session.import": "Importovat relaci",
"command.file.open": "Otevřít soubor",
"command.tab.close": "Zavřít kartu",
"command.tab.reopenClosed": "Znovu otevřete zavřenou kartu",
+1
View File
@@ -46,6 +46,7 @@ export const dict = {
"command.language.set": "Brug sprog: {{language}}",
"command.session.new": "Ny session",
"command.session.import": "Importer session",
"command.file.open": "Åbn fil",
"command.tab.close": "Luk fane",
"command.tab.reopenClosed": "Åbn lukket fane igen",
+1
View File
@@ -44,6 +44,7 @@ export const dict = {
"command.language.cycle": "Sprache wechseln",
"command.language.set": "Sprache verwenden: {{language}}",
"command.session.new": "Neue Sitzung",
"command.session.import": "Sitzung importieren",
"command.file.open": "Datei öffnen",
"command.tab.close": "Tab schließen",
"command.tab.reopenClosed": "Geschlossenen Tab wieder öffnen",
+1
View File
@@ -136,6 +136,7 @@ export const dict = {
"command.language.cycle": "ސައިކަލް ބަސް",
"command.language.set": "ބަސް ބޭނުންކުރުން: {{language}}",
"command.session.new": "އާ ޖަލްސާއެއް",
"command.session.import": "ޖަލްސާ އިމްޕޯޓް ކުރައްވާ",
"command.file.open": "ފައިލް ހުޅުވާލާށެވެ",
"command.tab.close": "ޓެބް ބަންދުކުރުން",
"command.tab.reopenClosed": "ބަންދުކޮށްފައިވާ ޓެބް އަލުން ހުޅުވާލާށެވެ",
+1
View File
@@ -136,6 +136,7 @@ export const dict: Record<string, string> = {
"command.language.cycle": "འཁོར་བའི་སྐད་ཡིག།",
"command.language.set": "སྐད་ཡིག་ལག་ལེན་འཐབ།: {{language}}",
"command.session.new": "ལཱ་ཡུན་གསརཔ།",
"command.session.import": "ལཱ་ཡུན་ནང་འདྲེན།",
"command.file.open": "ཡིག་སྣོད་ཁ་ཕྱེ།",
"command.tab.close": "མཆོང་ལྡེ་ཁ་བསྡམས།",
"command.tab.reopenClosed": "ཁ་བསྡམས་ཡོད་པའི་མཆོང་ལྡེ་ལོག་ཁ་ཕྱེ།",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "Γλώσσα κύκλου",
"command.language.set": "Γλώσσα χρήσης: {{language}}",
"command.session.new": "Νέα συνεδρία",
"command.session.import": "Εισαγωγή συνεδρίας",
"command.file.open": "Άνοιγμα αρχείου",
"command.tab.close": "Κλείσιμο καρτέλας",
"command.tab.reopenClosed": "Άνοιγμα ξανά κλειστής καρτέλας",
+4 -3
View File
@@ -100,6 +100,7 @@ export const dict = {
"command.session.fork.description": "Create a new session from a previous message",
"command.session.export": "Export session",
"command.session.export.description": "Export the full session transcript as JSON",
"command.session.import": "Import session",
"command.session.copyID": "Copy Session ID",
"palette.search.placeholder": "Search files, commands, and sessions",
@@ -675,11 +676,14 @@ export const dict = {
"session.error.incompatible.description":
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
"session.background.moveTasks": "Move {{tasks}} to background",
"session.background.moveRunning": "Move running work to background",
"session.background.inBackground": "Running {{tasks}} in background",
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
"session.background.running": "Running work in background",
"session.background.runningCount.one": "{{count}} item running in background",
"session.background.runningCount.other": "{{count}} items running in background",
"session.background.tasksRunning.one": "{{count}} background task running",
"session.background.tasksRunning.other": "{{count}} background tasks running",
"session.background.combine": "{{first}} and {{second}}",
"session.background.shell.one": "{{count}} shell",
"session.background.shell.other": "{{count}} shells",
@@ -808,9 +812,6 @@ export const dict = {
"titlebar.update": "Update",
"titlebar.tabs": "Tabs",
"titlebar.tabs.toggle": "Toggle vertical tabs",
"titlebar.tabs.open": "Open tabs",
"titlebar.tabs.empty": "No open tabs",
"titlebar.updateVersion": "Update {{version}}",
"common.closeTab": "Close tab",
+1
View File
@@ -147,6 +147,7 @@ export const dict = {
"command.language.set": "Usar idioma: {{language}}",
"command.session.new": "Nueva sesión",
"command.session.import": "Importar sesión",
"command.file.open": "Abrir archivo",
"command.tab.close": "Cerrar pestaña",
"command.tab.reopenClosed": "Reabrir pestaña cerrada",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Tsükli keel",
"command.language.set": "Kasuta keelt: {{language}}",
"command.session.new": "Uus seanss",
"command.session.import": "Impordi seanss",
"command.file.open": "Ava fail",
"command.tab.close": "Sule vahekaart",
"command.tab.reopenClosed": "Ava suletud vaheleht uuesti",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "زبان چرخه",
"command.language.set": "استفاده از زبان: {{language}}",
"command.session.new": "جلسه جدید",
"command.session.import": "وارد کردن جلسه",
"command.file.open": "باز کردن فایل",
"command.tab.close": "بستن برگه",
"command.tab.reopenClosed": "برگه بسته را دوباره باز کنید",
+1
View File
@@ -40,6 +40,7 @@ export const dict = {
"command.language.cycle": "Vaihda kieltä",
"command.language.set": "Käytä kieltä: {{language}}",
"command.session.new": "Uusi istunto",
"command.session.import": "Tuo istunto",
"command.file.open": "Avaa tiedosto",
"command.tab.close": "Sulje välilehti",
"command.tab.reopenClosed": "Avaa suljettu välilehti uudelleen",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Súkklumál",
"command.language.set": "Brúka mál: {{language}}",
"command.session.new": "Nýggj setan",
"command.session.import": "Innflyt setan",
"command.file.open": "Opna fíluna",
"command.tab.close": "Lat flipan aftur",
"command.tab.reopenClosed": "Opna aftur stongdan flipan",
+1
View File
@@ -141,6 +141,7 @@ export const dict = {
"command.language.cycle": "Changer de langue",
"command.language.set": "Utiliser la langue : {{language}}",
"command.session.new": "Nouvelle session",
"command.session.import": "Importer une session",
"command.file.open": "Ouvrir un fichier",
"command.tab.close": "Fermer l'onglet",
"command.tab.reopenClosed": "Rouvrir l'onglet fermé",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "מעבר לשפה הבאה",
"command.language.set": "השתמש בשפה: {{language}}",
"command.session.new": "הפעלה חדשה",
"command.session.import": "ייבוא הפעלה",
"command.file.open": "פתח את הקובץ",
"command.tab.close": "סגור כרטיסייה",
"command.tab.reopenClosed": "פתח מחדש את הכרטיסייה הסגורה",
+1
View File
@@ -140,6 +140,7 @@ export const dict = {
"command.language.cycle": "भाषा बदलें",
"command.language.set": "भाषा का प्रयोग करें: {{language}}",
"command.session.new": "नया सेशन",
"command.session.import": "सेशन आयात करें",
"command.file.open": "फ़ाइल खोलें",
"command.tab.close": "टैब बंद करें",
"command.tab.reopenClosed": "बंद टैब पुनः खोलें",
+1
View File
@@ -137,6 +137,7 @@ export const dict = {
"command.language.cycle": "Promijeni jezik",
"command.language.set": "Koristite jezik: {{language}}",
"command.session.new": "Nova sesija",
"command.session.import": "Uvezi sesiju",
"command.file.open": "Otvori datoteku",
"command.tab.close": "Zatvori karticu",
"command.tab.reopenClosed": "Ponovno otvori zatvorenu karticu",
+1
View File
@@ -137,6 +137,7 @@ export const dict = {
"command.language.cycle": "Nyelv váltása",
"command.language.set": "Nyelv használata: {{language}}",
"command.session.new": "Új munkamenet",
"command.session.import": "Munkamenet importálása",
"command.file.open": "Nyissa meg a fájlt",
"command.tab.close": "Lap bezárása",
"command.tab.reopenClosed": "Nyissa meg újra a bezárt lapot",
+1
View File
@@ -135,6 +135,7 @@ export const dict = {
"command.language.cycle": "Ցիկլի լեզու",
"command.language.set": "Օգտագործել լեզուն՝ {{language}}",
"command.session.new": "Նոր նիստ",
"command.session.import": "Ներմուծել նիստը",
"command.file.open": "Բացել ֆայլ",
"command.tab.close": "Փակել ներդիրը",
"command.tab.reopenClosed": "Վերաբացել փակ ներդիրը",
+1
View File
@@ -147,6 +147,7 @@ export const dict = {
"command.language.set": "Gunakan bahasa: {{language}}",
"command.session.new": "Sesi baru",
"command.session.import": "Impor sesi",
"command.file.open": "Buka berkas",
"command.tab.close": "Tutup tab",
"command.tab.reopenClosed": "Buka kembali tab yang ditutup",
+1
View File
@@ -137,6 +137,7 @@ export const dict = {
"command.language.cycle": "Skipta um tungumál",
"command.language.set": "Notaðu tungumál: {{language}}",
"command.session.new": "Ný seta",
"command.session.import": "Flytja inn setu",
"command.file.open": "Opna skrá",
"command.tab.close": "Loka flipa",
"command.tab.reopenClosed": "Opnaðu aftur lokaðan flipa",
+1
View File
@@ -41,6 +41,7 @@ export const dict = {
"command.language.cycle": "Cambia lingua",
"command.language.set": "Usa la lingua: {{language}}",
"command.session.new": "Nuova sessione",
"command.session.import": "Importa sessione",
"command.file.open": "Apri file",
"command.tab.close": "Chiudi scheda",
"command.tab.reopenClosed": "Riapri la scheda chiusa",
+1
View File
@@ -139,6 +139,7 @@ export const dict = {
"command.language.cycle": "言語の切り替え",
"command.language.set": "言語を使用: {{language}}",
"command.session.new": "新しいセッション",
"command.session.import": "セッションをインポート",
"command.file.open": "ファイルを開く",
"command.tab.close": "タブを閉じる",
"command.tab.reopenClosed": "閉じたタブを再度開く",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "ციკლის ენა",
"command.language.set": "გამოიყენე ენა: {{language}}",
"command.session.new": "ახალი სესია",
"command.session.import": "სესიის იმპორტი",
"command.file.open": "გახსენით ფაილი",
"command.tab.close": "ჩანართის დახურვა",
"command.tab.reopenClosed": "დახურული ჩანართის ხელახლა გახსნა",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "ភាសាវដ្ត",
"command.language.set": "ប្រើភាសា៖ {{language}}",
"command.session.new": "សម័យថ្មី។",
"command.session.import": "នាំចូលសម័យ",
"command.file.open": "បើកឯកសារ",
"command.tab.close": "បិទផ្ទាំង",
"command.tab.reopenClosed": "បើកផ្ទាំងបិទឡើងវិញ",
+1
View File
@@ -37,6 +37,7 @@ export const dict = {
"command.language.cycle": "언어 순환",
"command.language.set": "언어 사용: {{language}}",
"command.session.new": "새 세션",
"command.session.import": "세션 가져오기",
"command.file.open": "파일 열기",
"command.tab.close": "탭 닫기",
"command.context.addSelection": "선택 영역을 컨텍스트에 추가",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "ພາສາຮອບວຽນ",
"command.language.set": "ໃຊ້ພາສາ: {{language}}",
"command.session.new": "ເຊດຊັນໃໝ່",
"command.session.import": "ນຳເຂົ້າເຊດຊັນ",
"command.file.open": "ເປີດໄຟລ໌",
"command.tab.close": "ປິດແຖບ",
"command.tab.reopenClosed": "ເປີດແຖບປິດຄືນໃໝ່",
+1
View File
@@ -137,6 +137,7 @@ export const dict = {
"command.language.cycle": "Perjungti kalbą",
"command.language.set": "Naudokite kalbą: {{language}}",
"command.session.new": "Naujas seansas",
"command.session.import": "Importuoti seansą",
"command.file.open": "Atidaryti failą",
"command.tab.close": "Uždaryti skirtuką",
"command.tab.reopenClosed": "Iš naujo atidaryti uždarytą skirtuką",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Mainīt valodu",
"command.language.set": "Izmantot valodu: {{language}}",
"command.session.new": "Jauna sesija",
"command.session.import": "Importēt sesiju",
"command.file.open": "Atvērt failu",
"command.tab.close": "Aizvērt cilni",
"command.tab.reopenClosed": "Atvērt aizvērtu cilni",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "Јазик на циклус",
"command.language.set": "Користете јазик: {{language}}",
"command.session.new": "Нова сесија",
"command.session.import": "Увези сесија",
"command.file.open": "Отворете ја датотеката",
"command.tab.close": "Затвори ја картичката",
"command.tab.reopenClosed": "Повторно отворете го затворениот таб",
+1
View File
@@ -135,6 +135,7 @@ export const dict = {
"command.language.cycle": "Циклийн хэл",
"command.language.set": "Хэл ашиглах: {{language}}",
"command.session.new": "Шинэ сесс",
"command.session.import": "Сесс импортлох",
"command.file.open": "Файлыг нээх",
"command.tab.close": "Табыг хаах",
"command.tab.reopenClosed": "Хаагдсан табыг дахин нээнэ үү",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Tukar bahasa",
"command.language.set": "Guna bahasa: {{language}}",
"command.session.new": "Sesi baharu",
"command.session.import": "Import sesi",
"command.file.open": "Buka fail",
"command.tab.close": "Tutup tab",
"command.tab.reopenClosed": "Buka semula tab tertutup",
+1
View File
@@ -135,6 +135,7 @@ export const dict = {
"command.language.cycle": "စက်ဝိုင်းဘာသာစကား",
"command.language.set": "ဘာသာစကားကို အသုံးပြုပါ- {{language}}",
"command.session.new": "စက်ရှင်အသစ်",
"command.session.import": "စက်ရှင် တင်သွင်းရန်",
"command.file.open": "ဖိုင်ကိုဖွင့်ပါ။",
"command.tab.close": "တဘ်ကို ပိတ်ပါ။",
"command.tab.reopenClosed": "ပိတ်ထားသော တက်ဘ်ကို ပြန်ဖွင့်ပါ။",
+1
View File
@@ -134,6 +134,7 @@ export const dict: Record<string, string> = {
"command.language.cycle": "साइकल भाषा",
"command.language.set": "भाषा प्रयोग गर्नुहोस्: {{language}}",
"command.session.new": "नयाँ सत्र",
"command.session.import": "सत्र आयात गर्नुहोस्",
"command.file.open": "फाइल खोल्नुहोस्",
"command.tab.close": "ट्याब बन्द गर्नुहोस्",
"command.tab.reopenClosed": "बन्द ट्याब पुन: खोल्नुहोस्",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Volgende taal",
"command.language.set": "Gebruik taal: {{language}}",
"command.session.new": "Nieuwe sessie",
"command.session.import": "Sessie importeren",
"command.file.open": "Bestand openen",
"command.tab.close": "Tabblad sluiten",
"command.tab.reopenClosed": "Gesloten tabblad opnieuw openen",
+1
View File
@@ -146,6 +146,7 @@ export const dict = {
"command.language.set": "Bruk språk: {{language}}",
"command.session.new": "Ny sesjon",
"command.session.import": "Importer sesjon",
"command.file.open": "Åpne fil",
"command.tab.close": "Lukk fane",
"command.context.addSelection": "Legg til markering i kontekst",
+1
View File
@@ -139,6 +139,7 @@ export const dict = {
"command.language.cycle": "اگلی بولی ورتو",
"command.language.set": "بولی ورتو: {{language}}",
"command.session.new": "نواں سیشن",
"command.session.import": "سیشن درآمد کرو",
"command.file.open": "فائل کھولو",
"command.tab.close": "ٹیب بند کرو",
"command.tab.reopenClosed": "بند ٹیب دوبارہ کھولو",
+1
View File
@@ -140,6 +140,7 @@ export const dict = {
"command.language.cycle": "Przełącz język",
"command.language.set": "Użyj języka: {{language}}",
"command.session.new": "Nowa sesja",
"command.session.import": "Importuj sesję",
"command.file.open": "Otwórz plik",
"command.tab.close": "Zamknij kartę",
"command.tab.reopenClosed": "Otwórz ponownie zamkniętą kartę",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Schimbă limba",
"command.language.set": "Folosește limba: {{language}}",
"command.session.new": "Sesiune nouă",
"command.session.import": "Importă sesiunea",
"command.file.open": "Deschide fișier",
"command.tab.close": "Închide fila",
"command.tab.reopenClosed": "Redeschide fila închisă",
+1
View File
@@ -146,6 +146,7 @@ export const dict = {
"command.language.set": "Использовать язык: {{language}}",
"command.session.new": "Новая сессия",
"command.session.import": "Импортировать сессию",
"command.file.open": "Открыть файл",
"command.tab.close": "Закрыть вкладку",
"command.tab.reopenClosed": "Повторно открыть закрытую вкладку",
+1
View File
@@ -133,6 +133,7 @@ export const dict: Record<string, string> = {
"command.language.cycle": "චක්‍ර භාෂාව",
"command.language.set": "භාෂාව භාවිතා කරන්න: {{language}}",
"command.session.new": "නව සැසිය",
"command.session.import": "සැසිය ආනයනය කරන්න",
"command.file.open": "ගොනුව විවෘත කරන්න",
"command.tab.close": "ටැබ් එක වසන්න",
"command.tab.reopenClosed": "වසා දැමූ ටැබය නැවත විවෘත කරන්න",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Prepnúť jazyk",
"command.language.set": "Použiť jazyk: {{language}}",
"command.session.new": "Nová relácia",
"command.session.import": "Importovať reláciu",
"command.file.open": "Otvoriť súbor",
"command.tab.close": "Zavrieť kartu",
"command.tab.reopenClosed": "Obnoviť zatvorenú kartu",
+1
View File
@@ -133,6 +133,7 @@ export const dict = {
"command.language.cycle": "Jezik cikla",
"command.language.set": "Uporabi jezik: {{language}}",
"command.session.new": "Nova seja",
"command.session.import": "Uvozi sejo",
"command.file.open": "Odpri datoteko",
"command.tab.close": "Zapri zavihek",
"command.tab.reopenClosed": "Ponovno odpri zaprt zavihek",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "Gjuha e ciklit",
"command.language.set": "Përdorni gjuhën: {{language}}",
"command.session.new": "Sesion i ri",
"command.session.import": "Importo sesionin",
"command.file.open": "Hap skedarin",
"command.tab.close": "Mbyll skedën",
"command.tab.reopenClosed": "Rihap skedën e mbyllur",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "језик циклуса",
"command.language.set": "Користи језик: {{language}}",
"command.session.new": "Нова сесија",
"command.session.import": "Увези сесију",
"command.file.open": "Отворите датотеку",
"command.tab.close": "Затвори картицу",
"command.tab.reopenClosed": "Поново отворите затворену картицу",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "Växla språk",
"command.language.set": "Använd språk: {{language}}",
"command.session.new": "Ny session",
"command.session.import": "Importera session",
"command.file.open": "Öppna filen",
"command.tab.close": "Stäng fliken",
"command.tab.reopenClosed": "Öppna stängd flik igen",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "Забони даврӣ",
"command.language.set": "Истифодаи забон: {{language}}",
"command.session.new": "Сеанси нав",
"command.session.import": "Воридоти сеанс",
"command.file.open": "Файлро кушоед",
"command.tab.close": "Варақаро пӯшед",
"command.tab.reopenClosed": "Варақаи пӯшидаро аз нав кушоед",
+1
View File
@@ -145,6 +145,7 @@ export const dict = {
"command.language.set": "ใช้ภาษา: {{language}}",
"command.session.new": "เซสชันใหม่",
"command.session.import": "นำเข้าเซสชัน",
"command.file.open": "เปิดไฟล์",
"command.tab.close": "ปิดแท็บ",
"command.tab.reopenClosed": "เปิดแท็บที่ปิดไปอีกครั้ง",
+1
View File
@@ -134,6 +134,7 @@ export const dict = {
"command.language.cycle": "Sikl dili",
"command.language.set": "Dil ulanyň: {{language}}",
"command.session.new": "Täze sessiýa",
"command.session.import": "Sessiýany import et",
"command.file.open": "Faýl açyň",
"command.tab.close": "Salgy ýapyň",
"command.tab.reopenClosed": "Closedapyk goýmany açyň",
+1
View File
@@ -151,6 +151,7 @@ export const dict = {
"command.language.set": "Dil kullan: {{language}}",
"command.session.new": "Yeni oturum",
"command.session.import": "Oturumu içe aktar",
"command.file.open": "Dosya aç",
"command.tab.close": "Sekmeyi kapat",
"command.tab.reopenClosed": "Kapatılan sekmeyi yeniden aç",
+1
View File
@@ -147,6 +147,7 @@ export const dict = {
"command.language.set": "Використати мову: {{language}}",
"command.session.new": "Нова сесія",
"command.session.import": "Імпортувати сесію",
"command.file.open": "Відкрити файл",
"command.tab.close": "Закрити вкладку",
"command.tab.reopenClosed": "Повторно відкрити закриту вкладку",
+1
View File
@@ -141,6 +141,7 @@ export const dict = {
"command.language.cycle": "اگلی زبان منتخب کریں",
"command.language.set": "زبان استعمال کریں: {{language}}",
"command.session.new": "نیا سیشن",
"command.session.import": "سیشن درآمد کریں",
"command.file.open": "فائل کھولیں۔",
"command.tab.close": "ٹیب بند کریں۔",
"command.tab.reopenClosed": "بند ٹیب کو دوبارہ کھولیں۔",
+1
View File
@@ -135,6 +135,7 @@ export const dict = {
"command.language.cycle": "Keyingi til",
"command.language.set": "Tildan foydalaning: {{language}}",
"command.session.new": "Yangi sessiya",
"command.session.import": "Sessiyani import qilish",
"command.file.open": "Faylni ochish",
"command.tab.close": "Tabni yoping",
"command.tab.reopenClosed": "Yopiq tabni qayta oching",
+1
View File
@@ -140,6 +140,7 @@ export const dict = {
"command.language.cycle": "Chuyển ngôn ngữ",
"command.language.set": "Sử dụng ngôn ngữ: {{language}}",
"command.session.new": "Phiên mới",
"command.session.import": "Nhập phiên",
"command.file.open": "Mở tệp",
"command.tab.close": "Đóng tab",
"command.tab.reopenClosed": "Mở lại tab đã đóng",
+1
View File
@@ -154,6 +154,7 @@ export const dict = {
"command.language.set": "使用语言:{{language}}",
"command.session.new": "新建会话",
"command.session.import": "导入会话",
"command.file.open": "打开文件",
+1
View File
@@ -149,6 +149,7 @@ export const dict = {
"command.language.set": "使用語言: {{language}}",
"command.session.new": "新增工作階段",
"command.session.import": "匯入工作階段",
"command.file.open": "開啟檔案",
"command.tab.close": "關閉分頁",
"command.tab.reopenClosed": "重新開啟已關閉的分頁",
@@ -59,8 +59,8 @@ type PlatformBase = {
/** Resolve the native source path for a desktop File. */
getPathForFile?(file: File): string
/** Open a native save file picker dialog (desktop only) */
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
/** Open a native save file dialog and write content to the selected path (desktop only) */
saveFile?(opts: SaveFilePickerOptions, content: string): Promise<boolean>
/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage
@@ -1,6 +1,12 @@
import { describe, expect, test } from "bun:test"
import type { AgentListOutput, ModelListOutput, Project, ProviderListOutput } from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
import {
directoryKey,
normalizeAgentList,
normalizeProjectInfo,
normalizeProviderList,
updateProjectInfo,
} from "./utils"
describe("normalizeAgentList", () => {
test("adapts current agents to the app agent shape", () => {
@@ -110,3 +116,34 @@ describe("directoryKey", () => {
expect(String(directoryKey("/"))).toBe("/")
})
})
describe("updateProjectInfo", () => {
test("applies saved metadata without losing workspace inventory", () => {
const update = {
id: "project",
canonical: "/repo",
name: "Repo",
icon: { color: "purple" },
time: { created: 1, updated: 2 },
sandboxes: ["/repo-sandbox"],
} satisfies Project
expect(
updateProjectInfo(
{
...update,
name: "Old name",
icon: { color: "gray" },
worktree: "/old-repo",
worktrees: [{ directory: "/repo", strategy: "git" }],
},
update,
),
).toMatchObject({
name: "Repo",
icon: { color: "purple" },
worktree: "/repo",
worktrees: [{ directory: "/repo", strategy: "git" }],
})
})
})
@@ -137,3 +137,12 @@ export function normalizeProjectInfo(project: Project | CurrentProject): Project
worktrees: "worktrees" in project ? project.worktrees : [{ directory: worktree }],
}
}
export function updateProjectInfo(project: Project, update: CurrentProject): Project {
return {
...project,
...update,
worktree: update.canonical,
worktrees: project.worktrees,
}
}
+9 -1
View File
@@ -11,7 +11,7 @@ import type { ProjectMeta } from "./global-sync/types"
import { formatServerError } from "@/runtime/server/errors"
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
import { directoryKey, updateProjectInfo } from "./global-sync/utils"
import { PathKey } from "@/workspaces/path-key"
import type { ServerScope } from "@/runtime/server/scope"
import { persisted } from "@/runtime/persistence/storage"
@@ -215,8 +215,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
return promise
}
function applyProjectUpdate(update: Parameters<typeof updateProjectInfo>[1]) {
setProjects((projects) =>
projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)),
)
}
const unsub = serverSDK.event.listen((event) => {
connection.handleEvent({ type: event.type })
if (event.type === "project.updated") applyProjectUpdate(event.data)
if (!event.location) {
if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated")
@@ -243,6 +250,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
})
const projectApi = {
update: applyProjectUpdate,
meta(directory: string, patch: ProjectMeta) {
children.projectMeta(directory, patch)
},
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test"
import type { SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { ServerApi } from "@/runtime/server/api"
import { fetchSessionExport, sessionExportFilename } from "./export"
import type { Platform } from "@/runtime/platform/platform"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "./export"
describe("sessionExportFilename", () => {
test("generates filename from title", () => {
@@ -55,3 +56,31 @@ describe("fetchSessionExport", () => {
expect(fetchSessionExport({ sessionID: "ses_missing", api })).rejects.toThrow("Session not found")
})
})
describe("saveSessionExport", () => {
test("returns false when the native save dialog is cancelled", async () => {
const calls: string[][] = []
const platform: Pick<Platform, "saveFile"> = {
saveFile: async (_options, content) => {
calls.push([content])
return false
},
}
expect(await saveSessionExport("session.json", { id: "ses_1" }, platform)).toBe(false)
expect(calls).toEqual([['{\n "id": "ses_1"\n}']])
})
test("passes serialized data to the native save operation", async () => {
const writes: string[][] = []
const platform: Pick<Platform, "saveFile"> = {
saveFile: async (options, content) => {
writes.push([options.defaultPath ?? "", content])
return true
},
}
expect(await saveSessionExport("session.json", { id: "ses_1" }, platform)).toBe(true)
expect(writes).toEqual([["session.json", '{\n "id": "ses_1"\n}']])
})
})
@@ -1,5 +1,6 @@
import type { SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { ServerApi } from "@/runtime/server/api"
import type { Platform } from "@/runtime/platform/platform"
export type SessionExportData = {
info: SessionInfo
@@ -53,3 +54,15 @@ export function downloadSessionExport(filename: string, data: unknown) {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
export async function saveSessionExport(
filename: string,
data: unknown,
platform: Pick<Platform, "saveFile">,
) {
if (!platform.saveFile) {
downloadSessionExport(filename, data)
return true
}
return platform.saveFile({ defaultPath: filename }, JSON.stringify(data, null, 2))
}
@@ -9,7 +9,7 @@ import { useServerSDK } from "@/runtime/server/client"
import { useSettings } from "@/settings/model"
import { useTerminal } from "@/session/terminal/context"
import { showToast } from "@/shell/notifications/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
import { usePlatform } from "@/runtime/platform/platform"
import type { SessionModel } from "@/session/model"
import type { SessionRevert } from "@/session/revert"
@@ -104,7 +104,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
api: serverSDK.api,
})
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
if (!(await saveSessionExport(filename, data, platform))) return
showToast({
variant: "success",
icon: "circle-check",
@@ -156,10 +156,10 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
data-action="session-queue-steer"
type="button"
size="small"
variant="ghost-muted"
variant="ghost-faint"
icon="arrow-up"
disabled={props.queue.busy()}
class="text-v2-text-text-muted ![font-weight:530]"
class="![font-weight:530]"
onClick={() => void props.queue.steer(props.id)}
>
{props.queue.working() ? language.t("session.queue.steer") : language.t("session.queue.send")}
@@ -12,8 +12,9 @@ import { Markdown } from "@opencode-ai/session-ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { showToast } from "@/shell/notifications/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useProviders } from "@/providers/catalog/providers"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
@@ -84,6 +85,7 @@ const emptyMessages: SessionMessageInfo[] = []
export function SessionContextTab() {
const data = useData()
const language = useLanguage()
const platform = usePlatform()
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const providers = useProviders(() => sdk().directory)
@@ -199,7 +201,7 @@ export function SessionContextTab() {
api: serverSDK.api,
})
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
if (!(await saveSessionExport(filename, data, platform))) return
showToast({
variant: "success",
icon: "circle-check",
@@ -289,14 +289,9 @@ export function SessionSidePanel(props: {
tabs().move(source.id.toString(), source.index)
}}
>
<Tabs
value={activeTab()}
onChange={activateTab}
style={{ "--tabs-bar-height": "var(--shell-header-height,48px)" }}
>
<Tabs value={activeTab()} onChange={activateTab}>
<div class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center">
<Tabs.List
style={{ height: "var(--shell-header-height,52px)" }}
ref={(el: HTMLDivElement) => {
tabList = el
const stop = createFileTabListSync({ el, contextOpen })

Some files were not shown because too many files have changed in this diff Show More