mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-31 22:16:18 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5894e46688 | ||
|
|
327dc809c5 | ||
|
|
e9f7331516 | ||
|
|
8be3ce8b6c | ||
|
|
30721b8b5d | ||
|
|
eb083cce63 | ||
|
|
1afb7c614e | ||
|
|
56e773831c | ||
|
|
6b1ed3918a | ||
|
|
1b3eb1138e | ||
|
|
711a0a2da2 | ||
|
|
ac77cc46b8 | ||
|
|
7197fdfb4e |
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-fG6VYtNC0pce4VM9po7vVucPuJul42yuuijTjNSr7rk=",
|
||||
"aarch64-linux": "sha256-3TznrmNqdt25cOxia6vcdi/5qKaeyLPIsNXGYBSJNrs=",
|
||||
"aarch64-darwin": "sha256-8Kmagb5tfECSWZNsIJgrRP1d3X5tuEoWLEWkV3UENZo=",
|
||||
"x86_64-darwin": "sha256-mIV+mDwIGD02BNYZVi37sY4ls1T01N6z76eBtH0sKiA="
|
||||
"x86_64-linux": "sha256-JStMvgtXBA5GrhyBJ5FtdqD8LWkcaPA9NXef+c2xUzw=",
|
||||
"aarch64-linux": "sha256-WQxF+yS0ZImW0KW620XnZUBsKAJDoJ1jLDOMBaXI2/E=",
|
||||
"aarch64-darwin": "sha256-km7G6s45dfFdW3Z6lFrs4NohD+vmwN1vR8PmEL0WCto=",
|
||||
"x86_64-darwin": "sha256-YFbkcHpuspgTp+B+th3IJ3cnu5C2gEUSiy3NDXpD8UA="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,7 +267,7 @@ export const CachePolicyObject = Schema.Struct({
|
||||
Schema.Union([
|
||||
Schema.Literal("latest-user-message"),
|
||||
Schema.Literal("latest-assistant"),
|
||||
Schema.Struct({ tail: Schema.Number }),
|
||||
Schema.Struct({ tail: Schema.Natural }),
|
||||
]),
|
||||
),
|
||||
ttlSeconds: Schema.optional(Schema.Number),
|
||||
|
||||
@@ -8,6 +8,30 @@ story.beforeEach(async ({ mount }) => {
|
||||
await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("spaces the first mobile message without changing desktop spacing", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountTimelineVirtualizer } = await import(fixture)
|
||||
mountTimelineVirtualizer({ count: 1, rowHeight: 60, immediate: true })
|
||||
}, fixture)
|
||||
const root = page.getByTestId("timeline-virtualizer-fixture")
|
||||
await root.getByRole("button", { name: "Complete Markdown", exact: true }).click()
|
||||
const content = root.locator("[data-timeline-virtual-content]")
|
||||
await expect(content).toHaveCSS("visibility", "visible")
|
||||
const gap = () =>
|
||||
root.locator('[data-timeline-key="user-message:message-0"]').evaluate((element) => {
|
||||
const viewport = element.closest("[data-scrollable]")!
|
||||
return element.getBoundingClientRect().top - viewport.getBoundingClientRect().top
|
||||
})
|
||||
await expect.poll(gap).toBe(16)
|
||||
await root.evaluate((element) => element.setAttribute("dir", "rtl"))
|
||||
await expect.poll(gap).toBe(16)
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await expect.poll(gap).toBe(0)
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect.poll(gap).toBe(16)
|
||||
})
|
||||
|
||||
story("bounds the cheap suffix and reveals only ready measured rows", async ({ page }) => {
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountTimelineVirtualizer } = await import(fixture)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`mobile files browse, search, switch, and close shared file tabs in ${direction}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
fileList: (path) =>
|
||||
path
|
||||
? []
|
||||
: ["first.ts", "second.ts"].map((name) => ({
|
||||
name,
|
||||
path: name,
|
||||
absolute: `${fixture.directory}/${name}`,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
})),
|
||||
fileContent: (path) => `contents:${path}`,
|
||||
findFiles: ({ query }) => ["first.ts", "second.ts"].filter((path) => path.includes(query)),
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
const files = page.locator('[data-slot="session-mobile-files"]')
|
||||
await expect(files.getByRole("button", { name: "first.ts", exact: true })).toBeVisible()
|
||||
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
|
||||
await expect(files.locator('[data-slot="session-mobile-files-header"]')).toHaveCSS("border-bottom-width", "0px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
files.getByRole("tablist", { name: "Open files", exact: true }).evaluate((element) => {
|
||||
const header = element.closest('[data-slot="session-mobile-files-header"]')!
|
||||
const separator = getComputedStyle(element, "::before")
|
||||
return {
|
||||
height: separator.height,
|
||||
fullWidth: parseFloat(separator.width) === header.clientWidth,
|
||||
start: separator.insetInlineStart,
|
||||
bottom: separator.bottom,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.toEqual({ height: "1px", fullWidth: true, start: "0px", bottom: "0px" })
|
||||
await files.getByRole("button", { name: "first.ts", exact: true }).click()
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
await files.locator('[data-column-number="1"]').click()
|
||||
const editor = files.locator('[data-component="line-comment-v2"][data-variant="editor"]')
|
||||
await expect(editor.getByRole("textbox")).toBeVisible()
|
||||
for (const width of [390, 700]) {
|
||||
await page.setViewportSize({ width, height: 844 })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const panel = await files.boundingBox()
|
||||
const comment = await editor.boundingBox()
|
||||
if (!panel || !comment) return false
|
||||
return Math.abs(comment.x - panel.x - 12) < 2 && Math.abs(comment.width - panel.width + 24) < 2
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
await editor.getByRole("textbox").fill("Full-width file comment")
|
||||
await editor.getByRole("button", { name: "Comment", exact: true }).click()
|
||||
const comment = files.locator('[data-component="line-comment-v2"][data-variant="display"]')
|
||||
await expect(comment).toContainText("Full-width file comment")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const panel = await files.boundingBox()
|
||||
const card = await comment.boundingBox()
|
||||
if (!panel || !card) return false
|
||||
return Math.abs(card.x - panel.x - 12) < 2 && Math.abs(card.width - panel.width + 24) < 2
|
||||
})
|
||||
.toBe(true)
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect(files.getByRole("combobox", { name: "Filter files", exact: true })).toBeHidden()
|
||||
await files.getByRole("button", { name: "All files", exact: true }).click()
|
||||
await files.getByRole("combobox", { name: "Filter files", exact: true }).fill("second")
|
||||
await files.getByRole("option", { name: "second.ts", exact: true }).click()
|
||||
await expect(files.getByText("contents:second.ts", { exact: true })).toBeVisible()
|
||||
const openTabs = files.getByRole("tablist", { name: "Open files", exact: true })
|
||||
await expect(openTabs.getByRole("tab")).toHaveText(["first.ts", "second.ts"])
|
||||
await expect
|
||||
.poll(() =>
|
||||
openTabs.getByRole("tab", { name: "second.ts", exact: true }).evaluate((element) => {
|
||||
const tab = element.closest('[data-slot="tabs-v2-trigger-wrapper"]')!
|
||||
const header = element.closest('[data-slot="session-mobile-files-header"]')!
|
||||
return Math.abs(tab.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom) < 1
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
await openTabs.getByRole("tab", { name: "first.ts", exact: true }).click()
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
|
||||
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
await files
|
||||
.locator('[data-slot="tabs-v2-trigger-wrapper"]')
|
||||
.filter({ has: page.getByRole("tab", { name: "first.ts", exact: true }) })
|
||||
.getByRole("button", { name: "Close tab", exact: true })
|
||||
.click()
|
||||
await expect(openTabs.getByRole("tab")).toHaveText(["second.ts"])
|
||||
await expect(files.getByText("contents:second.ts", { exact: true })).toBeVisible()
|
||||
await files.getByRole("button", { name: "Close tab", exact: true }).click()
|
||||
await expect(files.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
|
||||
await expect(openTabs.getByRole("tab")).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test("opening changed files selects the requested tab before file loading completes", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const loading = Promise.withResolvers<void>()
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
fileList: () => [],
|
||||
fileContent: async (path) => {
|
||||
if (path === "second.ts") await loading.promise
|
||||
return `contents:${path}`
|
||||
},
|
||||
vcsDiff: ["first.ts", "second.ts"].map((file) => ({
|
||||
file,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-before\n+after\n`,
|
||||
})),
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
const files = page.locator('[data-slot="session-mobile-files"]')
|
||||
for (const file of ["first.ts", "second.ts", "first.ts", "second.ts"]) {
|
||||
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
const diff = page.locator(`[data-component="session-review"] [data-file="${file}"]`)
|
||||
const header = diff.getByRole("button", { name: file, exact: true })
|
||||
await expect(header).toBeEnabled()
|
||||
if ((await header.getAttribute("aria-expanded")) === "false") await header.click()
|
||||
await diff.getByRole("button", { name: "Open file", exact: true }).click()
|
||||
await expect(navigation.getByRole("tab", { name: "Files", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(files.getByRole("tab", { name: file, exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
if (file === "second.ts") loading.resolve()
|
||||
await expect(files.getByText(`contents:${file}`, { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`mobile change summaries appear only for expanded diffs in ${direction}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page, {
|
||||
vcsDiff: [
|
||||
{
|
||||
file: "added.ts",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
patch:
|
||||
"diff --git a/added.ts b/added.ts\n--- /dev/null\n+++ b/added.ts\n@@ -0,0 +1 @@\n+export const added = 1\n",
|
||||
},
|
||||
{
|
||||
file: "removed.ts",
|
||||
status: "deleted",
|
||||
additions: 0,
|
||||
deletions: 1,
|
||||
patch:
|
||||
"diff --git a/removed.ts b/removed.ts\n--- a/removed.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-export const removed = 1\n",
|
||||
},
|
||||
{
|
||||
file: "modified.ts",
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: `diff --git a/modified.ts b/modified.ts\n--- a/modified.ts\n+++ b/modified.ts\n@@ -1 +1 @@\n-export const value = 1\n+export const value = "${"long content ".repeat(30)}"\n`,
|
||||
},
|
||||
],
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
await expect(review.getByRole("button", { name: "Expand all", exact: true })).toBeVisible()
|
||||
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
|
||||
|
||||
await expect(review.locator('[data-file="added.ts"] [data-slot="accordion-trigger"]')).toHaveCSS(
|
||||
"border-top-width",
|
||||
"0px",
|
||||
)
|
||||
await expect(review.locator('[data-file="modified.ts"] [data-slot="accordion-trigger"]')).toHaveCSS(
|
||||
"border-bottom-width",
|
||||
"0px",
|
||||
)
|
||||
|
||||
for (const change of [
|
||||
{ file: "added.ts", status: "Added", additions: "+1", deletions: "-0" },
|
||||
{ file: "removed.ts", status: "Removed", additions: "+0", deletions: "-1" },
|
||||
{ file: "modified.ts", status: undefined, additions: "+1", deletions: "-1" },
|
||||
]) {
|
||||
const item = review.locator(`[data-file="${change.file}"]`)
|
||||
const trigger = item.getByRole("button", { name: change.file, exact: true })
|
||||
const summary = item.locator('[data-slot="session-review-change-summary"]')
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(trigger.locator('[data-component="diff-changes"]')).toHaveCount(0)
|
||||
await expect(trigger.locator('[data-slot="session-review-change"]')).toHaveCount(0)
|
||||
await expect(summary).toHaveCount(0)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
if (change.file === "modified.ts") {
|
||||
await expect(item.locator('[data-slot="accordion-content"]')).toHaveCSS("border-bottom-width", "0px")
|
||||
await expect(trigger).not.toHaveCSS("border-bottom-width", "0px")
|
||||
}
|
||||
await expect(summary).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Open file", exact: true })).toBeVisible()
|
||||
await expect(summary.locator('[data-slot="diff-changes-additions"]')).toHaveText(change.additions)
|
||||
await expect(summary.locator('[data-slot="diff-changes-deletions"]')).toHaveText(change.deletions)
|
||||
if (change.status) await expect(summary.getByText(change.status, { exact: true })).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(summary).toHaveCount(0)
|
||||
}
|
||||
await expect(review.locator('[data-slot="session-review-view-button"]')).toHaveCount(0)
|
||||
const modified = review.locator('[data-file="modified.ts"]')
|
||||
await modified.getByRole("button", { name: "modified.ts", exact: true }).click()
|
||||
await expect(modified.locator("[data-line-number-content]")).toHaveText(["1", "1"])
|
||||
await expect(modified.locator("[data-diff]")).not.toHaveAttribute("data-disable-line-numbers")
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
|
||||
await expect(review.getByRole("button", { name: "Diff options", exact: true })).toHaveCount(0)
|
||||
await page.keyboard.press("Control+,")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const wrap = settings.getByRole("switch", { name: "Wrap lines", exact: true })
|
||||
const wrapControl = settings.locator('[data-action="settings-mobile-diff-wrap"] [data-slot="switch-control"]')
|
||||
await expect(wrap).toBeChecked()
|
||||
await wrapControl.click()
|
||||
await expect(wrap).not.toBeChecked()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
|
||||
.toBe(false)
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
|
||||
await expect
|
||||
.poll(() => modified.locator("[data-code]").evaluate((element) => element.scrollWidth > element.clientWidth))
|
||||
.toBe(true)
|
||||
await modified.locator("[data-code]").evaluate((element) => {
|
||||
element.scrollLeft = 100
|
||||
})
|
||||
await expect
|
||||
.poll(() => modified.locator("[data-code]").evaluate((element) => Math.abs(element.scrollLeft)))
|
||||
.toBeGreaterThan(0)
|
||||
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
|
||||
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
|
||||
await page.keyboard.press("Control+,")
|
||||
await expect(wrap).not.toBeChecked()
|
||||
await wrapControl.click()
|
||||
await expect(wrap).toBeChecked()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
|
||||
.toBe(true)
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
|
||||
const openFile = modified.getByRole("button", { name: "Open file", exact: true })
|
||||
await expect(openFile).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const button = await openFile.boundingBox()
|
||||
const summary = await modified.locator('[data-slot="session-review-change-summary"]').boundingBox()
|
||||
if (!button || !summary) return false
|
||||
return direction === "ltr"
|
||||
? button.x > summary.x + summary.width / 2
|
||||
: button.x + button.width < summary.x + summary.width / 2
|
||||
})
|
||||
.toBe(true)
|
||||
await openFile.click()
|
||||
await expect(
|
||||
page.getByRole("tablist", { name: "Session view", exact: true }).getByRole("tab", { name: "Files", exact: true }),
|
||||
).toHaveAttribute("aria-selected", "true")
|
||||
const files = page.locator('[data-slot="session-mobile-files"]')
|
||||
await expect(files.getByRole("tab", { name: "modified.ts", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(files).toHaveAttribute("data-browsing", "false")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
for (const position of ["top", "bottom"] as const) {
|
||||
test(`mobile session tabs switch views and keep the terminal cached with ${position} navigation`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
fileList: () => [],
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.addInitScript(
|
||||
(position) =>
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: position } })),
|
||||
position,
|
||||
)
|
||||
await page.route("**/api/pty*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory, project: { id: fixture.project.id, directory: fixture.directory } },
|
||||
data: {
|
||||
id: "pty_mobile_views",
|
||||
title: "Terminal 1",
|
||||
command: "sh",
|
||||
args: [],
|
||||
cwd: fixture.directory,
|
||||
status: "running",
|
||||
pid: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket("**/api/pty/pty_mobile_views/connect", () => undefined)
|
||||
await page.route("**/api/pty/pty_mobile_views/connect-token*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory, project: { id: fixture.project.id, directory: fixture.directory } },
|
||||
data: { ticket: "e2e-ticket", expires_in: 60 },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
|
||||
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
const navigation = page.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
const more = navigation.getByRole("button", { name: "More options", exact: true })
|
||||
const picker = tabs.getByRole("tab", { selected: true })
|
||||
const message = page.locator(
|
||||
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
|
||||
)
|
||||
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(picker).toHaveText("Session")
|
||||
await expect(message).toBeVisible()
|
||||
await expect(composer).toBeVisible()
|
||||
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "Files", "Terminal"])
|
||||
await expect(tabs).toHaveCSS("padding-left", "0px")
|
||||
await expect(tabs).toHaveCSS("padding-right", "0px")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await navigation.boundingBox()
|
||||
return !!bounds && bounds.x >= 8 && bounds.x <= 9 && bounds.width >= 372 && bounds.width <= 374
|
||||
})
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bar = await tabs.boundingBox()
|
||||
const input = await composer.boundingBox()
|
||||
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
|
||||
return !!bar && !!input && !!panel && Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(page.locator("[data-session-title]")).toHaveCount(0)
|
||||
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText(fixture.expected.targetTitle)
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
const drawer = page.getByRole("dialog", { name: "Tabs", exact: true })
|
||||
await expect(drawer).toHaveAttribute("data-open", "")
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
|
||||
await drawer.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible()
|
||||
await page.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(drawer).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
|
||||
await expect(picker).toHaveCount(0)
|
||||
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
|
||||
const usage = page.locator('[data-slot="session-usage-content"]')
|
||||
await expect(usage).toHaveCSS("padding-top", "16px")
|
||||
await expect(usage).toHaveCSS("padding-inline-start", "16px")
|
||||
await expect(usage).toHaveCSS("padding-inline-end", "16px")
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
const status = page.getByRole("dialog", { name: "Status", exact: true })
|
||||
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
await status.getByRole("tab", { name: "Plugins", exact: true }).click()
|
||||
await expect(status.getByText("opencode.json", { exact: true })).toBeVisible()
|
||||
await status.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(status).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
const details = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
|
||||
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
|
||||
await details.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(details).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
|
||||
await expect(details).not.toHaveAttribute("data-transitioning")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(details).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await details.getByRole("button", { name: "No changes", exact: true }).click()
|
||||
await expect(details).toBeHidden()
|
||||
await expect(picker).toHaveText("Changes")
|
||||
await expect(page.getByText("No uncommitted changes yet", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("height", "40px")
|
||||
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("padding-left", "8px")
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await expect(picker).toHaveText("Files")
|
||||
await expect(page.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await tabs.getByRole("tab", { name: "Terminal", exact: true }).click()
|
||||
const panel = page.locator("#terminal-panel")
|
||||
await expect(panel).toHaveAttribute("data-opened", "true")
|
||||
await expect(panel.getByRole("tab", { name: /Terminal 1/ })).toBeVisible()
|
||||
await expect(panel.locator('[data-component="terminal"]')).toBeVisible()
|
||||
await expect(panel.locator("textarea")).toBeEditable()
|
||||
await expect(panel).toHaveCount(1)
|
||||
await panel.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await tabs.getByRole("tab", { name: "Session", exact: true }).click()
|
||||
await expect(message).toBeVisible()
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(panel).toHaveAttribute("inert", "")
|
||||
await expect(panel).toHaveAttribute("data-cache-probe", "original")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(picker).toHaveText("Terminal")
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel).toHaveAttribute("data-cache-probe", "original")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(picker).toHaveText("Session")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(picker).toHaveText("Terminal")
|
||||
await panel.getByRole("button", { name: "Close terminal", exact: true }).click()
|
||||
await expect(picker).toHaveText("Session")
|
||||
await expect(panel).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
|
||||
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(picker).toHaveText("Session")
|
||||
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText(fixture.expected.sourceTitle)
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await expect(picker).toBeHidden()
|
||||
await expect(page.locator("[data-session-title]")).toBeVisible()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test("status drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const more = page
|
||||
.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
.getByRole("button", { name: "More options", exact: true })
|
||||
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
|
||||
const overlay = page.locator('[data-slot="mobile-status-overlay"]')
|
||||
|
||||
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
|
||||
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
|
||||
if (dismissal === "escape") await page.keyboard.press("Escape")
|
||||
if (dismissal === "drag") {
|
||||
const handle = drawer.locator('[data-slot="mobile-status-drag-handle"]')
|
||||
const bounds = await handle.boundingBox()
|
||||
expect(bounds).not.toBeNull()
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2 + 1)
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, 843)
|
||||
await page.mouse.up()
|
||||
}
|
||||
await expect(drawer, `dismissal: ${dismissal}`).toBeHidden()
|
||||
await expect(overlay).toHaveCount(0)
|
||||
await expect(more).toBeFocused()
|
||||
}
|
||||
})
|
||||
@@ -89,7 +89,7 @@ async function openReview(page: Page) {
|
||||
)
|
||||
await changes.click()
|
||||
expect((await (await diffResponse).json()).data).toHaveLength(1)
|
||||
await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/)
|
||||
await expect(changes).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
await expectAppVisible(review)
|
||||
|
||||
@@ -8,6 +8,12 @@ export async function expectAppVisible(locator: Locator) {
|
||||
}
|
||||
|
||||
export async function expectSessionTitle(page: Page, title: string) {
|
||||
if ((page.viewportSize()?.width ?? 1280) < 768) {
|
||||
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
|
||||
await expectAppVisible(trigger)
|
||||
await expect(trigger.locator('span[dir="auto"]')).toHaveText(title, { timeout: APP_READY_TIMEOUT })
|
||||
return
|
||||
}
|
||||
await expectAppVisible(page.getByRole("heading", { name: title }))
|
||||
}
|
||||
|
||||
|
||||
@@ -114,8 +114,10 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
<form
|
||||
data-component="composer"
|
||||
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
|
||||
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
|
||||
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl"
|
||||
classList={{
|
||||
"bg-v2-background-bg-layer-01": props.borderUnderlay,
|
||||
"bg-v2-background-bg-base": !props.borderUnderlay,
|
||||
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
|
||||
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
|
||||
}}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AppBaseProviders, AppInterface } from "@/app"
|
||||
import { loadInitialLocale } from "@/runtime/i18n/language"
|
||||
import { PlatformProvider } from "@/runtime/platform/platform"
|
||||
import { createWebPlatform } from "@/runtime/platform/web"
|
||||
import { isStandalone, PwaRoutePersistence, restorePwaRoute } from "@/runtime/platform/pwa"
|
||||
import en from "@/runtime/i18n/en"
|
||||
import zh from "@/runtime/i18n/zh"
|
||||
import { authFromToken } from "@/runtime/server/api"
|
||||
@@ -71,6 +72,8 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
|
||||
void loadInitialLocale().then((locale) => {
|
||||
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
|
||||
clearAuthToken()
|
||||
const standalone = isStandalone()
|
||||
if (standalone) restorePwaRoute()
|
||||
const server: ServerConnection.Http = {
|
||||
type: "http",
|
||||
authToken: !!auth,
|
||||
@@ -87,7 +90,9 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
|
||||
defaultServer={ServerConnection.Key.make(web.defaultServerUrl)}
|
||||
canonicalLocalServer={ServerConnection.key(server)}
|
||||
servers={[server]}
|
||||
/>
|
||||
>
|
||||
{standalone && <PwaRoutePersistence />}
|
||||
</AppInterface>
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
),
|
||||
|
||||
@@ -660,6 +660,10 @@ export const dict = {
|
||||
"home.providerTip": "Connect to 75+ providers to use other models, including Claude, GPT, Gemini, etc",
|
||||
|
||||
"session.tab.session": "Session",
|
||||
"session.tab.files": "Files",
|
||||
"session.files.openTabs": "Open files",
|
||||
"session.tab.usage": "Usage",
|
||||
"session.view.select": "Session view",
|
||||
"session.tab.review": "Review",
|
||||
"session.tab.context": "Context",
|
||||
"session.tab.unknown": "Unknown Session",
|
||||
@@ -698,6 +702,7 @@ export const dict = {
|
||||
"session.review.change.one": "Change",
|
||||
"session.review.change.other": "Changes",
|
||||
"session.review.loadingChanges": "Loading changes...",
|
||||
"session.review.wrapLines": "Wrap lines",
|
||||
"session.review.empty": "No changes in this session yet",
|
||||
"session.review.noVcs": "No Git Version Control System detected, changes not displayed",
|
||||
"session.review.noVcs.createGit.title": "Create a Git repository",
|
||||
@@ -978,7 +983,9 @@ export const dict = {
|
||||
"settings.general.row.showProjectIcon.description": "Show the project icon in the session header",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Place the title bar and session tabs at the bottom of the screen on mobile",
|
||||
"Place the title bar at the bottom of the screen on mobile",
|
||||
"settings.general.row.mobileDiffWrap.description":
|
||||
"Wrap long lines in mobile diffs instead of scrolling horizontally",
|
||||
"settings.general.row.showCustomAgents.title": "Show agent",
|
||||
"settings.general.row.showCustomAgents.description":
|
||||
"Switch between agents in the composer. When hidden, defaults to Build agent.",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useLocation } from "@solidjs/router"
|
||||
import { createEffect } from "solid-js"
|
||||
|
||||
const LAST_ROUTE_KEY = "opencode.pwa.last-route"
|
||||
|
||||
export function isStandalone() {
|
||||
return (
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
("standalone" in navigator && navigator.standalone === true)
|
||||
)
|
||||
}
|
||||
|
||||
export function restorePwaRoute() {
|
||||
if (location.pathname !== "/" || location.search || location.hash) return
|
||||
try {
|
||||
const value = localStorage.getItem(LAST_ROUTE_KEY)
|
||||
if (!value) return
|
||||
const url = new URL(value, location.origin)
|
||||
if (url.origin !== location.origin || url.searchParams.has("auth_token")) return
|
||||
if (
|
||||
url.pathname !== "/" &&
|
||||
url.pathname !== "/new-session" &&
|
||||
!/^\/server\/[^/]+\/session\/[^/]+$/.test(url.pathname)
|
||||
)
|
||||
return
|
||||
history.replaceState(history.state, "", url.pathname + url.search + url.hash)
|
||||
} catch {
|
||||
// Storage may be unavailable; keep the launch URL in that case.
|
||||
}
|
||||
}
|
||||
|
||||
export function PwaRoutePersistence() {
|
||||
const location = useLocation()
|
||||
createEffect(() => {
|
||||
const value = location.pathname + location.search + location.hash
|
||||
try {
|
||||
localStorage.setItem(LAST_ROUTE_KEY, value)
|
||||
} catch {
|
||||
// Navigation must still work when storage is unavailable or full.
|
||||
}
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -11,14 +11,7 @@ type SessionComposerRegionState = Pick<
|
||||
|
||||
export type SessionComposerRegionViewController = Pick<
|
||||
SessionComposerRegionController,
|
||||
| "centered"
|
||||
| "onResponseSubmit"
|
||||
| "openParent"
|
||||
| "setPromptRef"
|
||||
| "setDockRef"
|
||||
| "parentID"
|
||||
| "child"
|
||||
| "showComposer"
|
||||
"centered" | "onResponseSubmit" | "openParent" | "setPromptRef" | "setDockRef" | "parentID" | "child" | "showComposer"
|
||||
> & { state: SessionComposerRegionState }
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
@@ -68,10 +61,7 @@ export function SessionComposerRegion(props: {
|
||||
"relative z-[70]": true,
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={controller.child()}
|
||||
fallback={<Show when={!controller.state.blocked()}>{props.composer}</Show>}
|
||||
>
|
||||
<Show when={controller.child()} fallback={<Show when={!controller.state.blocked()}>{props.composer}</Show>}>
|
||||
<div
|
||||
ref={controller.setPromptRef}
|
||||
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
|
||||
|
||||
@@ -271,7 +271,7 @@ export function SessionContextTab() {
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div class="px-6 pt-4 pb-10 flex flex-col gap-10">
|
||||
<div data-slot="session-usage-content" class="px-4 pt-4 pb-6 flex flex-col gap-6 md:px-6 md:pb-10 md:gap-10">
|
||||
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
|
||||
<For each={stats}>
|
||||
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createMemo, createSignal, createUniqueId, Show } from "solid-js"
|
||||
import { createMemo, createUniqueId, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createQuery } from "@tanstack/solid-query"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode-ai/session-ui/v2/session-file-panel-v2"
|
||||
@@ -34,6 +35,7 @@ export function SessionFileBrowserTab(props: {
|
||||
onSelect: (path: string) => void
|
||||
onSelectPermanent: (path: string) => void
|
||||
filterRef?: (element: HTMLInputElement) => void
|
||||
mobile?: boolean
|
||||
}) {
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
@@ -42,8 +44,10 @@ export function SessionFileBrowserTab(props: {
|
||||
const serverSDK = useServerSDK()
|
||||
const { workspaceKey } = useSessionLayout()
|
||||
const resultsID = `session-file-browser-results-${createUniqueId()}`
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const [explicitHighlight, setExplicitHighlight] = createSignal<string>()
|
||||
const [store, setStore] = createStore({ filter: "", explicitHighlight: undefined as string | undefined })
|
||||
const filter = () => store.filter
|
||||
const setFilter = (value: string) => setStore("filter", value)
|
||||
const setExplicitHighlight = (value: string) => setStore("explicitHighlight", value)
|
||||
const sidebarOpened = () => props.placeholder || props.state.sidebarOpened()
|
||||
const query = createMemo(() => filter().trim())
|
||||
const search = createQuery(() => {
|
||||
@@ -61,7 +65,7 @@ export function SessionFileBrowserTab(props: {
|
||||
const highlighted = createMemo(() => {
|
||||
const values = files()
|
||||
if (values.length === 0) return undefined
|
||||
const explicit = explicitHighlight()
|
||||
const explicit = store.explicitHighlight
|
||||
if (explicit && values.includes(explicit)) return explicit
|
||||
return values[0]
|
||||
})
|
||||
@@ -105,13 +109,13 @@ export function SessionFileBrowserTab(props: {
|
||||
filter={filter()}
|
||||
onFilterChange={setFilter}
|
||||
onFilterKeyDown={onFilterKeyDown}
|
||||
filterAutofocus={props.placeholder}
|
||||
filterRef={props.filterRef}
|
||||
filterAutofocus={props.placeholder && !props.mobile}
|
||||
filterRef={(element) => props.filterRef?.(element)}
|
||||
filterControls={resultsID}
|
||||
filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
|
||||
filterExpanded={query().length > 0 && files().length > 0}
|
||||
width={props.state.sidebarWidth()}
|
||||
onWidthChange={props.state.resizeSidebar}
|
||||
onWidthChange={props.mobile ? undefined : props.state.resizeSidebar}
|
||||
>
|
||||
<Show
|
||||
when={query()}
|
||||
@@ -119,6 +123,7 @@ export function SessionFileBrowserTab(props: {
|
||||
<FileTreeV2
|
||||
active={props.active}
|
||||
kinds={props.kinds}
|
||||
draggable={!props.mobile}
|
||||
onFileClick={(node) => props.onSelect(node.path)}
|
||||
onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
[data-slot="session-mobile-files"]
|
||||
[data-slot="session-mobile-files-header"]
|
||||
[data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"]
|
||||
[data-slot="tabs-v2-list"] {
|
||||
position: static;
|
||||
|
||||
&::before {
|
||||
inset-inline-start: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="session-mobile-files"] [data-component="line-comment-v2"] {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
[data-slot="session-mobile-files"][data-browsing="true"] {
|
||||
[data-component="session-review-v2-sidebar-root"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="session-review-v2-sidebar"] {
|
||||
width: 100% !important;
|
||||
border-inline-end: 0;
|
||||
}
|
||||
|
||||
[data-slot="session-review-v2-preview"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="session-mobile-files"] [data-slot="tabs-v2-trigger-close-button"] [data-slot="tabs-close-button"] {
|
||||
width: 32px;
|
||||
height: 36px;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { createMemo, For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { createSessionTabs, SESSION_OPEN_FILE_TAB } from "@/session/helpers"
|
||||
import { useFile } from "@/workspaces/files/model"
|
||||
import { SessionFileBrowserTab } from "./session-file-browser-tab"
|
||||
import type { Kind } from "./file-tree-v2"
|
||||
import "./session-mobile-files.css"
|
||||
|
||||
export function SessionMobileFiles() {
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const layout = useSessionLayout()
|
||||
const tabs = createSessionTabs({
|
||||
tabs: layout.tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab: file.tab,
|
||||
})
|
||||
const [store, setStore] = createStore({ browsing: !tabs.activeFileTab() })
|
||||
const browsing = () => store.browsing || !tabs.activeFileTab()
|
||||
const active = createMemo(() => file.pathFromTab(tabs.activeFileTab() ?? ""))
|
||||
const kinds = new Map<string, Kind>()
|
||||
const open = (path: string) => {
|
||||
layout.tabs().open(file.tab(path))
|
||||
void file.load(path)
|
||||
setStore("browsing", false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="session-mobile-files" data-browsing={browsing()} class="flex h-full min-h-0 flex-col">
|
||||
<div data-slot="session-mobile-files-header" class="relative flex h-10 shrink-0 items-center">
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
class="shrink-0 mx-2"
|
||||
onClick={() => setStore("browsing", true)}
|
||||
aria-pressed={browsing()}
|
||||
>
|
||||
{language.t("session.files.all")}
|
||||
</Button>
|
||||
<Tabs
|
||||
value={browsing() ? SESSION_OPEN_FILE_TAB : tabs.activeFileTab()}
|
||||
onChange={(tab) => {
|
||||
// Kobalte falls back to a file tab when the browse view has no trigger.
|
||||
if (browsing()) return
|
||||
const path = file.pathFromTab(tab)
|
||||
if (path) open(path)
|
||||
}}
|
||||
variant="line"
|
||||
class="min-w-0 flex-1 !h-auto"
|
||||
>
|
||||
<Tabs.List aria-label={language.t("session.files.openTabs")} class="!h-10 !px-0 overflow-x-auto">
|
||||
<For each={tabs.openedTabs()}>
|
||||
{(tab) => (
|
||||
<Tabs.Trigger
|
||||
value={tab}
|
||||
onClick={() => open(file.pathFromTab(tab)!)}
|
||||
class="shrink-0 max-w-48"
|
||||
classes={{ button: "min-w-0" }}
|
||||
closeButton={
|
||||
<Tabs.CloseButton
|
||||
aria-label={language.t("common.closeTab")}
|
||||
onClick={() => layout.tabs().close(tab)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span dir="ltr" class="truncate">
|
||||
{getFilename(file.pathFromTab(tab) ?? tab)}
|
||||
</span>
|
||||
</Tabs.Trigger>
|
||||
)}
|
||||
</For>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1">
|
||||
<SessionFileBrowserTab
|
||||
mobile
|
||||
tab={tabs.activeFileTab() ?? SESSION_OPEN_FILE_TAB}
|
||||
placeholder={browsing()}
|
||||
active={active()}
|
||||
kinds={kinds}
|
||||
state={{
|
||||
sidebarOpened: browsing,
|
||||
sidebarWidth: () => 240,
|
||||
sidebarTransition: () => false,
|
||||
resizeSidebar: () => undefined,
|
||||
toggleSidebar: () => setStore("browsing", !browsing()),
|
||||
}}
|
||||
onSelect={open}
|
||||
onSelectPermanent={open}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,10 @@ export function SessionHeader() {
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
const actions = createMemo<SessionHeaderActionsState>(() => ({
|
||||
status: status() ? { label: language.t("status.popover.trigger"), content: () => <StatusPopover /> } : undefined,
|
||||
status:
|
||||
isDesktop() && status()
|
||||
? { label: language.t("status.popover.trigger"), content: () => <StatusPopover /> }
|
||||
: undefined,
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: reviewTooltipKeybind(command),
|
||||
reviewVisible: isDesktop(),
|
||||
|
||||
@@ -36,6 +36,28 @@ describe("createOpenReviewFile", () => {
|
||||
|
||||
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts", "active:file://src/a.ts"])
|
||||
})
|
||||
|
||||
test("selects immediately and does not steal focus when loading finishes", async () => {
|
||||
const loading = Promise.withResolvers<void>()
|
||||
const state = { active: "file://previous.ts", opened: [] as string[] }
|
||||
const openReviewFile = createOpenReviewFile({
|
||||
showAllFiles: () => undefined,
|
||||
tabForPath: (path) => `file://${path}`,
|
||||
openTab: (tab) => state.opened.push(tab),
|
||||
setActive: (tab) => {
|
||||
state.active = tab
|
||||
},
|
||||
loadFile: () => loading.promise,
|
||||
})
|
||||
|
||||
openReviewFile("requested.ts")
|
||||
expect(state.opened).toEqual(["file://requested.ts"])
|
||||
expect(state.active).toBe("file://requested.ts")
|
||||
state.active = "file://previous.ts"
|
||||
loading.resolve()
|
||||
await loading.promise
|
||||
expect(state.active).toBe("file://previous.ts")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createOpenSessionFileTab", () => {
|
||||
|
||||
@@ -124,14 +124,10 @@ export const createOpenReviewFile = (input: {
|
||||
return (path: string) => {
|
||||
batch(() => {
|
||||
input.showAllFiles()
|
||||
const maybePromise = input.loadFile(path)
|
||||
const open = () => {
|
||||
const tab = input.tabForPath(path)
|
||||
input.openTab(tab)
|
||||
input.setActive(tab)
|
||||
}
|
||||
if (maybePromise instanceof Promise) void maybePromise.then(open)
|
||||
else open()
|
||||
input.loadFile(path)
|
||||
const tab = input.tabForPath(path)
|
||||
input.openTab(tab)
|
||||
input.setActive(tab)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function createSessionReview(input: {
|
||||
const location = useWorkspaceLocation()
|
||||
const server = useServerSDK()
|
||||
const [state, setState] = createStore({
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
mobileTab: "session" as "session" | "changes" | "files" | "usage",
|
||||
detailsOpen: false,
|
||||
scroll: undefined as HTMLDivElement | undefined,
|
||||
pendingFile: undefined as string | undefined,
|
||||
@@ -66,7 +66,9 @@ export function createSessionReview(input: {
|
||||
}
|
||||
return list
|
||||
})
|
||||
const mobileChanges = createMemo(() => !input.session.isDesktop() && state.mobileTab === "changes")
|
||||
const mobileChanges = createMemo(
|
||||
() => !input.session.isDesktop() && !input.screen.terminal.open() && state.mobileTab === "changes",
|
||||
)
|
||||
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
||||
const value = mode()
|
||||
return value === "git" || value === "branch" ? value : undefined
|
||||
@@ -407,7 +409,7 @@ export function createSessionReview(input: {
|
||||
loadDiff,
|
||||
mobile: {
|
||||
changes: mobileChanges,
|
||||
setTab: (tab: "session" | "changes") => setState("mobileTab", tab),
|
||||
setTab: (tab: "session" | "changes" | "files" | "usage") => setState("mobileTab", tab),
|
||||
tab: () => state.mobileTab,
|
||||
},
|
||||
mode,
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface SessionReviewTabProps {
|
||||
diffs: ReviewDiff[]
|
||||
view: ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||
diffStyle: DiffStyle
|
||||
changeSummary?: boolean
|
||||
overflow?: "wrap" | "scroll"
|
||||
disableLineNumbers?: boolean
|
||||
onDiffStyleChange?: (style: DiffStyle) => void
|
||||
onViewFile?: (file: string) => void
|
||||
onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
|
||||
@@ -122,6 +125,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
createEffect(() => {
|
||||
props.diffs.length
|
||||
props.diffStyle
|
||||
props.overflow
|
||||
if (!layout.ready()) return
|
||||
queueRestore()
|
||||
})
|
||||
@@ -156,6 +160,9 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
}}
|
||||
diffs={props.diffs}
|
||||
diffStyle={props.diffStyle}
|
||||
changeSummary={props.changeSummary}
|
||||
overflow={props.overflow}
|
||||
disableLineNumbers={props.disableLineNumbers}
|
||||
onDiffStyleChange={props.onDiffStyleChange}
|
||||
onViewFile={props.onViewFile}
|
||||
focusedFile={props.focusedFile}
|
||||
|
||||
@@ -2,51 +2,137 @@ import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-
|
||||
import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Match, Show, Suspense, Switch } from "solid-js"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { For, Match, Show, Suspense, Switch, lazy, createEffect, onCleanup, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { SessionSidePanel } from "../files/session-side-panel"
|
||||
import { ReviewPanel } from "./panel"
|
||||
import { SessionReviewTab } from "./review-tab"
|
||||
import type { ChangeMode, SessionReviewModel } from "./model"
|
||||
|
||||
export function SessionMobileTabs(props: { review: SessionReviewModel; compact?: boolean; bottom?: boolean }) {
|
||||
const StatusDrawer = lazy(async () => {
|
||||
const { StatusDrawer } = await import("@/shell/status/status-drawer")
|
||||
return { default: StatusDrawer }
|
||||
})
|
||||
|
||||
const MobilePanelDrawer = lazy(async () => {
|
||||
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
|
||||
return { default: MobilePanelDrawer }
|
||||
})
|
||||
|
||||
export function SessionMobileViewTabs(props: {
|
||||
current: "session" | "changes" | "files" | "usage" | "terminal"
|
||||
onSelect: (view: "session" | "changes" | "files" | "usage" | "terminal") => void
|
||||
details?: (close: () => void) => JSX.Element
|
||||
onDetailsOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({
|
||||
menu: false,
|
||||
status: false,
|
||||
statusLoaded: false,
|
||||
details: false,
|
||||
detailsLoaded: false,
|
||||
pending: undefined as "status" | "details" | undefined,
|
||||
})
|
||||
createEffect(() => props.onDetailsOpenChange?.(store.details))
|
||||
onCleanup(() => props.onDetailsOpenChange?.(false))
|
||||
let trigger: HTMLButtonElement | undefined
|
||||
return (
|
||||
<Tabs value={props.review.mobile.tab()} class="h-auto">
|
||||
<Tabs.List
|
||||
classList={{
|
||||
"!h-9": props.compact,
|
||||
"[&::after]:!border-b-0 [&::after]:!border-t [&::after]:!border-border-weak-base": props.bottom,
|
||||
}}
|
||||
<div
|
||||
class="relative flex shrink-0 items-center before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-v2-border-border-base before:content-['']"
|
||||
data-slot="session-mobile-view-navigation"
|
||||
>
|
||||
<Tabs value={props.current} variant="line" class="!h-auto min-w-0 flex-1" data-slot="session-mobile-view-tabs">
|
||||
<Tabs.List aria-label={language.t("session.view.select")} class="!h-9 !gap-0 !px-0 before:!hidden">
|
||||
<For each={["session", "changes", "files", "terminal"] as const}>
|
||||
{(view) => (
|
||||
<Tabs.Trigger
|
||||
value={view}
|
||||
class="min-w-0 flex-1"
|
||||
classes={{ button: "w-full justify-center" }}
|
||||
onClick={() => props.onSelect(view)}
|
||||
>
|
||||
{view === "session"
|
||||
? language.t("session.tab.session")
|
||||
: view === "changes"
|
||||
? language.plural("session.review.change", 0)
|
||||
: view === "files"
|
||||
? language.t("session.tab.files")
|
||||
: language.t("terminal.title")}
|
||||
</Tabs.Trigger>
|
||||
)}
|
||||
</For>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<Menu
|
||||
appearance="standard"
|
||||
modal={false}
|
||||
placement="bottom-end"
|
||||
gutter={4}
|
||||
open={store.menu}
|
||||
onOpenChange={(open) => setStore("menu", open)}
|
||||
>
|
||||
<Tabs.Trigger
|
||||
value="session"
|
||||
classes={{ button: props.compact ? "w-full !py-2" : "w-full" }}
|
||||
classList={{
|
||||
"!w-1/2 !max-w-none": true,
|
||||
"!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent":
|
||||
props.bottom,
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
ref={(element: HTMLButtonElement) => {
|
||||
trigger = element
|
||||
}}
|
||||
onClick={() => props.review.mobile.setTab("session")}
|
||||
>
|
||||
{language.t("session.tab.session")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="changes"
|
||||
classes={{ button: props.compact ? "w-full !py-2" : "w-full" }}
|
||||
classList={{
|
||||
"!w-1/2 !max-w-none !border-r-0": true,
|
||||
"!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent":
|
||||
props.bottom,
|
||||
}}
|
||||
onClick={() => props.review.mobile.setTab("changes")}
|
||||
>
|
||||
{props.review.hasChanges()
|
||||
? language.t("session.review.filesChanged", { count: props.review.count() })
|
||||
: language.plural("session.review.change", 0)}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
icon={<Icon name="menu" />}
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="mx-1.5 shrink-0"
|
||||
state={props.current === "usage" || store.menu ? "pressed" : undefined}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!store.pending) return
|
||||
event.preventDefault()
|
||||
if (store.pending === "status") setStore({ status: true, statusLoaded: true })
|
||||
if (store.pending === "details") setStore({ details: true, detailsLoaded: true })
|
||||
setStore("pending", undefined)
|
||||
}}
|
||||
>
|
||||
<Menu.Item onSelect={() => props.onSelect("usage")}>{language.t("session.tab.usage")}</Menu.Item>
|
||||
<Show when={props.details}>
|
||||
<Menu.Item onSelect={() => setStore({ pending: "details", menu: false })}>
|
||||
{language.t("session.summary.title")}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Item onSelect={() => setStore({ pending: "status", menu: false })}>
|
||||
{language.t("status.popover.trigger")}
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
<Show when={store.statusLoaded}>
|
||||
<Suspense>
|
||||
<StatusDrawer
|
||||
open={store.status}
|
||||
onOpenChange={(open) => setStore("status", open)}
|
||||
returnFocus={() => trigger}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
<Show when={store.detailsLoaded}>
|
||||
<Suspense>
|
||||
<MobilePanelDrawer
|
||||
title={language.t("session.summary.title")}
|
||||
open={store.details}
|
||||
onOpenChange={(open) => setStore("details", open)}
|
||||
returnFocus={() => trigger}
|
||||
>
|
||||
{props.details?.(() => setStore("details", false))}
|
||||
</MobilePanelDrawer>
|
||||
</Suspense>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -88,14 +174,22 @@ export function SessionDesktopReview(props: { review: SessionReviewModel; presen
|
||||
}
|
||||
|
||||
function ReviewContent(props: { review: SessionReviewModel }) {
|
||||
const settings = useSettings()
|
||||
return (
|
||||
<Show when={!props.review.deferRender()}>
|
||||
<SessionReviewTab
|
||||
title={<ReviewTitle review={props.review} />}
|
||||
empty={<ReviewEmpty review={props.review} loadingClass="px-4 py-4 text-text-weak" />}
|
||||
empty={<ReviewEmpty review={props.review} loadingClass="px-2 py-2 text-text-weak" />}
|
||||
diffs={props.review.diffs()}
|
||||
view={props.review.view()}
|
||||
diffStyle="unified"
|
||||
changeSummary
|
||||
disableLineNumbers={false}
|
||||
overflow={settings.general.mobileDiffWrap() ? "wrap" : "scroll"}
|
||||
onViewFile={(file) => {
|
||||
props.review.openFile(file)
|
||||
props.review.mobile.setTab("files")
|
||||
}}
|
||||
onScrollRef={props.review.setScroll}
|
||||
focusedFile={props.review.activeFile()}
|
||||
onLineComment={props.review.comments.add}
|
||||
@@ -106,11 +200,11 @@ function ReviewContent(props: { review: SessionReviewModel }) {
|
||||
comments={props.review.comments.all()}
|
||||
focusedComment={props.review.comments.focus()}
|
||||
onFocusedCommentChange={props.review.comments.setFocus}
|
||||
onViewFile={props.review.openFile}
|
||||
classes={{
|
||||
root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
|
||||
header: "px-4 !h-16 !pb-4",
|
||||
container: "px-4",
|
||||
root: "[&_[data-slot=session-review-list]]:pb-0 [&_[data-slot=accordion-trigger]]:!rounded-none [&_[data-slot=accordion-trigger]]:!border-x-0 [&_[data-slot=accordion-item]:first-child_[data-slot=accordion-trigger]]:!border-t-0 [&_[data-slot=accordion-item]:last-child:not([data-expanded])_[data-slot=accordion-trigger]]:!border-b-0 [&_[data-slot=accordion-item]:last-child_[data-slot=accordion-content]]:!border-b-0 [&_[data-slot=accordion-item]:last-child_[data-slot=session-review-diff-placeholder]]:!border-b-0 [&_[data-slot=accordion-content]]:!rounded-none [&_[data-slot=accordion-content]]:!border-x-0 [&_[data-slot=session-review-diff-placeholder]]:!rounded-none [&_[data-slot=session-review-diff-placeholder]]:!border-x-0",
|
||||
header:
|
||||
"!px-2 !h-10 !pb-0 relative before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-v2-border-border-base before:content-['']",
|
||||
container: "!px-0",
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { ErrorBoundary, Show, Match, Switch, createMemo, createEffect, createComputed, on } from "solid-js"
|
||||
import {
|
||||
ErrorBoundary,
|
||||
Show,
|
||||
Match,
|
||||
Switch,
|
||||
Suspense,
|
||||
lazy,
|
||||
createMemo,
|
||||
createEffect,
|
||||
createComputed,
|
||||
on,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import createPresence from "solid-presence"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { SessionHeader } from "@/session/header/session-header"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { MessageTimeline } from "@/session/timeline/message-timeline"
|
||||
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import type { SessionModel } from "@/session/model"
|
||||
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
|
||||
import { SessionPanelFrame } from "@/session/session-frame"
|
||||
@@ -14,15 +26,25 @@ import { useUsageExceededDialogs } from "./usage-exceeded-dialogs"
|
||||
import { SessionErrorFallback } from "./route-error"
|
||||
import { createSessionScreenLayout } from "./screen-layout"
|
||||
import { createSessionReview } from "./review/model"
|
||||
import { SessionDesktopReview, SessionMobileReview, SessionMobileTabs } from "./review/view"
|
||||
import { SessionDesktopReview, SessionMobileReview, SessionMobileViewTabs } from "./review/view"
|
||||
import { SessionContextTab } from "./files/session-context-tab"
|
||||
import { createSessionTimelineInteraction } from "./timeline/interaction"
|
||||
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
const { SessionMobileFiles } = await import("./files/session-mobile-files")
|
||||
return { default: SessionMobileFiles }
|
||||
})
|
||||
|
||||
export function SessionScreen(props: { session: SessionModel }) {
|
||||
const session = props.session
|
||||
const layout = useLayout()
|
||||
const settings = useSettings()
|
||||
const server = useServer()
|
||||
const detailsProject = createMemo(() => {
|
||||
const info = session.data.info()
|
||||
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
|
||||
})
|
||||
const isDesktop = session.isDesktop
|
||||
const screen = createSessionScreenLayout(session)
|
||||
const timeline = createSessionTimelineInteraction(session)
|
||||
@@ -34,6 +56,8 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
sideRegionPresent: false,
|
||||
sideReviewPresent: false,
|
||||
sideTerminalPresent: false,
|
||||
mobileTerminalCached: false,
|
||||
mobileMoveDismissed: false,
|
||||
})
|
||||
const [elements, setElements] = createStore<{
|
||||
side?: HTMLDivElement
|
||||
@@ -41,7 +65,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
}>({})
|
||||
const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible)
|
||||
const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open())
|
||||
const bottomTerminalVisible = createMemo(() => screen.terminal.open() && (!isDesktop() || screen.terminal.bottom()))
|
||||
const bottomTerminalVisible = createMemo(() => isDesktop() && screen.terminal.open() && screen.terminal.bottom())
|
||||
const sidePresence = createPresence({
|
||||
show: sideVisible,
|
||||
element: () => elements.side ?? null,
|
||||
@@ -67,6 +91,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
createComputed((prev) => {
|
||||
const key = session.identity.sessionKey()
|
||||
if (key !== prev) {
|
||||
setStore("mobileMoveDismissed", false)
|
||||
setStore("deferRender", true)
|
||||
const owner = session.ownership.capture()
|
||||
requestAnimationFrame(() => {
|
||||
@@ -76,6 +101,11 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
return key
|
||||
})
|
||||
const review = createSessionReview({ session, screen, deferRender: () => store.deferRender })
|
||||
const mobileView = createMemo(() => (screen.terminal.open() ? "terminal" : review.mobile.tab()))
|
||||
const conversationVisible = createMemo(() => isDesktop() || mobileView() === "session")
|
||||
createEffect(() => {
|
||||
if (!isDesktop() && screen.terminal.open()) setStore("mobileTerminalCached", true)
|
||||
})
|
||||
const composer = createActiveSessionRegion({
|
||||
session,
|
||||
screen,
|
||||
@@ -84,36 +114,102 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
|
||||
useUsageExceededDialogs()
|
||||
|
||||
const mobileTabsBottom = createMemo(() => !isDesktop() && settings.general.mobileTitlebarPosition() === "bottom")
|
||||
|
||||
const sessionErrorFallback = (error: unknown, reset: () => void) => {
|
||||
createEffect(on(session.identity.sessionKey, reset, { defer: true }))
|
||||
return <SessionErrorFallback error={error} sessionID={session.identity.params.id} />
|
||||
}
|
||||
|
||||
const mobileTabs = () => (
|
||||
<Show when={session.identity.sessionKey()} keyed>
|
||||
{(_key) => (
|
||||
<SessionMobileViewTabs
|
||||
current={mobileView()}
|
||||
onDetailsOpenChange={review.details.setOpen}
|
||||
details={
|
||||
!session.data.isChild() && detailsProject()
|
||||
? (close) => (
|
||||
<Show when={detailsProject()}>
|
||||
{(project) => (
|
||||
<SessionSummaryPanel
|
||||
mobile
|
||||
project={project()}
|
||||
directory={session.workspace.directory()}
|
||||
local={!session.workspace.current()}
|
||||
branch={
|
||||
session.shared.data.location.vcs.info({ directory: session.workspace.directory() })?.branch
|
||||
.current
|
||||
}
|
||||
baseBranch={
|
||||
session.shared.data.location.vcs.info({ directory: project().worktree })?.branch.current
|
||||
}
|
||||
diffs={project().vcs === "git" ? review.details.diffs() : []}
|
||||
sessionID={session.identity.params.id ?? ""}
|
||||
moveEligible={composer.workspaceMoveEligible()}
|
||||
moveDismissed={store.mobileMoveDismissed}
|
||||
onMoveDismiss={() => setStore("mobileMoveDismissed", true)}
|
||||
onReview={() => {
|
||||
close()
|
||||
review.mobile.setTab("changes")
|
||||
session.layout.view().terminal.close()
|
||||
}}
|
||||
backgroundTasks={composer.region.state.background.tasks()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
onSelect={(view) => {
|
||||
if (view === "terminal") {
|
||||
session.layout.view().terminal.open()
|
||||
return
|
||||
}
|
||||
review.mobile.setTab(view)
|
||||
session.layout.view().terminal.close()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
|
||||
const sessionPanelContent = () => (
|
||||
<>
|
||||
<Show when={!isDesktop() && !!session.identity.params.id && !mobileTabsBottom()}>
|
||||
<SessionMobileTabs review={review} compact />
|
||||
</Show>
|
||||
<Show when={!isDesktop() && !!session.identity.params.id}>{mobileTabs()}</Show>
|
||||
{/* Surface query errors without suspending session metadata while messages load. */}
|
||||
<Show when={timeline.resource.error}>
|
||||
{(error) => {
|
||||
throw error()
|
||||
}}
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<div class="relative flex-1 min-h-0 overflow-hidden">
|
||||
<Show when={!isDesktop() && store.mobileTerminalCached}>
|
||||
<div class="absolute inset-0" classList={{ invisible: mobileView() !== "terminal" }}>
|
||||
<TerminalPanel fill embedded present contentHeight="100%" />
|
||||
</div>
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={!isDesktop() && mobileView() === "terminal"}>
|
||||
<></>
|
||||
</Match>
|
||||
<Match when={!isDesktop() && mobileView() === "usage"}>
|
||||
<SessionContextTab />
|
||||
</Match>
|
||||
<Match when={!isDesktop() && mobileView() === "files"}>
|
||||
<Suspense>
|
||||
<SessionMobileFiles />
|
||||
</Suspense>
|
||||
</Match>
|
||||
<Match when={session.identity.params.id && review.mobile.changes()}>
|
||||
<SessionMobileReview review={review} />
|
||||
</Match>
|
||||
<Match when={session.identity.params.id}>
|
||||
<Show when={!messagesReady()}>
|
||||
<Show when={isDesktop() && !messagesReady()}>
|
||||
<SessionIdentityHeader sessionID={session.identity.params.id ?? ""} session={session.data.info()} />
|
||||
</Show>
|
||||
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<MessageTimeline
|
||||
hideHeader={!isDesktop()}
|
||||
session={session}
|
||||
background={composer.region.state.background}
|
||||
actions={composer.actions.timeline}
|
||||
@@ -143,14 +239,11 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={!review.mobile.changes() ? session.identity.params.id : undefined} keyed>
|
||||
<Show when={conversationVisible() ? session.identity.params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<ActiveSessionComposerRegion model={composer} session={session} onResponseSubmit={timeline.actions.resume} />
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!!session.identity.params.id && mobileTabsBottom()}>
|
||||
<SessionMobileTabs review={review} compact bottom />
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -300,7 +393,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={bottomTerminalPresence.present() || store.bottomTerminalCached}>
|
||||
<Show when={isDesktop() && (bottomTerminalPresence.present() || store.bottomTerminalCached)}>
|
||||
<div
|
||||
ref={(element) => setElements("bottomTerminal", element)}
|
||||
data-slot="terminal-panel-presence"
|
||||
|
||||
@@ -38,7 +38,14 @@ type CachedTerminalSurface = {
|
||||
}
|
||||
|
||||
export function TerminalPanel(
|
||||
props: { stacked?: boolean; fill?: boolean; framed?: boolean; present?: boolean; contentHeight?: string } = {},
|
||||
props: {
|
||||
stacked?: boolean
|
||||
fill?: boolean
|
||||
framed?: boolean
|
||||
present?: boolean
|
||||
contentHeight?: string
|
||||
embedded?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const layout = useLayout()
|
||||
const terminal = useTerminal()
|
||||
@@ -223,6 +230,7 @@ export function TerminalPanel(
|
||||
opened={opened()}
|
||||
present={present()}
|
||||
framed={props.framed}
|
||||
embedded={props.embedded}
|
||||
desktop={isDesktop()}
|
||||
stacked={stacked()}
|
||||
height={panelHeight()}
|
||||
|
||||
@@ -7,6 +7,7 @@ export function TerminalSurface(
|
||||
opened: boolean
|
||||
present?: boolean
|
||||
framed?: boolean
|
||||
embedded?: boolean
|
||||
desktop: boolean
|
||||
stacked: boolean
|
||||
height: string
|
||||
@@ -26,7 +27,7 @@ export function TerminalSurface(
|
||||
id="terminal-panel"
|
||||
data-component="terminal-panel"
|
||||
data-opened={props.opened}
|
||||
data-size-animated={!props.resizing && (!props.desktop || props.stacked)}
|
||||
data-size-animated={!props.embedded && !props.resizing && (!props.desktop || props.stacked)}
|
||||
role="region"
|
||||
aria-label={props.label}
|
||||
aria-hidden={!props.opened}
|
||||
@@ -37,11 +38,14 @@ export function TerminalSurface(
|
||||
"min-w-0 h-full flex-1": props.desktop && (props.present ?? props.opened) && !props.stacked,
|
||||
"w-0 h-full pointer-events-none": props.desktop && !(props.present ?? props.opened),
|
||||
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": props.desktop && (props.framed ?? true),
|
||||
"will-change-[height]": !props.resizing && (!props.desktop || props.stacked),
|
||||
"will-change-[height]": !props.embedded && !props.resizing && (!props.desktop || props.stacked),
|
||||
}}
|
||||
style={{ height: props.height, "--terminal-panel-height": props.contentHeight }}
|
||||
>
|
||||
<div classList={{ "md:hidden": !props.stacked, hidden: props.stacked }} onPointerDown={props.onResizeStart}>
|
||||
<div
|
||||
classList={{ "md:hidden": !props.stacked, hidden: props.stacked || props.embedded }}
|
||||
onPointerDown={props.onResizeStart}
|
||||
>
|
||||
<ResizeHandle
|
||||
class="-top-1"
|
||||
direction="vertical"
|
||||
@@ -57,7 +61,7 @@ export function TerminalSurface(
|
||||
data-slot="terminal-panel-content"
|
||||
class="absolute inset-x-0 top-0 flex flex-col overflow-hidden"
|
||||
classList={{
|
||||
"border-t border-border-weak-base": props.opened && !props.desktop,
|
||||
"border-t border-border-weak-base": props.opened && !props.desktop && !props.embedded,
|
||||
"pointer-events-none": !props.opened,
|
||||
}}
|
||||
style={{ height: props.contentHeight }}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function BackgroundMoveHint(props: { keybind?: string[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[] }) {
|
||||
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) {
|
||||
const language = useLanguage()
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const taskType = (task: BackgroundTask) => {
|
||||
@@ -81,7 +81,7 @@ export function BackgroundWorkSummary(props: { tasks: BackgroundTask[] }) {
|
||||
return (
|
||||
<Popover
|
||||
open={open()}
|
||||
placement={language.direction() === "rtl" ? "right-end" : "left-end"}
|
||||
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-end" : "left-end"}
|
||||
gutter={4}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
@@ -126,6 +126,7 @@ export function BackgroundWorkSummary(props: { tasks: BackgroundTask[] }) {
|
||||
|
||||
function WorkspaceMoveAction(props: {
|
||||
variant: "inline" | "panel"
|
||||
mobile?: boolean
|
||||
eligible: boolean
|
||||
sessionID: string
|
||||
project: Project
|
||||
@@ -150,9 +151,17 @@ function WorkspaceMoveAction(props: {
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
placement={inline() ? "bottom-end" : language.direction() === "rtl" ? "right-start" : "left-start"}
|
||||
gutter={inline() ? 4 : -22}
|
||||
contentClass={inline() ? undefined : "relative top-3.5"}
|
||||
placement={
|
||||
props.mobile
|
||||
? "top-end"
|
||||
: inline()
|
||||
? "bottom-end"
|
||||
: language.direction() === "rtl"
|
||||
? "right-start"
|
||||
: "left-start"
|
||||
}
|
||||
gutter={props.mobile || inline() ? 4 : -22}
|
||||
contentClass={props.mobile || inline() ? undefined : "relative top-3.5"}
|
||||
class={
|
||||
inline()
|
||||
? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pe-6 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
|
||||
@@ -181,7 +190,8 @@ function WorkspaceMoveAction(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function SessionSummaryPanel(props: {
|
||||
export function SessionSummaryPanel(props: {
|
||||
mobile?: boolean
|
||||
project: Project
|
||||
avatar?: JSX.Element
|
||||
directory: string
|
||||
@@ -207,7 +217,7 @@ function SessionSummaryPanel(props: {
|
||||
"flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"
|
||||
|
||||
return (
|
||||
<div data-component="session-summary-panel" class="w-[280px]">
|
||||
<div data-component="session-summary-panel" class={props.mobile ? "w-full" : "w-[280px]"}>
|
||||
<div class="relative z-10 flex flex-col gap-1 overflow-hidden rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
|
||||
<div class={row}>
|
||||
{props.avatar ?? (
|
||||
@@ -217,19 +227,23 @@ function SessionSummaryPanel(props: {
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
)}
|
||||
<span class="min-w-0 flex-1 truncate text-v2-text-text-muted">{displayName(props.project)}</span>
|
||||
<span dir="auto" class="min-w-0 flex-1 truncate text-v2-text-text-muted">
|
||||
{displayName(props.project)}
|
||||
</span>
|
||||
</div>
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
placement={language.direction() === "rtl" ? "right-start" : "left-start"}
|
||||
gutter={-22}
|
||||
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start"}
|
||||
gutter={props.mobile ? 4 : -22}
|
||||
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
|
||||
>
|
||||
<Icon name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 flex-1 truncate text-start">{location()}</span>
|
||||
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
|
||||
{location()}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</SessionWorkspaceMenu>
|
||||
<div class={row}>
|
||||
@@ -252,7 +266,9 @@ function SessionSummaryPanel(props: {
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span class="min-w-0 truncate">{branch()}</span>
|
||||
<span dir="auto" class="min-w-0 truncate">
|
||||
{branch()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
@@ -272,12 +288,13 @@ function SessionSummaryPanel(props: {
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={props.backgroundTasks.length > 0}>
|
||||
<BackgroundWorkSummary tasks={props.backgroundTasks} />
|
||||
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.local && props.diffs && props.diffs.length > 0 && props.moveEligible}>
|
||||
<WorkspaceMoveAction
|
||||
variant="panel"
|
||||
mobile={props.mobile}
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
@@ -291,6 +308,7 @@ function SessionSummaryPanel(props: {
|
||||
}
|
||||
|
||||
type MessageTimelineProps = {
|
||||
hideHeader?: boolean
|
||||
session: TimelineSessionSource
|
||||
background: SessionBackground
|
||||
actions?: SessionUserActions
|
||||
@@ -393,7 +411,7 @@ function MessageTimelineView(
|
||||
}),
|
||||
)
|
||||
const turnPadding = () => "px-4 md:px-5"
|
||||
const showHeader = createMemo(() => props.data.showHeader() || workspaceSession())
|
||||
const showHeader = createMemo(() => !props.hideHeader && (props.data.showHeader() || workspaceSession()))
|
||||
const pinned = createMemo(() => props.pinned)
|
||||
const messageByID = projection.messageByID
|
||||
const virtualized = createTimelineVirtualizer({
|
||||
@@ -556,198 +574,200 @@ function MessageTimelineView(
|
||||
}}
|
||||
renderRow={(row, onSizeChange) => <rowRenderer.Row row={row} onSizeChange={onSizeChange} />}
|
||||
header={
|
||||
<SessionTitleHeader>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center min-w-0 flex-1 w-full">
|
||||
<Show
|
||||
when={workspaceSession()}
|
||||
fallback={
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Show when={showProjectIcon()} fallback={<Icon name="monitor" />}>
|
||||
{projectAvatar()}
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
placement="bottom-start"
|
||||
value={sessionDirectory()}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span
|
||||
tabIndex={0}
|
||||
aria-label={sessionDirectory()}
|
||||
classList={{
|
||||
"flex size-6 shrink-0 items-center justify-center": true,
|
||||
"text-v2-icon-icon-accent": !showProjectIcon(),
|
||||
}}
|
||||
>
|
||||
<Show when={showProjectIcon()} fallback={<Icon name="workspace-isolated" />}>
|
||||
{projectAvatar()}
|
||||
</Show>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="session-title-parent"
|
||||
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||
onClick={props.action.navigateParent}
|
||||
>
|
||||
{parentTitle()}
|
||||
</button>
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={childTitle() || title.editing}>
|
||||
<Show when={!props.hideHeader}>
|
||||
<SessionTitleHeader>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center min-w-0 flex-1 w-full">
|
||||
<Show
|
||||
when={title.editing}
|
||||
when={workspaceSession()}
|
||||
fallback={
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover"
|
||||
onClick={openTitleEditor}
|
||||
>
|
||||
{childTitle()}
|
||||
</h1>
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Show when={showProjectIcon()} fallback={<Icon name="monitor" />}>
|
||||
{projectAvatar()}
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
data-slot="session-title-child"
|
||||
dir="auto"
|
||||
value={title.draft}
|
||||
disabled={props.pending.rename()}
|
||||
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content self-start rounded-[6px] px-2 py-1"
|
||||
style={{
|
||||
"--inline-input-shadow": "none",
|
||||
"text-align": "start",
|
||||
}}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={() => void saveTitleEditor()}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={sessionID()} keyed>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<Show when={!parentID() && project()}>
|
||||
{(project) => (
|
||||
<Popover open={summaryOpen()} placement="bottom-end" gutter={6} onOpenChange={setSummary}>
|
||||
<Popover.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="window-analytics" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
state={summaryOpen() ? "pressed" : undefined}
|
||||
aria-label={language.t("session.summary.title")}
|
||||
aria-expanded={summaryOpen()}
|
||||
/>
|
||||
<Popover.Portal>
|
||||
<Popover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
|
||||
<SessionSummaryPanel
|
||||
project={project()}
|
||||
avatar={showProjectIcon() ? projectAvatar() : undefined}
|
||||
directory={sessionDirectory()}
|
||||
local={!workspaceSession()}
|
||||
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
|
||||
baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
|
||||
diffs={sessionDiffs()}
|
||||
sessionID={id}
|
||||
moveEligible={props.workspaceMoveEligible}
|
||||
moveDismissed={workspaceSuggestionDismissed()}
|
||||
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
|
||||
onReview={() => {
|
||||
setSummary(false)
|
||||
props.onReview()
|
||||
}}
|
||||
backgroundTasks={props.background.tasks()}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!parentID()}>
|
||||
<Menu
|
||||
gutter={6}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => {
|
||||
setTitle("menuOpen", open)
|
||||
if (open) return
|
||||
}}
|
||||
<Tooltip
|
||||
placement="bottom-start"
|
||||
value={sessionDirectory()}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="outline-dots" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
aria-expanded={title.menuOpen}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
style={{ width: "120px", "min-width": "120px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (title.pendingRename) {
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
return
|
||||
}
|
||||
}}
|
||||
<span
|
||||
tabIndex={0}
|
||||
aria-label={sessionDirectory()}
|
||||
classList={{
|
||||
"flex size-6 shrink-0 items-center justify-center": true,
|
||||
"text-v2-icon-icon-accent": !showProjectIcon(),
|
||||
}}
|
||||
>
|
||||
<Show when={showProjectIcon()} fallback={<Icon name="workspace-isolated" />}>
|
||||
{projectAvatar()}
|
||||
</Show>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="session-title-parent"
|
||||
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||
onClick={props.action.navigateParent}
|
||||
>
|
||||
{parentTitle()}
|
||||
</button>
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={childTitle() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover"
|
||||
onClick={openTitleEditor}
|
||||
>
|
||||
<Menu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
{language.t("common.rename")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onSelect={() => void props.action.export(id)}>
|
||||
{language.t("common.export")}...
|
||||
</Menu.Item>
|
||||
{/* TODO: Need a session archive API. */}
|
||||
<Menu.Separator />
|
||||
<Menu.Item onSelect={() => props.action.showDelete(id)}>
|
||||
{language.t("common.delete")}...
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
{childTitle()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
data-slot="session-title-child"
|
||||
dir="auto"
|
||||
value={title.draft}
|
||||
disabled={props.pending.rename()}
|
||||
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content self-start rounded-[6px] px-2 py-1"
|
||||
style={{
|
||||
"--inline-input-shadow": "none",
|
||||
"text-align": "start",
|
||||
}}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={() => void saveTitleEditor()}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</SessionTitleHeader>
|
||||
</div>
|
||||
<Show when={sessionID()} keyed>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<Show when={!parentID() && project()}>
|
||||
{(project) => (
|
||||
<Popover open={summaryOpen()} placement="bottom-end" gutter={6} onOpenChange={setSummary}>
|
||||
<Popover.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="window-analytics" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
state={summaryOpen() ? "pressed" : undefined}
|
||||
aria-label={language.t("session.summary.title")}
|
||||
aria-expanded={summaryOpen()}
|
||||
/>
|
||||
<Popover.Portal>
|
||||
<Popover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
|
||||
<SessionSummaryPanel
|
||||
project={project()}
|
||||
avatar={showProjectIcon() ? projectAvatar() : undefined}
|
||||
directory={sessionDirectory()}
|
||||
local={!workspaceSession()}
|
||||
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
|
||||
baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
|
||||
diffs={sessionDiffs()}
|
||||
sessionID={id}
|
||||
moveEligible={props.workspaceMoveEligible}
|
||||
moveDismissed={workspaceSuggestionDismissed()}
|
||||
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
|
||||
onReview={() => {
|
||||
setSummary(false)
|
||||
props.onReview()
|
||||
}}
|
||||
backgroundTasks={props.background.tasks()}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!parentID()}>
|
||||
<Menu
|
||||
gutter={6}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => {
|
||||
setTitle("menuOpen", open)
|
||||
if (open) return
|
||||
}}
|
||||
>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="outline-dots" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
aria-expanded={title.menuOpen}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
style={{ width: "120px", "min-width": "120px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (title.pendingRename) {
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
return
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Menu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
{language.t("common.rename")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onSelect={() => void props.action.export(id)}>
|
||||
{language.t("common.export")}...
|
||||
</Menu.Item>
|
||||
{/* TODO: Need a session archive API. */}
|
||||
<Menu.Separator />
|
||||
<Menu.Item onSelect={() => props.action.showDelete(id)}>
|
||||
{language.t("common.delete")}...
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</SessionTitleHeader>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import type { createTimelineProjection } from "./projection"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { filterVirtualIndexes } from "./virtual-items"
|
||||
@@ -73,6 +74,8 @@ type ViewProps = {
|
||||
|
||||
export function createTimelineVirtualizer(input: Input) {
|
||||
const language = useLanguage()
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const topOffset = () => (input.showHeader() ? 64 : isDesktop() ? 0 : 16)
|
||||
const ownerSessionKey = input.sessionKey()
|
||||
const cached = cache.get(ownerSessionKey)
|
||||
const initialMeasurements = cached?.measurements
|
||||
@@ -186,7 +189,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
},
|
||||
scrollEndThreshold: 80,
|
||||
get scrollMargin() {
|
||||
return input.showHeader() ? 64 : 0
|
||||
return topOffset()
|
||||
},
|
||||
paddingEnd: 64,
|
||||
get rangeExtractor() {
|
||||
@@ -446,7 +449,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
data-timeline-key={rowProps.rowKey}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `${item().start - (input.showHeader() ? 64 : 0)}px`,
|
||||
top: `${item().start - topOffset()}px`,
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: `${item().size}px`,
|
||||
@@ -516,7 +519,9 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
class="relative min-w-0 w-full h-full"
|
||||
style={{ "--sticky-accordion-top": input.showHeader() ? "48px" : "0px" }}
|
||||
>
|
||||
<Show when={input.showHeader()}>{props.header}</Show>
|
||||
<Show when={input.showHeader()} fallback={<div aria-hidden="true" class="h-4 md:hidden" />}>
|
||||
{props.header}
|
||||
</Show>
|
||||
<div
|
||||
data-timeline-virtual-content
|
||||
ref={(element) => {
|
||||
|
||||
@@ -362,6 +362,22 @@ export const SettingsGeneral: Component<{
|
||||
|
||||
<ReasoningModeSetting />
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("session.review.wrapLines")}
|
||||
description={language.t("settings.general.row.mobileDiffWrap.description")}
|
||||
>
|
||||
<div data-action="settings-mobile-diff-wrap">
|
||||
<Switch
|
||||
aria-label={language.t("session.review.wrapLines")}
|
||||
checked={settings.general.mobileDiffWrap()}
|
||||
onChange={settings.general.setMobileDiffWrap}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("session.review.wrapLines")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface Settings {
|
||||
editToolPartsExpanded: boolean
|
||||
showCustomAgents: boolean
|
||||
mobileTitlebarPosition: "top" | "bottom"
|
||||
mobileDiffWrap: boolean
|
||||
terminalPlacement: TerminalPlacement
|
||||
followUpBehavior: FollowUpBehavior
|
||||
}
|
||||
@@ -130,6 +131,7 @@ const defaultSettings: Settings = {
|
||||
editToolPartsExpanded: false,
|
||||
showCustomAgents: false,
|
||||
mobileTitlebarPosition: "top",
|
||||
mobileDiffWrap: true,
|
||||
terminalPlacement: "side",
|
||||
followUpBehavior: "steer",
|
||||
},
|
||||
@@ -268,6 +270,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setMobileTitlebarPosition(value: "top" | "bottom") {
|
||||
setStore("general", "mobileTitlebarPosition", value)
|
||||
},
|
||||
mobileDiffWrap: withFallback(() => store.general?.mobileDiffWrap, defaultSettings.general.mobileDiffWrap),
|
||||
setMobileDiffWrap(value: boolean) {
|
||||
setStore("general", "mobileDiffWrap", value)
|
||||
},
|
||||
terminalPlacement: withFallback(
|
||||
() => store.general?.terminalPlacement,
|
||||
defaultSettings.general.terminalPlacement,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import Drawer from "@corvu/drawer"
|
||||
import type { ParentProps } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import "./status/status-drawer.css"
|
||||
|
||||
export function MobilePanelDrawer(
|
||||
props: ParentProps<{
|
||||
title: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
returnFocus?: () => HTMLElement | undefined
|
||||
}>,
|
||||
) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<Drawer
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
side="bottom"
|
||||
finalFocusEl={props.returnFocus?.()}
|
||||
// Menu focus handoff must not dismiss the drawer during its opening transition.
|
||||
closeOnOutsideFocus={false}
|
||||
>
|
||||
{/* Preserve Corvu's content and dismissal lifecycle across reopenings. */}
|
||||
<Drawer.Portal forceMount>
|
||||
<Drawer.Overlay data-slot="mobile-status-overlay" />
|
||||
<Drawer.Content forceMount data-slot="mobile-status-drawer" dir={language.direction()}>
|
||||
<div data-slot="mobile-status-drag-handle" aria-hidden="true">
|
||||
<span />
|
||||
</div>
|
||||
<div data-slot="mobile-status-header" data-corvu-no-drag>
|
||||
<Drawer.Label>{props.title}</Drawer.Label>
|
||||
<Drawer.Close data-slot="mobile-status-close" aria-label={language.t("common.close")}>
|
||||
{language.t("common.close")}
|
||||
</Drawer.Close>
|
||||
</div>
|
||||
<div data-slot="mobile-status-content" data-corvu-no-drag>
|
||||
{props.children}
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
export function StatusPopoverBody(props: { shown: boolean; embedded?: boolean }) {
|
||||
const data = useData()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
@@ -42,7 +42,13 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-xl"
|
||||
classList={{
|
||||
"w-[360px] shadow-[var(--shadow-lg-border-base)]": !props.embedded,
|
||||
"w-full min-w-0": props.embedded,
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
aria-label={language.t("status.popover.ariaLabel")}
|
||||
class="tabs bg-background-strong rounded-xl overflow-hidden"
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
[data-slot="mobile-status-overlay"] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: var(--v2-overlay-simple-overlay-scrim);
|
||||
animation: mobile-status-backdrop-in 240ms ease-out;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
|
||||
animation: mobile-status-backdrop-out 200ms ease-in forwards;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-drawer"] {
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
z-index: 51;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
|
||||
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
|
||||
padding-left: max(12px, env(safe-area-inset-left, 0px));
|
||||
padding-right: max(12px, env(safe-area-inset-right, 0px));
|
||||
border-radius: 16px 16px 0 0;
|
||||
background: var(--v2-background-bg-deep);
|
||||
color: var(--v2-text-text-base);
|
||||
box-shadow: var(--v2-elevation-overlay);
|
||||
outline: none;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-drawer"][data-transitioning] {
|
||||
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-drawer"][data-closing] {
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-drawer"][data-closed] {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-drag-handle"] {
|
||||
display: flex;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-drag-handle"] span {
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--v2-border-border-strong);
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-header"] {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-inline-start: 8px;
|
||||
padding-block-end: 8px;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-header"] h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-close"] {
|
||||
min-height: 44px;
|
||||
flex-shrink: 0;
|
||||
padding-inline: 12px;
|
||||
border-radius: 6px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
[data-slot="mobile-status-close"]:hover {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-close"]:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-content"] {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-loading"] {
|
||||
display: flex;
|
||||
min-height: 56px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
@keyframes mobile-status-backdrop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mobile-status-backdrop-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-slot="mobile-status-drawer"][data-transitioning],
|
||||
[data-slot="mobile-status-drawer"][data-closing] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-overlay"],
|
||||
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { lazy, Suspense } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { MobilePanelDrawer } from "../mobile-panel-drawer"
|
||||
|
||||
const Body = lazy(async () => {
|
||||
const { StatusPopoverBody } = await import("./body")
|
||||
return { default: StatusPopoverBody }
|
||||
})
|
||||
|
||||
export function StatusDrawer(props: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
returnFocus?: () => HTMLElement | undefined
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<MobilePanelDrawer
|
||||
title={language.t("status.popover.trigger")}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
returnFocus={props.returnFocus}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div data-slot="mobile-status-loading" role="status">
|
||||
{language.t("common.loading")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Body shown={props.open} embedded />
|
||||
</Suspense>
|
||||
</MobilePanelDrawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, expect, test } from "bun:test"
|
||||
import { MemoryRouter, createMemoryHistory } from "@solidjs/router"
|
||||
import { createComponent, render } from "solid-js/web"
|
||||
import { isStandalone, PwaRoutePersistence, restorePwaRoute } from "../src/runtime/platform/pwa"
|
||||
|
||||
const key = "opencode.pwa.last-route"
|
||||
const originalUrl = window.location.href
|
||||
|
||||
beforeEach(() => {
|
||||
window.location.href = "http://localhost/"
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(key)
|
||||
window.location.href = originalUrl
|
||||
})
|
||||
|
||||
test("normal browser windows are not standalone", () => {
|
||||
expect(isStandalone()).toBe(false)
|
||||
})
|
||||
|
||||
test("restores the last PWA route including query and hash without adding history", () => {
|
||||
window.history.replaceState({ retained: true }, "", "http://localhost/")
|
||||
const length = window.history.length
|
||||
localStorage.setItem(key, "/server/local/session/session-1?view=files#file")
|
||||
|
||||
restorePwaRoute()
|
||||
|
||||
expect(window.location.pathname + window.location.search + window.location.hash).toBe(
|
||||
"/server/local/session/session-1?view=files#file",
|
||||
)
|
||||
expect(window.history.length).toBe(length)
|
||||
expect(window.history.state).toEqual({ retained: true })
|
||||
})
|
||||
|
||||
test("preserves explicit launch routes, queries, and hashes", () => {
|
||||
localStorage.setItem(key, "/server/local/session/saved")
|
||||
for (const route of ["/server/local/session/linked", "/new-session?draftId=123", "/?launch=1", "/#launch"]) {
|
||||
window.history.replaceState(null, "", `http://localhost${route}`)
|
||||
restorePwaRoute()
|
||||
expect(window.location.pathname + window.location.search + window.location.hash).toBe(route)
|
||||
}
|
||||
})
|
||||
|
||||
test("ignores missing, invalid, external, and auth-bearing saved routes", () => {
|
||||
window.history.replaceState(null, "", "http://localhost/")
|
||||
restorePwaRoute()
|
||||
expect(window.location.pathname).toBe("/")
|
||||
|
||||
for (const value of [
|
||||
"/removed-route",
|
||||
"https://example.com/new-session",
|
||||
"//example.com/new-session",
|
||||
"http://[",
|
||||
"/new-session?auth_token=secret",
|
||||
]) {
|
||||
localStorage.setItem(key, value)
|
||||
restorePwaRoute()
|
||||
expect(window.location.href).toBe("http://localhost/")
|
||||
}
|
||||
})
|
||||
|
||||
test("persists router navigation including returning home", async () => {
|
||||
const host = document.createElement("div")
|
||||
const history = createMemoryHistory()
|
||||
history.set({ value: "/new-session?draftId=123", replace: true, scroll: false })
|
||||
const dispose = render(() => createComponent(MemoryRouter, { history, root: PwaRoutePersistence }), host)
|
||||
try {
|
||||
expect(localStorage.getItem(key)).toBe("/new-session?draftId=123")
|
||||
history.set({ value: "/server/local/session/next#file", scroll: false })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(localStorage.getItem(key)).toBe("/server/local/session/next#file")
|
||||
history.set({ value: "/", scroll: false })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(localStorage.getItem(key)).toBe("/")
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
@@ -3,7 +3,7 @@ export * as SharedEvents from "./shared-events.js"
|
||||
export function make<A extends { readonly type: string }>(connect: (signal: AbortSignal) => AsyncIterable<A>) {
|
||||
type Completion = { readonly error: unknown } | Record<string, never>
|
||||
type Subscriber = {
|
||||
push: (value: A) => Promise<void>
|
||||
push: (value: A) => void
|
||||
finish: (completion: Completion) => void
|
||||
}
|
||||
type Connection = {
|
||||
@@ -13,7 +13,7 @@ export function make<A extends { readonly type: string }>(connect: (signal: Abor
|
||||
}
|
||||
|
||||
let current: Connection | undefined
|
||||
const delivered = Promise.resolve()
|
||||
const capacity = 4_096
|
||||
|
||||
function stop(connection: Connection) {
|
||||
connection.connected = undefined
|
||||
@@ -31,7 +31,7 @@ export function make<A extends { readonly type: string }>(connect: (signal: Abor
|
||||
const item = await iterator.next()
|
||||
if (item.done || connection.controller.signal.aborted) break
|
||||
if (item.value.type === "server.connected") connection.connected = item.value
|
||||
await Promise.all(Array.from(connection.subscribers, (subscriber) => subscriber.push(item.value)))
|
||||
connection.subscribers.forEach((subscriber) => subscriber.push(item.value))
|
||||
}
|
||||
} catch (error) {
|
||||
completion = { error }
|
||||
@@ -51,15 +51,14 @@ export function make<A extends { readonly type: string }>(connect: (signal: Abor
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
const pending: ReturnType<typeof Promise.withResolvers<IteratorResult<A>>>[] = []
|
||||
const queued: A[] = []
|
||||
let started = false
|
||||
let completion: Completion | undefined
|
||||
let connection: Connection | undefined
|
||||
let offered: { readonly value: A; readonly accepted: ReturnType<typeof Promise.withResolvers<void>> } | undefined
|
||||
|
||||
function finish(result: Completion) {
|
||||
function finish(result: Completion, discard = true) {
|
||||
completion = result
|
||||
offered?.accepted.resolve()
|
||||
offered = undefined
|
||||
if (discard) queued.length = 0
|
||||
options?.signal?.removeEventListener("abort", abort)
|
||||
if (connection?.subscribers.delete(subscriber) && !connection.subscribers.size) stop(connection)
|
||||
pending.splice(0).forEach((request) => {
|
||||
@@ -73,17 +72,21 @@ export function make<A extends { readonly type: string }>(connect: (signal: Abor
|
||||
}
|
||||
|
||||
const subscriber: Subscriber = {
|
||||
finish,
|
||||
finish(result) {
|
||||
finish(result, false)
|
||||
},
|
||||
push(value) {
|
||||
if (completion) return delivered
|
||||
if (completion) return
|
||||
const request = pending.shift()
|
||||
if (request) {
|
||||
request.resolve({ done: false, value })
|
||||
return delivered
|
||||
return
|
||||
}
|
||||
const accepted = Promise.withResolvers<void>()
|
||||
offered = { value, accepted }
|
||||
return accepted.promise
|
||||
if (queued.length === capacity) {
|
||||
finish({ error: new Error(`Event subscriber exceeded its ${capacity}-event capacity`) })
|
||||
return
|
||||
}
|
||||
queued.push(value)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -102,12 +105,8 @@ export function make<A extends { readonly type: string }>(connect: (signal: Abor
|
||||
|
||||
return {
|
||||
next(): Promise<IteratorResult<A>> {
|
||||
if (offered) {
|
||||
const current = offered
|
||||
offered = undefined
|
||||
current.accepted.resolve()
|
||||
return Promise.resolve({ done: false, value: current.value })
|
||||
}
|
||||
const value = queued.shift()
|
||||
if (value) return Promise.resolve({ done: false, value })
|
||||
if (completion) {
|
||||
if ("error" in completion) return Promise.reject(completion.error)
|
||||
return Promise.resolve({ done: true, value: undefined })
|
||||
|
||||
@@ -618,13 +618,17 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
.catch((error) => console.error("Failed to load projected model switch message", error))
|
||||
return
|
||||
case "session.renamed":
|
||||
case "session.renamed": {
|
||||
// Preserve the live title when it races the session's initial read.
|
||||
void result.session.sync(event.data.sessionID).then(() => {
|
||||
const family = sync.pending(`session.family:${event.data.sessionID}`)
|
||||
? result.session.sync(event.data.sessionID, { children: true })
|
||||
: Promise.resolve()
|
||||
void Promise.all([result.session.sync(event.data.sessionID), family]).then(() => {
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "title", event.data.title)
|
||||
})
|
||||
return
|
||||
}
|
||||
case "session.moved": {
|
||||
const current = store.session.info[event.data.sessionID]
|
||||
if (current) {
|
||||
|
||||
@@ -97,7 +97,11 @@ test("multiple consumers share one source and receive live native and RPC events
|
||||
const first = shared.subscribe()[Symbol.asyncIterator]()
|
||||
const second = shared.subscribe()[Symbol.asyncIterator]()
|
||||
|
||||
for (const event of [{ type: "server.connected" }, { type: "session.updated" }, { type: "rpc.example.updated", value: 1 }]) {
|
||||
for (const event of [
|
||||
{ type: "server.connected" },
|
||||
{ type: "session.updated" },
|
||||
{ type: "rpc.example.updated", value: 1 },
|
||||
]) {
|
||||
const reads = [first.next(), second.next()]
|
||||
events.connections[0].push(event)
|
||||
expect(await Promise.all(reads)).toEqual([
|
||||
@@ -115,6 +119,60 @@ test("multiple consumers share one source and receive live native and RPC events
|
||||
await events.connections[0].closed
|
||||
})
|
||||
|
||||
test("an idle consumer does not stall events for an active consumer", async () => {
|
||||
const events = source()
|
||||
const shared = SharedEvents.make(events.connect)
|
||||
const idle = shared.subscribe()[Symbol.asyncIterator]()
|
||||
const active = shared.subscribe()[Symbol.asyncIterator]()
|
||||
const connected = [idle.next(), active.next()]
|
||||
events.connections[0].push({ type: "server.connected" })
|
||||
await Promise.all(connected)
|
||||
|
||||
for (const event of [
|
||||
{ type: "session.updated", value: 1 },
|
||||
{ type: "session.updated", value: 2 },
|
||||
]) {
|
||||
const next = active.next()
|
||||
events.connections[0].push(event)
|
||||
expect(
|
||||
await Promise.race([next, Bun.sleep(1_000).then(() => ({ done: true as const, value: { type: "timeout" } }))]),
|
||||
).toEqual({ done: false, value: event })
|
||||
}
|
||||
|
||||
await idle.return!()
|
||||
await active.return!()
|
||||
await events.connections[0].closed
|
||||
})
|
||||
|
||||
test("an idle consumer fails instead of buffering events without bound", async () => {
|
||||
const events = source()
|
||||
const idle = SharedEvents.make(events.connect).subscribe()[Symbol.asyncIterator]()
|
||||
const connected = idle.next()
|
||||
events.connections[0].push({ type: "server.connected" })
|
||||
await connected
|
||||
|
||||
Array.from({ length: 4_097 }, (_, value) => events.connections[0].push({ type: "session.updated", value }))
|
||||
await events.connections[0].closed
|
||||
await expect(idle.next()).rejects.toThrow("Event subscriber exceeded its 4096-event capacity")
|
||||
})
|
||||
|
||||
test("source completion preserves events already buffered for an idle consumer", async () => {
|
||||
const events = source()
|
||||
const idle = SharedEvents.make(events.connect).subscribe()[Symbol.asyncIterator]()
|
||||
const connected = idle.next()
|
||||
events.connections[0].push({ type: "server.connected" })
|
||||
await connected
|
||||
events.connections[0].push({ type: "session.updated", value: 1 })
|
||||
events.connections[0].push({ type: "session.updated", value: 2 })
|
||||
events.connections[0].close()
|
||||
await events.connections[0].closed
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(await idle.next()).toEqual({ done: false, value: { type: "session.updated", value: 1 } })
|
||||
expect(await idle.next()).toEqual({ done: false, value: { type: "session.updated", value: 2 } })
|
||||
expect(await idle.next()).toEqual({ done: true, value: undefined })
|
||||
})
|
||||
|
||||
test("late consumers receive the latest connection marker but no business event replay", async () => {
|
||||
const events = source()
|
||||
const shared = SharedEvents.make(events.connect)
|
||||
|
||||
@@ -73,6 +73,63 @@ test("revalidates after an event overtakes an active session read", async () =>
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves a live session rename across concurrent session and family reads", async () => {
|
||||
const family = Promise.withResolvers<void>()
|
||||
const renamed = Promise.withResolvers<void>()
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
let requests = 0
|
||||
const stale = { ...session(0), title: undefined }
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
if (!request.url.endsWith(`/api/session/${stale.id}`)) return Response.json({ data: [], cursor: {} })
|
||||
requests++
|
||||
await (requests === 1 ? family.promise : renamed.promise)
|
||||
return Response.json({ data: stale })
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
|
||||
try {
|
||||
const initial = setup.data.session.sync(stale.id, { children: true })
|
||||
await wait(() => requests === 1)
|
||||
const event: OpenCodeEvent = {
|
||||
id: "evt_renamed",
|
||||
created: 1,
|
||||
type: "session.renamed",
|
||||
durable: { aggregateID: stale.id, seq: 1, version: 1 },
|
||||
data: { sessionID: stale.id, title: "Generated title" },
|
||||
}
|
||||
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
||||
await wait(() => requests === 2)
|
||||
renamed.resolve()
|
||||
await wait(() => setup.data.session.get(stale.id) !== undefined)
|
||||
family.resolve()
|
||||
await initial
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(setup.data.session.get(stale.id)?.title).toBe("Generated title")
|
||||
} finally {
|
||||
family.resolve()
|
||||
renamed.resolve()
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("updates authoritative cached project metadata from live events", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const original: Project = {
|
||||
|
||||
@@ -54,9 +54,8 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
||||
const matching = state
|
||||
.get()
|
||||
.formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
|
||||
const extension = path.extname(filepath)
|
||||
const matching = state.get().formatters.filter((formatter) => formatter.extensions.includes(extension))
|
||||
|
||||
for (const formatter of matching) {
|
||||
const enabled = yield* command(formatter)
|
||||
|
||||
@@ -95,7 +95,7 @@ export function convertHTMLToMarkdown(html: string) {
|
||||
const remaining = limit - outputBytes
|
||||
const next = bytes.byteLength <= remaining ? value : sliceBytes(value, remaining)
|
||||
output.push(next)
|
||||
outputBytes += encoder.encode(next).byteLength
|
||||
outputBytes += bytes.byteLength <= remaining ? bytes.byteLength : encoder.encode(next).byteLength
|
||||
last = next.at(-1) ?? last
|
||||
}
|
||||
const appendRaw = (value: string) => {
|
||||
|
||||
@@ -58,6 +58,34 @@ function withFormatter<A, E, R>(
|
||||
}
|
||||
|
||||
describe("Formatter", () => {
|
||||
;[
|
||||
{ file: "test.match", extension: ".match", matches: true },
|
||||
{ file: "test.other", extension: ".match", matches: false },
|
||||
{ file: "test.MATCH", extension: ".match", matches: false },
|
||||
{ file: "test.MATCH", extension: ".MATCH", matches: true },
|
||||
{ file: ".match", extension: ".match", matches: false },
|
||||
{ file: ".match", extension: "", matches: true },
|
||||
{ file: "README", extension: ".match", matches: false },
|
||||
{ file: "README", extension: "", matches: true },
|
||||
{ file: "test.part.match", extension: ".match", matches: true },
|
||||
{ file: "test.part.match", extension: ".part.match", matches: false },
|
||||
].forEach((entry) =>
|
||||
it.live(`matches ${entry.file} against ${JSON.stringify(entry.extension)}: ${entry.matches}`, () =>
|
||||
withFormatter(
|
||||
{
|
||||
matching: {
|
||||
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
||||
extensions: [entry.extension],
|
||||
},
|
||||
},
|
||||
(formatter, directory) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* formatter.file(path.join(directory, entry.file))).toBe(entry.matches)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not run formatters marked as disabled in config", () =>
|
||||
withFormatter(
|
||||
{
|
||||
|
||||
@@ -128,6 +128,15 @@ describe("WebFetchTool helpers", () => {
|
||||
expect(output).toHaveLength(WebFetchTool.MAX_RESPONSE_BYTES - 64 * 1024)
|
||||
})
|
||||
|
||||
test.each(["x", "\u00e9", "\u{1f600}"])("preserves UTF-8 boundaries at the content limit for %s", (character) => {
|
||||
const budget = WebFetchTool.MAX_RESPONSE_BYTES - 64 * 1024
|
||||
const fitting = "aa" + character.repeat(Math.floor((budget - 2) / Buffer.byteLength(character)))
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(fitting)).toBe(fitting)
|
||||
const truncated = WebFetchTool.convertHTMLToMarkdown(fitting + character)
|
||||
expect(truncated).toBe(fitting)
|
||||
expect(Buffer.byteLength(truncated)).toBe(Buffer.byteLength(fitting))
|
||||
})
|
||||
|
||||
test("bounds deeply nested list output and fragmented code fences", () => {
|
||||
const lists = `${"<ul><li>item".repeat(2_000)}${"</li></ul>".repeat(2_000)}`
|
||||
const quotes = `${"<blockquote><p>item".repeat(2_000)}${"</p></blockquote>".repeat(2_000)}`
|
||||
|
||||
@@ -12,6 +12,7 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { Api } from "../api"
|
||||
import { CorsConfig, isAllowedRequestOrigin } from "../cors"
|
||||
import { runPtySocket } from "./pty-socket"
|
||||
|
||||
export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.experimental", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -191,7 +192,7 @@ export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.experiment
|
||||
}
|
||||
})
|
||||
|
||||
yield* Effect.race(
|
||||
yield* runPtySocket(
|
||||
drain,
|
||||
socket.runRaw(
|
||||
(message) =>
|
||||
@@ -221,9 +222,9 @@ export const PersistentPtyHandler = HttpApiBuilder.group(Api, "server.experiment
|
||||
),
|
||||
{ onOpen },
|
||||
),
|
||||
() => attachment?.detach(),
|
||||
).pipe(
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.ensuring(Effect.sync(() => attachment?.detach())),
|
||||
Effect.orDie,
|
||||
)
|
||||
return HttpServerResponse.empty()
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ProviderNotFoundError, ServiceUnavailableError } from "@opencode-ai/protocol/errors"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { ProviderNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { response } from "../location"
|
||||
import { pluginReadiness } from "./plugin-readiness"
|
||||
|
||||
const flushPlugins = pluginReadiness(
|
||||
() =>
|
||||
new ServiceUnavailableError({
|
||||
message: "Provider catalog initialization timed out",
|
||||
service: "provider.catalog",
|
||||
}),
|
||||
)
|
||||
|
||||
export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -11,6 +20,7 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han
|
||||
.handle(
|
||||
"provider.list",
|
||||
Effect.fn(function* () {
|
||||
yield* flushPlugins
|
||||
const catalog = yield* Catalog.Service
|
||||
return yield* response(catalog.provider.available())
|
||||
}),
|
||||
@@ -18,6 +28,7 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han
|
||||
.handle(
|
||||
"provider.get",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* flushPlugins
|
||||
const catalog = yield* Catalog.Service
|
||||
const provider = yield* catalog.provider.get(ctx.params.providerID)
|
||||
if (!provider)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Effect } from "effect"
|
||||
|
||||
export function runPtySocket<A, E, R, A2, E2, R2>(
|
||||
drain: Effect.Effect<A, E, R>,
|
||||
socket: Effect.Effect<A2, E2, R2>,
|
||||
detach: () => void,
|
||||
) {
|
||||
return Effect.raceFirst(drain, socket).pipe(Effect.ensuring(Effect.sync(detach)))
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@opencode-ai/protocol/groups/pty"
|
||||
import { response } from "../location"
|
||||
import { PtyEnvironment } from "../pty-environment"
|
||||
import { runPtySocket } from "./pty-socket"
|
||||
|
||||
const ticketScope = Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
@@ -209,15 +210,15 @@ export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) =>
|
||||
}
|
||||
})
|
||||
|
||||
yield* Effect.race(
|
||||
yield* runPtySocket(
|
||||
drain,
|
||||
socket.runRaw((message) => {
|
||||
const decoded = PtyProtocol.decodeInput(message)
|
||||
if (decoded !== undefined) attachment.write(decoded)
|
||||
}),
|
||||
attachment.detach,
|
||||
).pipe(
|
||||
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
|
||||
Effect.ensuring(Effect.sync(() => attachment.detach())),
|
||||
Effect.orDie,
|
||||
)
|
||||
return HttpServerResponse.empty()
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { startServer } from "./fixture/server"
|
||||
|
||||
it.live(
|
||||
"waits for plugin initialization on the first provider list request",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* configuredProvider("opencode-provider-list-endpoint-")
|
||||
const url = new URL("/api/provider", fixture.server.base)
|
||||
url.searchParams.set("location[directory]", fixture.path)
|
||||
const response = yield* Effect.promise(() => fetch(url, { headers: fixture.server.headers }))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body: unknown = yield* Effect.promise(() => response.json())
|
||||
if (!isRecord(body) || !Array.isArray(body["data"])) throw new Error("Expected a provider list response")
|
||||
expect(body["data"].some((provider) => isRecord(provider) && provider["id"] === "custom")).toBeTrue()
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"waits for plugin initialization on the first provider get request",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* configuredProvider("opencode-provider-get-endpoint-")
|
||||
const url = new URL("/api/provider/custom", fixture.server.base)
|
||||
url.searchParams.set("location[directory]", fixture.path)
|
||||
const response = yield* Effect.promise(() => fetch(url, { headers: fixture.server.headers }))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body: unknown = yield* Effect.promise(() => response.json())
|
||||
if (!isRecord(body) || !isRecord(body["data"])) throw new Error("Expected a provider response")
|
||||
expect(body["data"]["id"]).toBe("custom")
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
|
||||
const configuredProvider = Effect.fnUntraced(function* (prefix: string) {
|
||||
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir(prefix)))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
custom: {
|
||||
package: "@opencode-ai/ai/providers/openai-compatible",
|
||||
settings: { apiKey: "secret" },
|
||||
models: { chat: {} },
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
return { server: yield* startServer(tmp.path), path: tmp.path }
|
||||
})
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Option, Result } from "effect"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { runPtySocket } from "../src/handlers/pty-socket"
|
||||
|
||||
it.live("detaches when the socket fails while the outbox drain is blocked", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = { detached: false }
|
||||
const result = yield* runPtySocket(Effect.never, Effect.fail("socket closed"), () => {
|
||||
state.detached = true
|
||||
}).pipe(Effect.result, Effect.timeoutOption("100 millis"))
|
||||
|
||||
expect(Option.isSome(result) && Result.isFailure(result.value)).toBeTrue()
|
||||
expect(state.detached).toBeTrue()
|
||||
}),
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect } from "bun:test"
|
||||
import { expect, setDefaultTimeout } from "bun:test"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
@@ -12,6 +12,8 @@ import { Effect, Layer } from "effect"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
setDefaultTimeout(30_000)
|
||||
|
||||
it.live("updates completed assistant message content through the session HTTP API", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story.describe("touch timeline", () => {
|
||||
story.use({ hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } })
|
||||
|
||||
story("keeps message actions and metadata visible without hover", async ({ mount, page }) => {
|
||||
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "interruption" } })
|
||||
expect(await page.evaluate(() => matchMedia("(hover: none)").matches)).toBe(true)
|
||||
|
||||
for (const action of [
|
||||
{ slot: "user-message-copy-wrapper", name: "Copy message" },
|
||||
{ slot: "text-part-copy-wrapper", name: "Copy response" },
|
||||
]) {
|
||||
const actions = timeline.locator(`[data-slot="${action.slot}"]`)
|
||||
await expect(actions).toHaveCount(1)
|
||||
await expect(actions).toHaveCSS("opacity", "1")
|
||||
await expect(actions).toHaveCSS("pointer-events", "auto")
|
||||
await expect(actions.getByRole("button", { name: action.name, exact: true })).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(timeline.locator('[data-slot="user-message-meta"]')).toContainText("Build")
|
||||
await expect(timeline.locator('[data-slot="user-message-meta-tail"]')).not.toBeEmpty()
|
||||
await expect(timeline.locator('[data-slot="text-part-meta"]')).toContainText("Build")
|
||||
await expect(timeline.locator('[data-slot="text-part-meta"]')).toContainText("Sonnet")
|
||||
})
|
||||
|
||||
story("keeps shell copy visible without hover", async ({ mount }) => {
|
||||
const timeline = await mount("current-session-terminal-work--expanded-shell")
|
||||
const copy = timeline.locator('[data-slot="bash-copy"]')
|
||||
await expect(copy).toHaveCount(1)
|
||||
await expect(copy).toHaveCSS("opacity", "1")
|
||||
await expect(copy).toHaveCSS("pointer-events", "auto")
|
||||
})
|
||||
|
||||
story("keeps error copy visible without hover", async ({ mount, page }) => {
|
||||
const errors = await mount("components-tool-error-card--all")
|
||||
const patch = errors.locator('[data-kind="tool-error-card"]').filter({ hasText: "Patch" })
|
||||
await patch.getByRole("button", { name: /Patch.*Verification failed/ }).tap()
|
||||
await page.touchscreen.tap(385, 800)
|
||||
const copy = patch.locator('[data-slot="tool-error-card-copy"]')
|
||||
await expect(copy).toHaveCSS("opacity", "1")
|
||||
await expect(copy).toHaveCSS("pointer-events", "auto")
|
||||
})
|
||||
|
||||
story("keeps fenced code copy visible without hover", async ({ mount }) => {
|
||||
const markdown = await mount("components-markdown--complete-response")
|
||||
const code = markdown.locator('[data-component="markdown-code"]').filter({ hasText: "export const value = 42" })
|
||||
await expect(code).toHaveCount(1)
|
||||
await expect(code.locator('[data-slot="markdown-copy-button"]')).toHaveCSS("opacity", "1")
|
||||
})
|
||||
})
|
||||
|
||||
story("desktop message actions still appear on hover and keyboard focus", async ({ mount, page }) => {
|
||||
const timeline = await mount("current-session-timeline-rows--conversation", { args: { scenario: "interruption" } })
|
||||
expect(await page.evaluate(() => matchMedia("(hover: hover)").matches)).toBe(true)
|
||||
|
||||
for (const action of [
|
||||
{ slot: "user-message-copy-wrapper", name: "Copy message" },
|
||||
{ slot: "text-part-copy-wrapper", name: "Copy response" },
|
||||
]) {
|
||||
const actions = timeline.locator(`[data-slot="${action.slot}"]`)
|
||||
await expect(actions).toHaveCount(1)
|
||||
await expect(actions).toHaveCSS("opacity", "0")
|
||||
await expect(actions).toHaveCSS("pointer-events", "none")
|
||||
await actions.locator("..").hover()
|
||||
await expect(actions).toHaveCSS("opacity", "1")
|
||||
await expect(actions).toHaveCSS("pointer-events", "auto")
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(actions).toHaveCSS("opacity", "0")
|
||||
await actions.getByRole("button", { name: action.name, exact: true }).focus()
|
||||
await expect(actions).toHaveCSS("opacity", "1")
|
||||
await expect(actions).toHaveCSS("pointer-events", "auto")
|
||||
await page.getByRole("button", { name: "Reset", exact: true }).focus()
|
||||
}
|
||||
})
|
||||
@@ -249,10 +249,13 @@
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-faint);
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
@media (hover: hover) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="task-tool-title"] {
|
||||
@@ -391,11 +394,15 @@
|
||||
}
|
||||
|
||||
.webfetch-link-icon {
|
||||
display: none;
|
||||
display: inline-flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-icon-icon-accent, var(--v2-text-text-accent));
|
||||
|
||||
@media (hover: hover) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -1085,7 +1085,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
|
||||
|
||||
const perf = large() ? { ...base, ...largeOptions } : base
|
||||
if (!mobile()) return perf
|
||||
return { ...perf, disableLineNumbers: true }
|
||||
return { ...perf, disableLineNumbers: props.disableLineNumbers ?? true }
|
||||
})
|
||||
|
||||
const notify = (done?: VoidFunction) => {
|
||||
|
||||
@@ -240,9 +240,12 @@
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
inset-inline-end: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 1;
|
||||
|
||||
@media (hover: hover) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="markdown-code"]:hover [data-slot="markdown-copy-button"],
|
||||
|
||||
@@ -167,11 +167,14 @@
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
will-change: opacity;
|
||||
|
||||
@media (hover: hover) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-component="tooltip-v2-trigger"] {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
@@ -235,11 +238,14 @@
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
will-change: opacity;
|
||||
|
||||
@media (hover: hover) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-component="tooltip-v2-trigger"] {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
@@ -371,9 +377,12 @@
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
@media (hover: hover) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover [data-slot="bash-copy"],
|
||||
|
||||
@@ -25,13 +25,14 @@
|
||||
height: 40px;
|
||||
padding-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
[data-slot="session-review-title"] {
|
||||
justify-self: start;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-large);
|
||||
font-weight: var(--font-weight-medium);
|
||||
@@ -40,6 +41,8 @@
|
||||
}
|
||||
|
||||
[data-slot="session-review-actions"] {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 12px;
|
||||
@@ -130,19 +133,25 @@
|
||||
color: var(--text-base);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
opacity: 1;
|
||||
will-change: opacity;
|
||||
transform: translateZ(0);
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-strong);
|
||||
background: var(--surface-base);
|
||||
@media (hover: hover) {
|
||||
opacity: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-strong);
|
||||
background: var(--surface-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="accordion-trigger"]:hover [data-slot="session-review-view-button"] {
|
||||
opacity: 1;
|
||||
@media (hover: hover) {
|
||||
[data-slot="accordion-trigger"]:hover [data-slot="session-review-view-button"] {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="session-review-trigger-actions"] {
|
||||
@@ -170,12 +179,33 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
[data-slot="session-review-change-summary"] {
|
||||
position: sticky;
|
||||
top: calc(32px + var(--sticky-accordion-offset, 0px));
|
||||
z-index: 9;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 32px;
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
line-height: var(--line-height-compact);
|
||||
background-color: var(--v2-background-bg-base);
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
[data-slot="session-review-change"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
[data-slot="session-review-summary-open-file"] {
|
||||
margin-inline-start: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="session-review-change"][data-type="added"] {
|
||||
color: var(--icon-diff-add-base);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,9 @@ export interface SessionReviewProps {
|
||||
empty?: JSX.Element
|
||||
split?: boolean
|
||||
diffStyle?: SessionReviewDiffStyle
|
||||
changeSummary?: boolean
|
||||
overflow?: "wrap" | "scroll"
|
||||
disableLineNumbers?: boolean
|
||||
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
|
||||
onDiffRendered?: VoidFunction
|
||||
onLineComment?: (comment: SessionReviewLineComment) => void
|
||||
@@ -339,6 +342,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
<div data-slot="session-review-title">
|
||||
{props.title === undefined ? i18n.t("ui.sessionReview.title") : props.title}
|
||||
</div>
|
||||
<Show when={hasDiffs()}>
|
||||
<DiffChanges appearance="standard" changes={props.diffs} />
|
||||
</Show>
|
||||
<div data-slot="session-review-actions">
|
||||
<Show when={hasDiffs() && props.onDiffStyleChange}>
|
||||
<SegmentedControl
|
||||
@@ -364,7 +370,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
<Button
|
||||
size="small"
|
||||
icon="chevron-grabber-vertical"
|
||||
class="w-[106px] justify-start"
|
||||
class="shrink-0 whitespace-nowrap justify-start"
|
||||
onClick={handleExpandOrCollapseAll}
|
||||
>
|
||||
<Switch>
|
||||
@@ -506,6 +512,37 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
commentsUi.onLineSelectionEnd(range)
|
||||
}
|
||||
|
||||
const changes = (summary = false) => (
|
||||
<>
|
||||
<Switch>
|
||||
<Match when={isAdded()}>
|
||||
<div data-slot="session-review-change-group" data-type="added">
|
||||
<span data-slot="session-review-change" data-type="added">
|
||||
{i18n.t("ui.sessionReview.change.added")}
|
||||
</span>
|
||||
<DiffChanges appearance="standard" changes={diff()} />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={isDeleted()}>
|
||||
<span data-slot="session-review-change" data-type="removed">
|
||||
{i18n.t("ui.sessionReview.change.removed")}
|
||||
</span>
|
||||
<Show when={summary}>
|
||||
<DiffChanges appearance="standard" changes={diff()} />
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={!!mediaKind()}>
|
||||
<span data-slot="session-review-change" data-type="modified">
|
||||
{i18n.t("ui.sessionReview.change.modified")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges appearance="standard" changes={diff()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Accordion.Item
|
||||
value={diffCanRender() ? file : null!}
|
||||
@@ -524,7 +561,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
<span data-slot="session-review-directory">{`\u202A${getDirectory(file)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="session-review-filename">{getFilename(file)}</span>
|
||||
<Show when={props.onViewFile && diffCanRender()}>
|
||||
<Show when={props.onViewFile && diffCanRender() && !props.changeSummary}>
|
||||
<Tooltip appearance="standard" value={openFileLabel()} placement="top" gutter={4}>
|
||||
<button
|
||||
data-slot="session-review-view-button"
|
||||
@@ -542,29 +579,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="session-review-trigger-actions">
|
||||
<Switch>
|
||||
<Match when={isAdded()}>
|
||||
<div data-slot="session-review-change-group" data-type="added">
|
||||
<span data-slot="session-review-change" data-type="added">
|
||||
{i18n.t("ui.sessionReview.change.added")}
|
||||
</span>
|
||||
<DiffChanges appearance="standard" changes={diff()} />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={isDeleted()}>
|
||||
<span data-slot="session-review-change" data-type="removed">
|
||||
{i18n.t("ui.sessionReview.change.removed")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={!!mediaKind()}>
|
||||
<span data-slot="session-review-change" data-type="modified">
|
||||
{i18n.t("ui.sessionReview.change.modified")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges appearance="standard" changes={diff()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={!props.changeSummary || !diffCanRender()}>{changes()}</Show>
|
||||
<Show when={diffCanRender()}>
|
||||
<span data-slot="session-review-diff-chevron">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
@@ -574,6 +589,22 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Show when={props.changeSummary && expanded()}>
|
||||
<div data-slot="session-review-change-summary">
|
||||
{changes(true)}
|
||||
<Show when={props.onViewFile}>
|
||||
<Button
|
||||
data-slot="session-review-summary-open-file"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
icon="open-file"
|
||||
onClick={() => props.onViewFile?.(file)}
|
||||
>
|
||||
{openFileLabel()}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Accordion.Content data-slot="session-review-accordion-content">
|
||||
<div
|
||||
data-slot="session-review-diff-wrapper"
|
||||
@@ -621,6 +652,8 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
fileDiff={diff().fileDiff}
|
||||
preloadedDiff={diff().preloaded}
|
||||
diffStyle={diffStyle()}
|
||||
overflow={props.overflow ?? "wrap"}
|
||||
disableLineNumbers={props.disableLineNumbers}
|
||||
onRendered={() => {
|
||||
props.onDiffRendered?.()
|
||||
}}
|
||||
|
||||
@@ -126,9 +126,12 @@
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
margin-left: 4px;
|
||||
|
||||
@media (hover: hover) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="session-turn-diffs-group"]:hover [data-slot="session-turn-diffs-toggle"] {
|
||||
|
||||
@@ -123,10 +123,13 @@
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
will-change: opacity;
|
||||
|
||||
@media (hover: hover) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover [data-slot="tool-error-card-copy"],
|
||||
|
||||
@@ -137,6 +137,19 @@ const unsafeCSS = `
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* File annotations share the code column; reclaim the measured number gutter. */
|
||||
[data-file] [data-line-annotation] {
|
||||
margin-inline-start: calc(-1 * var(--diffs-column-number-width, 0px));
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
[data-file] [data-annotation-content] {
|
||||
width: var(--diffs-column-width, auto);
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: fine) {
|
||||
[data-gutter-utility-slot] {
|
||||
opacity: 0;
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
export type AutocompleteRef = {
|
||||
onInput: (value: string) => void
|
||||
visible: false | "reference" | "command" | "directory"
|
||||
completeQueueableCommand: () => boolean
|
||||
}
|
||||
|
||||
export type AutocompleteOption = {
|
||||
@@ -50,6 +51,7 @@ export type AutocompleteOption = {
|
||||
absolute?: string
|
||||
destructive?: { id: string; confirm: string; run: () => void }
|
||||
kind?: "skill"
|
||||
queueable?: boolean
|
||||
}
|
||||
|
||||
type AutocompleteResults = {
|
||||
@@ -542,6 +544,7 @@ export function Autocomplete(props: {
|
||||
results.push({
|
||||
display: "/" + serverCommand.name,
|
||||
description: serverCommand.description,
|
||||
queueable: true,
|
||||
onSelect: () => insertSlash(serverCommand.name),
|
||||
})
|
||||
}
|
||||
@@ -732,6 +735,7 @@ export function Autocomplete(props: {
|
||||
mode: "autocomplete",
|
||||
target: props.input,
|
||||
enabled: () => Boolean(store.visible),
|
||||
bindings: ["prompt.queue"],
|
||||
commands: [
|
||||
{
|
||||
id: "prompt.autocomplete.prev",
|
||||
@@ -837,6 +841,11 @@ export function Autocomplete(props: {
|
||||
get visible() {
|
||||
return store.visible
|
||||
},
|
||||
completeQueueableCommand() {
|
||||
if (store.visible !== "command" || !options()[store.selected]?.queueable) return false
|
||||
select()
|
||||
return true
|
||||
},
|
||||
onInput(value) {
|
||||
if (dismissedValue() === value) return
|
||||
setDismissedValue(undefined)
|
||||
|
||||
@@ -468,6 +468,7 @@ export function Prompt(props: PromptProps) {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
if (!input.focused) return
|
||||
if (auto()?.visible && !auto()?.completeQueueableCommand()) return
|
||||
const handled = await submit("queue")
|
||||
if (!handled) return
|
||||
dialog.clear()
|
||||
|
||||
@@ -843,7 +843,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
}
|
||||
|
||||
const select = (item?: PromptOption) => {
|
||||
const select = (item?: PromptOption, delivery: RunDelivery = "steer") => {
|
||||
const next = item ?? options()[menu.selected()]
|
||||
if (!next || !area || area.isDestroyed) {
|
||||
return
|
||||
@@ -920,7 +920,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
hide()
|
||||
syncDraft()
|
||||
if (!shell()) {
|
||||
submitPrompt(promptCopy(draft))
|
||||
submitPrompt(promptCopy(draft), delivery)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1047,7 +1047,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
priority: 1,
|
||||
enabled: input.prompt() && !visible(),
|
||||
enabled: input.prompt() && (!visible() || mode() === "slash"),
|
||||
commands: [
|
||||
{
|
||||
id: "prompt.queue",
|
||||
@@ -1059,6 +1059,13 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
submitPrompt(promptCopy(draft), "queue")
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
priority: 1,
|
||||
enabled: input.prompt() && !visible(),
|
||||
commands: [
|
||||
{
|
||||
id: "prompt.editor",
|
||||
title: "Open editor",
|
||||
@@ -1201,7 +1208,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
|
||||
if (visible()) {
|
||||
if (mode() !== "slash" || options().length > 0) {
|
||||
select()
|
||||
select(undefined, delivery)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -864,20 +864,32 @@ test("closing a tab is not undone by another TUI viewing the same session", asyn
|
||||
clients.push(first)
|
||||
const second = await renderSessionTabs("shared", { state: temporary.path })
|
||||
clients.push(second)
|
||||
await wait(() => first.tabs.tabs().some((tab) => tab.sessionID === "shared"))
|
||||
await wait(() => second.tabs.tabs().some((tab) => tab.sessionID === "shared"))
|
||||
first.tabs.close()
|
||||
await wait(() => first.route.data.type === "home")
|
||||
await wait(() => !second.tabs.tabs().some((tab) => tab.sessionID === "shared"))
|
||||
await wait(() => first.tabs.tabs().some((tab) => tab.sessionID === "shared"), 2_000, "first tab to open")
|
||||
await wait(() => second.tabs.tabs().some((tab) => tab.sessionID === "shared"), 2_000, "second tab to open")
|
||||
await Promise.all([first.flush(), second.flush()])
|
||||
first.tabs.close()
|
||||
await wait(() => first.route.data.type === "home", 2_000, "first client to navigate home")
|
||||
await first.flush()
|
||||
await wait(
|
||||
() => !second.tabs.tabs().some((tab) => tab.sessionID === "shared"),
|
||||
2_000,
|
||||
"second client to observe close",
|
||||
)
|
||||
await second.flush()
|
||||
|
||||
const stored = await Bun.file(path.join(temporary.path, "test", "tui", "tabs.json")).json()
|
||||
expect(stored.cwd[directory].tabs).toEqual([])
|
||||
|
||||
second.route.navigate({ type: "home" })
|
||||
await wait(() => second.route.data.type === "home")
|
||||
await wait(() => second.route.data.type === "home", 2_000, "second client to navigate home")
|
||||
second.route.navigate({ type: "session", sessionID: "shared" })
|
||||
await wait(() => first.tabs.tabs().some((tab) => tab.sessionID === "shared"))
|
||||
await wait(() => second.tabs.tabs().some((tab) => tab.sessionID === "shared"), 2_000, "second client to reopen tab")
|
||||
await second.flush()
|
||||
await wait(
|
||||
() => first.tabs.tabs().some((tab) => tab.sessionID === "shared"),
|
||||
2_000,
|
||||
"first client to observe reopen",
|
||||
)
|
||||
} finally {
|
||||
await Promise.allSettled(clients.map((client) => client.destroy()))
|
||||
}
|
||||
|
||||
@@ -50,11 +50,10 @@
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
|
||||
&:hover:not([data-disabled]) {
|
||||
background-color: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
&:active:not([data-disabled]) {
|
||||
background-color: var(--v2-overlay-simple-overlay-pressed);
|
||||
@media (hover: hover) {
|
||||
&:hover:not([data-disabled]) {
|
||||
background-color: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
@@ -401,4 +401,9 @@ input:where([type="button"], [type="reset"], [type="submit"]),
|
||||
[contenteditable="true"] {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Bom } from "./bom.js"
|
||||
|
||||
test.each([
|
||||
{ prefix: "", bom: false, expected: undefined },
|
||||
{ prefix: "", bom: true, expected: "\uFEFF" },
|
||||
{ prefix: "\uFEFF", bom: false, expected: "" },
|
||||
{ prefix: "\uFEFF", bom: true, expected: undefined },
|
||||
{ prefix: "\uFEFF\uFEFF", bom: false, expected: "" },
|
||||
{ prefix: "\uFEFF\uFEFF", bom: true, expected: "\uFEFF" },
|
||||
])("syncBytes(%j)", (row) => {
|
||||
const encoder = new TextEncoder()
|
||||
const text = "a\uFEFF\u00e9"
|
||||
const input = encoder.encode(row.prefix + text)
|
||||
|
||||
expect(Bom.syncBytes(input, row.bom)).toEqual({
|
||||
text,
|
||||
bytes: row.expected === undefined ? undefined : encoder.encode(row.expected + text),
|
||||
})
|
||||
expect(input).toEqual(encoder.encode(row.prefix + text))
|
||||
})
|
||||
@@ -27,7 +27,7 @@ export function decodeBytes(content: Uint8Array) {
|
||||
export function syncBytes(content: Uint8Array, bom: boolean) {
|
||||
const decoded = decode(content)
|
||||
const current = split(decoded)
|
||||
const canonical = join(current.text, bom)
|
||||
const canonical = bom ? value + current.text : current.text
|
||||
return { text: current.text, bytes: decoded === canonical ? undefined : new TextEncoder().encode(canonical) }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user