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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 19 additions & 19 deletions packages/app/e2e/sidebar/sidebar-project-actions.spec.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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)

Expand All @@ -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)
})
})
})
1 change: 0 additions & 1 deletion packages/app/e2e/snap/sidebar-pinned.snap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"?',
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"?',
Expand Down
39 changes: 32 additions & 7 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import {
batch,
createEffect,
Expand Down Expand Up @@ -344,7 +344,6 @@
sessionByID: pawworkSessionByID,
loadSessionByID,
navigationSessions: pawworkNavigationSessions,
projectKeyForSession,
windowLoading: pawworkSessionWindowLoading,
showMore: showMorePawworkSessions,
} = createPawworkSessionController({
Expand Down Expand Up @@ -421,14 +420,11 @@
movePinnedSessionByOne,
setPawworkSortMode,
toggleProjectCollapsed,
hideProject,
unhideProject,
handleRenameProject,
expandPawworkProjectGroup,
} = createPawworkProjectControls({
store,
setStore,
language,
projects: () => layout.projects.list(),
sessions: () => pawworkSessionWindow().sessions,
renameProject,
Expand Down Expand Up @@ -601,8 +597,6 @@
projectRoot,
activeProjectRoot,
shellNavigation,
unhideProject,
projectKeyForSession,
layout,
})

Expand Down Expand Up @@ -693,6 +687,33 @@
})
}

// 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)),
},
],
})
}
Comment thread
Astro-Han marked this conversation as resolved.

function toggleProjectWorkspaces(project: LocalProject) {
const enabled = layout.sidebar.workspaces(project.worktree)()
if (enabled) {
Expand Down Expand Up @@ -893,6 +914,10 @@
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}
Expand All @@ -904,7 +929,7 @@
onOpenSession={navigateToSession}
onRenameSession={renamePawworkSession}
onRenameProject={handleRenameProject}
onRemoveProject={hideProject}
onRemoveProject={removeProject}
onTogglePinnedSession={togglePinnedSession}
onDragSession={dragPawworkSession}
onMovePinnedSession={movePinnedSessionByOne}
Expand Down
16 changes: 1 addition & 15 deletions packages/app/src/pages/layout/layout-page-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,11 @@ function projectCollapsed(value: unknown) {
return out
}

function projectHidden(value: unknown) {
if (!record(value)) return {} as Record<string, boolean>
const out: Record<string, boolean> = {}
for (const [key, val] of Object.entries(value)) {
if (typeof key === "string" && key && val === true) out[key] = true
}
return out
}

function hasLayoutPageFields(value: Record<string, unknown>) {
return (
"pawworkPinnedSessions" in value ||
"pawworkSortMode" in value ||
"pawworkProjectCollapsed" in value ||
"pawworkProjectHidden" in value
"pawworkProjectCollapsed" in value
)
}

Expand All @@ -68,7 +58,6 @@ export function createDefaultLayoutPageState() {
pawworkPinnedSessions: [] as string[],
pawworkSortMode: "time" as "time" | "project",
pawworkProjectCollapsed: {} as Record<string, boolean>,
pawworkProjectHidden: {} as Record<string, boolean>,
}
}

Expand All @@ -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
Expand All @@ -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),
}
}

Expand All @@ -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),
}
}

Expand Down
31 changes: 1 addition & 30 deletions packages/app/src/pages/layout/pawwork-project-controls.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -10,7 +9,6 @@ type LayoutPageState = ReturnType<typeof createDefaultLayoutPageState>
export type PawworkProjectControlsInput = {
store: LayoutPageState
setStore: SetStoreFunction<LayoutPageState>
language: { t: (key: string, params?: Record<string, string | number | boolean>) => string }
projects: () => LocalProject[]
sessions: () => { directory: string }[]
renameProject: (project: LocalProject, next: string) => Promise<void>
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -143,8 +116,6 @@ export function createPawworkProjectControls(input: PawworkProjectControlsInput)
movePinnedSessionByOne,
setPawworkSortMode,
toggleProjectCollapsed,
hideProject,
unhideProject,
handleRenameProject,
expandPawworkProjectGroup,
}
Expand Down
44 changes: 7 additions & 37 deletions packages/app/src/pages/layout/pawwork-routing-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean>; workspaceExpanded?: Record<string, boolean> } = {},
) {
function setup(storeOverride: { workspaceExpanded?: Record<string, boolean> } = {}) {
const calls: Calls = {
navigate: [],
setStore: [],
Expand All @@ -30,12 +25,9 @@ function setup(
adopt: [],
openSession: [],
openNewSession: [],
unhideProject: [],
projectsOpen: [],
order: [],
}
const store = {
pawworkProjectHidden: storeOverride.pawworkProjectHidden ?? {},
workspaceExpanded: storeOverride.workspaceExpanded ?? {},
}
const input = {
Expand All @@ -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()
})
})
Expand Down
Loading
Loading