diff --git a/packages/app/e2e/sidebar/sidebar-project-actions.spec.ts b/packages/app/e2e/sidebar/sidebar-project-actions.spec.ts index d06d8a229..f5b182d11 100644 --- a/packages/app/e2e/sidebar/sidebar-project-actions.spec.ts +++ b/packages/app/e2e/sidebar/sidebar-project-actions.spec.ts @@ -1,6 +1,11 @@ import { test, expect } from "../fixtures" import { openSidebar, withSession, clickMenuItem } from "../actions" -import { pawworkSidebarSelector, contextMenuContentSelector, dropdownMenuContentSelector } from "../selectors" +import { + pawworkSidebarSelector, + contextMenuContentSelector, + dropdownMenuContentSelector, + pawworkSessionNewSelector, +} from "../selectors" import type { TestInfo } from "@playwright/test" async function capture(page: any, testInfo: TestInfo, name: string) { @@ -136,10 +141,10 @@ test("project group can be removed from sidebar", async ({ page, sdk, gotoSessio }) }) -test("hidden project restores on direct navigation", async ({ page, sdk, gotoSession }) => { +test("removed project stays removed when starting a new session", async ({ page, sdk, gotoSession }) => { const stamp = Date.now() - await withSession(sdk, `restore nav ${stamp}`, async (a) => { - await withSession(sdk, `restore nav b ${stamp}`, async () => { + await withSession(sdk, `remove stays ${stamp}`, async (a) => { + await withSession(sdk, `remove stays b ${stamp}`, async () => { await gotoSession(a.id) await openSidebar(page) @@ -165,24 +170,19 @@ test("hidden project restores on direct navigation", async ({ page, sdk, gotoSes await expect(dialog).toBeVisible() await dialog.locator('button').filter({ hasText: /Remove/ }).first().click() - // Group should be hidden - const countAfterRemove = await groups.count() - expect(countAfterRemove).toBeLessThan(initialCount) + // The only project is gone, so the sidebar falls back to the empty state. + await expect.poll(async () => await groups.count()).toBeLessThan(initialCount) + await expect(sidebar).toContainText(/No projects open/i) - // Navigate directly to session via URL (triggers syncSessionRoute → unhideProject) - await gotoSession(a.id) - - // Wait for sidebar to load content - await expect(sidebar.locator('[data-session-id]')).not.toHaveCount(0, { timeout: 5000 }) + // Start a new session (client-side navigation). This is the reported + // regression path: previously the new-session flow silently un-hid the + // project and it reappeared. Removal is now a real close, so the empty + // state must persist across new-session navigation. + await page.locator(pawworkSessionNewSelector).first().click() await openSidebar(page) - // Switch to project sort again - await sidebar.locator('[data-action="pawwork-sort-trigger"]').click() - await page.locator('[data-action="pawwork-sort-option"][data-value="project"]').click() - - // Group should be back - const groupsAfter = sidebar.locator('[data-action="pawwork-group-toggle"]') - await expect.poll(async () => await groupsAfter.count()).toBeGreaterThan(countAfterRemove) + await expect(sidebar).toContainText(/No projects open/i) + await expect(sidebar.locator('[data-action="pawwork-group-toggle"]')).toHaveCount(0) }) }) }) diff --git a/packages/app/e2e/snap/sidebar-pinned.snap.ts b/packages/app/e2e/snap/sidebar-pinned.snap.ts index 76700b696..75d6415de 100644 --- a/packages/app/e2e/snap/sidebar-pinned.snap.ts +++ b/packages/app/e2e/snap/sidebar-pinned.snap.ts @@ -35,7 +35,6 @@ test("sidebar-pinned", async ({ page, sdk, directory, gotoSession }) => { pawworkPinnedSessions: [pinnedID], pawworkSortMode: "time", pawworkProjectCollapsed: {}, - pawworkProjectHidden: {}, }) window.localStorage.setItem("pawwork.global.dat:layout-page", stored) }, diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index d94f28ce8..35e667c14 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -1138,9 +1138,9 @@ export const dict = { "project.remove": "Remove project", "project.remove.title": "Remove project", "project.remove.confirm": 'Remove "{{name}}"?', - "project.remove.description": "The project will be hidden from the sidebar. You can undo this.", + "project.remove.description": "The project will be removed from your workspace. You can undo this.", "project.remove.toast.title": "Project removed", - "project.remove.toast.description": "The project has been hidden from the sidebar.", + "project.remove.toast.description": "The project has been removed from your workspace.", "session.delete.failed.title": "Failed to delete session", "session.delete.title": "Delete session", "session.delete.confirm": 'Delete session "{{name}}"?', diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 84160037b..273a8ee3d 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -1017,9 +1017,9 @@ export const dict = { "project.remove": "移除项目", "project.remove.title": "移除项目", "project.remove.confirm": "移除「{{name}}」?", - "project.remove.description": "该项目将从侧边栏隐藏,可以撤销。", + "project.remove.description": "该项目将从工作区移除,可以撤销。", "project.remove.toast.title": "项目已移除", - "project.remove.toast.description": "项目已从侧边栏隐藏。", + "project.remove.toast.description": "项目已从工作区移除。", "session.delete.failed.title": "删除会话失败", "session.delete.title": "删除会话", "session.delete.confirm": '删除会话 "{{name}}"?', diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 24d620ad7..1069d2acf 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -344,7 +344,6 @@ export default function Layout(props: ParentProps) { sessionByID: pawworkSessionByID, loadSessionByID, navigationSessions: pawworkNavigationSessions, - projectKeyForSession, windowLoading: pawworkSessionWindowLoading, showMore: showMorePawworkSessions, } = createPawworkSessionController({ @@ -421,14 +420,11 @@ export default function Layout(props: ParentProps) { movePinnedSessionByOne, setPawworkSortMode, toggleProjectCollapsed, - hideProject, - unhideProject, handleRenameProject, expandPawworkProjectGroup, } = createPawworkProjectControls({ store, setStore, - language, projects: () => layout.projects.list(), sessions: () => pawworkSessionWindow().sessions, renameProject, @@ -601,8 +597,6 @@ export default function Layout(props: ParentProps) { projectRoot, activeProjectRoot, shellNavigation, - unhideProject, - projectKeyForSession, layout, }) @@ -693,6 +687,33 @@ export default function Layout(props: ParentProps) { }) } + // Sidebar "Remove project" closes the working directory from the single source + // of truth (`server.projects`), so it disappears from the sidebar, the + // workspace chip, and survives new sessions. The group key may be a subfolder + // or sandbox, so resolve it to the open project root before closing; undo + // simply reopens it. + function removeProject(projectKey: string) { + const root = projectRoot(projectKey) + const entry = layout.projects.list().find((x) => workspaceKey(x.worktree) === workspaceKey(root)) + if (!entry) return + const worktree = entry.worktree + // closeProject navigates away when the removed project is the active one, so + // Undo has to restore focus there too — a bare reopen would leave the user + // wherever the close sent them. + const wasActive = workspaceKey(currentProject()?.worktree ?? "") === workspaceKey(worktree) + closeProject(worktree) + showToast({ + title: language.t("project.remove.toast.title"), + description: language.t("project.remove.toast.description"), + actions: [ + { + label: language.t("common.undo"), + onClick: () => (wasActive ? openProject(worktree, true) : layout.projects.open(worktree)), + }, + ], + }) + } + function toggleProjectWorkspaces(project: LocalProject) { const enabled = layout.sidebar.workspaces(project.worktree)() if (enabled) { @@ -893,6 +914,10 @@ export default function Layout(props: ParentProps) { capReached: pawworkSessionWindow().capReached, loading: pawworkSessionWindowLoading(), })} + // Intentionally keyed on open projects only: with zero projects we show + // the "open a project" empty state even if direct-start rows survive the + // session filter. The sidebar lists only open projects; direct-start + // sessions stay reachable once any project is open (or by reopening). showProjectEmptyState={projects().length === 0} activeSessionID={() => params.id} pinnedIDs={() => store.pawworkPinnedSessions} @@ -904,7 +929,7 @@ export default function Layout(props: ParentProps) { onOpenSession={navigateToSession} onRenameSession={renamePawworkSession} onRenameProject={handleRenameProject} - onRemoveProject={hideProject} + onRemoveProject={removeProject} onTogglePinnedSession={togglePinnedSession} onDragSession={dragPawworkSession} onMovePinnedSession={movePinnedSessionByOne} diff --git a/packages/app/src/pages/layout/layout-page-store.ts b/packages/app/src/pages/layout/layout-page-store.ts index 987c441b5..9ad51455e 100644 --- a/packages/app/src/pages/layout/layout-page-store.ts +++ b/packages/app/src/pages/layout/layout-page-store.ts @@ -40,21 +40,11 @@ function projectCollapsed(value: unknown) { return out } -function projectHidden(value: unknown) { - if (!record(value)) return {} as Record - const out: Record = {} - for (const [key, val] of Object.entries(value)) { - if (typeof key === "string" && key && val === true) out[key] = true - } - return out -} - function hasLayoutPageFields(value: Record) { return ( "pawworkPinnedSessions" in value || "pawworkSortMode" in value || - "pawworkProjectCollapsed" in value || - "pawworkProjectHidden" in value + "pawworkProjectCollapsed" in value ) } @@ -68,7 +58,6 @@ export function createDefaultLayoutPageState() { pawworkPinnedSessions: [] as string[], pawworkSortMode: "time" as "time" | "project", pawworkProjectCollapsed: {} as Record, - pawworkProjectHidden: {} as Record, } } @@ -85,7 +74,6 @@ export function migrateLayoutPageState(value: unknown) { pawworkPinnedSessions: pinnedSessions(decoded.pawworkPinnedSessions), pawworkSortMode: decoded.pawworkSortMode === "project" ? "project" : "time", pawworkProjectCollapsed: projectCollapsed(decoded.pawworkProjectCollapsed), - pawworkProjectHidden: projectHidden(decoded.pawworkProjectHidden), } } if (!hasLayoutPageFields(parsedPage)) return undefined @@ -94,7 +82,6 @@ export function migrateLayoutPageState(value: unknown) { pawworkPinnedSessions: pinnedSessions(parsedPage.pawworkPinnedSessions), pawworkSortMode: parsedPage.pawworkSortMode === "project" ? "project" : "time", pawworkProjectCollapsed: projectCollapsed(parsedPage.pawworkProjectCollapsed), - pawworkProjectHidden: projectHidden(parsedPage.pawworkProjectHidden), } } @@ -105,7 +92,6 @@ export function migrateLayoutPageState(value: unknown) { pawworkPinnedSessions: pinnedSessions(decoded.pawworkPinnedSessions), pawworkSortMode: decoded.pawworkSortMode === "project" ? "project" : "time", pawworkProjectCollapsed: projectCollapsed(decoded.pawworkProjectCollapsed), - pawworkProjectHidden: projectHidden(decoded.pawworkProjectHidden), } } diff --git a/packages/app/src/pages/layout/pawwork-project-controls.ts b/packages/app/src/pages/layout/pawwork-project-controls.ts index 0eaf20856..db6238ae8 100644 --- a/packages/app/src/pages/layout/pawwork-project-controls.ts +++ b/packages/app/src/pages/layout/pawwork-project-controls.ts @@ -1,5 +1,4 @@ -import { produce, reconcile, type SetStoreFunction } from "solid-js/store" -import { showToast } from "@opencode-ai/ui/toast" +import { reconcile, type SetStoreFunction } from "solid-js/store" import type { LocalProject } from "@/context/layout" import { reorderPawworkPinnedByVisible, unpinPawworkSession } from "./pawwork-session-nav" import { resolvePawworkProjectRenameTarget } from "./pawwork-session-source" @@ -10,7 +9,6 @@ type LayoutPageState = ReturnType export type PawworkProjectControlsInput = { store: LayoutPageState setStore: SetStoreFunction - language: { t: (key: string, params?: Record) => string } projects: () => LocalProject[] sessions: () => { directory: string }[] renameProject: (project: LocalProject, next: string) => Promise @@ -88,31 +86,6 @@ export function createPawworkProjectControls(input: PawworkProjectControlsInput) input.setStore("pawworkProjectCollapsed", reconcile(next)) } - function hideProject(projectKey: string) { - if (input.store.pawworkProjectHidden[projectKey]) return - input.setStore("pawworkProjectHidden", projectKey, true) - showToast({ - title: input.language.t("project.remove.toast.title"), - description: input.language.t("project.remove.toast.description"), - actions: [ - { - label: input.language.t("common.undo"), - onClick: () => unhideProject(projectKey), - }, - ], - }) - } - - function unhideProject(projectKey: string) { - if (!input.store.pawworkProjectHidden[projectKey]) return - input.setStore( - "pawworkProjectHidden", - produce((draft) => { - delete draft[projectKey] - }), - ) - } - async function handleRenameProject(projectKey: string, next: string) { const target = resolvePawworkProjectRenameTarget(projectKey, { projects: input.projects(), @@ -143,8 +116,6 @@ export function createPawworkProjectControls(input: PawworkProjectControlsInput) movePinnedSessionByOne, setPawworkSortMode, toggleProjectCollapsed, - hideProject, - unhideProject, handleRenameProject, expandPawworkProjectGroup, } diff --git a/packages/app/src/pages/layout/pawwork-routing-actions.test.ts b/packages/app/src/pages/layout/pawwork-routing-actions.test.ts index 5ab6aacfb..76d641dab 100644 --- a/packages/app/src/pages/layout/pawwork-routing-actions.test.ts +++ b/packages/app/src/pages/layout/pawwork-routing-actions.test.ts @@ -12,15 +12,10 @@ type Calls = { adopt: unknown[] openSession: unknown[] openNewSession: (string | undefined)[] - unhideProject: string[] projectsOpen: string[] - // Cross-function call order so tests can pin unhide-then-open ordering. - order: string[] } -function setup( - storeOverride: { pawworkProjectHidden?: Record; workspaceExpanded?: Record } = {}, -) { +function setup(storeOverride: { workspaceExpanded?: Record } = {}) { const calls: Calls = { navigate: [], setStore: [], @@ -30,12 +25,9 @@ function setup( adopt: [], openSession: [], openNewSession: [], - unhideProject: [], projectsOpen: [], - order: [], } const store = { - pawworkProjectHidden: storeOverride.pawworkProjectHidden ?? {}, workspaceExpanded: storeOverride.workspaceExpanded ?? {}, } const input = { @@ -51,54 +43,32 @@ function setup( projectRoot: (directory: string) => `/resolved${directory}`, activeProjectRoot: (directory: string) => `root:${directory}`, shellNavigation: { - openSession: (session: unknown) => { - calls.openSession.push(session) - calls.order.push("openSession") - }, - openNewSession: (directory: string | undefined) => { - calls.openNewSession.push(directory) - calls.order.push(`openNewSession:${directory}`) - }, + openSession: (session: unknown) => calls.openSession.push(session), + openNewSession: (directory: string | undefined) => calls.openNewSession.push(directory), }, - unhideProject: (key: string) => { - calls.unhideProject.push(key) - calls.order.push(`unhide:${key}`) - }, - projectKeyForSession: (session: { directory: string }) => session.directory, layout: { projects: { open: (directory: string) => calls.projectsOpen.push(directory) } }, } as unknown as PawworkRoutingActionsInput return { input, calls, store } } describe("createPawworkRoutingActions", () => { - test("navigateToSession unhides a hidden project BEFORE opening the session", () => { + test("navigateToSession opens the session", () => { createRoot((dispose) => { - const { input, calls } = setup({ pawworkProjectHidden: { "/repo": true } }) + const { input, calls } = setup() const actions = createPawworkRoutingActions(input) const session = { id: "s1", directory: "/repo" } actions.navigateToSession(session as never) - expect(calls.order).toEqual(["unhide:/repo", "openSession"]) expect(calls.openSession).toEqual([session]) dispose() }) }) - test("navigateToSession does not unhide a visible project", () => { + test("openPawworkHome opens a new session for the directory", () => { createRoot((dispose) => { const { input, calls } = setup() const actions = createPawworkRoutingActions(input) - actions.navigateToSession({ id: "s1", directory: "/repo" } as never) - expect(calls.order).toEqual(["openSession"]) - dispose() - }) - }) - - test("openPawworkHome unhides a hidden directory BEFORE opening a new session", () => { - createRoot((dispose) => { - const { input, calls } = setup({ pawworkProjectHidden: { "/repo": true } }) - const actions = createPawworkRoutingActions(input) actions.openPawworkHome("/repo") - expect(calls.order).toEqual(["unhide:/repo", "openNewSession:/repo"]) + expect(calls.openNewSession).toEqual(["/repo"]) dispose() }) }) diff --git a/packages/app/src/pages/layout/pawwork-routing-actions.ts b/packages/app/src/pages/layout/pawwork-routing-actions.ts index 4c33a01ee..074ed1969 100644 --- a/packages/app/src/pages/layout/pawwork-routing-actions.ts +++ b/packages/app/src/pages/layout/pawwork-routing-actions.ts @@ -8,14 +8,13 @@ import type { useNotification } from "@/context/notification" import type { useLayout } from "@/context/layout" import type { usePinnedDraft } from "@/components/prompt-input/pinned-draft" import { setSessionHandoff } from "@/pages/session/handoff" -import { openProjectRoute, workspaceKey } from "./helpers" +import { openProjectRoute } from "./helpers" import { collectNewSessionDeepLinks, collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks, } from "./deep-links" -import { pawworkSessionRouteUnhideKeys } from "./pawwork-session-source" import type { createShellNavigation } from "./shell-navigation" import { createDefaultLayoutPageState } from "./layout-page-store" @@ -32,17 +31,11 @@ export type PawworkRoutingActionsInput = { projectRoot: (directory: string) => string activeProjectRoot: (directory: string) => string shellNavigation: Pick, "openSession" | "openNewSession"> - unhideProject: (projectKey: string) => void - projectKeyForSession: (session: Session) => string layout: Pick, "projects"> } export function createPawworkRoutingActions(input: PawworkRoutingActionsInput) { function syncSessionRoute(directory: string, id: string, root = input.activeProjectRoot(directory)) { - for (const key of pawworkSessionRouteUnhideKeys(directory)) { - if (!input.store.pawworkProjectHidden[key]) continue - input.unhideProject(key) - } input.notification.session.markViewed(id) const expanded = untrack(() => input.store.workspaceExpanded[directory]) if (expanded === false) { @@ -60,22 +53,10 @@ export function createPawworkRoutingActions(input: PawworkRoutingActionsInput) { } function navigateToSession(session: Session | undefined) { - if (session) { - const key = input.projectKeyForSession(session) - if (input.store.pawworkProjectHidden[key]) { - input.unhideProject(key) - } - } input.shellNavigation.openSession(session) } function openPawworkHome(directory?: string) { - if (directory) { - const key = workspaceKey(directory) - if (input.store.pawworkProjectHidden[key]) { - input.unhideProject(key) - } - } input.shellNavigation.openNewSession(directory) } diff --git a/packages/app/src/pages/layout/pawwork-session-controller.ts b/packages/app/src/pages/layout/pawwork-session-controller.ts index 05f7d6f9d..0babac09d 100644 --- a/packages/app/src/pages/layout/pawwork-session-controller.ts +++ b/packages/app/src/pages/layout/pawwork-session-controller.ts @@ -13,11 +13,13 @@ import { nextPawworkSessionWindowLimit, PAWWORK_SESSION_WINDOW_INITIAL, pawworkSessionWindowActiveRoot, + shouldAutoExpandPawworkSessionWindow, sortPawworkSessionWindowSessions, type PawworkWindowSession, } from "./pawwork-session-window" import { buildPawworkSidebarSessionRows, + filterPawworkRowsByOpenProjects, resolvePawworkSessionProjectKey, resolvePawworkSessionProjectLabel, sortPawworkSidebarSessions, @@ -45,6 +47,10 @@ export type PawworkSessionControllerInput = { export function createPawworkSessionController(input: PawworkSessionControllerInput) { const [pawworkSessionWindowState, setPawworkSessionWindowState] = createStore({ limit: PAWWORK_SESSION_WINDOW_INITIAL, + // Limit of the last successfully loaded page. The auto-expand effect only + // advances once this catches up to `limit`, so it steps one page at a time + // and never retries a failed load. + loadedLimit: 0, normal: [] as PawworkWindowSession[], pinned: [] as PawworkWindowSession[], active: undefined as PawworkWindowSession | undefined, @@ -127,8 +133,7 @@ export function createPawworkSessionController(input: PawworkSessionControllerIn return tuple?.[0].part[messageID] }, }) - const hidden = input.store.pawworkProjectHidden - const filtered = rows.filter((row) => !hidden[row.projectKey]) + const filtered = filterPawworkRowsByOpenProjects(rows, input.projects()) return sortPawworkSidebarSessions(filtered.map((item) => ({ ...item, id: item.session.id }))).map( ({ id: _, ...item }) => item, ) @@ -232,6 +237,7 @@ export function createPawworkSessionController(input: PawworkSessionControllerIn setPawworkSessionWindowState("pinned", reconcile(pinned, { key: "id" })) setPawworkSessionWindowState("active", activeRoot) setPawworkSessionWindowState("hasMore", !!response.response?.headers.get("x-next-cursor")) + setPawworkSessionWindowState("loadedLimit", pawworkSessionWindowState.limit) setPawworkSessionWindowState("loading", false) }) } catch (error) { @@ -317,6 +323,25 @@ export function createPawworkSessionController(input: PawworkSessionControllerIn setPawworkSessionWindowState("limit", (limit) => nextPawworkSessionWindowLimit(limit)) } + // Auto-expand the window one page when the visible (open-project) list is empty + // but the global window still has pages below the cap, so an open project's + // older sessions surface instead of leaving the sidebar blank. See + // shouldAutoExpandPawworkSessionWindow for the guard rationale. + createEffect(() => { + if ( + shouldAutoExpandPawworkSessionWindow({ + openProjectCount: input.projects().length, + visibleCount: pawworkSessions().length, + loading: pawworkSessionWindowState.loading, + hasMore: pawworkSessionWindowState.hasMore, + limit: pawworkSessionWindowState.limit, + loadedLimit: pawworkSessionWindowState.loadedLimit, + }) + ) { + showMore() + } + }) + const windowLoading = () => pawworkSessionWindowState.loading return { diff --git a/packages/app/src/pages/layout/pawwork-session-source.ts b/packages/app/src/pages/layout/pawwork-session-source.ts index 22ddb4602..a5802f206 100644 --- a/packages/app/src/pages/layout/pawwork-session-source.ts +++ b/packages/app/src/pages/layout/pawwork-session-source.ts @@ -159,10 +159,6 @@ export function resolvePawworkProjectRenameTarget { if (!parts) return false if (parts.some((part) => part.type === "compaction")) return false @@ -233,6 +229,45 @@ export function buildPawworkSidebarSessionRows( })) } +/** + * Keep only the sidebar rows whose owning project is currently open. The sidebar + * window is a global root-session list, so visibility has to be derived from the + * single source of truth (`server.projects`) — a row belongs to an open project + * when its owning worktree matches an open worktree or sandbox. + * + * Owner resolution order matters: `executionContext.ownerDirectory` is always + * present and is the canonical owning worktree (so a subfolder session like + * `/repo/packages/app` resolves to `/repo`). `project.worktree` only exists on + * the list endpoint's GlobalInfo, and a `session.get` backfill (active / pinned + * sessions outside the first page) returns a plain Session with neither — so the + * bare `directory` is the last resort. + * + * Direct-start rows are exempt: they belong to no open project by design (their + * key is `PAWWORK_DIRECT_START_PROJECT_KEY`), so they always survive the filter. + */ +export function filterPawworkRowsByOpenProjects< + T extends { + projectKey?: string + session: { + directory: string + project?: SessionProjectLike | null + executionContext?: { ownerDirectory?: string | null } | null + } + }, +>(rows: T[], projects: Pick[]): T[] { + const openKeys = new Set() + for (const project of projects) { + openKeys.add(workspaceKey(project.worktree)) + for (const sandbox of project.sandboxes ?? []) openKeys.add(workspaceKey(sandbox)) + } + return rows.filter((row) => { + if (row.projectKey === PAWWORK_DIRECT_START_PROJECT_KEY) return true + const session = row.session + const owner = session.executionContext?.ownerDirectory ?? session.project?.worktree ?? session.directory + return openKeys.has(workspaceKey(owner)) + }) +} + export function pawworkSessionDirectories(input: { project: LocalProject | undefined activeProjectWorktree?: string diff --git a/packages/app/src/pages/layout/pawwork-session-window.test.ts b/packages/app/src/pages/layout/pawwork-session-window.test.ts index 9961be764..d29a6aaae 100644 --- a/packages/app/src/pages/layout/pawwork-session-window.test.ts +++ b/packages/app/src/pages/layout/pawwork-session-window.test.ts @@ -7,6 +7,7 @@ import { buildPawworkSessionWindow, nextPawworkSessionWindowLimit, pawworkSessionWindowActiveRoot, + shouldAutoExpandPawworkSessionWindow, sortPawworkSessionWindowSessions, } from "./pawwork-session-window" @@ -35,6 +36,46 @@ describe("nextPawworkSessionWindowLimit", () => { }) }) +describe("shouldAutoExpandPawworkSessionWindow", () => { + const base = { + openProjectCount: 1, + visibleCount: 0, + loading: false, + hasMore: true, + limit: PAWWORK_SESSION_WINDOW_INITIAL, + loadedLimit: PAWWORK_SESSION_WINDOW_INITIAL, + } + + test("expands when an open project's list is empty but the window has more settled pages", () => { + expect(shouldAutoExpandPawworkSessionWindow(base)).toBe(true) + }) + + test("does not expand with zero open projects (empty state is deliberate)", () => { + expect(shouldAutoExpandPawworkSessionWindow({ ...base, openProjectCount: 0 })).toBe(false) + }) + + test("does not expand once any row is visible", () => { + expect(shouldAutoExpandPawworkSessionWindow({ ...base, visibleCount: 1 })).toBe(false) + }) + + test("does not expand mid-load", () => { + expect(shouldAutoExpandPawworkSessionWindow({ ...base, loading: true })).toBe(false) + }) + + test("does not expand when there are no more pages", () => { + expect(shouldAutoExpandPawworkSessionWindow({ ...base, hasMore: false })).toBe(false) + }) + + test("does not expand past the cap", () => { + expect(shouldAutoExpandPawworkSessionWindow({ ...base, limit: PAWWORK_SESSION_WINDOW_MAX })).toBe(false) + }) + + test("does not expand until the current limit's request has settled", () => { + // showMore bumped limit to 60 but the 60-page load has not landed yet. + expect(shouldAutoExpandPawworkSessionWindow({ ...base, limit: 60, loadedLimit: 30 })).toBe(false) + }) +}) + describe("buildPawworkSessionWindow", () => { test("sorts the normal window by activity time before applying the limit", () => { const result = buildPawworkSessionWindow({ diff --git a/packages/app/src/pages/layout/pawwork-session-window.ts b/packages/app/src/pages/layout/pawwork-session-window.ts index a14d02060..83c41ba18 100644 --- a/packages/app/src/pages/layout/pawwork-session-window.ts +++ b/packages/app/src/pages/layout/pawwork-session-window.ts @@ -21,6 +21,34 @@ export function nextPawworkSessionWindowLimit(current: number) { ) } +/** + * The session window is fetched globally (activity-sorted, paginated) and then + * filtered to open projects client-side, so a page of closed-project sessions + * can filter to nothing while the window still has more pages. This decides when + * to auto-advance the window one page so an open project's older sessions surface + * instead of leaving the sidebar blank. + * + * Guards: only with open projects (the zero-project empty state is deliberate), + * only when nothing is visible, only when more pages exist below the cap, and + * only once the current limit's request has settled (`loadedLimit === limit`) — + * so it steps one page at a time and never fires mid-load or retries a failed load. + */ +export function shouldAutoExpandPawworkSessionWindow(input: { + openProjectCount: number + visibleCount: number + loading: boolean + hasMore: boolean + limit: number + loadedLimit: number +}) { + if (input.openProjectCount === 0) return false + if (input.visibleCount > 0) return false + if (input.loading) return false + if (!input.hasMore) return false + if (input.limit >= PAWWORK_SESSION_WINDOW_MAX) return false + return input.loadedLimit === input.limit +} + export function mergeSessionsByID(...lists: Array) { const map = new Map() for (const list of lists) { diff --git a/packages/app/src/pages/layout/pawwork-sidebar-session-rows.test.ts b/packages/app/src/pages/layout/pawwork-sidebar-session-rows.test.ts index e4c08ff5e..aec498e22 100644 --- a/packages/app/src/pages/layout/pawwork-sidebar-session-rows.test.ts +++ b/packages/app/src/pages/layout/pawwork-sidebar-session-rows.test.ts @@ -2,24 +2,69 @@ import { readFileSync } from "node:fs" import { describe, expect, test } from "bun:test" import { buildPawworkSidebarSessionRows, + filterPawworkRowsByOpenProjects, PAWWORK_DIRECT_START_PROJECT_KEY, - pawworkSessionRouteUnhideKeys, resolvePawworkProjectRenameTarget, resolvePawworkSessionProjectKey, resolvePawworkSessionProjectLabel, } from "./pawwork-session-source" -describe("buildPawworkSidebarSessionRows", () => { - test("keeps the parent root group hidden when syncing a subfolder session route", () => { - const hidden: Record = { "/repo/packages/app": true, "/repo": true } +describe("filterPawworkRowsByOpenProjects", () => { + const row = (directory: string, projectWorktree?: string) => ({ + session: { id: `s:${directory}`, directory, project: projectWorktree ? { worktree: projectWorktree } : undefined }, + }) - for (const key of pawworkSessionRouteUnhideKeys("/repo/packages/app")) { - delete hidden[key] - } + test("keeps a row whose owning project root is open", () => { + const result = filterPawworkRowsByOpenProjects([row("/repo", "/repo")], [{ worktree: "/repo" }]) + expect(result).toHaveLength(1) + }) + + test("drops a row whose owning project is not in the open project list", () => { + const result = filterPawworkRowsByOpenProjects([row("/repo", "/repo")], [{ worktree: "/other" }]) + expect(result).toHaveLength(0) + }) - expect(hidden).toEqual({ "/repo": true }) + test("keeps a subfolder session of an open project", () => { + const result = filterPawworkRowsByOpenProjects([row("/repo/packages/app", "/repo")], [{ worktree: "/repo" }]) + expect(result).toHaveLength(1) }) + test("keeps a subfolder session resolved via executionContext.ownerDirectory when project is absent", () => { + // session.get backfill (active / pinned sessions off the first page) returns + // a plain Session with no `project` field; ownerDirectory still points at the + // open root, so the row must survive. + const result = filterPawworkRowsByOpenProjects( + [{ session: { id: "s1", directory: "/repo/packages/app", executionContext: { ownerDirectory: "/repo" } } }], + [{ worktree: "/repo" }], + ) + expect(result).toHaveLength(1) + }) + + test("keeps a sandbox session matched by sandbox path when project worktree is absent", () => { + const result = filterPawworkRowsByOpenProjects( + [row("/repo-worktree")], + [{ worktree: "/repo", sandboxes: ["/repo-worktree"] }], + ) + expect(result).toHaveLength(1) + }) + + test("drops every row when no project is open", () => { + const result = filterPawworkRowsByOpenProjects([row("/repo", "/repo"), row("/other", "/other")], []) + expect(result).toHaveLength(0) + }) + + test("keeps a direct-start row even when its directory is not an open project", () => { + // Direct-start sessions live in the server cwd and belong to no managed + // project — their key marks them exempt so they survive even with 0 projects open. + const result = filterPawworkRowsByOpenProjects( + [{ projectKey: PAWWORK_DIRECT_START_PROJECT_KEY, session: { id: "s1", directory: "/server-cwd" } }], + [], + ) + expect(result).toHaveLength(1) + }) +}) + +describe("buildPawworkSidebarSessionRows", () => { test("renames sandbox session groups as local workspace labels", () => { const project = { id: "proj_repo", name: "Repo", worktree: "/repo", sandboxes: ["/repo-worktree"] } let renamedProject: typeof project | undefined diff --git a/packages/app/src/pages/layout/pawwork-sidebar.tsx b/packages/app/src/pages/layout/pawwork-sidebar.tsx index ff6e8f1e9..92bd2669b 100644 --- a/packages/app/src/pages/layout/pawwork-sidebar.tsx +++ b/packages/app/src/pages/layout/pawwork-sidebar.tsx @@ -411,7 +411,12 @@ export const PawworkSidebar = (props: { data-component="pawwork-side-scroll" class="flex-1 min-h-0 overflow-y-auto px-3 pb-3" > - 0}> + {/* Keep the nav (and its Show more / search-history entries) mounted + * whenever the window can still load or has hit the cap, even if the + * current filtered list is empty — otherwise a page of closed-project + * sessions filters to nothing and the only way to load deeper would + * vanish with the list. */} + 0 || props.sessionWindow().canShowMore || props.sessionWindow().capReached}>