From 14cfeda37fb2d682caf7a17c20efe55af8654006 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 4 May 2026 12:08:25 +0800 Subject: [PATCH 1/8] fix(app): stabilize shell navigation state --- packages/app/src/pages/layout.tsx | 36 +++++++--- .../src/pages/layout/shell-navigation.test.ts | 69 +++++++++++++++++++ .../app/src/pages/layout/shell-navigation.ts | 49 +++++++++++++ .../blockers/question-fallback.test.ts | 5 +- .../session/blockers/question-fallback.ts | 6 +- .../session/session-view-controller.test.ts | 23 +++---- .../pages/session/session-view-controller.ts | 18 +---- 7 files changed, 157 insertions(+), 49 deletions(-) create mode 100644 packages/app/src/pages/layout/shell-navigation.test.ts create mode 100644 packages/app/src/pages/layout/shell-navigation.ts diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 79263f2ac..004f2cff4 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -66,9 +66,7 @@ import { displayName, effectiveWorkspaceOrder, errorMessage, - newSessionRoute, openProjectRoute, - openSessionRoute, startupAutoselectDirectory, sortedRootSessions, workspaceKey, @@ -84,6 +82,7 @@ import { pawworkSessionDirectories, sortPawworkSidebarSessions, } from "./layout/pawwork-session-source" +import { createShellNavigation } from "./layout/shell-navigation" import { buildPawworkSessionWindow, nextPawworkSessionWindowLimit, @@ -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()) }) @@ -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]) @@ -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) diff --git a/packages/app/src/pages/layout/shell-navigation.test.ts b/packages/app/src/pages/layout/shell-navigation.test.ts new file mode 100644 index 000000000..2948b6985 --- /dev/null +++ b/packages/app/src/pages/layout/shell-navigation.test.ts @@ -0,0 +1,69 @@ +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:new-session", "chooseProject"]) + }) +}) diff --git a/packages/app/src/pages/layout/shell-navigation.ts b/packages/app/src/pages/layout/shell-navigation.ts new file mode 100644 index 000000000..cfea56118 --- /dev/null +++ b/packages/app/src/pages/layout/shell-navigation.ts @@ -0,0 +1,49 @@ +import { newSessionRoute, openSessionRoute } from "./helpers" + +export type ShellNavigationReleaseReason = "new-session" | "session" | "settings" | "project" + +export type ShellNavigationSession = { + directory: string + id: string +} + +export function createShellNavigation(input: { + navigate: (route: string) => void + releaseTransientLocks: (reason: ShellNavigationReleaseReason) => void + resolveProjectRoot: (directory: string) => string | undefined + currentProjectRoot: () => string | undefined + chooseProject: () => void + openSettingsSurface: () => void +}) { + const resolveNewSessionRoot = (directory?: string) => { + if (directory) return input.resolveProjectRoot(directory) + return input.currentProjectRoot() + } + + const openNewSession = (directory?: string) => { + input.releaseTransientLocks("new-session") + const root = resolveNewSessionRoot(directory) + if (!root) { + input.chooseProject() + return + } + input.navigate(newSessionRoute(root)) + } + + const openSession = (session: ShellNavigationSession | undefined) => { + if (!session) return + input.releaseTransientLocks("session") + input.navigate(openSessionRoute(session.directory, session.id)) + } + + const openSettings = () => { + input.releaseTransientLocks("settings") + input.openSettingsSurface() + } + + return { + openNewSession, + openSession, + openSettings, + } +} diff --git a/packages/app/src/pages/session/blockers/question-fallback.test.ts b/packages/app/src/pages/session/blockers/question-fallback.test.ts index 7a11b4ed6..4f0c2f930 100644 --- a/packages/app/src/pages/session/blockers/question-fallback.test.ts +++ b/packages/app/src/pages/session/blockers/question-fallback.test.ts @@ -59,15 +59,14 @@ describe("findRunningQuestionFallbackSession", () => { ).toBeUndefined() }) - test("ignores running question parts older than the lookback window", () => { + test("recovers running question parts even when they are older than the lookback window", () => { expect( findRunningQuestionFallbackSession({ sessionID: "s", hasQuestionRequest: false, - lookback: 2, messages: [message("old"), message("recent-1"), message("recent-2")], partsByMessageID: { old: [toolPart("question")] }, }), - ).toBeUndefined() + ).toBe("s") }) }) diff --git a/packages/app/src/pages/session/blockers/question-fallback.ts b/packages/app/src/pages/session/blockers/question-fallback.ts index 3c33740d6..92cc25105 100644 --- a/packages/app/src/pages/session/blockers/question-fallback.ts +++ b/packages/app/src/pages/session/blockers/question-fallback.ts @@ -1,21 +1,17 @@ import type { Message, Part } from "@opencode-ai/sdk/v2" -export const QUESTION_FALLBACK_LOOKBACK_MESSAGES = 5 - export function findRunningQuestionFallbackSession(input: { sessionID?: string hasQuestionRequest: boolean messages?: Message[] partsByMessageID: Record - lookback?: number }): string | undefined { if (!input.sessionID) return undefined if (input.hasQuestionRequest) return undefined const messages = input.messages if (!messages?.length) return undefined - const lookback = input.lookback ?? QUESTION_FALLBACK_LOOKBACK_MESSAGES - for (let i = messages.length - 1; i >= Math.max(0, messages.length - lookback); i--) { + for (let i = messages.length - 1; i >= 0; i--) { const parts = input.partsByMessageID[messages[i].id] if (!parts) continue for (const part of parts) { diff --git a/packages/app/src/pages/session/session-view-controller.test.ts b/packages/app/src/pages/session/session-view-controller.test.ts index 08e42eeda..860bb6667 100644 --- a/packages/app/src/pages/session/session-view-controller.test.ts +++ b/packages/app/src/pages/session/session-view-controller.test.ts @@ -23,7 +23,7 @@ describe("createSessionViewController", () => { }) }) - test("keeps route and visible state distinct while the route session is not ready", () => { + test("keeps route and visible identity aligned while the route session is not ready", () => { createRoot((dispose) => { const controller = createSessionViewController({ directory: () => "repo", @@ -34,8 +34,8 @@ describe("createSessionViewController", () => { expect(controller.route.id()).toBe("ses_target") expect(controller.route.key()).toBe("repo/ses_target") expect(controller.route.ready()).toBe(false) - expect(controller.visible.id()).toBeUndefined() - expect(controller.visible.key()).toBe("repo") + expect(controller.visible.id()).toBe("ses_target") + expect(controller.visible.key()).toBe("repo/ses_target") expect(controller.visible.ready()).toBe(false) expect(controller.transitioning()).toBe(true) @@ -45,9 +45,8 @@ describe("createSessionViewController", () => { }) describe("nextSessionViewState", () => { - test("keeps visible session on the previous ready session while route session loads", () => { + test("does not keep the previous visible session while the route session loads", () => { const loading = nextSessionViewState({ - currentVisibleSessionID: "ses_source", directory: "repo", routeSessionID: "ses_target", routeMessagesReady: false, @@ -56,14 +55,13 @@ describe("nextSessionViewState", () => { expect(loading).toMatchObject({ routeSessionID: "ses_target", routeReady: false, - visibleSessionID: "ses_source", + visibleSessionID: "ses_target", transitioning: true, routeSessionKey: "repo/ses_target", - visibleSessionKey: "repo/ses_source", + visibleSessionKey: "repo/ses_target", }) const ready = nextSessionViewState({ - currentVisibleSessionID: loading.visibleSessionID, directory: "repo", routeSessionID: "ses_target", routeMessagesReady: true, @@ -81,7 +79,6 @@ describe("nextSessionViewState", () => { test("clears visible session when leaving a concrete session route", () => { const next = nextSessionViewState({ - currentVisibleSessionID: "ses_source", directory: "repo", routeSessionID: undefined, routeMessagesReady: true, @@ -95,10 +92,8 @@ describe("nextSessionViewState", () => { expect(next.transitioning).toBe(false) }) - test("does not carry visible session state across directories", () => { + test("uses the target route identity when changing directories", () => { const next = nextSessionViewState({ - currentVisibleDirectory: "repo-a", - currentVisibleSessionID: "ses_source", directory: "repo-b", routeSessionID: "ses_target", routeMessagesReady: false, @@ -107,10 +102,10 @@ describe("nextSessionViewState", () => { expect(next).toMatchObject({ routeSessionID: "ses_target", routeReady: false, - visibleSessionID: undefined, + visibleSessionID: "ses_target", transitioning: true, routeSessionKey: "repo-b/ses_target", - visibleSessionKey: "repo-b", + visibleSessionKey: "repo-b/ses_target", }) }) }) diff --git a/packages/app/src/pages/session/session-view-controller.ts b/packages/app/src/pages/session/session-view-controller.ts index a57b14654..b0694477b 100644 --- a/packages/app/src/pages/session/session-view-controller.ts +++ b/packages/app/src/pages/session/session-view-controller.ts @@ -1,8 +1,6 @@ import { createMemo, type Accessor } from "solid-js" export type SessionViewStateInput = { - currentVisibleDirectory?: string - currentVisibleSessionID: string | undefined directory: string routeSessionID: string | undefined routeMessagesReady: boolean @@ -19,32 +17,23 @@ export function sessionKey(input: { directory: string; sessionID: string | undef } export function nextVisibleSessionID(input: { - current: string | undefined route: string | undefined - routeReady: boolean }) { if (!input.route) return undefined - if (input.routeReady) return input.route - return input.current + return input.route } export function nextSessionViewState(input: SessionViewStateInput) { const routeReady = !input.routeSessionID || input.routeMessagesReady - const currentVisibleSessionID = - input.currentVisibleDirectory && input.currentVisibleDirectory !== input.directory - ? undefined - : input.currentVisibleSessionID const visibleSessionID = nextVisibleSessionID({ - current: currentVisibleSessionID, route: input.routeSessionID, - routeReady, }) return { routeSessionID: input.routeSessionID, routeReady, visibleSessionID, - transitioning: visibleSessionID !== input.routeSessionID, + transitioning: !!input.routeSessionID && (!routeReady || visibleSessionID !== input.routeSessionID), routeSessionKey: sessionKey({ directory: input.directory, sessionID: input.routeSessionID }), visibleSessionKey: sessionKey({ directory: input.directory, sessionID: visibleSessionID }), } @@ -56,8 +45,6 @@ export function createSessionViewController(input: SessionViewControllerInput) { const directory = input.directory() return { ...nextSessionViewState({ - currentVisibleDirectory: current?.directory, - currentVisibleSessionID: current?.visibleSessionID, directory, routeSessionID: input.routeSessionID(), routeMessagesReady: input.routeMessagesReady(), @@ -69,7 +56,6 @@ export function createSessionViewController(input: SessionViewControllerInput) { const visibleReady = () => { const next = state() if (!next.visibleSessionID) return !next.routeSessionID || next.routeReady - if (next.visibleSessionID !== next.routeSessionID) return true return next.routeReady } From 0606f6e08a499507888a1f055b7d0eedbd35e7dc Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 4 May 2026 12:28:05 +0800 Subject: [PATCH 2/8] fix(app): show loading while opening session --- packages/app/src/i18n/en.ts | 1 + packages/app/src/i18n/zh.ts | 1 + .../src/pages/layout/shell-navigation.test.ts | 2 +- .../app/src/pages/layout/shell-navigation.ts | 5 +- packages/app/src/pages/session.tsx | 1 + .../pages/session/session-main-view-state.ts | 12 +++ .../pages/session/session-main-view.test.ts | 41 +++++++++ .../src/pages/session/session-main-view.tsx | 87 ++++++++++++------- .../pages/session/session-view-controller.ts | 2 +- 9 files changed, 117 insertions(+), 35 deletions(-) create mode 100644 packages/app/src/pages/session/session-main-view-state.ts create mode 100644 packages/app/src/pages/session/session-main-view.test.ts diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 229f6c9ed..c27539145 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -623,6 +623,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", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 22415cd6f..26abe5d7f 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -585,6 +585,7 @@ export const dict = { "session.messages.loadingEarlier": "正在加载更早的消息...", "session.messages.loadEarlier": "加载更早的消息", "session.messages.loading": "正在加载消息...", + "session.opening": "正在打开会话...", "session.messages.jumpToLatest": "跳转到最新", "session.turnChange.undoBlocked": "无法撤销", "session.turnChange.redoBlocked": "无法重做", diff --git a/packages/app/src/pages/layout/shell-navigation.test.ts b/packages/app/src/pages/layout/shell-navigation.test.ts index 2948b6985..f0a2dbdb7 100644 --- a/packages/app/src/pages/layout/shell-navigation.test.ts +++ b/packages/app/src/pages/layout/shell-navigation.test.ts @@ -64,6 +64,6 @@ describe("createShellNavigation", () => { shell.openNewSession() - expect(calls).toEqual(["release:new-session", "chooseProject"]) + expect(calls).toEqual(["release:choose-project", "chooseProject"]) }) }) diff --git a/packages/app/src/pages/layout/shell-navigation.ts b/packages/app/src/pages/layout/shell-navigation.ts index cfea56118..ee4387f0d 100644 --- a/packages/app/src/pages/layout/shell-navigation.ts +++ b/packages/app/src/pages/layout/shell-navigation.ts @@ -1,6 +1,6 @@ import { newSessionRoute, openSessionRoute } from "./helpers" -export type ShellNavigationReleaseReason = "new-session" | "session" | "settings" | "project" +export type ShellNavigationReleaseReason = "new-session" | "session" | "settings" | "project" | "choose-project" export type ShellNavigationSession = { directory: string @@ -21,12 +21,13 @@ export function createShellNavigation(input: { } const openNewSession = (directory?: string) => { - input.releaseTransientLocks("new-session") const root = resolveNewSessionRoot(directory) if (!root) { + input.releaseTransientLocks("choose-project") input.chooseProject() return } + input.releaseTransientLocks("new-session") input.navigate(newSessionRoute(root)) } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 4bb0e9efe..4d1404b3f 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -549,6 +549,7 @@ export default function Page() { language={language} timelineSessionID={timelineSessionID()} timelineSessionKey={timelineSessionKey()} + timelineMessagesReady={timelineMessagesReady()} timelineMessages={timelineMessages()} mobileChanges={mobileChanges()} mobileFallback={reviewPanel.mobileFallback()} diff --git a/packages/app/src/pages/session/session-main-view-state.ts b/packages/app/src/pages/session/session-main-view-state.ts new file mode 100644 index 000000000..b7848e08d --- /dev/null +++ b/packages/app/src/pages/session/session-main-view-state.ts @@ -0,0 +1,12 @@ +export function shouldShowSessionOpeningState(input: { + activeSessionID?: string + timelineSessionID?: string + timelineMessagesReady: boolean +}) { + return ( + !!input.activeSessionID && + !!input.timelineSessionID && + input.activeSessionID === input.timelineSessionID && + !input.timelineMessagesReady + ) +} diff --git a/packages/app/src/pages/session/session-main-view.test.ts b/packages/app/src/pages/session/session-main-view.test.ts new file mode 100644 index 000000000..fc62788e6 --- /dev/null +++ b/packages/app/src/pages/session/session-main-view.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test" +import { shouldShowSessionOpeningState } from "./session-main-view-state" + +describe("shouldShowSessionOpeningState", () => { + test("shows a target session loading state while route messages are not ready", () => { + expect( + shouldShowSessionOpeningState({ + activeSessionID: "ses_target", + timelineSessionID: "ses_target", + timelineMessagesReady: false, + }), + ).toBe(true) + }) + + test("does not replace the new-session home or ready timeline", () => { + expect( + shouldShowSessionOpeningState({ + activeSessionID: undefined, + timelineSessionID: undefined, + timelineMessagesReady: false, + }), + ).toBe(false) + expect( + shouldShowSessionOpeningState({ + activeSessionID: "ses_target", + timelineSessionID: "ses_target", + timelineMessagesReady: true, + }), + ).toBe(false) + }) + + test("does not show loading for a mismatched timeline identity", () => { + expect( + shouldShowSessionOpeningState({ + activeSessionID: "ses_route", + timelineSessionID: "ses_other", + timelineMessagesReady: false, + }), + ).toBe(false) + }) +}) diff --git a/packages/app/src/pages/session/session-main-view.tsx b/packages/app/src/pages/session/session-main-view.tsx index 035c80424..0f21141fa 100644 --- a/packages/app/src/pages/session/session-main-view.tsx +++ b/packages/app/src/pages/session/session-main-view.tsx @@ -7,6 +7,7 @@ import type { createSizing } from "@/pages/session/helpers" import { MessageTimeline } from "@/pages/session/message-timeline" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { TerminalPanel } from "@/pages/session/terminal-panel" +import { shouldShowSessionOpeningState } from "@/pages/session/session-main-view-state" import type { createSessionHistoryWindow } from "@/pages/session/use-session-history-window" import type { createSessionReviewState } from "@/pages/session/use-session-review-state" import type { createSessionScrollDock } from "@/pages/session/use-session-scroll-dock" @@ -21,6 +22,7 @@ export function SessionMainView(props: { language: ReturnType timelineSessionID?: string timelineSessionKey: string + timelineMessagesReady: boolean timelineMessages: TimelineProps["sessionMessages"] mobileChanges: boolean mobileFallback: JSX.Element @@ -84,37 +86,60 @@ export function SessionMainView(props: {
- - {(sessionID) => ( - { - void props.historyWindow.loadAndReveal() - }} - renderedUserMessages={props.historyWindow.renderedUserMessages()} - anchor={props.anchor} - /> - )} + +
+
+
+
{props.language.t("session.opening")}
+
{props.language.t("session.messages.loading")}
+
+
+ + + { + void props.historyWindow.loadAndReveal() + }} + renderedUserMessages={props.historyWindow.renderedUserMessages()} + anchor={props.anchor} + /> diff --git a/packages/app/src/pages/session/session-view-controller.ts b/packages/app/src/pages/session/session-view-controller.ts index b0694477b..6f01bf434 100644 --- a/packages/app/src/pages/session/session-view-controller.ts +++ b/packages/app/src/pages/session/session-view-controller.ts @@ -33,7 +33,7 @@ export function nextSessionViewState(input: SessionViewStateInput) { routeSessionID: input.routeSessionID, routeReady, visibleSessionID, - transitioning: !!input.routeSessionID && (!routeReady || visibleSessionID !== input.routeSessionID), + transitioning: !!input.routeSessionID && !routeReady, routeSessionKey: sessionKey({ directory: input.directory, sessionID: input.routeSessionID }), visibleSessionKey: sessionKey({ directory: input.directory, sessionID: visibleSessionID }), } From 2eb551c4991b45d13d5b39bdd880e16a34a35cc0 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 4 May 2026 12:29:37 +0800 Subject: [PATCH 3/8] test(app): cover shell navigation fallback --- .../src/pages/layout/shell-navigation.test.ts | 16 ++++++++++++++++ .../src/pages/session/session-view-controller.ts | 8 ++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/app/src/pages/layout/shell-navigation.test.ts b/packages/app/src/pages/layout/shell-navigation.test.ts index f0a2dbdb7..6d3255792 100644 --- a/packages/app/src/pages/layout/shell-navigation.test.ts +++ b/packages/app/src/pages/layout/shell-navigation.test.ts @@ -66,4 +66,20 @@ describe("createShellNavigation", () => { 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"]) + }) }) diff --git a/packages/app/src/pages/session/session-view-controller.ts b/packages/app/src/pages/session/session-view-controller.ts index 6f01bf434..c94b984a8 100644 --- a/packages/app/src/pages/session/session-view-controller.ts +++ b/packages/app/src/pages/session/session-view-controller.ts @@ -33,7 +33,7 @@ export function nextSessionViewState(input: SessionViewStateInput) { routeSessionID: input.routeSessionID, routeReady, visibleSessionID, - transitioning: !!input.routeSessionID && !routeReady, + transitioning: !routeReady, routeSessionKey: sessionKey({ directory: input.directory, sessionID: input.routeSessionID }), visibleSessionKey: sessionKey({ directory: input.directory, sessionID: visibleSessionID }), } @@ -53,11 +53,7 @@ export function createSessionViewController(input: SessionViewControllerInput) { } }) - const visibleReady = () => { - const next = state() - if (!next.visibleSessionID) return !next.routeSessionID || next.routeReady - return next.routeReady - } + const visibleReady = () => state().routeReady return { route: { From dbeeece46c34c65bbbca5459bc050cadaaa657ee Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 4 May 2026 12:36:25 +0800 Subject: [PATCH 4/8] fix(app): route sidebar sessions through shell owner --- packages/app/src/i18n/en.ts | 1 + packages/app/src/i18n/zh.ts | 1 + packages/app/src/pages/layout.tsx | 2 ++ .../app/src/pages/layout/pawwork-sidebar.tsx | 4 +++ .../layout/sidebar-item-navigation.test.ts | 31 +++++++++++++++++++ .../pages/layout/sidebar-item-navigation.ts | 21 +++++++++++++ .../app/src/pages/layout/sidebar-items.tsx | 15 ++++++++- .../src/pages/layout/sidebar-workspace.tsx | 2 ++ packages/app/src/pages/session.tsx | 19 +++++++++++- .../pages/session/session-main-view-state.ts | 10 +++--- .../pages/session/session-main-view.test.ts | 12 ++++--- .../src/pages/session/session-main-view.tsx | 25 ++++++++++++++- .../session/use-session-timeline-data.ts | 2 ++ 13 files changed, 134 insertions(+), 11 deletions(-) create mode 100644 packages/app/src/pages/layout/sidebar-item-navigation.test.ts create mode 100644 packages/app/src/pages/layout/sidebar-item-navigation.ts diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index c27539145..e8193fa0a 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -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", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 26abe5d7f..3573bc78c 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -246,6 +246,7 @@ export const dict = { "common.goForward": "前进", "common.loading": "加载中", "common.loading.ellipsis": "...", + "common.retry": "重试", "common.showMore": "显示更多", "common.cancel": "取消", "common.connect": "连接", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 004f2cff4..041e61de1 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2051,6 +2051,7 @@ export default function Layout(props: ParentProps) { currentDir, navList: currentSessions, prefetchSession, + openSession: navigateToSession, workspaceName, renameWorkspace, editorOpen, @@ -2093,6 +2094,7 @@ export default function Layout(props: ParentProps) { sortMode={() => store.pawworkSortMode} setScrollContainerRef={workspaceSidebarCtx.setScrollContainerRef} prefetchSession={prefetchSession} + onOpenSession={navigateToSession} onRenameSession={renamePawworkSession} onTogglePinnedSession={togglePinnedSession} exportSessionAvailable={exportSessionAvailable} diff --git a/packages/app/src/pages/layout/pawwork-sidebar.tsx b/packages/app/src/pages/layout/pawwork-sidebar.tsx index 12bcae27e..ff8afa7f3 100644 --- a/packages/app/src/pages/layout/pawwork-sidebar.tsx +++ b/packages/app/src/pages/layout/pawwork-sidebar.tsx @@ -40,6 +40,8 @@ export const PawworkSidebar = (props: { sortMode: Accessor 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 onTogglePinnedSession: (sessionID: string) => void exportSessionAvailable: Accessor @@ -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 diff --git a/packages/app/src/pages/layout/sidebar-item-navigation.test.ts b/packages/app/src/pages/layout/sidebar-item-navigation.test.ts new file mode 100644 index 000000000..73327d402 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-item-navigation.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test" +import { defaultSessionHref, shouldOpenSessionWithShell, type SidebarSessionClick } from "./sidebar-item-navigation" + +const click = (overrides: Partial = {}): SidebarSessionClick => ({ + defaultPrevented: false, + button: 0, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + ...overrides, +}) + +describe("sidebar item navigation", () => { + test("builds the fallback href for normal browser navigation", () => { + expect(defaultSessionHref("repo-slug", { id: "ses_123" })).toBe("/repo-slug/session/ses_123") + }) + + test("routes ordinary left-clicks through the shell owner", () => { + expect(shouldOpenSessionWithShell(click())).toBe(true) + }) + + test("keeps modified or non-left clicks on the anchor default path", () => { + expect(shouldOpenSessionWithShell(click({ metaKey: true }))).toBe(false) + expect(shouldOpenSessionWithShell(click({ ctrlKey: true }))).toBe(false) + expect(shouldOpenSessionWithShell(click({ shiftKey: true }))).toBe(false) + expect(shouldOpenSessionWithShell(click({ altKey: true }))).toBe(false) + expect(shouldOpenSessionWithShell(click({ button: 1 }))).toBe(false) + expect(shouldOpenSessionWithShell(click({ defaultPrevented: true }))).toBe(false) + }) +}) diff --git a/packages/app/src/pages/layout/sidebar-item-navigation.ts b/packages/app/src/pages/layout/sidebar-item-navigation.ts new file mode 100644 index 000000000..de930818c --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-item-navigation.ts @@ -0,0 +1,21 @@ +import type { Session } from "@opencode-ai/sdk/v2/client" + +export type SidebarSessionClick = { + defaultPrevented: boolean + button: number + metaKey: boolean + ctrlKey: boolean + shiftKey: boolean + altKey: boolean +} + +export function defaultSessionHref(slug: string, session: Pick) { + return `/${slug}/session/${session.id}` +} + +export function shouldOpenSessionWithShell(event: SidebarSessionClick) { + if (event.defaultPrevented) return false + if (event.button !== 0) return false + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false + return true +} diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 93c38bd87..a24168991 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -16,6 +16,7 @@ import { sessionTitle } from "@/utils/session-title" import { sessionPermissionRequest } from "../session/blockers/request-tree" import { createSessionRunning } from "../session/session-running-state" import { childSessionOnPath, hasProjectPermissions } from "./helpers" +import { defaultSessionHref, shouldOpenSessionWithShell } from "./sidebar-item-navigation" export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { const globalSync = useGlobalSync() @@ -70,6 +71,8 @@ export type SessionItemProps = { showChild?: boolean level?: number prefetchSession: (session: Session, priority?: "high" | "low") => void + hrefForSession?: (session: Session) => string + onOpenSession?: (session: Session) => void titleContent?: (input: { session: Session; title: Accessor }) => JSX.Element actionSlot?: (session: Session) => JSX.Element pinned?: (session: Session) => boolean @@ -82,16 +85,19 @@ const SessionRow = (props: { dense?: boolean warmPress: () => void warmFocus: () => void + href: string + onOpenSession?: (event: MouseEvent) => void titleContent?: JSX.Element }): JSX.Element => { const title = () => sessionTitle(props.session.title) return ( {title()}}> {props.titleContent} @@ -165,6 +171,13 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { session={props.session} slug={props.slug} dense={props.dense} + href={props.hrefForSession?.(props.session) ?? defaultSessionHref(props.slug, props.session)} + onOpenSession={(event) => { + if (!props.onOpenSession) return + if (!shouldOpenSessionWithShell(event)) return + event.preventDefault() + props.onOpenSession(props.session) + }} warmPress={() => warm(2, "high")} warmFocus={() => warm(2, "high")} titleContent={props.titleContent?.({ session: props.session, title: () => sessionTitle(props.session.title) ?? "" })} diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index a108f5404..ab906c139 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -34,6 +34,7 @@ export type WorkspaceSidebarContext = { currentDir: Accessor navList: Accessor prefetchSession: (session: Session, priority?: "high" | "low") => void + openSession: (session: Session) => void workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void editorOpen: (id: string) => boolean @@ -250,6 +251,7 @@ const WorkspaceSessionList = (props: { slug={props.slug()} showChild prefetchSession={props.ctx.prefetchSession} + onOpenSession={props.ctx.openSession} /> )} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 4d1404b3f..00e97e113 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -4,7 +4,7 @@ import { createMediaQuery } from "@solid-primitives/media" import { useLocal } from "@/context/local" import { useFile } from "@/context/file" import { showToast } from "@opencode-ai/ui/toast" -import { useLocation, useSearchParams } from "@solidjs/router" +import { useLocation, useNavigate, useSearchParams } from "@solidjs/router" import type { PawworkSkillName } from "@/components/session/pawwork-skill-meta" import { useComments } from "@/context/comments" import { useGlobalSync } from "@/context/global-sync" @@ -55,6 +55,7 @@ export default function Page() { const comments = useComments() const terminal = useTerminal() const location = useLocation() + const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const { params, sessionKey, tabs, view } = useSessionLayout() @@ -540,6 +541,17 @@ export default function Page() { /> ) + const retryOpenRouteSession = () => { + const id = params.id + if (!id) return + void sync.session.sync(id, { force: true }) + } + + const openNewRouteSession = () => { + if (!params.dir) return + navigate(`/${params.dir}/session`) + } + return ( renderComposerRegion("home", ctx)} canReview={canReview} diff --git a/packages/app/src/pages/session/session-main-view-state.ts b/packages/app/src/pages/session/session-main-view-state.ts index b7848e08d..c6aa3ad07 100644 --- a/packages/app/src/pages/session/session-main-view-state.ts +++ b/packages/app/src/pages/session/session-main-view-state.ts @@ -1,12 +1,14 @@ export function shouldShowSessionOpeningState(input: { activeSessionID?: string + routeSessionID?: string + routeReady: boolean timelineSessionID?: string - timelineMessagesReady: boolean }) { return ( !!input.activeSessionID && - !!input.timelineSessionID && - input.activeSessionID === input.timelineSessionID && - !input.timelineMessagesReady + !!input.routeSessionID && + input.activeSessionID === input.routeSessionID && + input.timelineSessionID === input.routeSessionID && + !input.routeReady ) } diff --git a/packages/app/src/pages/session/session-main-view.test.ts b/packages/app/src/pages/session/session-main-view.test.ts index fc62788e6..72735c56e 100644 --- a/packages/app/src/pages/session/session-main-view.test.ts +++ b/packages/app/src/pages/session/session-main-view.test.ts @@ -6,8 +6,9 @@ describe("shouldShowSessionOpeningState", () => { expect( shouldShowSessionOpeningState({ activeSessionID: "ses_target", + routeSessionID: "ses_target", + routeReady: false, timelineSessionID: "ses_target", - timelineMessagesReady: false, }), ).toBe(true) }) @@ -16,15 +17,17 @@ describe("shouldShowSessionOpeningState", () => { expect( shouldShowSessionOpeningState({ activeSessionID: undefined, + routeSessionID: undefined, + routeReady: false, timelineSessionID: undefined, - timelineMessagesReady: false, }), ).toBe(false) expect( shouldShowSessionOpeningState({ activeSessionID: "ses_target", + routeSessionID: "ses_target", + routeReady: true, timelineSessionID: "ses_target", - timelineMessagesReady: true, }), ).toBe(false) }) @@ -33,8 +36,9 @@ describe("shouldShowSessionOpeningState", () => { expect( shouldShowSessionOpeningState({ activeSessionID: "ses_route", + routeSessionID: "ses_route", + routeReady: false, timelineSessionID: "ses_other", - timelineMessagesReady: false, }), ).toBe(false) }) diff --git a/packages/app/src/pages/session/session-main-view.tsx b/packages/app/src/pages/session/session-main-view.tsx index 0f21141fa..dae8ca48f 100644 --- a/packages/app/src/pages/session/session-main-view.tsx +++ b/packages/app/src/pages/session/session-main-view.tsx @@ -20,6 +20,9 @@ export function SessionMainView(props: { mobileTab: "session" | "changes" setMobileTab: (tab: "session" | "changes") => void language: ReturnType + routeSessionID?: string + routeReady: boolean + transitioning: boolean timelineSessionID?: string timelineSessionKey: string timelineMessagesReady: boolean @@ -41,6 +44,8 @@ export function SessionMainView(props: { historyMore: boolean historyLoading: boolean anchor: TimelineProps["anchor"] + onRetryOpenSession: () => void + onOpenNewSession: () => void composerSession: JSX.Element composerHome: (ctx: { onModeChange: (mode: "normal" | "shell") => void @@ -89,19 +94,37 @@ export function SessionMainView(props: {
{props.language.t("session.opening")}
{props.language.t("session.messages.loading")}
+
+ + +
diff --git a/packages/app/src/pages/session/use-session-timeline-data.ts b/packages/app/src/pages/session/use-session-timeline-data.ts index 867dac9c1..831e79301 100644 --- a/packages/app/src/pages/session/use-session-timeline-data.ts +++ b/packages/app/src/pages/session/use-session-timeline-data.ts @@ -41,6 +41,7 @@ export function createSessionTimelineData(input: { }) const sessionID = sessionView.visible.id const sessionKey = sessionView.visible.key + const transitioning = sessionView.transitioning const sessionInfo = createMemo(() => { const id = sessionID() if (!id) return @@ -131,6 +132,7 @@ export function createSessionTimelineData(input: { routeMessagesReady, sessionID, sessionKey, + transitioning, sessionInfo, isChildSession, messages, From 2c42eea5ade3ac1ba5dc8f3af9ac313e3a6130cc Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 4 May 2026 13:20:32 +0800 Subject: [PATCH 5/8] fix(app): route workspace new sessions through shell owner --- packages/app/src/pages/layout.tsx | 1 + .../app/src/pages/layout/shell-navigation.ts | 2 +- .../layout/sidebar-item-navigation.test.ts | 53 +++++++++++++++---- .../pages/layout/sidebar-item-navigation.ts | 19 ++++++- .../app/src/pages/layout/sidebar-items.tsx | 13 +++-- .../src/pages/layout/sidebar-workspace.tsx | 15 +++--- .../src/pages/session/session-main-view.tsx | 19 +++---- .../pages/session/session-view-controller.ts | 11 +--- .../session/timeline-session-state.test.ts | 4 +- .../pages/session/timeline-session-state.ts | 2 - packages/app/src/shell-frame-contract.test.ts | 2 +- 11 files changed, 93 insertions(+), 48 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 041e61de1..4d4b17d03 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2052,6 +2052,7 @@ export default function Layout(props: ParentProps) { navList: currentSessions, prefetchSession, openSession: navigateToSession, + openNewSession: openPawworkHome, workspaceName, renameWorkspace, editorOpen, diff --git a/packages/app/src/pages/layout/shell-navigation.ts b/packages/app/src/pages/layout/shell-navigation.ts index ee4387f0d..2cb5f522e 100644 --- a/packages/app/src/pages/layout/shell-navigation.ts +++ b/packages/app/src/pages/layout/shell-navigation.ts @@ -1,6 +1,6 @@ import { newSessionRoute, openSessionRoute } from "./helpers" -export type ShellNavigationReleaseReason = "new-session" | "session" | "settings" | "project" | "choose-project" +export type ShellNavigationReleaseReason = "new-session" | "session" | "settings" | "choose-project" export type ShellNavigationSession = { directory: string diff --git a/packages/app/src/pages/layout/sidebar-item-navigation.test.ts b/packages/app/src/pages/layout/sidebar-item-navigation.test.ts index 73327d402..a02d6934a 100644 --- a/packages/app/src/pages/layout/sidebar-item-navigation.test.ts +++ b/packages/app/src/pages/layout/sidebar-item-navigation.test.ts @@ -1,7 +1,13 @@ import { describe, expect, test } from "bun:test" -import { defaultSessionHref, shouldOpenSessionWithShell, type SidebarSessionClick } from "./sidebar-item-navigation" +import { + defaultNewSessionHref, + defaultSessionHref, + openSidebarLinkWithShell, + shouldOpenLinkWithShell, + type SidebarLinkClick, +} from "./sidebar-item-navigation" -const click = (overrides: Partial = {}): SidebarSessionClick => ({ +const click = (overrides: Partial = {}): SidebarLinkClick => ({ defaultPrevented: false, button: 0, metaKey: false, @@ -14,18 +20,47 @@ const click = (overrides: Partial = {}): SidebarSessionClic describe("sidebar item navigation", () => { test("builds the fallback href for normal browser navigation", () => { expect(defaultSessionHref("repo-slug", { id: "ses_123" })).toBe("/repo-slug/session/ses_123") + expect(defaultNewSessionHref("repo-slug")).toBe("/repo-slug/session") }) test("routes ordinary left-clicks through the shell owner", () => { - expect(shouldOpenSessionWithShell(click())).toBe(true) + expect(shouldOpenLinkWithShell(click())).toBe(true) + }) + + test("opens sidebar links through the shell owner after preventing default navigation", () => { + const calls: string[] = [] + const handled = openSidebarLinkWithShell( + { + ...click(), + preventDefault: () => calls.push("preventDefault"), + }, + () => calls.push("open"), + ) + + expect(handled).toBe(true) + expect(calls).toEqual(["preventDefault", "open"]) }) test("keeps modified or non-left clicks on the anchor default path", () => { - expect(shouldOpenSessionWithShell(click({ metaKey: true }))).toBe(false) - expect(shouldOpenSessionWithShell(click({ ctrlKey: true }))).toBe(false) - expect(shouldOpenSessionWithShell(click({ shiftKey: true }))).toBe(false) - expect(shouldOpenSessionWithShell(click({ altKey: true }))).toBe(false) - expect(shouldOpenSessionWithShell(click({ button: 1 }))).toBe(false) - expect(shouldOpenSessionWithShell(click({ defaultPrevented: true }))).toBe(false) + expect(shouldOpenLinkWithShell(click({ metaKey: true }))).toBe(false) + expect(shouldOpenLinkWithShell(click({ ctrlKey: true }))).toBe(false) + expect(shouldOpenLinkWithShell(click({ shiftKey: true }))).toBe(false) + expect(shouldOpenLinkWithShell(click({ altKey: true }))).toBe(false) + expect(shouldOpenLinkWithShell(click({ button: 1 }))).toBe(false) + expect(shouldOpenLinkWithShell(click({ defaultPrevented: true }))).toBe(false) + }) + + test("does not call the shell owner for modified clicks", () => { + const calls: string[] = [] + const handled = openSidebarLinkWithShell( + { + ...click({ metaKey: true }), + preventDefault: () => calls.push("preventDefault"), + }, + () => calls.push("open"), + ) + + expect(handled).toBe(false) + expect(calls).toEqual([]) }) }) diff --git a/packages/app/src/pages/layout/sidebar-item-navigation.ts b/packages/app/src/pages/layout/sidebar-item-navigation.ts index de930818c..d5becdaa4 100644 --- a/packages/app/src/pages/layout/sidebar-item-navigation.ts +++ b/packages/app/src/pages/layout/sidebar-item-navigation.ts @@ -1,6 +1,6 @@ import type { Session } from "@opencode-ai/sdk/v2/client" -export type SidebarSessionClick = { +export type SidebarLinkClick = { defaultPrevented: boolean button: number metaKey: boolean @@ -9,13 +9,28 @@ export type SidebarSessionClick = { altKey: boolean } +export type SidebarShellLinkEvent = SidebarLinkClick & { + preventDefault: () => void +} + export function defaultSessionHref(slug: string, session: Pick) { return `/${slug}/session/${session.id}` } -export function shouldOpenSessionWithShell(event: SidebarSessionClick) { +export function defaultNewSessionHref(slug: string) { + return `/${slug}/session` +} + +export function shouldOpenLinkWithShell(event: SidebarLinkClick) { if (event.defaultPrevented) return false if (event.button !== 0) return false if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false return true } + +export function openSidebarLinkWithShell(event: SidebarShellLinkEvent, open: () => void) { + if (!shouldOpenLinkWithShell(event)) return false + event.preventDefault() + open() + return true +} diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index a24168991..1a30e4cd3 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -16,7 +16,7 @@ import { sessionTitle } from "@/utils/session-title" import { sessionPermissionRequest } from "../session/blockers/request-tree" import { createSessionRunning } from "../session/session-running-state" import { childSessionOnPath, hasProjectPermissions } from "./helpers" -import { defaultSessionHref, shouldOpenSessionWithShell } from "./sidebar-item-navigation" +import { defaultNewSessionHref, defaultSessionHref, openSidebarLinkWithShell } from "./sidebar-item-navigation" export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { const globalSync = useGlobalSync() @@ -174,9 +174,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { href={props.hrefForSession?.(props.session) ?? defaultSessionHref(props.slug, props.session)} onOpenSession={(event) => { if (!props.onOpenSession) return - if (!shouldOpenSessionWithShell(event)) return - event.preventDefault() - props.onOpenSession(props.session) + openSidebarLinkWithShell(event, () => props.onOpenSession?.(props.session)) }} warmPress={() => warm(2, "high")} warmFocus={() => warm(2, "high")} @@ -248,14 +246,19 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { export const NewSessionItem = (props: { slug: string dense?: boolean + onOpenNewSession?: () => void }): JSX.Element => { const language = useLanguage() const label = language.t("command.session.new") const item = (
{ + if (!props.onOpenNewSession) return + openSidebarLinkWithShell(event, props.onOpenNewSession) + }} >
diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index ab906c139..66f3df293 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -1,4 +1,4 @@ -import { useNavigate, useParams } from "@solidjs/router" +import { useParams } from "@solidjs/router" import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { createSortable } from "@thisbeyond/solid-dnd" @@ -35,6 +35,7 @@ export type WorkspaceSidebarContext = { navList: Accessor prefetchSession: (session: Session, priority?: "high" | "low") => void openSession: (session: Session) => void + openNewSession: (directory: string) => void workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void editorOpen: (id: string) => boolean @@ -145,7 +146,7 @@ const WorkspaceActions = (props: { showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"] showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"] root: string - navigateToNewSession: () => void + openNewSession: () => void }): JSX.Element => (
{ event.preventDefault() event.stopPropagation() - props.navigateToNewSession() + props.openNewSession() }} /> @@ -227,6 +228,7 @@ const WorkspaceActions = (props: { const WorkspaceSessionList = (props: { slug: Accessor + directory: string ctx: WorkspaceSidebarContext showNew: Accessor loading: Accessor @@ -237,7 +239,7 @@ const WorkspaceSessionList = (props: { }): JSX.Element => (
@@ -404,6 +405,7 @@ export const SortableWorkspace = (props: { false} loading={loading} diff --git a/packages/app/src/pages/session/session-main-view.tsx b/packages/app/src/pages/session/session-main-view.tsx index dae8ca48f..7f66560c1 100644 --- a/packages/app/src/pages/session/session-main-view.tsx +++ b/packages/app/src/pages/session/session-main-view.tsx @@ -59,6 +59,14 @@ export function SessionMainView(props: { files: ReturnType["artifactFiles"] size: ReturnType }) { + const showSessionOpeningState = () => + shouldShowSessionOpeningState({ + activeSessionID: props.activeSessionID, + routeSessionID: props.routeSessionID, + routeReady: props.routeReady, + timelineSessionID: props.timelineSessionID, + }) + return (
@@ -91,14 +99,7 @@ export function SessionMainView(props: {
- +
- {props.composerSession} + {props.composerSession}
{ test("keeps the legacy exports wired to the session view controller", () => { expect(nextSessionViewState).toBe(controller.nextSessionViewState) - expect(nextVisibleSessionID).toBe(controller.nextVisibleSessionID) - expect(nextTimelineSessionID).toBe(controller.nextVisibleSessionID) expect(sessionKey).toBe(controller.sessionKey) }) }) diff --git a/packages/app/src/pages/session/timeline-session-state.ts b/packages/app/src/pages/session/timeline-session-state.ts index 1b6fca5a1..439c25075 100644 --- a/packages/app/src/pages/session/timeline-session-state.ts +++ b/packages/app/src/pages/session/timeline-session-state.ts @@ -1,7 +1,5 @@ export { nextSessionViewState, - nextVisibleSessionID, - nextVisibleSessionID as nextTimelineSessionID, sessionKey, } from "./session-view-controller" export type { SessionViewStateInput } from "./session-view-controller" diff --git a/packages/app/src/shell-frame-contract.test.ts b/packages/app/src/shell-frame-contract.test.ts index 3def91f53..448383d8e 100644 --- a/packages/app/src/shell-frame-contract.test.ts +++ b/packages/app/src/shell-frame-contract.test.ts @@ -54,7 +54,7 @@ test("session composer is docked outside the scroll-clipped timeline region", () expect(session).toContain('variant: "session" | "home"') expect(sessionMainView).toContain('
') expect(sessionMainView).toContain( - "
\n {props.composerSession}", + "
\n {props.composerSession}", ) expect(messageTimeline).toContain('"padding-bottom": "calc(var(--composer-dock-height, 0px) + 32px)"') }) From 3044fed03eaa2064fa0acabd0fd382866c7af17b Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 4 May 2026 13:29:30 +0800 Subject: [PATCH 6/8] test(app): cover delayed sidebar session opening --- .../e2e/sidebar/sidebar-session-links.spec.ts | 80 ++++++++++++++++++- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/sidebar/sidebar-session-links.spec.ts b/packages/app/e2e/sidebar/sidebar-session-links.spec.ts index 09d331ce6..b2db4d363 100644 --- a/packages/app/e2e/sidebar/sidebar-session-links.spec.ts +++ b/packages/app/e2e/sidebar/sidebar-session-links.spec.ts @@ -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() @@ -16,6 +17,31 @@ async function expectUrlToStayMatched(page: Page, pattern: RegExp, stableFor = 3 .toBe(true) } +async function seedUserMessage(input: { + sdk: ReturnType + 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() @@ -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((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 page.locator(`[data-session-id="${target.id}"] a`).first().click() + + await expect(page).toHaveURL(new RegExp(`/${slug}/session/${target.id}(?:\\?|#|$)`)) + await expect.poll(() => targetMessageRequests, { timeout: 10_000 }).toBeGreaterThan(0) + await expect(page.locator('[data-component="session-opening-state"]')).toBeVisible() + 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) + } + }) + }) +}) From 90f9a0b9396dc7b8e99e87dc64b9625a821cf7e0 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 4 May 2026 13:37:22 +0800 Subject: [PATCH 7/8] fix(app): route opening recovery through shell owner --- packages/app/src/context/shell-surface.tsx | 3 +++ packages/app/src/pages/layout.tsx | 10 ++++++- .../layout/sidebar-item-navigation.test.ts | 26 +++++++++---------- .../pages/layout/sidebar-item-navigation.ts | 10 +++---- .../app/src/pages/layout/sidebar-items.tsx | 6 ++--- packages/app/src/pages/session.tsx | 11 +++++--- 6 files changed, 40 insertions(+), 26 deletions(-) diff --git a/packages/app/src/context/shell-surface.tsx b/packages/app/src/context/shell-surface.tsx index a97485f0b..844079c84 100644 --- a/packages/app/src/context/shell-surface.tsx +++ b/packages/app/src/context/shell-surface.tsx @@ -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 + openNewSession: (directory?: string) => void + openSession: (session: Session | undefined) => void openSettings: () => void closeSettings: () => void } diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 4d4b17d03..3db6e163a 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2125,7 +2125,15 @@ export default function Layout(props: ParentProps) { }, }} > - +
= {}): SidebarLinkClick => ({ +const click = (overrides: Partial = {}): ShellLinkClick => ({ defaultPrevented: false, button: 0, metaKey: false, @@ -24,12 +24,12 @@ describe("sidebar item navigation", () => { }) test("routes ordinary left-clicks through the shell owner", () => { - expect(shouldOpenLinkWithShell(click())).toBe(true) + expect(shouldUseShellOwnerForLink(click())).toBe(true) }) test("opens sidebar links through the shell owner after preventing default navigation", () => { const calls: string[] = [] - const handled = openSidebarLinkWithShell( + const handled = openShellLinkWithOwner( { ...click(), preventDefault: () => calls.push("preventDefault"), @@ -42,17 +42,17 @@ describe("sidebar item navigation", () => { }) test("keeps modified or non-left clicks on the anchor default path", () => { - expect(shouldOpenLinkWithShell(click({ metaKey: true }))).toBe(false) - expect(shouldOpenLinkWithShell(click({ ctrlKey: true }))).toBe(false) - expect(shouldOpenLinkWithShell(click({ shiftKey: true }))).toBe(false) - expect(shouldOpenLinkWithShell(click({ altKey: true }))).toBe(false) - expect(shouldOpenLinkWithShell(click({ button: 1 }))).toBe(false) - expect(shouldOpenLinkWithShell(click({ defaultPrevented: true }))).toBe(false) + expect(shouldUseShellOwnerForLink(click({ metaKey: true }))).toBe(false) + expect(shouldUseShellOwnerForLink(click({ ctrlKey: true }))).toBe(false) + expect(shouldUseShellOwnerForLink(click({ shiftKey: true }))).toBe(false) + expect(shouldUseShellOwnerForLink(click({ altKey: true }))).toBe(false) + expect(shouldUseShellOwnerForLink(click({ button: 1 }))).toBe(false) + expect(shouldUseShellOwnerForLink(click({ defaultPrevented: true }))).toBe(false) }) test("does not call the shell owner for modified clicks", () => { const calls: string[] = [] - const handled = openSidebarLinkWithShell( + const handled = openShellLinkWithOwner( { ...click({ metaKey: true }), preventDefault: () => calls.push("preventDefault"), diff --git a/packages/app/src/pages/layout/sidebar-item-navigation.ts b/packages/app/src/pages/layout/sidebar-item-navigation.ts index d5becdaa4..3f0a3b7f3 100644 --- a/packages/app/src/pages/layout/sidebar-item-navigation.ts +++ b/packages/app/src/pages/layout/sidebar-item-navigation.ts @@ -1,6 +1,6 @@ import type { Session } from "@opencode-ai/sdk/v2/client" -export type SidebarLinkClick = { +export type ShellLinkClick = { defaultPrevented: boolean button: number metaKey: boolean @@ -9,7 +9,7 @@ export type SidebarLinkClick = { altKey: boolean } -export type SidebarShellLinkEvent = SidebarLinkClick & { +export type ShellOwnerLinkEvent = ShellLinkClick & { preventDefault: () => void } @@ -21,15 +21,15 @@ export function defaultNewSessionHref(slug: string) { return `/${slug}/session` } -export function shouldOpenLinkWithShell(event: SidebarLinkClick) { +export function shouldUseShellOwnerForLink(event: ShellLinkClick) { if (event.defaultPrevented) return false if (event.button !== 0) return false if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false return true } -export function openSidebarLinkWithShell(event: SidebarShellLinkEvent, open: () => void) { - if (!shouldOpenLinkWithShell(event)) return false +export function openShellLinkWithOwner(event: ShellOwnerLinkEvent, open: () => void) { + if (!shouldUseShellOwnerForLink(event)) return false event.preventDefault() open() return true diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 1a30e4cd3..1653191ed 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -16,7 +16,7 @@ import { sessionTitle } from "@/utils/session-title" import { sessionPermissionRequest } from "../session/blockers/request-tree" import { createSessionRunning } from "../session/session-running-state" import { childSessionOnPath, hasProjectPermissions } from "./helpers" -import { defaultNewSessionHref, defaultSessionHref, openSidebarLinkWithShell } from "./sidebar-item-navigation" +import { defaultNewSessionHref, defaultSessionHref, openShellLinkWithOwner } from "./sidebar-item-navigation" export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { const globalSync = useGlobalSync() @@ -174,7 +174,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { href={props.hrefForSession?.(props.session) ?? defaultSessionHref(props.slug, props.session)} onOpenSession={(event) => { if (!props.onOpenSession) return - openSidebarLinkWithShell(event, () => props.onOpenSession?.(props.session)) + openShellLinkWithOwner(event, () => props.onOpenSession?.(props.session)) }} warmPress={() => warm(2, "high")} warmFocus={() => warm(2, "high")} @@ -257,7 +257,7 @@ export const NewSessionItem = (props: { class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none leading-[1.4] ${props.dense ? "py-1" : "py-[5px]"}`} onClick={(event) => { if (!props.onOpenNewSession) return - openSidebarLinkWithShell(event, props.onOpenNewSession) + openShellLinkWithOwner(event, props.onOpenNewSession) }} >
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 00e97e113..874f30267 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -4,7 +4,7 @@ import { createMediaQuery } from "@solid-primitives/media" import { useLocal } from "@/context/local" import { useFile } from "@/context/file" import { showToast } from "@opencode-ai/ui/toast" -import { useLocation, useNavigate, useSearchParams } from "@solidjs/router" +import { useLocation, useSearchParams } from "@solidjs/router" import type { PawworkSkillName } from "@/components/session/pawwork-skill-meta" import { useComments } from "@/context/comments" import { useGlobalSync } from "@/context/global-sync" @@ -14,6 +14,7 @@ import { usePrompt } from "@/context/prompt" import { createSessionPerformanceDiagnostics, emitRendererDiagnostic } from "@/context/renderer-diagnostics" import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" +import { useShellSurface } from "@/context/shell-surface" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { buildDesktopContext } from "@/utils/desktop-context" @@ -38,6 +39,7 @@ import { createSessionTimelineData } from "@/pages/session/use-session-timeline- import { createSessionTimelineInteraction } from "@/pages/session/use-session-timeline-interaction" import { useSessionVcsRefresh } from "@/pages/session/use-session-vcs-refresh" import { diffs as list } from "@/utils/diffs" +import { decode64 } from "@/utils/base64" import { extractPromptFromParts } from "@/utils/prompt" import { formatServerError } from "@/utils/server-errors" @@ -51,11 +53,11 @@ export default function Page() { const language = useLanguage() const sdk = useSDK() const settings = useSettings() + const shellSurface = useShellSurface() const prompt = usePrompt() const comments = useComments() const terminal = useTerminal() const location = useLocation() - const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const { params, sessionKey, tabs, view } = useSessionLayout() @@ -548,8 +550,9 @@ export default function Page() { } const openNewRouteSession = () => { - if (!params.dir) return - navigate(`/${params.dir}/session`) + const directory = decode64(params.dir) + if (!directory) return + shellSurface.openNewSession(directory) } return ( From 0c057d22d212449dbfa6710c60ca8d7433f9a280 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 4 May 2026 13:41:08 +0800 Subject: [PATCH 8/8] test(app): clarify delayed session opening e2e --- packages/app/e2e/sidebar/sidebar-session-links.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/e2e/sidebar/sidebar-session-links.spec.ts b/packages/app/e2e/sidebar/sidebar-session-links.spec.ts index b2db4d363..784501d38 100644 --- a/packages/app/e2e/sidebar/sidebar-session-links.spec.ts +++ b/packages/app/e2e/sidebar/sidebar-session-links.spec.ts @@ -141,11 +141,11 @@ test("opening a delayed sidebar session never shows the previous session as load 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.poll(() => targetMessageRequests, { timeout: 10_000 }).toBeGreaterThan(0) await expect(page.locator('[data-component="session-opening-state"]')).toBeVisible() await expect(page.locator(sessionTurnListSelector).getByText(sourceText)).toHaveCount(0) await expect(page.locator(sessionTurnListSelector).getByText(targetText)).toHaveCount(0)