Skip to content
Merged
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
80 changes: 77 additions & 3 deletions packages/app/e2e/sidebar/sidebar-session-links.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Page } from "@playwright/test"
import type { Page, Route } from "@playwright/test"
import { test, expect } from "../fixtures"
import { cleanupSession, cleanupTestProject, createTestProject, openSidebar, waitSession } from "../actions"
import { promptSelector } from "../selectors"
import { cleanupSession, cleanupTestProject, createTestProject, openSidebar, waitSession, withSession } from "../actions"
import { promptSelector, sessionTurnListSelector } from "../selectors"
import type { createSdk } from "../utils"

async function expectUrlToStayMatched(page: Page, pattern: RegExp, stableFor = 300) {
let stableSince = Date.now()
Expand All @@ -16,6 +17,31 @@ async function expectUrlToStayMatched(page: Page, pattern: RegExp, stableFor = 3
.toBe(true)
}

async function seedUserMessage(input: {
sdk: ReturnType<typeof createSdk>
sessionID: string
text: string
}) {
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
noReply: true,
parts: [{ type: "text", text: input.text }],
})

await expect
.poll(
async () => {
const messages = await input.sdk.session.messages({ sessionID: input.sessionID, limit: 20 }).then((r) => r.data ?? [])
return messages.some((message) =>
message.info.role === "user" &&
message.parts.some((part) => part.type === "text" && part.text.includes(input.text)),
)
},
{ timeout: 30_000 },
)
.toBe(true)
}

test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()

Expand Down Expand Up @@ -87,3 +113,51 @@ test("sidebar session links can switch workspaces without opening the error boun
await cleanupTestProject(other)
}
})

test("opening a delayed sidebar session never shows the previous session as loading UI", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()
const sourceText = `e2e stale source ${stamp}`
const targetText = `e2e delayed target ${stamp}`

await withSession(sdk, `e2e stale source title ${stamp}`, async (source) => {
await withSession(sdk, `e2e delayed target title ${stamp}`, async (target) => {
await seedUserMessage({ sdk, sessionID: source.id, text: sourceText })
await seedUserMessage({ sdk, sessionID: target.id, text: targetText })

let releaseMessages: (() => void) | undefined
const messagesReleased = new Promise<void>((resolve) => {
releaseMessages = resolve
})
let targetMessageRequests = 0
const delayTargetMessages = async (route: Route) => {
targetMessageRequests++
await messagesReleased
await route.continue().catch(() => undefined)
}

await page.route(`**/session/${target.id}/message*`, delayTargetMessages)

try {
await gotoSession(source.id)
await expect(page.locator(sessionTurnListSelector).getByText(sourceText)).toBeVisible()
await openSidebar(page)
await expect.poll(() => targetMessageRequests, { timeout: 10_000 }).toBeGreaterThan(0)

await page.locator(`[data-session-id="${target.id}"] a`).first().click()

await expect(page).toHaveURL(new RegExp(`/${slug}/session/${target.id}(?:\\?|#|$)`))
await expect(page.locator('[data-component="session-opening-state"]')).toBeVisible()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
await expect(page.locator(sessionTurnListSelector).getByText(sourceText)).toHaveCount(0)
await expect(page.locator(sessionTurnListSelector).getByText(targetText)).toHaveCount(0)
await expect(page.locator(promptSelector)).toHaveCount(0)

await page.locator('[data-component="session-opening-state"]').getByRole("button", { name: "New session" }).click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:\\?|#|$)`))
await expect(page.locator('[data-component="session-new-home"]')).toBeVisible()
} finally {
releaseMessages?.()
await page.unroute(`**/session/${target.id}/message*`, delayTargetMessages)
}
})
})
})
3 changes: 3 additions & 0 deletions packages/app/src/context/shell-surface.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { createContext, useContext, type Accessor } from "solid-js"
import type { Session } from "@opencode-ai/sdk/v2/client"

