mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 10:14:26 +00:00
feat(app): add mobile session panels and detail drawers (#46389)
This commit is contained in:
parent
ac77cc46b8
commit
711a0a2da2
24 changed files with 1247 additions and 277 deletions
|
|
@ -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)
|
||||
|
|
|
|||
110
packages/app/e2e/regression/mobile-files.spec.ts
Normal file
110
packages/app/e2e/regression/mobile-files.spec.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
186
packages/app/e2e/regression/mobile-session-views.spec.ts
Normal file
186
packages/app/e2e/regression/mobile-session-views.spec.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
37
packages/app/e2e/regression/mobile-status-drawer.spec.ts
Normal file
37
packages/app/e2e/regression/mobile-status-drawer.spec.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test("status drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const more = page
|
||||
.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
.getByRole("button", { name: "More options", exact: true })
|
||||
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
|
||||
const overlay = page.locator('[data-slot="mobile-status-overlay"]')
|
||||
|
||||
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
|
||||
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
|
||||
if (dismissal === "escape") await page.keyboard.press("Escape")
|
||||
if (dismissal === "drag") {
|
||||
const handle = drawer.locator('[data-slot="mobile-status-drag-handle"]')
|
||||
const bounds = await handle.boundingBox()
|
||||
expect(bounds).not.toBeNull()
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2 + 1)
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, 843)
|
||||
await page.mouse.up()
|
||||
}
|
||||
await expect(drawer, `dismissal: ${dismissal}`).toBeHidden()
|
||||
await expect(overlay).toHaveCount(0)
|
||||
await expect(more).toBeFocused()
|
||||
}
|
||||
})
|
||||
|
|
@ -89,7 +89,7 @@ async function openReview(page: Page) {
|
|||
)
|
||||
await changes.click()
|
||||
expect((await (await diffResponse).json()).data).toHaveLength(1)
|
||||
await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/)
|
||||
await expect(changes).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
await expectAppVisible(review)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ export async function expectAppVisible(locator: Locator) {
|
|||
}
|
||||
|
||||
export async function expectSessionTitle(page: Page, title: string) {
|
||||
if ((page.viewportSize()?.width ?? 1280) < 768) {
|
||||
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
|
||||
await expectAppVisible(trigger)
|
||||
await expect(trigger.locator('span[dir="auto"]')).toHaveText(title, { timeout: APP_READY_TIMEOUT })
|
||||
return
|
||||
}
|
||||
await expectAppVisible(page.getByRole("heading", { name: title }))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ export function SessionContextTab() {
|
|||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div class="px-6 pt-4 pb-10 flex flex-col gap-10">
|
||||
<div data-slot="session-usage-content" class="px-4 pt-4 pb-6 flex flex-col gap-6 md:px-6 md:pb-10 md:gap-10">
|
||||
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
|
||||
<For each={stats}>
|
||||
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createMemo, createSignal, createUniqueId, Show } from "solid-js"
|
||||
import { createMemo, createUniqueId, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createQuery } from "@tanstack/solid-query"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode-ai/session-ui/v2/session-file-panel-v2"
|
||||
|
|
@ -34,6 +35,7 @@ export function SessionFileBrowserTab(props: {
|
|||
onSelect: (path: string) => void
|
||||
onSelectPermanent: (path: string) => void
|
||||
filterRef?: (element: HTMLInputElement) => void
|
||||
mobile?: boolean
|
||||
}) {
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
|
|
@ -42,8 +44,10 @@ export function SessionFileBrowserTab(props: {
|
|||
const serverSDK = useServerSDK()
|
||||
const { workspaceKey } = useSessionLayout()
|
||||
const resultsID = `session-file-browser-results-${createUniqueId()}`
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const [explicitHighlight, setExplicitHighlight] = createSignal<string>()
|
||||
const [store, setStore] = createStore({ filter: "", explicitHighlight: undefined as string | undefined })
|
||||
const filter = () => store.filter
|
||||
const setFilter = (value: string) => setStore("filter", value)
|
||||
const setExplicitHighlight = (value: string) => setStore("explicitHighlight", value)
|
||||
const sidebarOpened = () => props.placeholder || props.state.sidebarOpened()
|
||||
const query = createMemo(() => filter().trim())
|
||||
const search = createQuery(() => {
|
||||
|
|
@ -61,7 +65,7 @@ export function SessionFileBrowserTab(props: {
|
|||
const highlighted = createMemo(() => {
|
||||
const values = files()
|
||||
if (values.length === 0) return undefined
|
||||
const explicit = explicitHighlight()
|
||||
const explicit = store.explicitHighlight
|
||||
if (explicit && values.includes(explicit)) return explicit
|
||||
return values[0]
|
||||
})
|
||||
|
|
@ -105,13 +109,13 @@ export function SessionFileBrowserTab(props: {
|
|||
filter={filter()}
|
||||
onFilterChange={setFilter}
|
||||
onFilterKeyDown={onFilterKeyDown}
|
||||
filterAutofocus={props.placeholder}
|
||||
filterRef={props.filterRef}
|
||||
filterAutofocus={props.placeholder && !props.mobile}
|
||||
filterRef={(element) => props.filterRef?.(element)}
|
||||
filterControls={resultsID}
|
||||
filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
|
||||
filterExpanded={query().length > 0 && files().length > 0}
|
||||
width={props.state.sidebarWidth()}
|
||||
onWidthChange={props.state.resizeSidebar}
|
||||
onWidthChange={props.mobile ? undefined : props.state.resizeSidebar}
|
||||
>
|
||||
<Show
|
||||
when={query()}
|
||||
|
|
@ -119,6 +123,7 @@ export function SessionFileBrowserTab(props: {
|
|||
<FileTreeV2
|
||||
active={props.active}
|
||||
kinds={props.kinds}
|
||||
draggable={!props.mobile}
|
||||
onFileClick={(node) => props.onSelect(node.path)}
|
||||
onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
|
||||
/>
|
||||
|
|
|
|||
35
packages/app/src/session/files/session-mobile-files.css
Normal file
35
packages/app/src/session/files/session-mobile-files.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
100
packages/app/src/session/files/session-mobile-files.tsx
Normal file
100
packages/app/src/session/files/session-mobile-files.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { createMemo, For } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { createSessionTabs, SESSION_OPEN_FILE_TAB } from "@/session/helpers"
|
||||
import { useFile } from "@/workspaces/files/model"
|
||||
import { SessionFileBrowserTab } from "./session-file-browser-tab"
|
||||
import type { Kind } from "./file-tree-v2"
|
||||
import "./session-mobile-files.css"
|
||||
|
||||
export function SessionMobileFiles() {
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const layout = useSessionLayout()
|
||||
const tabs = createSessionTabs({
|
||||
tabs: layout.tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab: file.tab,
|
||||
})
|
||||
const [store, setStore] = createStore({ browsing: !tabs.activeFileTab() })
|
||||
const browsing = () => store.browsing || !tabs.activeFileTab()
|
||||
const active = createMemo(() => file.pathFromTab(tabs.activeFileTab() ?? ""))
|
||||
const kinds = new Map<string, Kind>()
|
||||
const open = (path: string) => {
|
||||
layout.tabs().open(file.tab(path))
|
||||
void file.load(path)
|
||||
setStore("browsing", false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="session-mobile-files" data-browsing={browsing()} class="flex h-full min-h-0 flex-col">
|
||||
<div data-slot="session-mobile-files-header" class="relative flex h-10 shrink-0 items-center">
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
class="shrink-0 mx-2"
|
||||
onClick={() => setStore("browsing", true)}
|
||||
aria-pressed={browsing()}
|
||||
>
|
||||
{language.t("session.files.all")}
|
||||
</Button>
|
||||
<Tabs
|
||||
value={browsing() ? SESSION_OPEN_FILE_TAB : tabs.activeFileTab()}
|
||||
onChange={(tab) => {
|
||||
// Kobalte falls back to a file tab when the browse view has no trigger.
|
||||
if (browsing()) return
|
||||
const path = file.pathFromTab(tab)
|
||||
if (path) open(path)
|
||||
}}
|
||||
variant="line"
|
||||
class="min-w-0 flex-1 !h-auto"
|
||||
>
|
||||
<Tabs.List aria-label={language.t("session.files.openTabs")} class="!h-10 !px-0 overflow-x-auto">
|
||||
<For each={tabs.openedTabs()}>
|
||||
{(tab) => (
|
||||
<Tabs.Trigger
|
||||
value={tab}
|
||||
onClick={() => open(file.pathFromTab(tab)!)}
|
||||
class="shrink-0 max-w-48"
|
||||
classes={{ button: "min-w-0" }}
|
||||
closeButton={
|
||||
<Tabs.CloseButton
|
||||
aria-label={language.t("common.closeTab")}
|
||||
onClick={() => layout.tabs().close(tab)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span dir="ltr" class="truncate">
|
||||
{getFilename(file.pathFromTab(tab) ?? tab)}
|
||||
</span>
|
||||
</Tabs.Trigger>
|
||||
)}
|
||||
</For>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1">
|
||||
<SessionFileBrowserTab
|
||||
mobile
|
||||
tab={tabs.activeFileTab() ?? SESSION_OPEN_FILE_TAB}
|
||||
placeholder={browsing()}
|
||||
active={active()}
|
||||
kinds={kinds}
|
||||
state={{
|
||||
sidebarOpened: browsing,
|
||||
sidebarWidth: () => 240,
|
||||
sidebarTransition: () => false,
|
||||
resizeSidebar: () => undefined,
|
||||
toggleSidebar: () => setStore("browsing", !browsing()),
|
||||
}}
|
||||
onSelect={open}
|
||||
onSelectPermanent={open}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -19,7 +19,10 @@ export function SessionHeader() {
|
|||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
const actions = createMemo<SessionHeaderActionsState>(() => ({
|
||||
status: status() ? { label: language.t("status.popover.trigger"), content: () => <StatusPopover /> } : undefined,
|
||||
status:
|
||||
isDesktop() && status()
|
||||
? { label: language.t("status.popover.trigger"), content: () => <StatusPopover /> }
|
||||
: undefined,
|
||||
reviewLabel: language.t("command.review.toggle"),
|
||||
reviewKeybind: reviewTooltipKeybind(command),
|
||||
reviewVisible: isDesktop(),
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export function createSessionReview(input: {
|
|||
const location = useWorkspaceLocation()
|
||||
const server = useServerSDK()
|
||||
const [state, setState] = createStore({
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
mobileTab: "session" as "session" | "changes" | "files" | "usage",
|
||||
detailsOpen: false,
|
||||
scroll: undefined as HTMLDivElement | undefined,
|
||||
pendingFile: undefined as string | undefined,
|
||||
|
|
@ -66,7 +66,9 @@ export function createSessionReview(input: {
|
|||
}
|
||||
return list
|
||||
})
|
||||
const mobileChanges = createMemo(() => !input.session.isDesktop() && state.mobileTab === "changes")
|
||||
const mobileChanges = createMemo(
|
||||
() => !input.session.isDesktop() && !input.screen.terminal.open() && state.mobileTab === "changes",
|
||||
)
|
||||
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
||||
const value = mode()
|
||||
return value === "git" || value === "branch" ? value : undefined
|
||||
|
|
@ -407,7 +409,7 @@ export function createSessionReview(input: {
|
|||
loadDiff,
|
||||
mobile: {
|
||||
changes: mobileChanges,
|
||||
setTab: (tab: "session" | "changes") => setState("mobileTab", tab),
|
||||
setTab: (tab: "session" | "changes" | "files" | "usage") => setState("mobileTab", tab),
|
||||
tab: () => state.mobileTab,
|
||||
},
|
||||
mode,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Tabs value={props.review.mobile.tab()} class="h-auto">
|
||||
<Tabs.List
|
||||
classList={{
|
||||
"!h-9": props.compact,
|
||||
"[&::after]:!border-b-0 [&::after]:!border-t [&::after]:!border-border-weak-base": props.bottom,
|
||||
}}
|
||||
<div
|
||||
class="relative flex shrink-0 items-center before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-v2-border-border-base before:content-['']"
|
||||
data-slot="session-mobile-view-navigation"
|
||||
>
|
||||
<Tabs value={props.current} variant="line" class="!h-auto min-w-0 flex-1" data-slot="session-mobile-view-tabs">
|
||||
<Tabs.List aria-label={language.t("session.view.select")} class="!h-9 !gap-0 !px-0 before:!hidden">
|
||||
<For each={["session", "changes", "files", "terminal"] as const}>
|
||||
{(view) => (
|
||||
<Tabs.Trigger
|
||||
value={view}
|
||||
class="min-w-0 flex-1"
|
||||
classes={{ button: "w-full justify-center" }}
|
||||
onClick={() => props.onSelect(view)}
|
||||
>
|
||||
{view === "session"
|
||||
? language.t("session.tab.session")
|
||||
: view === "changes"
|
||||
? language.plural("session.review.change", 0)
|
||||
: view === "files"
|
||||
? language.t("session.tab.files")
|
||||
: language.t("terminal.title")}
|
||||
</Tabs.Trigger>
|
||||
)}
|
||||
</For>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<Menu
|
||||
appearance="standard"
|
||||
modal={false}
|
||||
placement="bottom-end"
|
||||
gutter={4}
|
||||
open={store.menu}
|
||||
onOpenChange={(open) => setStore("menu", open)}
|
||||
>
|
||||
<Tabs.Trigger
|
||||
value="session"
|
||||
classes={{ button: props.compact ? "w-full !py-2" : "w-full" }}
|
||||
classList={{
|
||||
"!w-1/2 !max-w-none": true,
|
||||
"!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent":
|
||||
props.bottom,
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
ref={(element: HTMLButtonElement) => {
|
||||
trigger = element
|
||||
}}
|
||||
onClick={() => props.review.mobile.setTab("session")}
|
||||
>
|
||||
{language.t("session.tab.session")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="changes"
|
||||
classes={{ button: props.compact ? "w-full !py-2" : "w-full" }}
|
||||
classList={{
|
||||
"!w-1/2 !max-w-none !border-r-0": true,
|
||||
"!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent":
|
||||
props.bottom,
|
||||
}}
|
||||
onClick={() => props.review.mobile.setTab("changes")}
|
||||
>
|
||||
{props.review.hasChanges()
|
||||
? language.t("session.review.filesChanged", { count: props.review.count() })
|
||||
: language.plural("session.review.change", 0)}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
icon={<Icon name="menu" />}
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="mx-1.5 shrink-0"
|
||||
state={props.current === "usage" || store.menu ? "pressed" : undefined}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!store.pending) return
|
||||
event.preventDefault()
|
||||
if (store.pending === "status") setStore({ status: true, statusLoaded: true })
|
||||
if (store.pending === "details") setStore({ details: true, detailsLoaded: true })
|
||||
setStore("pending", undefined)
|
||||
}}
|
||||
>
|
||||
<Menu.Item onSelect={() => props.onSelect("usage")}>{language.t("session.tab.usage")}</Menu.Item>
|
||||
<Show when={props.details}>
|
||||
<Menu.Item onSelect={() => setStore({ pending: "details", menu: false })}>
|
||||
{language.t("session.summary.title")}
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Item onSelect={() => setStore({ pending: "status", menu: false })}>
|
||||
{language.t("status.popover.trigger")}
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
<Show when={store.statusLoaded}>
|
||||
<Suspense>
|
||||
<StatusDrawer
|
||||
open={store.status}
|
||||
onOpenChange={(open) => setStore("status", open)}
|
||||
returnFocus={() => trigger}
|
||||
/>
|
||||
</Suspense>
|
||||
</Show>
|
||||
<Show when={store.detailsLoaded}>
|
||||
<Suspense>
|
||||
<MobilePanelDrawer
|
||||
title={language.t("session.summary.title")}
|
||||
open={store.details}
|
||||
onOpenChange={(open) => setStore("details", open)}
|
||||
returnFocus={() => trigger}
|
||||
>
|
||||
{props.details?.(() => setStore("details", false))}
|
||||
</MobilePanelDrawer>
|
||||
</Suspense>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -92,7 +177,7 @@ function ReviewContent(props: { review: SessionReviewModel }) {
|
|||
<Show when={!props.review.deferRender()}>
|
||||
<SessionReviewTab
|
||||
title={<ReviewTitle review={props.review} />}
|
||||
empty={<ReviewEmpty review={props.review} loadingClass="px-4 py-4 text-text-weak" />}
|
||||
empty={<ReviewEmpty review={props.review} loadingClass="px-2 py-2 text-text-weak" />}
|
||||
diffs={props.review.diffs()}
|
||||
view={props.review.view()}
|
||||
diffStyle="unified"
|
||||
|
|
@ -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",
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,23 @@
|
|||
import { ErrorBoundary, Show, Match, Switch, createMemo, createEffect, createComputed, on } from "solid-js"
|
||||
import {
|
||||
ErrorBoundary,
|
||||
Show,
|
||||
Match,
|
||||
Switch,
|
||||
Suspense,
|
||||
lazy,
|
||||
createMemo,
|
||||
createEffect,
|
||||
createComputed,
|
||||
on,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import createPresence from "solid-presence"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { SessionHeader } from "@/session/header/session-header"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { MessageTimeline } from "@/session/timeline/message-timeline"
|
||||
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import type { SessionModel } from "@/session/model"
|
||||
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
|
||||
import { SessionPanelFrame } from "@/session/session-frame"
|
||||
|
|
@ -14,15 +26,25 @@ import { useUsageExceededDialogs } from "./usage-exceeded-dialogs"
|
|||
import { SessionErrorFallback } from "./route-error"
|
||||
import { createSessionScreenLayout } from "./screen-layout"
|
||||
import { createSessionReview } from "./review/model"
|
||||
import { SessionDesktopReview, SessionMobileReview, SessionMobileTabs } from "./review/view"
|
||||
import { SessionDesktopReview, SessionMobileReview, SessionMobileViewTabs } from "./review/view"
|
||||
import { SessionContextTab } from "./files/session-context-tab"
|
||||
import { createSessionTimelineInteraction } from "./timeline/interaction"
|
||||
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
const { SessionMobileFiles } = await import("./files/session-mobile-files")
|
||||
return { default: SessionMobileFiles }
|
||||
})
|
||||
|
||||
export function SessionScreen(props: { session: SessionModel }) {
|
||||
const session = props.session
|
||||
const layout = useLayout()
|
||||
const settings = useSettings()
|
||||
const server = useServer()
|
||||
const detailsProject = createMemo(() => {
|
||||
const info = session.data.info()
|
||||
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
|
||||
})
|
||||
const isDesktop = session.isDesktop
|
||||
const screen = createSessionScreenLayout(session)
|
||||
const timeline = createSessionTimelineInteraction(session)
|
||||
|
|
@ -34,6 +56,8 @@ export function SessionScreen(props: { session: SessionModel }) {
|
|||
sideRegionPresent: false,
|
||||
sideReviewPresent: false,
|
||||
sideTerminalPresent: false,
|
||||
mobileTerminalCached: false,
|
||||
mobileMoveDismissed: false,
|
||||
})
|
||||
const [elements, setElements] = createStore<{
|
||||
side?: HTMLDivElement
|
||||
|
|
@ -41,7 +65,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
|||
}>({})
|
||||
const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible)
|
||||
const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open())
|
||||
const bottomTerminalVisible = createMemo(() => screen.terminal.open() && (!isDesktop() || screen.terminal.bottom()))
|
||||
const bottomTerminalVisible = createMemo(() => isDesktop() && screen.terminal.open() && screen.terminal.bottom())
|
||||
const sidePresence = createPresence({
|
||||
show: sideVisible,
|
||||
element: () => elements.side ?? null,
|
||||
|
|
@ -67,6 +91,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
|||
createComputed((prev) => {
|
||||
const key = session.identity.sessionKey()
|
||||
if (key !== prev) {
|
||||
setStore("mobileMoveDismissed", false)
|
||||
setStore("deferRender", true)
|
||||
const owner = session.ownership.capture()
|
||||
requestAnimationFrame(() => {
|
||||
|
|
@ -76,6 +101,11 @@ export function SessionScreen(props: { session: SessionModel }) {
|
|||
return key
|
||||
})
|
||||
const review = createSessionReview({ session, screen, deferRender: () => store.deferRender })
|
||||
const mobileView = createMemo(() => (screen.terminal.open() ? "terminal" : review.mobile.tab()))
|
||||
const conversationVisible = createMemo(() => isDesktop() || mobileView() === "session")
|
||||
createEffect(() => {
|
||||
if (!isDesktop() && screen.terminal.open()) setStore("mobileTerminalCached", true)
|
||||
})
|
||||
const composer = createActiveSessionRegion({
|
||||
session,
|
||||
screen,
|
||||
|
|
@ -84,36 +114,102 @@ export function SessionScreen(props: { session: SessionModel }) {
|
|||
|
||||
useUsageExceededDialogs()
|
||||
|
||||
const mobileTabsBottom = createMemo(() => !isDesktop() && settings.general.mobileTitlebarPosition() === "bottom")
|
||||
|
||||
const sessionErrorFallback = (error: unknown, reset: () => void) => {
|
||||
createEffect(on(session.identity.sessionKey, reset, { defer: true }))
|
||||
return <SessionErrorFallback error={error} sessionID={session.identity.params.id} />
|
||||
}
|
||||
|
||||
const mobileTabs = () => (
|
||||
<Show when={session.identity.sessionKey()} keyed>
|
||||
{(_key) => (
|
||||
<SessionMobileViewTabs
|
||||
current={mobileView()}
|
||||
onDetailsOpenChange={review.details.setOpen}
|
||||
details={
|
||||
!session.data.isChild() && detailsProject()
|
||||
? (close) => (
|
||||
<Show when={detailsProject()}>
|
||||
{(project) => (
|
||||
<SessionSummaryPanel
|
||||
mobile
|
||||
project={project()}
|
||||
directory={session.workspace.directory()}
|
||||
local={!session.workspace.current()}
|
||||
branch={
|
||||
session.shared.data.location.vcs.info({ directory: session.workspace.directory() })?.branch
|
||||
.current
|
||||
}
|
||||
baseBranch={
|
||||
session.shared.data.location.vcs.info({ directory: project().worktree })?.branch.current
|
||||
}
|
||||
diffs={project().vcs === "git" ? review.details.diffs() : []}
|
||||
sessionID={session.identity.params.id ?? ""}
|
||||
moveEligible={composer.workspaceMoveEligible()}
|
||||
moveDismissed={store.mobileMoveDismissed}
|
||||
onMoveDismiss={() => setStore("mobileMoveDismissed", true)}
|
||||
onReview={() => {
|
||||
close()
|
||||
review.mobile.setTab("changes")
|
||||
session.layout.view().terminal.close()
|
||||
}}
|
||||
backgroundTasks={composer.region.state.background.tasks()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
onSelect={(view) => {
|
||||
if (view === "terminal") {
|
||||
session.layout.view().terminal.open()
|
||||
return
|
||||
}
|
||||
review.mobile.setTab(view)
|
||||
session.layout.view().terminal.close()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
|
||||
const sessionPanelContent = () => (
|
||||
<>
|
||||
<Show when={!isDesktop() && !!session.identity.params.id && !mobileTabsBottom()}>
|
||||
<SessionMobileTabs review={review} compact />
|
||||
</Show>
|
||||
<Show when={!isDesktop() && !!session.identity.params.id}>{mobileTabs()}</Show>
|
||||
{/* Surface query errors without suspending session metadata while messages load. */}
|
||||
<Show when={timeline.resource.error}>
|
||||
{(error) => {
|
||||
throw error()
|
||||
}}
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<div class="relative flex-1 min-h-0 overflow-hidden">
|
||||
<Show when={!isDesktop() && store.mobileTerminalCached}>
|
||||
<div class="absolute inset-0" classList={{ invisible: mobileView() !== "terminal" }}>
|
||||
<TerminalPanel fill embedded present contentHeight="100%" />
|
||||
</div>
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={!isDesktop() && mobileView() === "terminal"}>
|
||||
<></>
|
||||
</Match>
|
||||
<Match when={!isDesktop() && mobileView() === "usage"}>
|
||||
<SessionContextTab />
|
||||
</Match>
|
||||
<Match when={!isDesktop() && mobileView() === "files"}>
|
||||
<Suspense>
|
||||
<SessionMobileFiles />
|
||||
</Suspense>
|
||||
</Match>
|
||||
<Match when={session.identity.params.id && review.mobile.changes()}>
|
||||
<SessionMobileReview review={review} />
|
||||
</Match>
|
||||
<Match when={session.identity.params.id}>
|
||||
<Show when={!messagesReady()}>
|
||||
<Show when={isDesktop() && !messagesReady()}>
|
||||
<SessionIdentityHeader sessionID={session.identity.params.id ?? ""} session={session.data.info()} />
|
||||
</Show>
|
||||
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<MessageTimeline
|
||||
hideHeader={!isDesktop()}
|
||||
session={session}
|
||||
background={composer.region.state.background}
|
||||
actions={composer.actions.timeline}
|
||||
|
|
@ -143,14 +239,11 @@ export function SessionScreen(props: { session: SessionModel }) {
|
|||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={!review.mobile.changes() ? session.identity.params.id : undefined} keyed>
|
||||
<Show when={conversationVisible() ? session.identity.params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<ActiveSessionComposerRegion model={composer} session={session} onResponseSubmit={timeline.actions.resume} />
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!!session.identity.params.id && mobileTabsBottom()}>
|
||||
<SessionMobileTabs review={review} compact bottom />
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
|
|
@ -300,7 +393,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
|||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={bottomTerminalPresence.present() || store.bottomTerminalCached}>
|
||||
<Show when={isDesktop() && (bottomTerminalPresence.present() || store.bottomTerminalCached)}>
|
||||
<div
|
||||
ref={(element) => setElements("bottomTerminal", element)}
|
||||
data-slot="terminal-panel-presence"
|
||||
|
|
|
|||
|
|
@ -38,7 +38,14 @@ type CachedTerminalSurface = {
|
|||
}
|
||||
|
||||
export function TerminalPanel(
|
||||
props: { stacked?: boolean; fill?: boolean; framed?: boolean; present?: boolean; contentHeight?: string } = {},
|
||||
props: {
|
||||
stacked?: boolean
|
||||
fill?: boolean
|
||||
framed?: boolean
|
||||
present?: boolean
|
||||
contentHeight?: string
|
||||
embedded?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const layout = useLayout()
|
||||
const terminal = useTerminal()
|
||||
|
|
@ -223,6 +230,7 @@ export function TerminalPanel(
|
|||
opened={opened()}
|
||||
present={present()}
|
||||
framed={props.framed}
|
||||
embedded={props.embedded}
|
||||
desktop={isDesktop()}
|
||||
stacked={stacked()}
|
||||
height={panelHeight()}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export function TerminalSurface(
|
|||
opened: boolean
|
||||
present?: boolean
|
||||
framed?: boolean
|
||||
embedded?: boolean
|
||||
desktop: boolean
|
||||
stacked: boolean
|
||||
height: string
|
||||
|
|
@ -26,7 +27,7 @@ export function TerminalSurface(
|
|||
id="terminal-panel"
|
||||
data-component="terminal-panel"
|
||||
data-opened={props.opened}
|
||||
data-size-animated={!props.resizing && (!props.desktop || props.stacked)}
|
||||
data-size-animated={!props.embedded && !props.resizing && (!props.desktop || props.stacked)}
|
||||
role="region"
|
||||
aria-label={props.label}
|
||||
aria-hidden={!props.opened}
|
||||
|
|
@ -37,11 +38,14 @@ export function TerminalSurface(
|
|||
"min-w-0 h-full flex-1": props.desktop && (props.present ?? props.opened) && !props.stacked,
|
||||
"w-0 h-full pointer-events-none": props.desktop && !(props.present ?? props.opened),
|
||||
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": props.desktop && (props.framed ?? true),
|
||||
"will-change-[height]": !props.resizing && (!props.desktop || props.stacked),
|
||||
"will-change-[height]": !props.embedded && !props.resizing && (!props.desktop || props.stacked),
|
||||
}}
|
||||
style={{ height: props.height, "--terminal-panel-height": props.contentHeight }}
|
||||
>
|
||||
<div classList={{ "md:hidden": !props.stacked, hidden: props.stacked }} onPointerDown={props.onResizeStart}>
|
||||
<div
|
||||
classList={{ "md:hidden": !props.stacked, hidden: props.stacked || props.embedded }}
|
||||
onPointerDown={props.onResizeStart}
|
||||
>
|
||||
<ResizeHandle
|
||||
class="-top-1"
|
||||
direction="vertical"
|
||||
|
|
@ -57,7 +61,7 @@ export function TerminalSurface(
|
|||
data-slot="terminal-panel-content"
|
||||
class="absolute inset-x-0 top-0 flex flex-col overflow-hidden"
|
||||
classList={{
|
||||
"border-t border-border-weak-base": props.opened && !props.desktop,
|
||||
"border-t border-border-weak-base": props.opened && !props.desktop && !props.embedded,
|
||||
"pointer-events-none": !props.opened,
|
||||
}}
|
||||
style={{ height: props.contentHeight }}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export function BackgroundMoveHint(props: { keybind?: string[] }) {
|
|||
)
|
||||
}
|
||||
|
||||
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[] }) {
|
||||
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) {
|
||||
const language = useLanguage()
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const taskType = (task: BackgroundTask) => {
|
||||
|
|
@ -81,7 +81,7 @@ export function BackgroundWorkSummary(props: { tasks: BackgroundTask[] }) {
|
|||
return (
|
||||
<Popover
|
||||
open={open()}
|
||||
placement={language.direction() === "rtl" ? "right-end" : "left-end"}
|
||||
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-end" : "left-end"}
|
||||
gutter={4}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
|
|
@ -126,6 +126,7 @@ export function BackgroundWorkSummary(props: { tasks: BackgroundTask[] }) {
|
|||
|
||||
function WorkspaceMoveAction(props: {
|
||||
variant: "inline" | "panel"
|
||||
mobile?: boolean
|
||||
eligible: boolean
|
||||
sessionID: string
|
||||
project: Project
|
||||
|
|
@ -150,9 +151,17 @@ function WorkspaceMoveAction(props: {
|
|||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
placement={inline() ? "bottom-end" : language.direction() === "rtl" ? "right-start" : "left-start"}
|
||||
gutter={inline() ? 4 : -22}
|
||||
contentClass={inline() ? undefined : "relative top-3.5"}
|
||||
placement={
|
||||
props.mobile
|
||||
? "top-end"
|
||||
: inline()
|
||||
? "bottom-end"
|
||||
: language.direction() === "rtl"
|
||||
? "right-start"
|
||||
: "left-start"
|
||||
}
|
||||
gutter={props.mobile || inline() ? 4 : -22}
|
||||
contentClass={props.mobile || inline() ? undefined : "relative top-3.5"}
|
||||
class={
|
||||
inline()
|
||||
? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pe-6 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
|
||||
|
|
@ -181,7 +190,8 @@ function WorkspaceMoveAction(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function SessionSummaryPanel(props: {
|
||||
export function SessionSummaryPanel(props: {
|
||||
mobile?: boolean
|
||||
project: Project
|
||||
avatar?: JSX.Element
|
||||
directory: string
|
||||
|
|
@ -207,7 +217,7 @@ function SessionSummaryPanel(props: {
|
|||
"flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"
|
||||
|
||||
return (
|
||||
<div data-component="session-summary-panel" class="w-[280px]">
|
||||
<div data-component="session-summary-panel" class={props.mobile ? "w-full" : "w-[280px]"}>
|
||||
<div class="relative z-10 flex flex-col gap-1 overflow-hidden rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
|
||||
<div class={row}>
|
||||
{props.avatar ?? (
|
||||
|
|
@ -217,19 +227,23 @@ function SessionSummaryPanel(props: {
|
|||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
)}
|
||||
<span class="min-w-0 flex-1 truncate text-v2-text-text-muted">{displayName(props.project)}</span>
|
||||
<span dir="auto" class="min-w-0 flex-1 truncate text-v2-text-text-muted">
|
||||
{displayName(props.project)}
|
||||
</span>
|
||||
</div>
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
directory={props.directory}
|
||||
placement={language.direction() === "rtl" ? "right-start" : "left-start"}
|
||||
gutter={-22}
|
||||
placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-start" : "left-start"}
|
||||
gutter={props.mobile ? 4 : -22}
|
||||
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
|
||||
>
|
||||
<Icon name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 flex-1 truncate text-start">{location()}</span>
|
||||
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
|
||||
{location()}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</SessionWorkspaceMenu>
|
||||
<div class={row}>
|
||||
|
|
@ -252,7 +266,9 @@ function SessionSummaryPanel(props: {
|
|||
</span>
|
||||
}
|
||||
>
|
||||
<span class="min-w-0 truncate">{branch()}</span>
|
||||
<span dir="auto" class="min-w-0 truncate">
|
||||
{branch()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -272,12 +288,13 @@ function SessionSummaryPanel(props: {
|
|||
</Show>
|
||||
</button>
|
||||
<Show when={props.backgroundTasks.length > 0}>
|
||||
<BackgroundWorkSummary tasks={props.backgroundTasks} />
|
||||
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.local && props.diffs && props.diffs.length > 0 && props.moveEligible}>
|
||||
<WorkspaceMoveAction
|
||||
variant="panel"
|
||||
mobile={props.mobile}
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
project={props.project}
|
||||
|
|
@ -291,6 +308,7 @@ function SessionSummaryPanel(props: {
|
|||
}
|
||||
|
||||
type MessageTimelineProps = {
|
||||
hideHeader?: boolean
|
||||
session: TimelineSessionSource
|
||||
background: SessionBackground
|
||||
actions?: SessionUserActions
|
||||
|
|
@ -393,7 +411,7 @@ function MessageTimelineView(
|
|||
}),
|
||||
)
|
||||
const turnPadding = () => "px-4 md:px-5"
|
||||
const showHeader = createMemo(() => props.data.showHeader() || workspaceSession())
|
||||
const showHeader = createMemo(() => !props.hideHeader && (props.data.showHeader() || workspaceSession()))
|
||||
const pinned = createMemo(() => props.pinned)
|
||||
const messageByID = projection.messageByID
|
||||
const virtualized = createTimelineVirtualizer({
|
||||
|
|
@ -556,198 +574,200 @@ function MessageTimelineView(
|
|||
}}
|
||||
renderRow={(row, onSizeChange) => <rowRenderer.Row row={row} onSizeChange={onSizeChange} />}
|
||||
header={
|
||||
<SessionTitleHeader>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center min-w-0 flex-1 w-full">
|
||||
<Show
|
||||
when={workspaceSession()}
|
||||
fallback={
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Show when={showProjectIcon()} fallback={<Icon name="monitor" />}>
|
||||
{projectAvatar()}
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
placement="bottom-start"
|
||||
value={sessionDirectory()}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span
|
||||
tabIndex={0}
|
||||
aria-label={sessionDirectory()}
|
||||
classList={{
|
||||
"flex size-6 shrink-0 items-center justify-center": true,
|
||||
"text-v2-icon-icon-accent": !showProjectIcon(),
|
||||
}}
|
||||
>
|
||||
<Show when={showProjectIcon()} fallback={<Icon name="workspace-isolated" />}>
|
||||
{projectAvatar()}
|
||||
</Show>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="session-title-parent"
|
||||
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||
onClick={props.action.navigateParent}
|
||||
>
|
||||
{parentTitle()}
|
||||
</button>
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={childTitle() || title.editing}>
|
||||
<Show when={!props.hideHeader}>
|
||||
<SessionTitleHeader>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center min-w-0 flex-1 w-full">
|
||||
<Show
|
||||
when={title.editing}
|
||||
when={workspaceSession()}
|
||||
fallback={
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover"
|
||||
onClick={openTitleEditor}
|
||||
>
|
||||
{childTitle()}
|
||||
</h1>
|
||||
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Show when={showProjectIcon()} fallback={<Icon name="monitor" />}>
|
||||
{projectAvatar()}
|
||||
</Show>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
data-slot="session-title-child"
|
||||
dir="auto"
|
||||
value={title.draft}
|
||||
disabled={props.pending.rename()}
|
||||
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content self-start rounded-[6px] px-2 py-1"
|
||||
style={{
|
||||
"--inline-input-shadow": "none",
|
||||
"text-align": "start",
|
||||
}}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={() => void saveTitleEditor()}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={sessionID()} keyed>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<Show when={!parentID() && project()}>
|
||||
{(project) => (
|
||||
<Popover open={summaryOpen()} placement="bottom-end" gutter={6} onOpenChange={setSummary}>
|
||||
<Popover.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="window-analytics" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
state={summaryOpen() ? "pressed" : undefined}
|
||||
aria-label={language.t("session.summary.title")}
|
||||
aria-expanded={summaryOpen()}
|
||||
/>
|
||||
<Popover.Portal>
|
||||
<Popover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
|
||||
<SessionSummaryPanel
|
||||
project={project()}
|
||||
avatar={showProjectIcon() ? projectAvatar() : undefined}
|
||||
directory={sessionDirectory()}
|
||||
local={!workspaceSession()}
|
||||
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
|
||||
baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
|
||||
diffs={sessionDiffs()}
|
||||
sessionID={id}
|
||||
moveEligible={props.workspaceMoveEligible}
|
||||
moveDismissed={workspaceSuggestionDismissed()}
|
||||
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
|
||||
onReview={() => {
|
||||
setSummary(false)
|
||||
props.onReview()
|
||||
}}
|
||||
backgroundTasks={props.background.tasks()}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!parentID()}>
|
||||
<Menu
|
||||
gutter={6}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => {
|
||||
setTitle("menuOpen", open)
|
||||
if (open) return
|
||||
}}
|
||||
<Tooltip
|
||||
placement="bottom-start"
|
||||
value={sessionDirectory()}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="outline-dots" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
aria-expanded={title.menuOpen}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
style={{ width: "120px", "min-width": "120px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (title.pendingRename) {
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
return
|
||||
}
|
||||
}}
|
||||
<span
|
||||
tabIndex={0}
|
||||
aria-label={sessionDirectory()}
|
||||
classList={{
|
||||
"flex size-6 shrink-0 items-center justify-center": true,
|
||||
"text-v2-icon-icon-accent": !showProjectIcon(),
|
||||
}}
|
||||
>
|
||||
<Show when={showProjectIcon()} fallback={<Icon name="workspace-isolated" />}>
|
||||
{projectAvatar()}
|
||||
</Show>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="session-title-parent"
|
||||
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||
onClick={props.action.navigateParent}
|
||||
>
|
||||
{parentTitle()}
|
||||
</button>
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={childTitle() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover"
|
||||
onClick={openTitleEditor}
|
||||
>
|
||||
<Menu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
{language.t("common.rename")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onSelect={() => void props.action.export(id)}>
|
||||
{language.t("common.export")}...
|
||||
</Menu.Item>
|
||||
{/* TODO: Need a session archive API. */}
|
||||
<Menu.Separator />
|
||||
<Menu.Item onSelect={() => props.action.showDelete(id)}>
|
||||
{language.t("common.delete")}...
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
{childTitle()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
data-slot="session-title-child"
|
||||
dir="auto"
|
||||
value={title.draft}
|
||||
disabled={props.pending.rename()}
|
||||
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content self-start rounded-[6px] px-2 py-1"
|
||||
style={{
|
||||
"--inline-input-shadow": "none",
|
||||
"text-align": "start",
|
||||
}}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={() => void saveTitleEditor()}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</SessionTitleHeader>
|
||||
</div>
|
||||
<Show when={sessionID()} keyed>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<Show when={!parentID() && project()}>
|
||||
{(project) => (
|
||||
<Popover open={summaryOpen()} placement="bottom-end" gutter={6} onOpenChange={setSummary}>
|
||||
<Popover.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="window-analytics" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
state={summaryOpen() ? "pressed" : undefined}
|
||||
aria-label={language.t("session.summary.title")}
|
||||
aria-expanded={summaryOpen()}
|
||||
/>
|
||||
<Popover.Portal>
|
||||
<Popover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
|
||||
<SessionSummaryPanel
|
||||
project={project()}
|
||||
avatar={showProjectIcon() ? projectAvatar() : undefined}
|
||||
directory={sessionDirectory()}
|
||||
local={!workspaceSession()}
|
||||
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
|
||||
baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
|
||||
diffs={sessionDiffs()}
|
||||
sessionID={id}
|
||||
moveEligible={props.workspaceMoveEligible}
|
||||
moveDismissed={workspaceSuggestionDismissed()}
|
||||
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
|
||||
onReview={() => {
|
||||
setSummary(false)
|
||||
props.onReview()
|
||||
}}
|
||||
backgroundTasks={props.background.tasks()}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!parentID()}>
|
||||
<Menu
|
||||
gutter={6}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => {
|
||||
setTitle("menuOpen", open)
|
||||
if (open) return
|
||||
}}
|
||||
>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
icon={<Icon name="outline-dots" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
aria-expanded={title.menuOpen}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
style={{ width: "120px", "min-width": "120px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (title.pendingRename) {
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
return
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Menu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
{language.t("common.rename")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onSelect={() => void props.action.export(id)}>
|
||||
{language.t("common.export")}...
|
||||
</Menu.Item>
|
||||
{/* TODO: Need a session archive API. */}
|
||||
<Menu.Separator />
|
||||
<Menu.Item onSelect={() => props.action.showDelete(id)}>
|
||||
{language.t("common.delete")}...
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</SessionTitleHeader>
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import type { createTimelineProjection } from "./projection"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { filterVirtualIndexes } from "./virtual-items"
|
||||
|
|
@ -73,6 +74,8 @@ type ViewProps = {
|
|||
|
||||
export function createTimelineVirtualizer(input: Input) {
|
||||
const language = useLanguage()
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const topOffset = () => (input.showHeader() ? 64 : isDesktop() ? 0 : 16)
|
||||
const ownerSessionKey = input.sessionKey()
|
||||
const cached = cache.get(ownerSessionKey)
|
||||
const initialMeasurements = cached?.measurements
|
||||
|
|
@ -186,7 +189,7 @@ export function createTimelineVirtualizer(input: Input) {
|
|||
},
|
||||
scrollEndThreshold: 80,
|
||||
get scrollMargin() {
|
||||
return input.showHeader() ? 64 : 0
|
||||
return topOffset()
|
||||
},
|
||||
paddingEnd: 64,
|
||||
get rangeExtractor() {
|
||||
|
|
@ -446,7 +449,7 @@ export function createTimelineVirtualizer(input: Input) {
|
|||
data-timeline-key={rowProps.rowKey}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `${item().start - (input.showHeader() ? 64 : 0)}px`,
|
||||
top: `${item().start - topOffset()}px`,
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: `${item().size}px`,
|
||||
|
|
@ -516,7 +519,9 @@ export function createTimelineVirtualizer(input: Input) {
|
|||
class="relative min-w-0 w-full h-full"
|
||||
style={{ "--sticky-accordion-top": input.showHeader() ? "48px" : "0px" }}
|
||||
>
|
||||
<Show when={input.showHeader()}>{props.header}</Show>
|
||||
<Show when={input.showHeader()} fallback={<div aria-hidden="true" class="h-4 md:hidden" />}>
|
||||
{props.header}
|
||||
</Show>
|
||||
<div
|
||||
data-timeline-virtual-content
|
||||
ref={(element) => {
|
||||
|
|
|
|||
44
packages/app/src/shell/mobile-panel-drawer.tsx
Normal file
44
packages/app/src/shell/mobile-panel-drawer.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import Drawer from "@corvu/drawer"
|
||||
import type { ParentProps } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import "./status/status-drawer.css"
|
||||
|
||||
export function MobilePanelDrawer(
|
||||
props: ParentProps<{
|
||||
title: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
returnFocus?: () => HTMLElement | undefined
|
||||
}>,
|
||||
) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<Drawer
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
side="bottom"
|
||||
finalFocusEl={props.returnFocus?.()}
|
||||
// Menu focus handoff must not dismiss the drawer during its opening transition.
|
||||
closeOnOutsideFocus={false}
|
||||
>
|
||||
{/* Preserve Corvu's content and dismissal lifecycle across reopenings. */}
|
||||
<Drawer.Portal forceMount>
|
||||
<Drawer.Overlay data-slot="mobile-status-overlay" />
|
||||
<Drawer.Content forceMount data-slot="mobile-status-drawer" dir={language.direction()}>
|
||||
<div data-slot="mobile-status-drag-handle" aria-hidden="true">
|
||||
<span />
|
||||
</div>
|
||||
<div data-slot="mobile-status-header" data-corvu-no-drag>
|
||||
<Drawer.Label>{props.title}</Drawer.Label>
|
||||
<Drawer.Close data-slot="mobile-status-close" aria-label={language.t("common.close")}>
|
||||
{language.t("common.close")}
|
||||
</Drawer.Close>
|
||||
</div>
|
||||
<div data-slot="mobile-status-content" data-corvu-no-drag>
|
||||
{props.children}
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
|||
)
|
||||
}
|
||||
|
||||
export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
export function StatusPopoverBody(props: { shown: boolean; embedded?: boolean }) {
|
||||
const data = useData()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const serverSDK = useServerSDK()
|
||||
|
|
@ -42,7 +42,13 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
|||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-xl"
|
||||
classList={{
|
||||
"w-[360px] shadow-[var(--shadow-lg-border-base)]": !props.embedded,
|
||||
"w-full min-w-0": props.embedded,
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
aria-label={language.t("status.popover.ariaLabel")}
|
||||
class="tabs bg-background-strong rounded-xl overflow-hidden"
|
||||
|
|
|
|||
145
packages/app/src/shell/status/status-drawer.css
Normal file
145
packages/app/src/shell/status/status-drawer.css
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
[data-slot="mobile-status-overlay"] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: var(--v2-overlay-simple-overlay-scrim);
|
||||
animation: mobile-status-backdrop-in 240ms ease-out;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
|
||||
animation: mobile-status-backdrop-out 200ms ease-in forwards;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-drawer"] {
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
z-index: 51;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
|
||||
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
|
||||
padding-left: max(12px, env(safe-area-inset-left, 0px));
|
||||
padding-right: max(12px, env(safe-area-inset-right, 0px));
|
||||
border-radius: 16px 16px 0 0;
|
||||
background: var(--v2-background-bg-deep);
|
||||
color: var(--v2-text-text-base);
|
||||
box-shadow: var(--v2-elevation-overlay);
|
||||
outline: none;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-drawer"][data-transitioning] {
|
||||
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-drawer"][data-closing] {
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-drawer"][data-closed] {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-drag-handle"] {
|
||||
display: flex;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-drag-handle"] span {
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--v2-border-border-strong);
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-header"] {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-inline-start: 8px;
|
||||
padding-block-end: 8px;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-header"] h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-close"] {
|
||||
min-height: 44px;
|
||||
flex-shrink: 0;
|
||||
padding-inline: 12px;
|
||||
border-radius: 6px;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
[data-slot="mobile-status-close"]:hover {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-close"]:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-content"] {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-loading"] {
|
||||
display: flex;
|
||||
min-height: 56px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
@keyframes mobile-status-backdrop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mobile-status-backdrop-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-slot="mobile-status-drawer"][data-transitioning],
|
||||
[data-slot="mobile-status-drawer"][data-closing] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
[data-slot="mobile-status-overlay"],
|
||||
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
35
packages/app/src/shell/status/status-drawer.tsx
Normal file
35
packages/app/src/shell/status/status-drawer.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { lazy, Suspense } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { MobilePanelDrawer } from "../mobile-panel-drawer"
|
||||
|
||||
const Body = lazy(async () => {
|
||||
const { StatusPopoverBody } = await import("./body")
|
||||
return { default: StatusPopoverBody }
|
||||
})
|
||||
|
||||
export function StatusDrawer(props: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
returnFocus?: () => HTMLElement | undefined
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<MobilePanelDrawer
|
||||
title={language.t("status.popover.trigger")}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
returnFocus={props.returnFocus}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div data-slot="mobile-status-loading" role="status">
|
||||
{language.t("common.loading")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Body shown={props.open} embedded />
|
||||
</Suspense>
|
||||
</MobilePanelDrawer>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue