Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6,312 changes: 3,156 additions & 3,156 deletions bun.lock

Large diffs are not rendered by default.

139 changes: 139 additions & 0 deletions packages/app/e2e/regression/mobile-tab-scroll.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { expect, test, type Page, type Route } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { currentSession } from "../utils/mock-server"

const server = "http://127.0.0.1:4096"

// Generate 8 sessions to trigger horizontal overflow on mobile.
const sessions = Array.from({ length: 8 }, (_, i) => session(`ses_tab_${i}`, `Session ${i + 1}`))

test.describe("mobile tab strip scrolling", () => {
test.use({ viewport: { width: 375, height: 800 } })

test("tab titles remain visible and scrollable when many tabs are open", async ({ page }) => {
await mockServer(page, sessions)
await seedTabs(page, sessions)

const href = `/server/${base64Encode(server)}/session/${sessions[0].id}`
await page.goto(href)

// Wait for all tab slots to render.
const slots = page.locator("[data-titlebar-tab-slot]")
await expect(slots).toHaveCount(8, { timeout: 10_000 })

// Every tab slot must maintain a width >= 200px (shrink-0 keeps them at ~224px).
const count = await slots.count()
for (let i = 0; i < count; i++) {
const box = await slots.nth(i).boundingBox()
expect(box!.width).toBeGreaterThanOrEqual(200)
}

// Every tab title must be visible (not hidden by @container query).
const titles = page.locator("[data-titlebar-tab-title]")
const titleCount = await titles.count()
expect(titleCount).toBe(8)
for (let i = 0; i < titleCount; i++) {
await expect(titles.nth(i)).toBeVisible()
const text = await titles.nth(i).textContent()
expect(text!.length).toBeGreaterThan(0)
}

// The scroll container must overflow horizontally.
const scroll = page.locator('[data-slot="titlebar-tabs-scroll"]')
await expect(scroll).toBeVisible()
const overflow = await scroll.evaluate((el) => el.scrollWidth > el.clientWidth)
expect(overflow).toBe(true)
})
})

function session(id: string, title: string) {
return {
id,
slug: id,
projectID: "project-mobile-tabs",
directory: "C:/mobile-tab-project",
title,
version: "dev",
time: { created: 1, updated: 1 },
}
}

async function seedTabs(page: Page, sessions: ReturnType<typeof session>) {
await page.addInitScript(
({ server, sessionIds }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(sessionIds.map((id) => ({ type: "session", server, sessionId: id }))),
)
},
{ server, sessionIds: sessions.map((s) => s.id) },
)
}

async function mockServer(page: Page, sessions: ReturnType<typeof session>) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== server) return route.fallback()
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const match = url.pathname.match(/^\/api\/session\/([^/]+)$/)
if (match) {
const s = sessions.find((item) => item.id === match[1])
if (s) return json(route, { data: currentSession(s) })
}
if (/^\/session\/[^/]+\/message$/.test(url.pathname))
return json(route, [])
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
if (byId) return json(route, byId)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (url.pathname === "/project" || url.pathname === "/project/current") {
const project = {
id: sessions[0].projectID,
worktree: sessions[0].directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
}
return json(route, url.pathname === "/project" ? [project] : project)
}
if (url.pathname === "/path" || url.pathname === "/api/path")
return json(route, {
state: sessions[0].directory,
config: sessions[0].directory,
worktree: sessions[0].directory,
directory: sessions[0].directory,
home: sessions[0].directory,
})
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: sessions[0].directory },
data: { branch: "main", defaultBranch: "main" },
})
return json(route, {})
})
}

function json(route: Route, body: unknown, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify(body),
})
}

function sse(route: Route) {
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
}
17 changes: 0 additions & 17 deletions packages/app/src/components/titlebar-tab-nav.css
Original file line number Diff line number Diff line change
Expand Up @@ -81,20 +81,3 @@
display: none;
}

