Skip to content
Draft
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
1 change: 1 addition & 0 deletions change-logs/2026/06/09/feature-sidebar-attention-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added an "attention" mode to the Active Tasks sidebar. The project/global scope toggle was replaced by three icon buttons — folder (this project), globe (all projects), and a new bell. Clicking the bell filters the list cross-project to only tasks in `user-questions` or `review-by-user` status, sorted oldest-first. The bell shows a pulsing count badge when tasks are waiting and the mode is not already active.
89 changes: 60 additions & 29 deletions src/mainview/components/ActiveTasksSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ import AgentLauncherBadge from "./AgentLauncherBadge";
import VariantDots from "./VariantDots";
import { getTaskAgentMeta } from "../utils/taskAgentMeta";

type SidebarScope = "project" | "global";
type SidebarScope = "project" | "global" | "attention";
const LS_SIDEBAR_SCOPE = "dev3-sidebar-scope";

/** Statuses that require the user's attention — the "attention" scope shows only these. */
const ATTENTION_STATUSES: TaskStatus[] = ["user-questions", "review-by-user"];

function readScope(): SidebarScope {
try {
const v = localStorage.getItem(LS_SIDEBAR_SCOPE);
if (v === "global" || v === "project") return v;
if (v === "global" || v === "project" || v === "attention") return v;
} catch { /* ignore */ }
return "project";
}
Expand Down Expand Up @@ -96,9 +99,9 @@ function ActiveTasksSidebar({
return () => window.removeEventListener("keydown", handleKeyDown);
}, [disableGlobalFindShortcut]);

