diff --git a/src/lib/settings.ts b/src/lib/settings.ts index b199e3fc..2a8ad3f0 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -274,8 +274,9 @@ export interface AcornSettings { */ defaultWorkspaceViewMode: DefaultWorkspaceViewMode; /** - * Move project sidebar tabs that need attention ahead of ready work. - * The saved manual order is preserved and restored when this is off. + * Move sidebar tabs that need attention to the front within their project + * or workspace. Single-tab workspaces may move inside the project instead. + * The saved manual order remains unchanged. */ prioritizeNeedsInputTabs: boolean; /** diff --git a/src/lib/sidebarProjectItems.test.ts b/src/lib/sidebarProjectItems.test.ts index 455e9486..4e76bace 100644 --- a/src/lib/sidebarProjectItems.test.ts +++ b/src/lib/sidebarProjectItems.test.ts @@ -102,7 +102,7 @@ describe("sidebar project items", () => { ]); }); - it("can move waiting and error project items above ready work", () => { + it("moves direct sessions and single-tab workspaces above ready work", () => { const items = buildProjectTopLevelItems( project(), [ @@ -126,6 +126,47 @@ describe("sidebar project items", () => { ]); }); + it("keeps source roots ordered while prioritizing sessions inside one", () => { + const primaryFolder = makeDefaultProjectFolder("/repo/app"); + const sourceFolder = makeDefaultProjectFolder("/repo/api"); + const primaryReady = session("primary-ready", "ready", 0); + const sourceReady = { + ...session("source-ready", "ready", 0), + repo_path: "/repo/api", + worktree_path: "/repo/api", + }; + const sourceNeeds = { + ...session("source-needs", "waiting_for_input", 1), + repo_path: "/repo/api", + worktree_path: "/repo/api", + }; + const multiRootProject: ProjectFolderProjectGroup = { + repoPath: "/repo/app", + name: "app", + sessions: [primaryReady, sourceReady, sourceNeeds], + folders: [ + { folder: primaryFolder, sessions: [primaryReady] }, + { folder: sourceFolder, sessions: [sourceReady, sourceNeeds] }, + ], + }; + + const items = buildProjectTopLevelItems( + multiRootProject, + ["folder:/repo/app", "folder:/repo/api"], + true, + ); + + expect(items.map((item) => item.id)).toEqual([ + "folder:/repo/app", + "folder:/repo/api", + ]); + expect( + orderSessionsByPriority([sourceReady, sourceNeeds], true).map( + (item) => item.id, + ), + ).toEqual(["source-needs", "source-ready"]); + }); + it("orders urgent items without rewriting the saved order", () => { const items = buildProjectTopLevelItems(project(), [ "session:running-root", @@ -259,8 +300,8 @@ describe("sidebar project items", () => { expect(index.get("session:failed-root")?.isPrioritized).toBe(true); expect(index.get("session:idle-root")?.isPrioritized).toBe(false); expect(index.get("session:running-root")?.isPrioritized).toBe(false); - // A folder joins the priority group as soon as one session inside it needs - // attention, matching how the folder row is displayed. + // A single-tab workspace has no meaningful internal reorder, so its row + // joins the project's priority group instead. expect(index.get("folder:needs-folder")).toEqual({ containerId: "project:/repo/app", isPrioritized: true, diff --git a/src/lib/sidebarProjectItems.ts b/src/lib/sidebarProjectItems.ts index e25d6291..f58d49a4 100644 --- a/src/lib/sidebarProjectItems.ts +++ b/src/lib/sidebarProjectItems.ts @@ -153,7 +153,7 @@ export function buildDragPriorityIndex( if (!isDefaultFolder) { index.set(sidebarFolderItemId(folderGroup.folder.id), { containerId: topLevelContainerId, - isPrioritized: folderGroupHasPriorityStatus(folderGroup), + isPrioritized: folderGroupHasProjectPriorityStatus(folderGroup), }); } const sessionContainerId = isDefaultFolder @@ -235,12 +235,17 @@ function orderItemsByPriority( function itemHasPriorityStatus(item: ProjectTopLevelItem): boolean { if (item.type === "session") return hasPriorityStatus(item.session); - return folderGroupHasPriorityStatus(item.folderGroup); + return folderGroupHasProjectPriorityStatus(item.folderGroup); } -/** A folder joins the priority group as soon as one session inside it does. */ -function folderGroupHasPriorityStatus(folderGroup: ProjectFolderGroup): boolean { - return folderGroup.sessions.some(hasPriorityStatus); +/** A single-tab workspace has no useful internal slot to promote into. */ +function folderGroupHasProjectPriorityStatus( + folderGroup: ProjectFolderGroup, +): boolean { + return ( + folderGroup.sessions.length <= 1 && + folderGroup.sessions.some(hasPriorityStatus) + ); } function hasPriorityStatus(session: Session): boolean { diff --git a/src/locales/en.json b/src/locales/en.json index 43e45e9a..c5dc66ca 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -94,7 +94,7 @@ "hint": "Controls the order of sessions and workspaces inside each project in the left sidebar.", "priority": { "label": "Move waiting and error tabs to the top", - "description": "Keeps your manual order saved, but displays tabs waiting on you or blocked by errors ahead of ready work." + "description": "Keeps your manual order saved. Waiting and error tabs move ahead within their project or workspace; a workspace with one tab can move ahead within its project." } }, "kanbanTerminalPopover": { diff --git a/src/locales/ja.json b/src/locales/ja.json index 9d693899..664eb01c 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -94,7 +94,7 @@ "hint": "左側のサイドバーの各プロジェクト内のセッションとワークスペースの順序を制御します。", "priority": { "label": "待機中およびエラーのタブを上部に移動します", - "description": "手動注文を保存したままにしますが、準備が整う前に待機中またはエラーによってブロックされたタブが表示されます。" + "description": "手動の順序は保存されます。入力待ちやエラーのタブはそのプロジェクトまたはワークスペース内で前に移動し、タブが1つのワークスペースはプロジェクト内で前に移動する場合があります。" } }, "kanbanTerminalPopover": { diff --git a/src/locales/ko.json b/src/locales/ko.json index 83f58946..e96177b1 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -94,7 +94,7 @@ "hint": "좌측 사이드바의 각 프로젝트 안에서 세션과 작업공간이 표시되는 순서를 제어합니다.", "priority": { "label": "입력 대기 및 오류 탭을 상단으로 이동", - "description": "수동 순서는 저장된 채로 유지하고, 사용자 입력을 기다리거나 오류로 막힌 탭을 준비된 작업보다 먼저 표시합니다." + "description": "수동 순서는 저장된 채로 유지합니다. 입력 대기 및 오류 탭은 해당 프로젝트나 작업공간 안에서 앞에 표시되며, 탭이 하나인 작업공간은 프로젝트 안에서 앞에 표시될 수 있습니다." } }, "kanbanTerminalPopover": { diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 39dc844c..3c5c01cd 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -94,7 +94,7 @@ "hint": "控制左侧侧边栏中每个项目内的会话和工作区的顺序。", "priority": { "label": "将等待和错误标签页移至顶部", - "description": "保留手动排序,但将等待您处理或因错误受阻的标签页显示在其他工作之前。" + "description": "保留手动排序。等待和错误标签页会在所属项目或工作区内提前;只有一个标签页的工作区可在项目内提前。" } }, "kanbanTerminalPopover": { diff --git a/tests/e2e/sidebar.spec.ts b/tests/e2e/sidebar.spec.ts index b8b12365..38340bdb 100644 --- a/tests/e2e/sidebar.spec.ts +++ b/tests/e2e/sidebar.spec.ts @@ -4295,11 +4295,11 @@ test.describe("sidebar: project lifecycle", () => { await pressHotkey(page, { mod: true, key: "," }); const settings = page.getByRole("dialog", { name: "Settings" }); - await settings - .getByRole("checkbox", { - name: /Move waiting and error tabs to the top/, - }) - .click(); + const priorityToggle = settings.getByRole("checkbox", { + name: /Move waiting and error tabs to the top/, + }); + await priorityToggle.click(); + await expect(priorityToggle).toBeChecked(); await page.keyboard.press("Escape"); await expect @@ -4329,11 +4329,9 @@ test.describe("sidebar: project lifecycle", () => { .toBe("reordered-priority-group"); await pressHotkey(page, { mod: true, key: "," }); - await settings - .getByRole("checkbox", { - name: /Move waiting and error tabs to the top/, - }) - .click(); + await expect(settings).toBeVisible(); + await priorityToggle.click(); + await expect(priorityToggle).not.toBeChecked(); await page.keyboard.press("Escape"); await expect @@ -4349,6 +4347,146 @@ test.describe("sidebar: project lifecycle", () => { .toBe("manual-order"); }); + test("priority sorting respects source workspace tab count", async ({ + page, + tauri, + }) => { + await page.addInitScript(() => { + localStorage.setItem( + "acorn:settings:v1", + JSON.stringify({ + interface: { prioritizeNeedsInputTabs: true }, + }), + ); + }); + await tauri.respond("list_projects", [ + { + repo_path: "/tmp/demo", + name: "demo", + created_at: "2026-01-01T00:00:00Z", + position: 0, + source_paths: ["/tmp/demo-api", "/tmp/demo-docs"], + }, + ]); + await tauri.respond("list_sessions", [ + { + id: "primary-ready", + name: "primary-ready", + repo_path: "/tmp/demo", + worktree_path: "/tmp/demo", + branch: "main", + isolated: false, + project_scoped: true, + status: "ready", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + last_message: null, + title_source: "manual", + kind: "regular", + owner: { kind: "user" }, + position: 0, + in_worktree: false, + }, + { + id: "source-ready", + name: "source-ready", + repo_path: "/tmp/demo-api", + worktree_path: "/tmp/demo-api", + branch: "main", + isolated: false, + project_scoped: true, + status: "ready", + created_at: "2026-01-01T00:00:01Z", + updated_at: "2026-01-01T00:00:01Z", + last_message: null, + title_source: "manual", + kind: "regular", + owner: { kind: "user" }, + position: 0, + in_worktree: false, + }, + { + id: "source-needs", + name: "source-needs", + repo_path: "/tmp/demo-api", + worktree_path: "/tmp/demo-api", + branch: "main", + isolated: false, + project_scoped: true, + status: "waiting_for_input", + created_at: "2026-01-01T00:00:02Z", + updated_at: "2026-01-01T00:00:02Z", + last_message: null, + title_source: "manual", + kind: "regular", + owner: { kind: "user" }, + position: 1, + in_worktree: false, + }, + { + id: "docs-needs", + name: "docs-needs", + repo_path: "/tmp/demo-docs", + worktree_path: "/tmp/demo-docs", + branch: "main", + isolated: false, + project_scoped: true, + status: "waiting_for_input", + created_at: "2026-01-01T00:00:03Z", + updated_at: "2026-01-01T00:00:03Z", + last_message: null, + title_source: "manual", + kind: "regular", + owner: { kind: "user" }, + position: 0, + in_worktree: false, + }, + ]); + + await page.goto("/"); + + const sidebar = page.locator("aside"); + const primaryWorkspace = sidebar.locator( + '[data-sidebar-workspace-id="/tmp/demo"]', + ); + const sourceWorkspace = sidebar.locator( + '[data-sidebar-workspace-id="/tmp/demo-api"]', + ); + const singleTabWorkspace = sidebar.locator( + '[data-sidebar-workspace-id="/tmp/demo-docs"]', + ); + const sourceReady = sidebar.getByRole("button", { + name: /^source-ready main · Ready/, + }); + const sourceNeeds = sidebar.getByRole("button", { + name: /^source-needs main · Waiting for input/, + }); + + await expect + .poll(async () => { + const primaryBox = await primaryWorkspace.boundingBox(); + const sourceBox = await sourceWorkspace.boundingBox(); + const singleTabBox = await singleTabWorkspace.boundingBox(); + const readyBox = await sourceReady.boundingBox(); + const needsBox = await sourceNeeds.boundingBox(); + if ( + !primaryBox || + !sourceBox || + !singleTabBox || + !readyBox || + !needsBox + ) { + return "missing"; + } + return singleTabBox.y < primaryBox.y && + primaryBox.y < sourceBox.y && + needsBox.y < readyBox.y + ? "scoped-priority-order" + : "different"; + }) + .toBe("scoped-priority-order"); + }); + test("every root of a project draws its own workspace row", async ({ page, tauri,