+
{isCloud ? (
<>
{stacks.map((stack) => (
@@ -470,54 +491,57 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
>
)}
-
- {/* A cloud instance without a configured cloud origin has nowhere to
- send the user, so the row (and its separator) drop out entirely. */}
- {isCloud && !createStackUrl ? null : (
- <>
+
+ {/* A cloud instance without a configured cloud origin has nowhere to
+ send the user, so the row drops out entirely. */}
+ {isCloud && !createStackUrl ? null : (
-
- Create new organization...
-
-
- >
- )}
- {showInvitePeople ? (
-
- {
- if (isEditingOrder) {
- event.preventDefault();
- return;
- }
- closeNavigationChrome();
- }}
- >
-
-
- {currentName ? `Invite people to ${currentName}` : "Invite people"}
+
+
-
-
- ) : null}
- {session?.session ? (
- <>
-
+
Create organization
+
+ )}
+ {showInvitePeople ? (
+
+ {
+ if (isEditingOrder) {
+ event.preventDefault();
+ return;
+ }
+ closeNavigationChrome();
+ }}
+ >
+
+
+
+
+ {currentName ? `Invite people to ${currentName}` : "Invite people"}
+
+
+
+ ) : null}
+ {session?.session ? (
signOutMutation.mutate()}
disabled={isEditingOrder || signOutMutation.isPending}
>
-
- {signOutMutation.isPending ? "Signing out..." : "Sign out"}
+
+
+
+
+ {signOutMutation.isPending ? "Signing out..." : "Sign out"}
+
- >
- ) : null}
+ ) : null}
+
);
diff --git a/ui/src/components/SidebarNavItem.test.tsx b/ui/src/components/SidebarNavItem.test.tsx
index a45849f08a..3f882ab265 100644
--- a/ui/src/components/SidebarNavItem.test.tsx
+++ b/ui/src/components/SidebarNavItem.test.tsx
@@ -98,18 +98,20 @@ describe("SidebarNavItem", () => {
expect(link().firstElementChild?.textContent).toBe("Recent task");
});
- it("uses the Paper nav surface for the active item", () => {
+ it("uses the sidebar accent surface for the active item", () => {
render(
);
- expect(classTokens(link())).toContain("bg-background");
- expect(classTokens(link())).not.toContain("bg-accent");
+ expect(classTokens(link())).toContain("bg-sidebar-accent");
+ expect(classTokens(link())).toContain("text-sidebar-accent-foreground");
+ expect(classTokens(link())).not.toContain("bg-background");
});
- it("uses the active nav surface for hover", () => {
+ it("uses the legible sidebar accent surface for hover", () => {
render(
);
- expect(classTokens(link())).toContain("hover:bg-background");
- expect(classTokens(link())).not.toContain("hover:bg-accent/50");
+ expect(classTokens(link())).toContain("hover:bg-sidebar-accent");
+ expect(classTokens(link())).toContain("hover:text-sidebar-accent-foreground");
+ expect(classTokens(link())).not.toContain("hover:bg-background");
});
it("clips the label (kept in flow for 1:1 row height) and collapses the badge to a dot in the rail", () => {
diff --git a/ui/src/components/SidebarNavItem.tsx b/ui/src/components/SidebarNavItem.tsx
index 52b1db3a31..e918ed3540 100644
--- a/ui/src/components/SidebarNavItem.tsx
+++ b/ui/src/components/SidebarNavItem.tsx
@@ -128,8 +128,8 @@ export function SidebarNavItem({
// (agents/projects) reserve extra right padding via className.
"flex items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 text-(length:--text-compact) font-medium transition-colors",
(active ?? isActive)
- ? "bg-background text-foreground"
- : "text-foreground/80 hover:bg-background hover:text-foreground",
+ ? "bg-sidebar-accent text-sidebar-accent-foreground"
+ : "text-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
className,
)
}
diff --git a/ui/src/components/SidebarProjects.tsx b/ui/src/components/SidebarProjects.tsx
index 697f1dcfe4..f35105461b 100644
--- a/ui/src/components/SidebarProjects.tsx
+++ b/ui/src/components/SidebarProjects.tsx
@@ -140,8 +140,8 @@ function ProjectItem({
className={cn(
"flex min-w-0 flex-1 items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pr-8 pointer-coarse:py-1 text-(length:--text-compact) font-medium transition-colors",
activeProjectRef === routeRef || activeProjectRef === project.id
- ? "bg-background text-foreground"
- : "text-foreground/80 hover:bg-background hover:text-foreground",
+ ? "bg-sidebar-accent text-sidebar-accent-foreground"
+ : "text-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
>
diff --git a/ui/src/components/SidebarRecentTasks.test.tsx b/ui/src/components/SidebarRecentTasks.test.tsx
index 8c572ba865..0b3ba98700 100644
--- a/ui/src/components/SidebarRecentTasks.test.tsx
+++ b/ui/src/components/SidebarRecentTasks.test.tsx
@@ -11,10 +11,20 @@ import {
readRecentTasks,
recordRecentTask,
} from "@/lib/recent-tasks";
+import { queryKeys } from "@/lib/queryKeys";
const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn() }));
-const mockIssuesApi = vi.hoisted(() => ({ get: vi.fn() }));
+const mockAgentsApi = vi.hoisted(() => ({ wakeup: vi.fn() }));
+const mockIssuesApi = vi.hoisted(() => ({
+ get: vi.fn(),
+ update: vi.fn(),
+ archiveFromInbox: vi.fn(),
+ getTreeControlState: vi.fn(),
+ createTreeHold: vi.fn(),
+ releaseTreeHold: vi.fn(),
+}));
+vi.mock("@/api/agents", () => ({ agentsApi: mockAgentsApi }));
vi.mock("@/api/auth", () => ({ authApi: mockAuthApi }));
vi.mock("@/api/issues", () => ({ issuesApi: mockIssuesApi }));
vi.mock("@/lib/router", () => ({
@@ -50,7 +60,8 @@ describe("SidebarRecentTasks", () => {
beforeEach(() => {
window.localStorage.clear();
- mockIssuesApi.get.mockReset();
+ Object.values(mockAgentsApi).forEach((mock) => mock.mockReset());
+ Object.values(mockIssuesApi).forEach((mock) => mock.mockReset());
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
@@ -86,10 +97,31 @@ describe("SidebarRecentTasks", () => {
return queryClient;
}
- it("renders a compact empty state", async () => {
+ async function openActions(taskTitle: string) {
+ const actions = container.querySelector
(
+ `button[aria-label="More actions for ${taskTitle}"]`,
+ );
+ await act(async () => {
+ actions?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
+ await Promise.resolve();
+ });
+ }
+
+ function menuItem(label: string) {
+ return Array.from(document.body.querySelectorAll('[role="menuitem"]'))
+ .find((item) => item.textContent?.trim() === label);
+ }
+
+ function setInputValue(input: HTMLInputElement, value: string) {
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
+ setter?.call(input, value);
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ }
+
+ it("hides the section when there are no recent tasks", async () => {
await render();
- expect(container.textContent).toContain("Recent Tasks");
- expect(container.textContent).toContain("Open or create a task");
+ expect(container.textContent).not.toContain("Recent Tasks");
+ expect(container.textContent).not.toContain("Open or create a task");
});
it("renders refreshed task text and live state without shifting for a status icon", async () => {
@@ -130,6 +162,246 @@ describe("SidebarRecentTasks", () => {
expect(link?.querySelector('[data-slot="recent-task-icon-spacer"]')).toBeNull();
expect(link?.querySelector('[data-slot="sidebar-nav-icon"]')).toBeNull();
expect(link?.firstElementChild?.textContent).toBe("Refreshed title");
+ const actions = container.querySelector(
+ 'button[aria-label="More actions for Refreshed title"]',
+ );
+ expect(actions).not.toBeNull();
+ expect(actions?.className).toContain("opacity-0");
+ });
+
+ it("opens the compact task actions menu from the ellipsis button", async () => {
+ recordRecentTask({
+ id: "issue-1",
+ companyId: "company-1",
+ title: "Menu task",
+ identifier: "PAP-1",
+ status: "todo",
+ updatedAt: new Date(1),
+ }, "user-1");
+ mockIssuesApi.get.mockResolvedValue({
+ id: "issue-1",
+ companyId: "company-1",
+ title: "Menu task",
+ identifier: "PAP-1",
+ status: "todo",
+ hiddenAt: null,
+ updatedAt: new Date(1),
+ });
+
+ await render();
+ await openActions("Menu task");
+
+ const menu = document.body.querySelector('[data-slot="dropdown-menu-content"]');
+ expect(menu?.textContent).toContain("Rename");
+ expect(menu?.textContent).toContain("Archive");
+ expect(menu?.textContent).toContain("Pause/Restart");
+ });
+
+ it("archives a task from the inbox without hiding or removing the recent task", async () => {
+ const issue = {
+ id: "issue-1",
+ companyId: "company-1",
+ title: "Archive me",
+ identifier: "PAP-1",
+ status: "todo" as const,
+ hiddenAt: null,
+ updatedAt: new Date(1),
+ };
+ recordRecentTask(issue, "user-1");
+ mockIssuesApi.get.mockResolvedValue(issue);
+ mockIssuesApi.archiveFromInbox.mockResolvedValue({
+ id: issue.id,
+ archivedAt: new Date(2),
+ });
+
+ await render();
+ await openActions("Archive me");
+ await act(async () => {
+ menuItem("Archive")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await Promise.resolve();
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ });
+
+ expect(mockIssuesApi.archiveFromInbox).toHaveBeenCalledWith("issue-1");
+ expect(mockIssuesApi.update).not.toHaveBeenCalled();
+ expect(container.textContent).toContain("Recent Tasks");
+ expect(container.querySelector('a[href="/issues/issue-1"]')?.textContent).toContain(
+ "Archive me",
+ );
+ expect(readRecentTasks(
+ getRecentTasksStorageKey("company-1", "user-1"),
+ "company-1",
+ )).toHaveLength(1);
+ });
+
+ it("refreshes task activity after a rename", async () => {
+ const issue = {
+ id: "issue-1",
+ companyId: "company-1",
+ title: "Old title",
+ identifier: "PAP-1",
+ status: "todo" as const,
+ hiddenAt: null,
+ updatedAt: new Date(1),
+ };
+ const renamedIssue = { ...issue, title: "New title", updatedAt: new Date(2) };
+ recordRecentTask(issue, "user-1");
+ mockIssuesApi.get.mockResolvedValue(issue);
+ mockIssuesApi.update.mockResolvedValue(renamedIssue);
+
+ const queryClient = await render();
+ const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries");
+ await openActions("Old title");
+ await act(async () => {
+ menuItem("Rename")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ const input = document.body.querySelector('input[aria-label="Task name"]');
+ expect(input).not.toBeNull();
+ await act(async () => {
+ setInputValue(input!, "New title");
+ await Promise.resolve();
+ });
+ await act(async () => {
+ input?.closest("form")?.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
+ await Promise.resolve();
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ });
+
+ expect(mockIssuesApi.update).toHaveBeenCalledWith("issue-1", { title: "New title" });
+ expect(invalidateQueries).toHaveBeenCalledWith({
+ queryKey: queryKeys.issues.activity("issue-1"),
+ });
+ });
+
+ it("pauses a running task or restarts its active pause hold", async () => {
+ const issue = {
+ id: "issue-1",
+ companyId: "company-1",
+ title: "Toggle work",
+ identifier: "PAP-1",
+ status: "in_progress" as const,
+ assigneeAgentId: "agent-1",
+ hiddenAt: null,
+ updatedAt: new Date(1),
+ };
+ recordRecentTask(issue, "user-1");
+ mockIssuesApi.get.mockResolvedValue(issue);
+ mockIssuesApi.getTreeControlState
+ .mockResolvedValueOnce({ activePauseHold: null })
+ .mockResolvedValueOnce({
+ activePauseHold: {
+ holdId: "hold-1",
+ rootIssueId: "issue-1",
+ issueId: "issue-1",
+ isRoot: true,
+ mode: "pause",
+ reason: null,
+ releasePolicy: { strategy: "manual" },
+ },
+ });
+ mockIssuesApi.createTreeHold.mockResolvedValue({ hold: {}, preview: {} });
+ mockIssuesApi.releaseTreeHold.mockResolvedValue({});
+ mockAgentsApi.wakeup.mockResolvedValue({ id: "run-1" });
+
+ await render();
+ await openActions("Toggle work");
+ await act(async () => {
+ menuItem("Pause/Restart")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await Promise.resolve();
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ });
+ expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("issue-1", {
+ mode: "pause",
+ reason: "Paused from Recent Tasks.",
+ releasePolicy: { strategy: "manual" },
+ });
+
+ await openActions("Toggle work");
+ await act(async () => {
+ menuItem("Pause/Restart")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await Promise.resolve();
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ });
+ expect(mockIssuesApi.releaseTreeHold).toHaveBeenCalledWith("issue-1", "hold-1", {
+ reason: "Restarted from Recent Tasks.",
+ });
+ expect(mockAgentsApi.wakeup).toHaveBeenCalledWith(
+ "agent-1",
+ {
+ source: "assignment",
+ triggerDetail: "manual",
+ reason: "recent_task_restart",
+ payload: { issueId: "issue-1" },
+ },
+ "company-1",
+ );
+ });
+
+ it("retries only the wake after a remount when restart releases the hold before wakeup fails", async () => {
+ const issue = {
+ id: "issue-1",
+ companyId: "company-1",
+ title: "Retry restart",
+ identifier: "PAP-1",
+ status: "in_progress" as const,
+ assigneeAgentId: "agent-1",
+ hiddenAt: null,
+ updatedAt: new Date(1),
+ };
+ recordRecentTask(issue, "user-1");
+ mockIssuesApi.get.mockResolvedValue(issue);
+ mockIssuesApi.getTreeControlState
+ .mockResolvedValueOnce({
+ activePauseHold: {
+ holdId: "hold-1",
+ rootIssueId: "issue-1",
+ issueId: "issue-1",
+ isRoot: true,
+ mode: "pause",
+ reason: null,
+ releasePolicy: { strategy: "manual" },
+ },
+ })
+ .mockResolvedValueOnce({ activePauseHold: null });
+ mockIssuesApi.releaseTreeHold.mockResolvedValue({});
+ mockAgentsApi.wakeup
+ .mockRejectedValueOnce(new Error("Wake failed"))
+ .mockResolvedValueOnce({ id: "run-1" });
+
+ await render();
+ await openActions("Retry restart");
+ await act(async () => {
+ menuItem("Pause/Restart")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await Promise.resolve();
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ });
+
+ await act(async () => root.unmount());
+ root = createRoot(container);
+ await render();
+
+ await openActions("Retry restart");
+ await act(async () => {
+ menuItem("Pause/Restart")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await Promise.resolve();
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+ });
+
+ expect(mockIssuesApi.releaseTreeHold).toHaveBeenCalledTimes(1);
+ expect(mockIssuesApi.createTreeHold).not.toHaveBeenCalled();
+ expect(mockAgentsApi.wakeup).toHaveBeenCalledTimes(2);
+ expect(mockAgentsApi.wakeup).toHaveBeenLastCalledWith(
+ "agent-1",
+ {
+ source: "assignment",
+ triggerDetail: "manual",
+ reason: "recent_task_restart_retry",
+ payload: { issueId: "issue-1" },
+ },
+ "company-1",
+ );
});
it("synchronizes recent tasks written by another tab", async () => {
diff --git a/ui/src/components/SidebarRecentTasks.tsx b/ui/src/components/SidebarRecentTasks.tsx
index 449251bbc4..be9797bbf4 100644
--- a/ui/src/components/SidebarRecentTasks.tsx
+++ b/ui/src/components/SidebarRecentTasks.tsx
@@ -1,11 +1,71 @@
-import { useQuery } from "@tanstack/react-query";
+import { useState, type FormEvent } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Archive, MoreHorizontal, Pencil, RefreshCw } from "lucide-react";
+import { agentsApi } from "@/api/agents";
import { authApi } from "@/api/auth";
+import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
import { useRecentTasks } from "@/hooks/useRecentTasks";
import { useSidebar } from "@/context/SidebarContext";
+import { useOptionalToastActions } from "@/context/ToastContext";
+import {
+ updateRecentTaskSnapshots,
+ type RecentTaskEntry,
+} from "@/lib/recent-tasks";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
import { SidebarSection } from "./SidebarSection";
import { SidebarNavItem } from "./SidebarNavItem";
+const RECENT_TASK_MENU_ITEM_CLASS =
+ "h-(--profile-popover-row-height) gap-(--profile-popover-row-gap) rounded-lg px-2.5 py-0 text-(length:--text-compact) font-medium leading-(--profile-popover-label-line-height) focus:bg-accent/50 focus:text-foreground";
+const RESTART_WAKE_RETRY_STORAGE_SUFFIX = ":restart-wake-retry";
+
+function restartWakeRetryStorageKey(storageKey: string | null) {
+ return storageKey ? `${storageKey}${RESTART_WAKE_RETRY_STORAGE_SUFFIX}` : null;
+}
+
+function readRestartWakeRetryIssueIds(storageKey: string | null) {
+ if (!storageKey) return new Set();
+ try {
+ const parsed = JSON.parse(window.localStorage.getItem(storageKey) ?? "[]") as unknown;
+ return new Set(Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : []);
+ } catch {
+ return new Set();
+ }
+}
+
+function setRestartWakeRetryPending(storageKey: string | null, issueId: string, pending: boolean) {
+ if (!storageKey) return;
+ const issueIds = readRestartWakeRetryIssueIds(storageKey);
+ if (pending) issueIds.add(issueId);
+ else issueIds.delete(issueId);
+ try {
+ if (issueIds.size > 0) window.localStorage.setItem(storageKey, JSON.stringify([...issueIds]));
+ else window.localStorage.removeItem(storageKey);
+ } catch {
+ // Recent Tasks remains usable when browser storage is unavailable.
+ }
+}
+
+function errorMessage(error: unknown, fallback: string) {
+ return error instanceof Error && error.message.trim() ? error.message : fallback;
+}
+
export function SidebarRecentTasks({
companyId,
liveIssueIds,
@@ -46,24 +106,243 @@ function RecentTasksList({
liveIssueIds: ReadonlySet;
rail: boolean;
}) {
- const { entries } = useRecentTasks({ companyId, userId });
+ const { entries, storageKey } = useRecentTasks({ companyId, userId });
+ const queryClient = useQueryClient();
+ const toastActions = useOptionalToastActions();
+ const [renameEntry, setRenameEntry] = useState(null);
+ const [renameValue, setRenameValue] = useState("");
+ const [pendingAction, setPendingAction] = useState<"rename" | "archive" | "pause" | null>(null);
+ const restartRetryStorageKey = restartWakeRetryStorageKey(storageKey);
- if (rail && entries.length === 0) return null;
+ if (entries.length === 0) return null;
+
+ const refreshIssueQueries = async (issueId: string) => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId) }),
+ queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }),
+ queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId) }),
+ ]);
+ };
+
+ const beginRename = (entry: RecentTaskEntry) => {
+ setRenameEntry(entry);
+ setRenameValue(entry.title);
+ };
+
+ const submitRename = async (event: FormEvent) => {
+ event.preventDefault();
+ const nextTitle = renameValue.trim();
+ if (!renameEntry || !nextTitle || nextTitle === renameEntry.title) {
+ setRenameEntry(null);
+ return;
+ }
+
+ setPendingAction("rename");
+ try {
+ const updated = await issuesApi.update(renameEntry.id, { title: nextTitle });
+ queryClient.setQueryData(queryKeys.issues.detail(renameEntry.id), updated);
+ if (storageKey) updateRecentTaskSnapshots(storageKey, companyId, [updated]);
+ await refreshIssueQueries(renameEntry.id);
+ setRenameEntry(null);
+ toastActions?.pushToast({ title: "Task renamed", tone: "success" });
+ } catch (error) {
+ toastActions?.pushToast({
+ title: "Task rename failed",
+ body: errorMessage(error, "Unable to rename this task."),
+ tone: "error",
+ });
+ } finally {
+ setPendingAction(null);
+ }
+ };
+
+ const archiveTask = async (entry: RecentTaskEntry) => {
+ setPendingAction("archive");
+ try {
+ await issuesApi.archiveFromInbox(entry.id);
+ await refreshIssueQueries(entry.id);
+ await queryClient.invalidateQueries({
+ queryKey: queryKeys.sidebarBadges(companyId),
+ });
+ toastActions?.pushToast({ title: "Task archived from inbox", tone: "success" });
+ } catch (error) {
+ toastActions?.pushToast({
+ title: "Task archive failed",
+ body: errorMessage(error, "Unable to archive this task from the inbox."),
+ tone: "error",
+ });
+ } finally {
+ setPendingAction(null);
+ }
+ };
+
+ const toggleTaskPause = async (entry: RecentTaskEntry) => {
+ setPendingAction("pause");
+ try {
+ const state = await issuesApi.getTreeControlState(entry.id);
+ if (state.activePauseHold?.isRoot) {
+ const restartIssue = await issuesApi.get(entry.id);
+ setRestartWakeRetryPending(restartRetryStorageKey, entry.id, true);
+ await issuesApi.releaseTreeHold(entry.id, state.activePauseHold.holdId, {
+ reason: "Restarted from Recent Tasks.",
+ });
+ if (restartIssue.assigneeAgentId) {
+ const wakeResult = await agentsApi.wakeup(
+ restartIssue.assigneeAgentId,
+ {
+ source: "assignment",
+ triggerDetail: "manual",
+ reason: "recent_task_restart",
+ payload: { issueId: restartIssue.id },
+ },
+ restartIssue.companyId,
+ );
+ if (!("id" in wakeResult)) {
+ throw new Error(wakeResult.message ?? "The assignee wake was skipped.");
+ }
+ }
+ setRestartWakeRetryPending(restartRetryStorageKey, entry.id, false);
+ toastActions?.pushToast({ title: "Task restarted", tone: "success" });
+ } else if (state.activePauseHold) {
+ throw new Error("This task is paused by a parent task. Restart it from the pause root.");
+ } else if (readRestartWakeRetryIssueIds(restartRetryStorageKey).has(entry.id)) {
+ const restartIssue = await issuesApi.get(entry.id);
+ if (restartIssue.assigneeAgentId) {
+ const wakeResult = await agentsApi.wakeup(
+ restartIssue.assigneeAgentId,
+ {
+ source: "assignment",
+ triggerDetail: "manual",
+ reason: "recent_task_restart_retry",
+ payload: { issueId: restartIssue.id },
+ },
+ restartIssue.companyId,
+ );
+ if (!("id" in wakeResult)) {
+ throw new Error(wakeResult.message ?? "The assignee wake was skipped.");
+ }
+ }
+ setRestartWakeRetryPending(restartRetryStorageKey, entry.id, false);
+ toastActions?.pushToast({ title: "Task restarted", tone: "success" });
+ } else {
+ await issuesApi.createTreeHold(entry.id, {
+ mode: "pause",
+ reason: "Paused from Recent Tasks.",
+ releasePolicy: { strategy: "manual" },
+ });
+ toastActions?.pushToast({ title: "Task paused", tone: "success" });
+ }
+ await queryClient.invalidateQueries({
+ queryKey: ["issues", "tree-control-state", entry.id],
+ });
+ } catch (error) {
+ toastActions?.pushToast({
+ title: "Task pause update failed",
+ body: errorMessage(error, "Unable to pause or restart this task."),
+ tone: "error",
+ });
+ } finally {
+ setPendingAction(null);
+ }
+ };
return (
-
- {entries.length === 0 ? (
-
- Open or create a task to keep it close at hand.
-
- ) : entries.map((entry) => (
-
- ))}
-
+ <>
+
+ {entries.map((entry) => (
+
+
+ {!rail ? (
+
+
+
+
+
+ beginRename(entry)}
+ >
+
+ Rename
+
+ void archiveTask(entry)}
+ >
+
+ Archive
+
+ void toggleTaskPause(entry)}
+ >
+
+ Pause/Restart
+
+
+
+ ) : null}
+
+ ))}
+
+
+
+ >
);
}
diff --git a/ui/src/components/SidebarStarredProjects.tsx b/ui/src/components/SidebarStarredProjects.tsx
index 3e00980641..14a5247f1c 100644
--- a/ui/src/components/SidebarStarredProjects.tsx
+++ b/ui/src/components/SidebarStarredProjects.tsx
@@ -124,8 +124,8 @@ export function SidebarStarredProjects() {
"flex min-w-0 flex-1 items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 pr-8 text-(length:--text-compact) font-medium transition-colors",
!rail && "pl-6",
isActive
- ? "bg-background text-foreground"
- : "text-foreground/80 hover:bg-background hover:text-foreground",
+ ? "bg-sidebar-accent text-sidebar-accent-foreground"
+ : "text-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
>
diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx
index 56d84e5677..cb01932ed6 100644
--- a/ui/src/components/TaskChatThread.test.tsx
+++ b/ui/src/components/TaskChatThread.test.tsx
@@ -347,10 +347,17 @@ describe("TaskChatThread draft pass-through", () => {
const dock = container.querySelector(
'[data-testid="task-chat-composer-dock"]',
);
+ const thread = container.querySelector(
+ '[data-testid="task-chat-thread"]',
+ );
+ expect(thread?.classList).not.toContain("h-(--tc-thread-max-h)");
+ expect(thread?.classList).toContain("flex-1");
expect(dock?.classList).toContain("px-4");
expect(dock?.classList).not.toContain("px-1");
- expect(dock?.classList).toContain("-mt-(--radius-task-composer)");
+ expect(dock?.classList).not.toContain("-mt-(--radius-task-composer)");
expect(dock?.classList).not.toContain("pt-1");
+ expect(dock?.classList).toContain("md:pb-0");
+ expect(dock?.classList).not.toContain("md:pb-4");
expect(dock?.classList).not.toContain("bg-background/80");
expect(dock?.classList).not.toContain("backdrop-blur");
});
diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx
index 5b3e04af84..6b95f4c50a 100644
--- a/ui/src/components/TaskChatThread.tsx
+++ b/ui/src/components/TaskChatThread.tsx
@@ -2388,7 +2388,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
@@ -2533,10 +2533,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
? "bottom-(--tc-composer-bottom) z-20 transition-[bottom] duration-200 ease-out"
: "bottom-0 z-10",
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-2 px-4 pb-2",
- streamlinedUiEnabled && "md:px-0 md:pb-4",
- streamlinedUiEnabled && !isMobile
- ? "-mt-(--radius-task-composer)"
- : "bg-background/80 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60",
+ streamlinedUiEnabled && "md:px-0 md:pb-0",
+ (!streamlinedUiEnabled || isMobile) &&
+ "bg-background/80 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60",
)}
>
{composerAccessory}
diff --git a/ui/src/components/ThemeToggle.test.tsx b/ui/src/components/ThemeToggle.test.tsx
index 959f6a1f92..b81c073d67 100644
--- a/ui/src/components/ThemeToggle.test.tsx
+++ b/ui/src/components/ThemeToggle.test.tsx
@@ -72,6 +72,23 @@ describe("ThemeToggle", () => {
await act(async () => root.unmount());
});
+ it("renders the compact profile-menu row without secondary copy", async () => {
+ const root = createRoot(container);
+ await act(async () => {
+ root.render(
);
+ });
+ await flushReact();
+
+ const button = container.querySelector("button");
+ expect(button?.classList).toContain("h-(--profile-popover-row-height)");
+ expect(button?.classList).toContain("gap-(--profile-popover-row-gap)");
+ expect(button?.querySelector("span")?.classList).toContain("size-5");
+ expect(container.textContent).toContain("Switch to light mode");
+ expect(container.textContent).not.toContain("Toggle the app appearance.");
+
+ await act(async () => root.unmount());
+ });
+
it("calls onAfterToggle after toggling (used by SidebarAccountMenu to close the popover)", async () => {
const onAfterToggle = vi.fn();
const root = createRoot(container);
diff --git a/ui/src/components/ThemeToggle.tsx b/ui/src/components/ThemeToggle.tsx
index 43bd24503b..6da91a0318 100644
--- a/ui/src/components/ThemeToggle.tsx
+++ b/ui/src/components/ThemeToggle.tsx
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTheme } from "../context/ThemeContext";
-type ThemeToggleVariant = "icon" | "menu-action";
+type ThemeToggleVariant = "icon" | "menu-action" | "compact-menu-action";
interface ThemeToggleProps {
className?: string;
@@ -14,7 +14,10 @@ interface ThemeToggleProps {
* other surface that just wants a toggle affordance.
*
* `menu-action`: full-width row with label + description + icon —
- * matches the surrounding `MenuAction` rows in `SidebarAccountMenu`.
+ * suitable for explanatory menus.
+ *
+ * `compact-menu-action`: compact label + icon row — matches the
+ * surrounding actions in `SidebarAccountMenu`.
*/
variant?: ThemeToggleVariant;
/**
@@ -42,6 +45,25 @@ export function ThemeToggle({ className, variant = "icon", onAfterToggle }: Them
onAfterToggle?.();
}
+ if (variant === "compact-menu-action") {
+ return (
+
+ );
+ }
+
if (variant === "menu-action") {
return (