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
5 changes: 3 additions & 2 deletions src/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
47 changes: 44 additions & 3 deletions src/lib/sidebarProjectItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
[
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 10 additions & 5 deletions src/lib/sidebarProjectItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion src/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
"hint": "左側のサイドバーの各プロジェクト内のセッションとワークスペースの順序を制御します。",
"priority": {
"label": "待機中およびエラーのタブを上部に移動します",
"description": "手動注文を保存したままにしますが、準備が整う前に待機中またはエラーによってブロックされたタブが表示されます。"
"description": "手動の順序は保存されます。入力待ちやエラーのタブはそのプロジェクトまたはワークスペース内で前に移動し、タブが1つのワークスペースはプロジェクト内で前に移動する場合があります。"
}
},
"kanbanTerminalPopover": {
Expand Down
2 changes: 1 addition & 1 deletion src/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
"hint": "좌측 사이드바의 각 프로젝트 안에서 세션과 작업공간이 표시되는 순서를 제어합니다.",
"priority": {
"label": "입력 대기 및 오류 탭을 상단으로 이동",
"description": "수동 순서는 저장된 채로 유지하고, 사용자 입력을 기다리거나 오류로 막힌 탭을 준비된 작업보다 먼저 표시합니다."
"description": "수동 순서는 저장된 채로 유지합니다. 입력 대기 및 오류 탭은 해당 프로젝트나 작업공간 안에서 앞에 표시되며, 탭이 하나인 작업공간은 프로젝트 안에서 앞에 표시될 수 있습니다."
}
},
"kanbanTerminalPopover": {
Expand Down
2 changes: 1 addition & 1 deletion src/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
"hint": "控制左侧侧边栏中每个项目内的会话和工作区的顺序。",
"priority": {
"label": "将等待和错误标签页移至顶部",
"description": "保留手动排序,但将等待您处理或因错误受阻的标签页显示在其他工作之前。"
"description": "保留手动排序。等待和错误标签页会在所属项目或工作区内提前;只有一个标签页的工作区可在项目内提前。"
}
},
"kanbanTerminalPopover": {
Expand Down
158 changes: 148 additions & 10 deletions tests/e2e/sidebar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
Loading