@container (max-width: 64px) {
[data-titlebar-tab-link] {
justify-content: center;
gap: 0;
padding-inline: 0;
}

[data-titlebar-tab-title] {
display: none;
}

[data-slot="tab-close"] {
right: auto;
left: 50%;
transform: translateX(-50%);
}
}
4 changes: 2 additions & 2 deletions packages/app/src/components/titlebar-tab-strip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function SessionTabSlot(props: {
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
class="relative flex w-56 min-w-7 max-w-56 shrink-0"
>
<TabNavItem
ref={(el) => {
Expand Down Expand Up @@ -192,7 +192,7 @@ function DraftTabSlot(props: {
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
class="relative flex w-56 min-w-7 max-w-56 shrink-0"
>
<DraftTabItem
ref={(el) => {
Expand Down
31 changes: 31 additions & 0 deletions packages/storybook/.storybook/mocks/app/context/global.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ServerConnection } from "./server"

const mockConn: ServerConnection.Http = {
type: "http",
http: { url: "http://localhost:3000" },
}

const mockServerCtx = {
sdk: { api: { session: { rename: async () => {} } } },
sync: {
session: {
peek: () => undefined,
resolve: async () => undefined,
},
ensureDirSyncContext: () => ({
session: { sync: async () => {} },
}),
},
projects: {
list: () => [{ worktree: "/home/user/project", expanded: true }],
},
}

export function useGlobal() {
return {
servers: {
list: () => [mockConn],
},
ensureServerCtx: () => mockServerCtx,
}
}
43 changes: 43 additions & 0 deletions packages/storybook/.storybook/mocks/app/context/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export namespace ServerConnection {
export type HttpBase = { url: string; username?: string; password?: string }
export type Http = { type: "http"; http: HttpBase; authToken?: boolean; displayName?: string; label?: string }
export type Sidecar = { type: "sidecar"; http: HttpBase; displayName?: string; label?: string } & (
| { variant: "base" }
| { variant: "wsl"; distro: string }
)
export type Ssh = { type: "ssh"; host: string; http: HttpBase; displayName?: string; label?: string }
export type Any = Http | Sidecar | Ssh

export type Key = string & { _brand: "Key" }
export const Key = { make: (v: string) => v as Key }

export const key = (conn: Any): Key => {
switch (conn.type) {
case "http":
return Key.make(conn.http.url)
case "sidecar":
return conn.variant === "wsl" ? Key.make(`wsl:${conn.distro}`) : Key.make("sidecar")
case "ssh":
return Key.make(`ssh:${conn.host}`)
}
}

export const builtin = (conn: Any) => conn.type === "sidecar" && conn.variant === "base"
export const local = (conn?: Any) => !!conn && builtin(conn)
}

export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = false) {
if (!conn) return ""
if (conn.displayName && !ignoreDisplayName) return conn.displayName
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
}

export function useServer() {
return {
key: ServerConnection.Key.make("http://localhost:3000"),
name: "localhost",
list: [{ type: "http" as const, http: { url: "http://localhost:3000" } }],
ready: Object.assign(() => true, { promise: Promise.resolve() }),
isLocal: () => true,
}
}
91 changes: 91 additions & 0 deletions packages/storybook/.storybook/mocks/app/context/tabs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { createStore } from "solid-js/store"
import { ServerConnection } from "./server"

export type SessionTab = {
type: "session"
server: ServerConnection.Key
sessionId: string
}

export type DraftTab = {
type: "draft"
draftID: string
server: ServerConnection.Key
directory: string
}

export type Tab = SessionTab | DraftTab
export type TabInfo = { title?: string }

export function tabHref(tab: Tab) {
return tab.type === "draft"
? `/new-session?draftId=${tab.draftID}`
: `/session/${encodeURIComponent(tab.sessionId)}`
}

export function tabKey(tab: Tab) {
return tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`
}

const serverKey = ServerConnection.Key.make("http://localhost:3000")

const initialTabs: Tab[] = [
{ type: "session", server: serverKey, sessionId: "sess-1" },
{ type: "session", server: serverKey, sessionId: "sess-2" },
{ type: "session", server: serverKey, sessionId: "sess-3" },
{ type: "session", server: serverKey, sessionId: "sess-4" },
{ type: "session", server: serverKey, sessionId: "sess-5" },
{ type: "session", server: serverKey, sessionId: "sess-6" },
{ type: "session", server: serverKey, sessionId: "sess-7" },
{ type: "session", server: serverKey, sessionId: "sess-8" },
]

const tabInfo: Record<string, TabInfo> = {
[`sess-1`]: { title: "Fix login bug" },
[`sess-2`]: { title: "Refactor auth module" },
[`sess-3`]: { title: "Add dark mode support" },
[`sess-4`]: { title: "Write API docs" },
[`sess-5`]: { title: "Optimize database queries" },
[`sess-6`]: { title: "Setup CI/CD pipeline" },
[`sess-7`]: { title: "Migrate to TypeScript" },
[`sess-8`]: { title: "Design system components" },
}

const [store] = createStore<Tab[]>(initialTabs)

export function useTabs() {
return {
store,
info: tabInfo,
ready: Object.assign(() => true, { promise: Promise.resolve(true) }),
recentReady: Object.assign(() => true, { promise: Promise.resolve(true) }),
select: () => {},
remember: () => {},
toggleHome: () => {},
addSessionTab: (tab: Omit<SessionTab, "type">) => ({ type: "session" as const, ...tab }),
reorder: () => {},
closeTab: () => {},
removeTab: () => {},
reopenClosedTab: () => {},
removeSessions: () => {},
removeSessionTab: () => {},
removeServer: () => {},
draft: (draftID: string) => ({
type: "draft" as const,
draftID,
server: serverKey,
directory: "/home/user/project",
}),
newDraft: async () => ({
type: "draft" as const,
draftID: "d1",
server: serverKey,
directory: "/home/user/project",
}),
updateDraft: () => {},
promoteDraft: () => {},
rememberSessionInfo: () => {},
state: () => undefined,
stateValue: () => undefined,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Show } from "solid-js"

export function SessionTabAvatar(props: {
project?: { worktree: string }
directory: string
sessionId: string
server: string
}) {
const initial = () => {
const name = props.project?.worktree?.split("/").pop() ?? props.directory.split("/").pop() ?? "?"
return name.charAt(0).toUpperCase()
}
return (
<span class="flex size-4 shrink-0 items-center justify-center rounded-[3px] bg-v2-icon-icon-accent/20 text-[9px] font-medium text-v2-icon-icon-accent">
{initial()}
</span>
)
}
Loading