export type ShellSurfaceContextValue = {
settingsOpen: Accessor<boolean>
openNewSession: (directory?: string) => void
openSession: (session: Session | undefined) => void
openSettings: () => void
closeSettings: () => void
}
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ export const dict = {
"common.goForward": "Navigate forward",
"common.loading": "Loading",
"common.loading.ellipsis": "...",
"common.retry": "Retry",
"common.showMore": "Show more",
"common.cancel": "Cancel",
"common.open": "Open",
Expand Down Expand Up @@ -623,6 +624,7 @@ export const dict = {
"session.messages.loadingEarlier": "Loading earlier messages...",
"session.messages.loadEarlier": "Load earlier messages",
"session.messages.loading": "Loading messages...",
"session.opening": "Opening session...",
"session.messages.jumpToLatest": "Jump to latest",
"session.turnChange.undoBlocked": "Undo blocked",
"session.turnChange.redoBlocked": "Redo blocked",
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ export const dict = {
"common.goForward": "前进",
"common.loading": "加载中",
"common.loading.ellipsis": "...",
"common.retry": "重试",
"common.showMore": "显示更多",
"common.cancel": "取消",
"common.connect": "连接",
Expand Down Expand Up @@ -585,6 +586,7 @@ export const dict = {
"session.messages.loadingEarlier": "正在加载更早的消息...",
"session.messages.loadEarlier": "加载更早的消息",
"session.messages.loading": "正在加载消息...",
"session.opening": "正在打开会话...",
"session.messages.jumpToLatest": "跳转到最新",
"session.turnChange.undoBlocked": "无法撤销",
"session.turnChange.redoBlocked": "无法重做",
Expand Down
49 changes: 37 additions & 12 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,7 @@ import {
displayName,
effectiveWorkspaceOrder,
errorMessage,
newSessionRoute,
openProjectRoute,
openSessionRoute,
startupAutoselectDirectory,
sortedRootSessions,
workspaceKey,
Expand All @@ -84,6 +82,7 @@ import {
pawworkSessionDirectories,
sortPawworkSidebarSessions,
} from "./layout/pawwork-session-source"
import { createShellNavigation } from "./layout/shell-navigation"
import {
buildPawworkSessionWindow,
nextPawworkSessionWindowLimit,
Expand Down Expand Up @@ -1364,11 +1363,15 @@ export default function Layout(props: ParentProps) {
})
}

function openSettings() {
function openSettingsSurface() {
setSettingsTab("general")
setSettingsOpen(true)
}

function openSettings() {
shellNavigation.openSettings()
}

createEffect(() => {
command.setModalOpen(settingsOpen())
})
Expand Down Expand Up @@ -1415,6 +1418,14 @@ export default function Layout(props: ParentProps) {
return currentProject()?.worktree ?? projectRoot(directory)
}

function releaseTransientShellLocks() {
if (sizet !== undefined) {
clearTimeout(sizet)
sizet = undefined
}
setState("sizing", false)
}

function syncSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
notification.session.markViewed(id)
const expanded = untrack(() => store.workspaceExpanded[directory])
Expand All @@ -1433,19 +1444,22 @@ export default function Layout(props: ParentProps) {
}

function navigateToSession(session: Session | undefined) {
if (!session) return
navigate(openSessionRoute(session.directory, session.id))
shellNavigation.openSession(session)
}

function openPawworkHome(directory?: string) {
const root = directory ? projectRoot(directory) : currentProject()?.worktree ?? projectRoot(currentDir())
if (!root) {
chooseProject()
return
}
navigate(newSessionRoute(root))
shellNavigation.openNewSession(directory)
}

const shellNavigation = createShellNavigation({
navigate,
releaseTransientLocks: releaseTransientShellLocks,
resolveProjectRoot: projectRoot,
currentProjectRoot: () => currentProject()?.worktree ?? projectRoot(currentDir()),
chooseProject,
openSettingsSurface,
})

function openProject(directory: string, shouldNavigate = true) {
layout.projects.open(directory)
if (shouldNavigate) return navigateToProject(directory)
Expand Down Expand Up @@ -2037,6 +2051,8 @@ export default function Layout(props: ParentProps) {
currentDir,
navList: currentSessions,
prefetchSession,
openSession: navigateToSession,
openNewSession: openPawworkHome,
workspaceName,
renameWorkspace,
editorOpen,
Expand Down Expand Up @@ -2079,6 +2095,7 @@ export default function Layout(props: ParentProps) {
sortMode={() => store.pawworkSortMode}
setScrollContainerRef={workspaceSidebarCtx.setScrollContainerRef}
prefetchSession={prefetchSession}
onOpenSession={navigateToSession}
onRenameSession={renamePawworkSession}
onTogglePinnedSession={togglePinnedSession}
exportSessionAvailable={exportSessionAvailable}
Expand Down Expand Up @@ -2108,7 +2125,15 @@ export default function Layout(props: ParentProps) {
},
}}
>
<ShellSurfaceContext.Provider value={{ settingsOpen, openSettings, closeSettings }}>
<ShellSurfaceContext.Provider
value={{
settingsOpen,
openNewSession: openPawworkHome,
openSession: navigateToSession,
openSettings,
closeSettings,
}}
>
<div
data-component="desktop-shell"
data-platform={platform.platform}
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/pages/layout/pawwork-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export const PawworkSidebar = (props: {
sortMode: Accessor<PawworkSortMode>
setScrollContainerRef: (el: HTMLDivElement | undefined) => void
prefetchSession: (session: Session, priority?: "high" | "low") => void
hrefForSession?: (session: Session) => string
onOpenSession: (session: Session) => void
onRenameSession: (session: Session, next: string) => Promise<void>
onTogglePinnedSession: (sessionID: string) => void
exportSessionAvailable: Accessor<boolean>
Expand Down Expand Up @@ -158,6 +160,8 @@ export const PawworkSidebar = (props: {
slug={entry.item.slug}
showChild
prefetchSession={props.prefetchSession}
hrefForSession={props.hrefForSession}
onOpenSession={props.onOpenSession}
pinned={() => isPinned()}
timeText={() =>
entry.item.created > 0
Expand Down
85 changes: 85 additions & 0 deletions packages/app/src/pages/layout/shell-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, test } from "bun:test"
import { base64Encode } from "@opencode-ai/util/encode"
import { createShellNavigation } from "./shell-navigation"

describe("createShellNavigation", () => {
test("opens a new session through one shell action and releases transient locks first", () => {
const calls: string[] = []
const shell = createShellNavigation({
navigate: (route) => calls.push(`navigate:${route}`),
releaseTransientLocks: (reason) => calls.push(`release:${reason}`),
resolveProjectRoot: (directory) => `/root:${directory}`,
currentProjectRoot: () => "/current",
chooseProject: () => calls.push("chooseProject"),
openSettingsSurface: () => calls.push("settings"),
})

shell.openNewSession("/repo")

expect(calls).toEqual([`release:new-session`, `navigate:/${base64Encode("/root:/repo")}/session`])
})

test("opens an existing session through one shell action and releases transient locks first", () => {
const calls: string[] = []
const shell = createShellNavigation({
navigate: (route) => calls.push(`navigate:${route}`),
releaseTransientLocks: (reason) => calls.push(`release:${reason}`),
resolveProjectRoot: (directory) => directory,
currentProjectRoot: () => "/current",
chooseProject: () => calls.push("chooseProject"),
openSettingsSurface: () => calls.push("settings"),
})

shell.openSession({ directory: "/repo", id: "ses_123" })

expect(calls).toEqual([`release:session`, `navigate:/${base64Encode("/repo")}/session/ses_123`])
})

test("opens settings through the same shell action owner instead of a standalone signal", () => {
const calls: string[] = []
const shell = createShellNavigation({
navigate: (route) => calls.push(`navigate:${route}`),
releaseTransientLocks: (reason) => calls.push(`release:${reason}`),
resolveProjectRoot: (directory) => directory,
currentProjectRoot: () => "/current",
chooseProject: () => calls.push("chooseProject"),
openSettingsSurface: () => calls.push("settings"),
})

shell.openSettings()

expect(calls).toEqual(["release:settings", "settings"])
})

test("falls back to project chooser when no directory can be resolved for a new session", () => {
const calls: string[] = []
const shell = createShellNavigation({
navigate: (route) => calls.push(`navigate:${route}`),
releaseTransientLocks: (reason) => calls.push(`release:${reason}`),
resolveProjectRoot: () => "",
currentProjectRoot: () => undefined,
chooseProject: () => calls.push("chooseProject"),
openSettingsSurface: () => calls.push("settings"),
})

shell.openNewSession()

expect(calls).toEqual(["release:choose-project", "chooseProject"])
})

test("falls back to project chooser when an explicit directory cannot be resolved", () => {
const calls: string[] = []
const shell = createShellNavigation({
navigate: (route) => calls.push(`navigate:${route}`),
releaseTransientLocks: (reason) => calls.push(`release:${reason}`),
resolveProjectRoot: () => undefined,
currentProjectRoot: () => "/current",
chooseProject: () => calls.push("chooseProject"),
openSettingsSurface: () => calls.push("settings"),
})

shell.openNewSession("/repo")

expect(calls).toEqual(["release:choose-project", "chooseProject"])
})
})
Loading
Loading