// Fetch active tasks from all projects when in global scope.
// Fetch active tasks from all projects when in global or attention scope.
useEffect(() => {
if (scope !== "global") return;
if (scope !== "global" && scope !== "attention") return;
let cancelled = false;
setGlobalLoading(true);
(async () => {
Expand All @@ -125,7 +128,7 @@ function ActiveTasksSidebar({

// Keep global tasks live across all projects.
useEffect(() => {
if (scope !== "global") return;
if (scope !== "global" && scope !== "attention") return;
function onTaskUpdated(e: Event) {
const { task } = (e as CustomEvent).detail as { task: Task };
setGlobalTasks((prev) => {
Expand All @@ -151,9 +154,24 @@ function ActiveTasksSidebar({
return () => window.removeEventListener("rpc:taskUpdated", onTaskUpdated);
}, [scope]);

const sourceTasks = scope === "global" ? globalTasks : tasks;
const sourceTasks = (scope === "global" || scope === "attention") ? globalTasks : tasks;

// Count of attention tasks across all available data (global when loaded, else project).
const attentionCount = useMemo(() => {
const pool = globalTasks.length > 0 ? globalTasks : tasks;
return pool.filter((t) => ATTENTION_STATUSES.includes(t.status)).length;
}, [globalTasks, tasks]);

let activeTasks = sourceTasks.filter((task) => ACTIVE_STATUSES.includes(task.status));
if (scope === "attention") {
activeTasks = activeTasks.filter((task) => ATTENTION_STATUSES.includes(task.status));
// Sort oldest-first so the longest-waiting task is always at the top.
activeTasks = activeTasks.slice().sort((a, b) => {
const aTime = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const bTime = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
return aTime - bTime;
});
}
if (searchQuery.trim()) {
activeTasks = activeTasks.filter((task) => matchesSearchQuery(task, searchQuery));
}
Expand Down Expand Up @@ -214,6 +232,7 @@ function ActiveTasksSidebar({
</span>
<div className="flex items-center gap-1.5 flex-shrink-0 h-5">
<div className="inline-flex items-center gap-px" aria-label={t("sidebar.scopeToggleTitle")}>
{/* Folder \u2014 this project only */}
<button
type="button"
onClick={() => setScope("project")}
Expand All @@ -231,26 +250,7 @@ function ActiveTasksSidebar({
{"\uF07C"}
</span>
</button>
<button
type="button"
role="switch"
aria-checked={scope === "global"}
onClick={() => setScope(scope === "global" ? "project" : "global")}
title={t("sidebar.scopeToggleTitle")}
className={`relative inline-flex items-center h-4 w-8 rounded-full transition-colors ${
scope === "global" ? "bg-accent" : "bg-fg/20"
}`}
data-testid="sidebar-scope-toggle"
>
<span
className={`absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-white shadow transform transition-transform ${
scope === "global" ? "translate-x-[1.125rem]" : "translate-x-0.5"
}`}
/>
<span className="sr-only">
{scope === "global" ? t("sidebar.scopeGlobal") : t("sidebar.scopeProject")}
</span>
</button>
{/* Globe \u2014 all projects */}
<button
type="button"
onClick={() => setScope("global")}
Expand All @@ -268,6 +268,33 @@ function ActiveTasksSidebar({
{"\uEB01"}
</span>
</button>
{/* Bell \u2014 attention mode: cross-project, filtered to tasks needing user input */}
<button
type="button"
onClick={() => setScope("attention")}
title={t("sidebar.scopeAttention")}
className={`relative inline-flex items-center justify-center h-5 w-5 leading-none transition-colors ${
scope === "attention"
? "text-amber-400"
: attentionCount > 0
? "text-amber-400/70 hover:text-amber-400"
: "text-fg-muted hover:text-fg-2"
}`}
data-testid="sidebar-scope-attention"
>
{/* Nerd Font: nf-fa-bell (U+F0A2) */}
<span
className={`text-sm leading-none ${scope !== "attention" && attentionCount > 0 ? "animate-pulse" : ""}`}
style={{ fontFamily: "'JetBrainsMono Nerd Font Mono'" }}
>
{"\uF0A2"}
</span>
{attentionCount > 0 && scope !== "attention" && (
<span className="absolute -top-1 -right-1 min-w-[0.875rem] h-3.5 flex items-center justify-center px-0.5 rounded-full bg-amber-500 text-[0.5rem] font-bold text-white leading-none pointer-events-none">
{attentionCount > 9 ? "9+" : attentionCount}
</span>
)}
</button>
</div>
<button
onClick={onSwitchToBoard}
Expand Down Expand Up @@ -342,13 +369,17 @@ function ActiveTasksSidebar({

{/* Task list */}
<div className="flex-1 overflow-y-auto overflow-x-hidden">
{scope === "global" && globalLoading && grouped.length === 0 ? (
{(scope === "global" || scope === "attention") && globalLoading && grouped.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-fg-muted">
{t("sidebar.globalLoading")}
</div>
) : grouped.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-fg-muted">
{searchQuery.trim() ? t("sidebar.noSearchResults") : t("sidebar.noActiveTasks")}
{searchQuery.trim()
? t("sidebar.noSearchResults")
: scope === "attention"
? t("sidebar.noAttentionTasks")
: t("sidebar.noActiveTasks")}
</div>
) : (
grouped.map(({ status, tasks: groupTasks }, groupIdx) => (
Expand Down Expand Up @@ -386,7 +417,7 @@ function ActiveTasksSidebar({
.filter(Boolean) as typeof projectLabels;
const groupMembers = task.groupId ? siblingMap.get(task.groupId) ?? [task] : [task];
const agentSummary = [agent?.name, configLabel].filter(Boolean).join(" · ");
const showProjectBadge = scope === "global" && task.projectId !== project.id;
const showProjectBadge = (scope === "global" || scope === "attention") && task.projectId !== project.id;
const projectBadgeName = taskProject?.name ?? t("sidebar.unknownProject");

return (
Expand Down
137 changes: 136 additions & 1 deletion src/mainview/components/__tests__/ActiveTasksSidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ describe("ActiveTasksSidebar", () => {
// Cross-project task is hidden in project scope.
expect(screen.queryByText("Cross-project task")).not.toBeInTheDocument();

await user.click(screen.getByTestId("sidebar-scope-toggle"));
await user.click(screen.getByTestId("sidebar-scope-global"));

await waitFor(() => {
expect(screen.getByText("Cross-project task")).toBeInTheDocument();
Expand All @@ -170,6 +170,141 @@ describe("ActiveTasksSidebar", () => {
});
});

it("attention scope shows only tasks needing user input, oldest-first, cross-project", async () => {
const user = userEvent.setup();
const { api } = await import("../../rpc");
const otherProject: Project = {
id: "p2",
name: "Other Project",
path: "/tmp/other",
setupScript: "",
devScript: "",
cleanupScript: "",
defaultBaseBranch: "main",
createdAt: "2025-01-01T00:00:00Z",
};
const olderReview = makeTask({
id: "rv-old", seq: 100, projectId: "p2",
title: "Older review", description: "Older review",
status: "review-by-user",
groupId: null as unknown as string, variantIndex: null,
updatedAt: "2025-01-01T00:00:00Z",
});
const newerReview = makeTask({
id: "rv-new", seq: 101, projectId: "p1",
title: "Newer review", description: "Newer review",
status: "review-by-user",
groupId: null as unknown as string, variantIndex: null,
updatedAt: "2025-06-01T00:00:00Z",
});
const question = makeTask({
id: "q1", seq: 102, projectId: "p1",
title: "Has a question", description: "Has a question",
status: "user-questions",
groupId: null as unknown as string, variantIndex: null,
updatedAt: "2025-03-01T00:00:00Z",
});
const working = makeTask({
id: "w1", seq: 103, projectId: "p1",
title: "Still working", description: "Still working",
status: "in-progress",
groupId: null as unknown as string, variantIndex: null,
});
(api.request.getAllProjectTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{ projectId: "p1", tasks: [newerReview, question, working] },
{ projectId: "p2", tasks: [olderReview] },
]);

render(
<I18nProvider>
<ActiveTasksSidebar
project={project}
tasks={[working]}
allProjects={[project, otherProject]}
activeTaskId="t1"
dispatch={vi.fn()}
navigate={vi.fn()}
agents={[claudeAgent]}
bellCounts={new Map()}
taskPorts={new Map()}
onSwitchToBoard={vi.fn()}
/>
</I18nProvider>,
);

await user.click(screen.getByTestId("sidebar-scope-attention"));

await waitFor(() => {
expect(screen.getByText("Older review")).toBeInTheDocument();
});
// in-progress task is excluded from attention scope
expect(screen.queryByText("Still working")).not.toBeInTheDocument();
// question task (other attention status) is included
expect(screen.getByText("Has a question")).toBeInTheDocument();
// oldest-first within the review-by-user group
const older = screen.getByText("Older review");
const newer = screen.getByText("Newer review");
expect(
older.compareDocumentPosition(newer) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(localStorage.getItem("dev3-sidebar-scope")).toBe("attention");
});

it("shows a count badge on the bell when tasks await input and attention scope is inactive", () => {
render(
<I18nProvider>
<ActiveTasksSidebar
project={project}
tasks={[
makeTask({ id: "a1", status: "review-by-user" }),
makeTask({ id: "a2", status: "user-questions" }),
makeTask({ id: "a3", status: "in-progress" }),
]}
activeTaskId="a1"
dispatch={vi.fn()}
navigate={vi.fn()}
agents={[claudeAgent]}
bellCounts={new Map()}
taskPorts={new Map()}
onSwitchToBoard={vi.fn()}
/>
</I18nProvider>,
);

// Two attention-status tasks → badge reads "2".
expect(screen.getByTestId("sidebar-scope-attention")).toHaveTextContent("2");
});

it("shows the attention empty state when nothing needs the user's input", async () => {
const user = userEvent.setup();
const { api } = await import("../../rpc");
(api.request.getAllProjectTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{ projectId: "p1", tasks: [makeTask({ id: "w1", status: "in-progress" })] },
]);

render(
<I18nProvider>
<ActiveTasksSidebar
project={project}
tasks={[makeTask({ id: "w1", status: "in-progress" })]}
activeTaskId="w1"
dispatch={vi.fn()}
navigate={vi.fn()}
agents={[claudeAgent]}
bellCounts={new Map()}
taskPorts={new Map()}
onSwitchToBoard={vi.fn()}
/>
</I18nProvider>,
);

await user.click(screen.getByTestId("sidebar-scope-attention"));

await waitFor(() => {
expect(screen.getByText("Nothing needs your attention")).toBeInTheDocument();
});
});

it("shows overview inline only for the active task when overview is set", () => {
render(
<I18nProvider>
Expand Down
2 changes: 2 additions & 0 deletions src/mainview/i18n/translations/en/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ const common = {
"sidebar.scopeToggleTitle": "Toggle task scope (this project / all projects)",
"sidebar.globalLoading": "Loading tasks from all projects…",
"sidebar.unknownProject": "Unknown project",
"sidebar.scopeAttention": "Needs your attention — tasks waiting for your input, across all projects",
"sidebar.noAttentionTasks": "Nothing needs your attention",

// Open in...
"openIn.menuTitle": "Open in...",
Expand Down
2 changes: 2 additions & 0 deletions src/mainview/i18n/translations/en/tips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ const tips = {
"tip.quitConfirm.body": "Quitting via Cmd+Q, the menu, or the dock shows a confirmation so your background tmux sessions don't vanish by surprise.",
"tip.sidebarHide.title": "Hide the Active Tasks panel",
"tip.sidebarHide.body": "Click the fullscreen icon in the sidebar header to collapse it and give the terminal the whole window.",
"tip.sidebarAttentionMode.title": "Bell = needs your eyes",
"tip.sidebarAttentionMode.body": "Click the bell icon in the sidebar to see every task waiting for your input across all projects, sorted oldest-first.",
} as const;

export default tips;
2 changes: 2 additions & 0 deletions src/mainview/i18n/translations/es/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ const common = {
"sidebar.scopeToggleTitle": "Alternar alcance (este proyecto / todos los proyectos)",
"sidebar.globalLoading": "Cargando tareas de todos los proyectos…",
"sidebar.unknownProject": "Proyecto desconocido",
"sidebar.scopeAttention": "Requiere tu atención — tareas esperando tu respuesta en todos los proyectos",
"sidebar.noAttentionTasks": "Nada requiere tu atención",

// Open in...
"openIn.menuTitle": "Abrir en...",
Expand Down
2 changes: 2 additions & 0 deletions src/mainview/i18n/translations/es/tips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ const tips = {
"tip.quitConfirm.body": "Salir con Cmd+Q, el menú o el dock muestra una confirmación para que tus sesiones tmux en segundo plano no desaparezcan por sorpresa.",
"tip.sidebarHide.title": "Ocultar el panel de Tareas activas",
"tip.sidebarHide.body": "Pulsa el icono de pantalla completa en la cabecera del panel para plegarlo y dar al terminal toda la ventana.",
"tip.sidebarAttentionMode.title": "Campana = necesita tu atención",
"tip.sidebarAttentionMode.body": "Pulsa el icono de campana en el panel para ver todas las tareas esperando tu respuesta en todos los proyectos, ordenadas de más antigua a más reciente.",
};

export default tips;
2 changes: 2 additions & 0 deletions src/mainview/i18n/translations/ru/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ const common = {
"sidebar.scopeToggleTitle": "Переключить область задач (этот проект / все проекты)",
"sidebar.globalLoading": "Загрузка задач изо всех проектов…",
"sidebar.unknownProject": "Неизвестный проект",
"sidebar.scopeAttention": "Требует внимания — задачи, ожидающие вашего ответа, по всем проектам",
"sidebar.noAttentionTasks": "Всё в порядке, ничего не ждёт вашего ответа",

// Open in...
"openIn.menuTitle": "Открыть в...",
Expand Down
2 changes: 2 additions & 0 deletions src/mainview/i18n/translations/ru/tips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ const tips = {
"tip.quitConfirm.body": "Выход через Cmd+Q, меню или док показывает подтверждение, чтобы фоновые tmux-сессии не исчезли внезапно.",
"tip.sidebarHide.title": "Скрыть панель Active Tasks",
"tip.sidebarHide.body": "Нажмите иконку полноэкранного режима в шапке панели, чтобы свернуть её и отдать всё окно терминалу.",
"tip.sidebarAttentionMode.title": "Колокол = ждёт твоих глаз",
"tip.sidebarAttentionMode.body": "Кликни иконку колокола в панели, чтобы увидеть все задачи, ожидающие твоего ответа по всем проектам, начиная с самых старых.",
};

export default tips;
7 changes: 7 additions & 0 deletions src/mainview/tips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,13 @@ const ALL_TIPS: Tip[] = [
bodyKey: "tip.sidebarHide.body",
icon: "\u{F0294}", // nf-md-fullscreen
},
// Batch 42: attention mode
{
id: "sidebar-attention-mode",
titleKey: "tip.sidebarAttentionMode.title",
bodyKey: "tip.sidebarAttentionMode.body",
icon: "\u{F0A2}", // nf-fa-bell
},
];

const COOLDOWN_MS = 3 * 24 * 60 * 60 * 1000; // 3 days
Expand Down