diff --git a/packages/app/e2e/sidebar/sidebar-session-links.spec.ts b/packages/app/e2e/sidebar/sidebar-session-links.spec.ts index 09d331ce6..784501d38 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 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() + 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) + } + }) + }) +}) 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/i18n/en.ts b/packages/app/src/i18n/en.ts index 229f6c9ed..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", @@ -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", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 22415cd6f..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": "连接", @@ -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": "无法重做", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 79263f2ac..3db6e163a 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) @@ -2037,6 +2051,8 @@ export default function Layout(props: ParentProps) { currentDir, navList: currentSessions, prefetchSession, + openSession: navigateToSession, + openNewSession: openPawworkHome, workspaceName, renameWorkspace, editorOpen, @@ -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} @@ -2108,7 +2125,15 @@ export default function Layout(props: ParentProps) { }, }} > - +
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/shell-navigation.test.ts b/packages/app/src/pages/layout/shell-navigation.test.ts new file mode 100644 index 000000000..6d3255792 --- /dev/null +++ b/packages/app/src/pages/layout/shell-navigation.test.ts @@ -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"]) + }) +}) 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..2cb5f522e --- /dev/null +++ b/packages/app/src/pages/layout/shell-navigation.ts @@ -0,0 +1,50 @@ +import { newSessionRoute, openSessionRoute } from "./helpers" + +export type ShellNavigationReleaseReason = "new-session" | "session" | "settings" | "choose-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) => { + const root = resolveNewSessionRoot(directory) + if (!root) { + input.releaseTransientLocks("choose-project") + input.chooseProject() + return + } + input.releaseTransientLocks("new-session") + 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/layout/sidebar-item-navigation.test.ts b/packages/app/src/pages/layout/sidebar-item-navigation.test.ts new file mode 100644 index 000000000..17fb5c7a5 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-item-navigation.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test" +import { + defaultNewSessionHref, + defaultSessionHref, + openShellLinkWithOwner, + shouldUseShellOwnerForLink, + type ShellLinkClick, +} from "./sidebar-item-navigation" + +const click = (overrides: Partial = {}): ShellLinkClick => ({ + 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") + expect(defaultNewSessionHref("repo-slug")).toBe("/repo-slug/session") + }) + + test("routes ordinary left-clicks through the shell owner", () => { + expect(shouldUseShellOwnerForLink(click())).toBe(true) + }) + + test("opens sidebar links through the shell owner after preventing default navigation", () => { + const calls: string[] = [] + const handled = openShellLinkWithOwner( + { + ...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(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 = openShellLinkWithOwner( + { + ...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 new file mode 100644 index 000000000..3f0a3b7f3 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-item-navigation.ts @@ -0,0 +1,36 @@ +import type { Session } from "@opencode-ai/sdk/v2/client" + +export type ShellLinkClick = { + defaultPrevented: boolean + button: number + metaKey: boolean + ctrlKey: boolean + shiftKey: boolean + altKey: boolean +} + +export type ShellOwnerLinkEvent = ShellLinkClick & { + preventDefault: () => void +} + +export function defaultSessionHref(slug: string, session: Pick) { + return `/${slug}/session/${session.id}` +} + +export function defaultNewSessionHref(slug: string) { + return `/${slug}/session` +} + +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 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 93c38bd87..1653191ed 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 { defaultNewSessionHref, defaultSessionHref, openShellLinkWithOwner } 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,11 @@ 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 + openShellLinkWithOwner(event, () => props.onOpenSession?.(props.session)) + }} warmPress={() => warm(2, "high")} warmFocus={() => warm(2, "high")} titleContent={props.titleContent?.({ session: props.session, title: () => sessionTitle(props.session.title) ?? "" })} @@ -235,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 + openShellLinkWithOwner(event, props.onOpenNewSession) + }} >
diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index a108f5404..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" @@ -34,6 +34,8 @@ export type WorkspaceSidebarContext = { currentDir: Accessor 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 @@ -144,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() }} /> @@ -226,6 +228,7 @@ const WorkspaceActions = (props: { const WorkspaceSessionList = (props: { slug: Accessor + directory: string ctx: WorkspaceSidebarContext showNew: Accessor loading: Accessor @@ -236,7 +239,7 @@ const WorkspaceSessionList = (props: { }): JSX.Element => (
@@ -402,6 +405,7 @@ export const SortableWorkspace = (props: { false} loading={loading} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 4bb0e9efe..874f30267 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -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,6 +53,7 @@ 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() @@ -540,6 +543,18 @@ export default function Page() { /> ) + const retryOpenRouteSession = () => { + const id = params.id + if (!id) return + void sync.session.sync(id, { force: true }) + } + + const openNewRouteSession = () => { + const directory = decode64(params.dir) + if (!directory) return + shellSurface.openNewSession(directory) + } + return ( renderComposerRegion("home", ctx)} canReview={canReview} 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-main-view-state.ts b/packages/app/src/pages/session/session-main-view-state.ts new file mode 100644 index 000000000..c6aa3ad07 --- /dev/null +++ b/packages/app/src/pages/session/session-main-view-state.ts @@ -0,0 +1,14 @@ +export function shouldShowSessionOpeningState(input: { + activeSessionID?: string + routeSessionID?: string + routeReady: boolean + timelineSessionID?: string +}) { + return ( + !!input.activeSessionID && + !!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 new file mode 100644 index 000000000..72735c56e --- /dev/null +++ b/packages/app/src/pages/session/session-main-view.test.ts @@ -0,0 +1,45 @@ +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", + routeSessionID: "ses_target", + routeReady: false, + timelineSessionID: "ses_target", + }), + ).toBe(true) + }) + + test("does not replace the new-session home or ready timeline", () => { + expect( + shouldShowSessionOpeningState({ + activeSessionID: undefined, + routeSessionID: undefined, + routeReady: false, + timelineSessionID: undefined, + }), + ).toBe(false) + expect( + shouldShowSessionOpeningState({ + activeSessionID: "ses_target", + routeSessionID: "ses_target", + routeReady: true, + timelineSessionID: "ses_target", + }), + ).toBe(false) + }) + + test("does not show loading for a mismatched timeline identity", () => { + expect( + shouldShowSessionOpeningState({ + activeSessionID: "ses_route", + routeSessionID: "ses_route", + routeReady: false, + timelineSessionID: "ses_other", + }), + ).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..7f66560c1 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" @@ -19,8 +20,12 @@ 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 timelineMessages: TimelineProps["sessionMessages"] mobileChanges: boolean mobileFallback: JSX.Element @@ -39,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 @@ -52,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 (
@@ -84,37 +99,71 @@ 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} + /> @@ -124,7 +173,7 @@ export function SessionMainView(props: {
- {props.composerSession} + {props.composerSession}
{ }) }) - 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..98c476e1e 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 @@ -18,33 +16,15 @@ export function sessionKey(input: { directory: string; sessionID: string | undef return `${input.directory}${input.sessionID ? `/${input.sessionID}` : ""}` } -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 -} - 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, - }) + const visibleSessionID = input.routeSessionID return { routeSessionID: input.routeSessionID, routeReady, visibleSessionID, - transitioning: visibleSessionID !== input.routeSessionID, + transitioning: !routeReady, routeSessionKey: sessionKey({ directory: input.directory, sessionID: input.routeSessionID }), visibleSessionKey: sessionKey({ directory: input.directory, sessionID: visibleSessionID }), } @@ -56,8 +36,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(), @@ -66,12 +44,7 @@ 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 - } + const visibleReady = () => state().routeReady return { route: { diff --git a/packages/app/src/pages/session/timeline-session-state.test.ts b/packages/app/src/pages/session/timeline-session-state.test.ts index 4af00e20b..4a8158b46 100644 --- a/packages/app/src/pages/session/timeline-session-state.test.ts +++ b/packages/app/src/pages/session/timeline-session-state.test.ts @@ -1,12 +1,10 @@ import { describe, expect, test } from "bun:test" -import { nextSessionViewState, nextTimelineSessionID, nextVisibleSessionID, sessionKey } from "./timeline-session-state" +import { nextSessionViewState, sessionKey } from "./timeline-session-state" import * as controller from "./session-view-controller" describe("timeline-session-state", () => { 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/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, 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)"') })