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")} + + )} + + + + setStore("menu", open)} > - { + trigger = element }} - onClick={() => props.review.mobile.setTab("session")} - > - {language.t("session.tab.session")} - - props.review.mobile.setTab("changes")} - > - {props.review.hasChanges() - ? language.t("session.review.filesChanged", { count: props.review.count() }) - : language.plural("session.review.change", 0)} - - - + 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("menuOpen", open) - if (open) return - }} + - } - variant="ghost-muted" - size="large" - aria-label={language.t("common.moreOptions")} - aria-expanded={title.menuOpen} - /> - - { - if (title.pendingRename) { - event.preventDefault() - setTitle("pendingRename", false) - openTitleEditor() - return - } - }} + + }> + {projectAvatar()} + + + + + + + + + + - { - 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()} + /> + + + + )} + + + { + setTitle("menuOpen", open) + if (open) return + }} + > + } + variant="ghost-muted" + size="large" + aria-label={language.t("common.moreOptions")} + aria-expanded={title.menuOpen} + /> + + { + if (title.pendingRename) { + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() + return + } + }} + > + { + 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")}... + + + + + +
+ )} +
+
+ + } /> ) 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} +
{ 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;