diff --git a/packages/app/component-tests/timeline-virtualizer.spec.ts b/packages/app/component-tests/timeline-virtualizer.spec.ts
index 33807aa4862..acebfa88b58 100644
--- a/packages/app/component-tests/timeline-virtualizer.spec.ts
+++ b/packages/app/component-tests/timeline-virtualizer.spec.ts
@@ -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)
diff --git a/packages/app/e2e/regression/mobile-files.spec.ts b/packages/app/e2e/regression/mobile-files.spec.ts
new file mode 100644
index 00000000000..018a2f253bf
--- /dev/null
+++ b/packages/app/e2e/regression/mobile-files.spec.ts
@@ -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)
+ })
+}
diff --git a/packages/app/e2e/regression/mobile-session-views.spec.ts b/packages/app/e2e/regression/mobile-session-views.spec.ts
new file mode 100644
index 00000000000..d3388341d4d
--- /dev/null
+++ b/packages/app/e2e/regression/mobile-session-views.spec.ts
@@ -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()
+ })
+}
diff --git a/packages/app/e2e/regression/mobile-status-drawer.spec.ts b/packages/app/e2e/regression/mobile-status-drawer.spec.ts
new file mode 100644
index 00000000000..a30d87f715c
--- /dev/null
+++ b/packages/app/e2e/regression/mobile-status-drawer.spec.ts
@@ -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()
+ }
+})
diff --git a/packages/app/e2e/regression/review-line-comment.spec.ts b/packages/app/e2e/regression/review-line-comment.spec.ts
index a485b79e938..321faaf534c 100644
--- a/packages/app/e2e/regression/review-line-comment.spec.ts
+++ b/packages/app/e2e/regression/review-line-comment.spec.ts
@@ -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)
diff --git a/packages/app/e2e/utils/waits.ts b/packages/app/e2e/utils/waits.ts
index 814386f7578..bcdac591b09 100644
--- a/packages/app/e2e/utils/waits.ts
+++ b/packages/app/e2e/utils/waits.ts
@@ -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 }))
}
diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts
index 385fefec93a..19acd474266 100644
--- a/packages/app/src/runtime/i18n/en.ts
+++ b/packages/app/src/runtime/i18n/en.ts
@@ -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",
@@ -978,7 +982,7 @@ 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.showCustomAgents.title": "Show agent",
"settings.general.row.showCustomAgents.description":
"Switch between agents in the composer. When hidden, defaults to Build agent.",
diff --git a/packages/app/src/session/files/session-context-tab.tsx b/packages/app/src/session/files/session-context-tab.tsx
index d2146241f35..d4307695a4f 100644
--- a/packages/app/src/session/files/session-context-tab.tsx
+++ b/packages/app/src/session/files/session-context-tab.tsx
@@ -271,7 +271,7 @@ export function SessionContextTab() {
}}
onScroll={handleScroll}
>
-
+
{(stat) => [0])} value={stat.value()} />}
diff --git a/packages/app/src/session/files/session-file-browser-tab.tsx b/packages/app/src/session/files/session-file-browser-tab.tsx
index b881f8f74ed..5d1b5e02400 100644
--- a/packages/app/src/session/files/session-file-browser-tab.tsx
+++ b/packages/app/src/session/files/session-file-browser-tab.tsx
@@ -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()
+ 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}
>
props.onSelect(node.path)}
onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
/>
diff --git a/packages/app/src/session/files/session-mobile-files.css b/packages/app/src/session/files/session-mobile-files.css
new file mode 100644
index 00000000000..7e531c9ca5d
--- /dev/null
+++ b/packages/app/src/session/files/session-mobile-files.css
@@ -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;
+}
diff --git a/packages/app/src/session/files/session-mobile-files.tsx b/packages/app/src/session/files/session-mobile-files.tsx
new file mode 100644
index 00000000000..d5d1c6e919c
--- /dev/null
+++ b/packages/app/src/session/files/session-mobile-files.tsx
@@ -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()
+ const open = (path: string) => {
+ layout.tabs().open(file.tab(path))
+ void file.load(path)
+ setStore("browsing", false)
+ }
+
+ return (
+
+
+
+ {
+ // 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"
+ >
+
+
+ {(tab) => (
+ open(file.pathFromTab(tab)!)}
+ class="shrink-0 max-w-48"
+ classes={{ button: "min-w-0" }}
+ closeButton={
+ layout.tabs().close(tab)}
+ />
+ }
+ >
+
+ {getFilename(file.pathFromTab(tab) ?? tab)}
+
+
+ )}
+
+
+
+
+
+ 240,
+ sidebarTransition: () => false,
+ resizeSidebar: () => undefined,
+ toggleSidebar: () => setStore("browsing", !browsing()),
+ }}
+ onSelect={open}
+ onSelectPermanent={open}
+ />
+
+
+ )
+}
diff --git a/packages/app/src/session/header/session-header.tsx b/packages/app/src/session/header/session-header.tsx
index bfd42b068ab..c07579de1f2 100644
--- a/packages/app/src/session/header/session-header.tsx
+++ b/packages/app/src/session/header/session-header.tsx
@@ -19,7 +19,10 @@ export function SessionHeader() {
const isDesktop = createMediaQuery("(min-width: 768px)")
const actions = createMemo(() => ({
- status: status() ? { label: language.t("status.popover.trigger"), content: () => } : undefined,
+ status:
+ isDesktop() && status()
+ ? { label: language.t("status.popover.trigger"), content: () => }
+ : undefined,
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: reviewTooltipKeybind(command),
reviewVisible: isDesktop(),
diff --git a/packages/app/src/session/review/model.ts b/packages/app/src/session/review/model.ts
index 07527f2bbc8..d4f1da6c4ca 100644
--- a/packages/app/src/session/review/model.ts
+++ b/packages/app/src/session/review/model.ts
@@ -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(() => {
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,
diff --git a/packages/app/src/session/review/view.tsx b/packages/app/src/session/review/view.tsx
index 687fcb98ac0..59f7880977a 100644
--- a/packages/app/src/session/review/view.tsx
+++ b/packages/app/src/session/review/view.tsx
@@ -2,51 +2,136 @@ 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 { 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 (
-
-
+
+
+
+ {(view) => (
+ 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")}
+
+ )}
+
+
+
+
-
+ icon={}
+ 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")}
+ />
+
+ {
+ 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)
+ }}
+ >
+ props.onSelect("usage")}>{language.t("session.tab.usage")}
+
+ setStore({ pending: "details", menu: false })}>
+ {language.t("session.summary.title")}
+
+
+ setStore({ pending: "status", menu: false })}>
+ {language.t("status.popover.trigger")}
+
+
+
+
+
+
+ setStore("status", open)}
+ returnFocus={() => trigger}
+ />
+
+
+
+
+ setStore("details", open)}
+ returnFocus={() => trigger}
+ >
+ {props.details?.(() => setStore("details", false))}
+
+
+
+
)
}
@@ -92,7 +177,7 @@ function ReviewContent(props: { review: SessionReviewModel }) {
}
- empty={}
+ empty={}
diffs={props.review.diffs()}
view={props.review.view()}
diffStyle="unified"
@@ -109,7 +194,7 @@ function ReviewContent(props: { review: SessionReviewModel }) {
onViewFile={props.review.openFile}
classes={{
root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
- header: "px-4 !h-16 !pb-4",
+ header: "!px-2 !h-10 !pb-0",
container: "px-4",
}}
/>
diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx
index 51ebd9a674c..26b32f9f1f0 100644
--- a/packages/app/src/session/screen.tsx
+++ b/packages/app/src/session/screen.tsx
@@ -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
}
+ const mobileTabs = () => (
+
+ {(_key) => (
+ (
+
+ {(project) => (
+ setStore("mobileMoveDismissed", true)}
+ onReview={() => {
+ close()
+ review.mobile.setTab("changes")
+ session.layout.view().terminal.close()
+ }}
+ backgroundTasks={composer.region.state.background.tasks()}
+ />
+ )}
+
+ )
+ : undefined
+ }
+ onSelect={(view) => {
+ if (view === "terminal") {
+ session.layout.view().terminal.open()
+ return
+ }
+ review.mobile.setTab(view)
+ session.layout.view().terminal.close()
+ }}
+ />
+ )}
+
+ )
+
const sessionPanelContent = () => (
<>
-
-
-
+ {mobileTabs()}
{/* Surface query errors without suspending session metadata while messages load. */}
{(error) => {
throw error()
}}
-
+
+
+
+
+
+
+
+ <>>
+
+
+
+
+
+
+
+
+
-
+
{(_id) => (
-
+
{(_id) => (
)}
-
-
-
>
)
@@ -300,7 +393,7 @@ export function SessionScreen(props: { session: SessionModel }) {
-
+
setElements("bottomTerminal", element)}
data-slot="terminal-panel-presence"
diff --git a/packages/app/src/session/terminal/panel.tsx b/packages/app/src/session/terminal/panel.tsx
index 6e944dc4138..40b7828669a 100644
--- a/packages/app/src/session/terminal/panel.tsx
+++ b/packages/app/src/session/terminal/panel.tsx
@@ -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()}
diff --git a/packages/app/src/session/terminal/surface.tsx b/packages/app/src/session/terminal/surface.tsx
index 445593aa8c1..f67c6c7c169 100644
--- a/packages/app/src/session/terminal/surface.tsx
+++ b/packages/app/src/session/terminal/surface.tsx
@@ -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 }}
>
-
+
{
@@ -81,7 +81,7 @@ export function BackgroundWorkSummary(props: { tasks: BackgroundTask[] }) {
return (
@@ -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 (
-
+
{props.avatar ?? (
@@ -217,19 +227,23 @@ function SessionSummaryPanel(props: {
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
)}
- {displayName(props.project)}
+
+ {displayName(props.project)}
+
- {location()}
+
+ {location()}
+
@@ -252,7 +266,9 @@ function SessionSummaryPanel(props: {
}
>
- {branch()}
+
+ {branch()}
+
0}>
-
+
0 && props.moveEligible}>
"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) => }
header={
-
-
-
-
-
- }>
- {projectAvatar()}
-
-
- }
- >
-
-
- }>
- {projectAvatar()}
-
-
-
-
-
-
-
- /
-
-
-
+
+
+
+
+
- {childTitle()}
-
+
+ }>
+ {projectAvatar()}
+
+
}
>
- {
- 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()}
- />
-
-
-
-
-
- {(id) => (
-
-
-
- {(project) => (
-
- }
- variant="ghost-muted"
- size="large"
- state={summaryOpen() ? "pressed" : undefined}
- aria-label={language.t("session.summary.title")}
- aria-expanded={summaryOpen()}
- />
-
-
- setWorkspaceSuggestionDismissed(true)}
- onReview={() => {
- setSummary(false)
- props.onReview()
- }}
- backgroundTasks={props.background.tasks()}
- />
-
-
-
- )}
-
-
-
+
+
+
+
+
+
+ /
+
+
+
+
- {
- setTitle("pendingRename", true)
- setTitle("menuOpen", false)
- }}
- >
- {language.t("common.rename")}
-
- void props.action.export(id)}>
- {language.t("common.export")}...
-
- {/* TODO: Need a session archive API. */}
-
- props.action.showDelete(id)}>
- {language.t("common.delete")}...
-
-
-
-
+ {childTitle()}
+
+ }
+ >
+ {
+ 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()}
+ />
+
- )}
-
-
-
+
+
+ {(id) => (
+
+
+
+ {(project) => (
+
+ }
+ variant="ghost-muted"
+ size="large"
+ state={summaryOpen() ? "pressed" : undefined}
+ aria-label={language.t("session.summary.title")}
+ aria-expanded={summaryOpen()}
+ />
+
+
+ setWorkspaceSuggestionDismissed(true)}
+ onReview={() => {
+ setSummary(false)
+ props.onReview()
+ }}
+ backgroundTasks={props.background.tasks()}
+ />
+
+
+
+ )}
+
+
+
+
+
+ )}
+
+
+
+
}
/>
)
diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx
index 407e0c243ea..c41d79e6fff 100644
--- a/packages/app/src/session/timeline/virtualizer.tsx
+++ b/packages/app/src/session/timeline/virtualizer.tsx
@@ -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" }}
>
-
{props.header}
+
}>
+ {props.header}
+
{
diff --git a/packages/app/src/shell/mobile-panel-drawer.tsx b/packages/app/src/shell/mobile-panel-drawer.tsx
new file mode 100644
index 00000000000..1a8716cffde
--- /dev/null
+++ b/packages/app/src/shell/mobile-panel-drawer.tsx
@@ -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 (
+
+ {/* Preserve Corvu's content and dismissal lifecycle across reopenings. */}
+
+
+
+
+
+
+
+ {props.title}
+
+ {language.t("common.close")}
+
+
+
+ {props.children}
+
+
+
+
+ )
+}
diff --git a/packages/app/src/shell/status/body.tsx b/packages/app/src/shell/status/body.tsx
index 748214de823..48a8c7fcad1 100644
--- a/packages/app/src/shell/status/body.tsx
+++ b/packages/app/src/shell/status/body.tsx
@@ -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 (
-
+
{
+ 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 (
+
+
+ {language.t("common.loading")}
+
+ }
+ >
+
+
+
+ )
+}
diff --git a/packages/session-ui/src/pierre/index.ts b/packages/session-ui/src/pierre/index.ts
index 9f038f139a4..0c523e92e67 100644
--- a/packages/session-ui/src/pierre/index.ts
+++ b/packages/session-ui/src/pierre/index.ts
@@ -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;