diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 418e820979..ddf69fd7c5 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -148,6 +148,10 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**. Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track. +A finished workspace can be marked unread after it has been reviewed. The daemon restores +`finished` attention on its newest eligible workspace-root agent without sending a new completion +notification. Opening the workspace clears that attention through the normal focus flow. + ## The subagents track The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`. diff --git a/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts new file mode 100644 index 0000000000..bc3d4bbf95 --- /dev/null +++ b/packages/app/e2e/browser/sidebar-workspace-mark-unread.spec.ts @@ -0,0 +1,229 @@ +import type { Page } from "@playwright/test"; +import { test as base, expect } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { + openAgentRoute, + seedMockAgentWorkspace, + type MockAgentWorkspace, +} from "../support/helpers/mock-agent"; +import { getServerId } from "../support/helpers/server-id"; +import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar"; + +interface FinishedWorkspaces { + subject: MockAgentWorkspace; + other: MockAgentWorkspace; +} + +const test = base.extend<{ workspaces: FinishedWorkspaces }>({ + workspaces: async ({ browserName: _browserName }, provide) => { + const seeded: MockAgentWorkspace[] = []; + async function finishedWorkspace(title: string) { + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "workspace-mark-unread-", + title, + initialPrompt: "Finish this turn.", + }); + seeded.push(workspace); + await workspace.client.waitForFinish(workspace.agentId, 20_000); + await workspace.client.clearWorkspaceAttention(workspace.workspaceId); + return workspace; + } + try { + await provide({ + subject: await finishedWorkspace("Unread subject"), + other: await finishedWorkspace("Other workspace"), + }); + } finally { + for (const workspace of seeded) await workspace.cleanup(); + } + }, +}); + +function workspaceRow(page: Page, workspaceId: string) { + return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`); +} + +async function openWorkspace(page: Page, workspaceId: string) { + await workspaceRow(page, workspaceId).click(); + await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}`)); +} + +async function chooseReadAction(page: Page, workspaceId: string, action: "read" | "unread") { + await workspaceRow(page, workspaceId).hover(); + await page.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${workspaceId}`).click(); + const item = page.getByRole("menuitem", { name: `Mark as ${action}`, exact: true }); + await expect(item).toBeVisible(); + await item.click(); +} + +async function markAsUnread(page: Page, workspaceId: string) { + await chooseReadAction(page, workspaceId, "unread"); + await expectStatus(page, workspaceId, "attention"); +} + +async function markAsRead(page: Page, workspaceId: string) { + await chooseReadAction(page, workspaceId, "read"); + await expectStatus(page, workspaceId, "done"); +} + +async function expectStatus(page: Page, workspaceId: string, status: "done" | "attention") { + await expect( + workspaceRow(page, workspaceId).getByTestId(`workspace-status-indicator-${status}`), + ).toBeVisible(); +} + +async function markBackgroundWorkspaceAndReopen(page: Page, workspaceId: string) { + await test.step("background workspace gains green dot and clears when clicked", async () => { + await markAsUnread(page, workspaceId); + await openWorkspace(page, workspaceId); + await expectStatus(page, workspaceId, "done"); + }); +} + +async function leaveMarkedWorkspaceAndRead(page: Page, { subject, other }: FinishedWorkspaces) { + await test.step("leaving preserves manual unread until Mark as read", async () => { + await markAsUnread(page, subject.workspaceId); + await openWorkspace(page, other.workspaceId); + await markAsRead(page, subject.workspaceId); + await openWorkspace(page, subject.workspaceId); + }); +} + +async function leaveMarkedWorkspaceAndReopen(page: Page, { subject, other }: FinishedWorkspaces) { + await test.step("leaving preserves manual unread until reopening", async () => { + await markAsUnread(page, subject.workspaceId); + await openWorkspace(page, other.workspaceId); + await expectStatus(page, subject.workspaceId, "attention"); + await openWorkspace(page, subject.workspaceId); + await expectStatus(page, subject.workspaceId, "done"); + }); +} + +async function completeTurnAndLeave(page: Page, { subject, other }: FinishedWorkspaces) { + await test.step("ordinary completion still clears on departure", async () => { + await subject.client.sendAgentMessage(subject.agentId, "Finish another turn."); + await subject.client.waitForFinish(subject.agentId, 20_000); + await expectStatus(page, subject.workspaceId, "attention"); + await openWorkspace(page, other.workspaceId); + await expectStatus(page, subject.workspaceId, "done"); + }); +} + +async function openWorkspaceOnCompact(page: Page, workspaceId: string) { + await openWorkspace(page, workspaceId); + await openMobileAgentSidebar(page); +} + +async function leaveMarkedWorkspaceAndReadOnCompact( + page: Page, + { subject, other }: FinishedWorkspaces, +) { + await test.step("leaving preserves manual unread until Mark as read", async () => { + await markAsUnread(page, subject.workspaceId); + await openWorkspaceOnCompact(page, other.workspaceId); + await markAsRead(page, subject.workspaceId); + await openWorkspaceOnCompact(page, subject.workspaceId); + }); +} + +async function leaveMarkedWorkspaceAndReopenOnCompact( + page: Page, + { subject, other }: FinishedWorkspaces, +) { + await test.step("leaving preserves manual unread until reopening", async () => { + await markAsUnread(page, subject.workspaceId); + await openWorkspaceOnCompact(page, other.workspaceId); + await expectStatus(page, subject.workspaceId, "attention"); + await openWorkspaceOnCompact(page, subject.workspaceId); + await expectStatus(page, subject.workspaceId, "done"); + }); +} + +async function completeTurnAndLeaveOnCompact(page: Page, { subject, other }: FinishedWorkspaces) { + await test.step("ordinary completion still clears on departure", async () => { + await closeMobileAgentSidebar(page); + await subject.client.sendAgentMessage(subject.agentId, "Finish another turn."); + await subject.client.waitForFinish(subject.agentId, 20_000); + await openMobileAgentSidebar(page); + await expectStatus(page, subject.workspaceId, "attention"); + await openWorkspaceOnCompact(page, other.workspaceId); + await expectStatus(page, subject.workspaceId, "done"); + }); +} + +async function markBackgroundWorkspaceAndRead(page: Page, workspaceId: string) { + await test.step("explicit Mark as read clears a background workspace", async () => { + await markAsUnread(page, workspaceId); + await markAsRead(page, workspaceId); + }); +} + +async function expectSelectedAgent(page: Page, agentId: string) { + await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toHaveAttribute( + "aria-selected", + "true", + ); +} + +async function addFinishedAgent(workspace: MockAgentWorkspace) { + return workspace.client.createAgent({ + provider: "mock", + cwd: workspace.cwd, + workspaceId: workspace.workspaceId, + title: "Newest agent", + modeId: "load-test", + model: "e2e-fast-stream", + }); +} + +async function openCompactWorkspace(page: Page, workspace: MockAgentWorkspace) { + await page.setViewportSize({ width: 390, height: 844 }); + await openAgentRoute(page, workspace); + await openMobileAgentSidebar(page); +} + +async function markUnreadThenResumeChat(page: Page, workspaceId: string) { + await test.step("interacting with the visible chat clears manual unread", async () => { + await markAsUnread(page, workspaceId); + await closeMobileAgentSidebar(page); + await page.getByRole("textbox", { name: "Message agent..." }).click(); + await openMobileAgentSidebar(page); + await expectStatus(page, workspaceId, "done"); + }); +} + +test("manual unread survives departure and clears on reopening without changing normal completions", async ({ + page, + workspaces, +}) => { + await gotoAppShell(page); + await openWorkspace(page, workspaces.other.workspaceId); + await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId); + await leaveMarkedWorkspaceAndRead(page, workspaces); + await leaveMarkedWorkspaceAndReopen(page, workspaces); + await completeTurnAndLeave(page, workspaces); + await markBackgroundWorkspaceAndRead(page, workspaces.subject.workspaceId); +}); + +test("clicking a multi-agent workspace reveals and clears its marked agent", async ({ + page, + workspaces, +}) => { + await openAgentRoute(page, workspaces.subject); + await expectSelectedAgent(page, workspaces.subject.agentId); + await openWorkspace(page, workspaces.other.workspaceId); + const newest = await addFinishedAgent(workspaces.subject); + await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId); + await expectSelectedAgent(page, newest.id); +}); + +test("manual unread survives leaving the current workspace on compact layout", async ({ + page, + workspaces, +}) => { + await openCompactWorkspace(page, workspaces.subject); + await leaveMarkedWorkspaceAndReadOnCompact(page, workspaces); + await leaveMarkedWorkspaceAndReopenOnCompact(page, workspaces); + await markUnreadThenResumeChat(page, workspaces.subject.workspaceId); + await completeTurnAndLeaveOnCompact(page, workspaces); +}); diff --git a/packages/app/e2e/browser/sidebar-workspace.spec.ts b/packages/app/e2e/browser/sidebar-workspace.spec.ts index 14421f84ca..85b52c1581 100644 --- a/packages/app/e2e/browser/sidebar-workspace.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace.spec.ts @@ -14,6 +14,7 @@ import { getServerId } from "../support/helpers/server-id"; import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; import { escapeRegex } from "../support/helpers/regex"; import { openFilesPanel } from "../support/helpers/workspace-tabs"; +import { seedMockAgentWorkspace } from "../support/helpers/mock-agent"; const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git"; @@ -48,6 +49,20 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo return row; } +async function openWorkspaceReadAction( + page: import("@playwright/test").Page, + workspaceId: string, + action: "read" | "unread", +) { + const workspaceKey = `${getServerId()}:${workspaceId}`; + const row = await waitForSidebarWorkspace(page, workspaceId); + await row.hover(); + await page.getByTestId(`sidebar-workspace-kebab-${workspaceKey}`).click(); + const item = page.getByTestId(`sidebar-workspace-menu-mark-as-${action}-${workspaceKey}`); + await expect(item).toBeVisible({ timeout: 30_000 }); + return item; +} + async function openWorkspaceHoverCard(page: import("@playwright/test").Page, workspaceId: string) { const row = await waitForSidebarWorkspace(page, workspaceId); await row.hover(); @@ -187,6 +202,32 @@ test.describe("Sidebar workspace list", () => { } }); + test("marks a finished workspace unread until it is opened again", async ({ page }) => { + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "sidebar-mark-unread-", + title: "Mark unread", + initialPrompt: "Finish this test turn.", + }); + + try { + await workspace.client.waitForFinish(workspace.agentId, 20_000); + await workspace.client.clearWorkspaceAttention(workspace.workspaceId); + expect(workspace.client.getLastServerInfoMessage()?.features?.workspaceMarkUnread).toBe(true); + await gotoAppShell(page); + + const row = await waitForSidebarWorkspace(page, workspace.workspaceId); + await expect(row.getByTestId("workspace-status-indicator-done")).toBeVisible(); + await (await openWorkspaceReadAction(page, workspace.workspaceId, "unread")).click(); + await openWorkspaceReadAction(page, workspace.workspaceId, "read"); + + await page.keyboard.press("Escape"); + await openWorkspaceFromSidebar(page, workspace.workspaceId); + await openWorkspaceReadAction(page, workspace.workspaceId, "unread"); + } finally { + await workspace.cleanup(); + } + }); + test("Paseo-owned worktree hover card shows the worktree directory name", async ({ page }) => { await withPaseoOwnedWorktree(async ({ projectName, workspaceId, worktreeSlug }) => { await gotoAppShell(page); diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index 5abfb54c1b..869e96b294 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -45,6 +45,7 @@ export interface SeedDaemonClient { entries: SeedWorkspaceDescriptor[]; }>; setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>; + clearWorkspaceAttention(workspaceId: string): Promise; setWorkspaceLabel(input: { workspaceId: string; label: { name: string; color: "red" }; @@ -159,6 +160,7 @@ export interface SeedDaemonClient { features?: { projectAdd?: boolean; workspaceRecovery?: boolean; + workspaceMarkUnread?: boolean; } | null; } | null; fetchAgentHistory(options?: { diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 347315bd19..23e4292aeb 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -124,7 +124,7 @@ import { Shortcut } from "@/components/ui/shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; -import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention"; +import { useWorkspaceReadState } from "@/hooks/use-workspace-read-state"; import type { PrHint } from "@/git/use-pr-status-query"; import { buildSidebarProjectRowModel, @@ -284,6 +284,7 @@ interface WorkspaceRowInnerProps { onCopyPath?: () => void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; isPinned?: boolean; onTogglePin?: () => void; @@ -612,6 +613,7 @@ function WorkspaceRowRightGroup({ archiveShortcutKeys, onArchive, onMarkAsRead, + onMarkAsUnread, onCopyBranchName, onCopyPath, onRename, @@ -631,6 +633,7 @@ function WorkspaceRowRightGroup({ archiveShortcutKeys?: ShortcutKey[][] | null; onArchive?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; onRename?: () => void; @@ -682,6 +685,7 @@ function WorkspaceRowRightGroup({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -1065,6 +1069,8 @@ function WorkspaceRowInner({ onCopyBranchName, onCopyPath, onRename, + onMarkAsRead, + onMarkAsUnread, archiveShortcutKeys, isPinned, onTogglePin, @@ -1135,6 +1141,8 @@ function WorkspaceRowInner({ onCopyPath={onCopyPath} onCopyBranchName={onCopyBranchName} onRename={onRename} + onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -1185,6 +1193,8 @@ function WorkspaceRowInner({ onCopyBranchName={onCopyBranchName} onCopyPath={onCopyPath} onRename={onRename} + onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} isPinned={isPinned} onTogglePin={onTogglePin} /> @@ -1286,15 +1296,21 @@ function WorkspaceRowWithMenu({ const onTogglePin = canPin ? handleTogglePin : undefined; const archiveShortcutKeys = useShortcutKeys("archive-workspace"); - const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ - serverId: workspace.serverId, - workspaceId: workspace.workspaceId, - }); + const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } = + useWorkspaceReadState({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + }); const handleMarkAsRead = useCallback(() => { void clearAttention().catch((error) => { toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); }); }, [clearAttention, toast]); + const handleMarkAsUnread = useCallback(() => { + void markUnread().catch((error) => { + toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread"); + }); + }, [markUnread, toast]); useKeyboardActionHandler({ handlerId: `workspace-archive-${workspace.workspaceKey}`, @@ -1332,6 +1348,7 @@ function WorkspaceRowWithMenu({ onCopyPath={handleCopyPath} onRename={handleOpenRename} onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} + onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} isPinned={isPinned} onTogglePin={onTogglePin} diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index def203c9cd..550f2d0581 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -48,7 +48,7 @@ import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; -import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention"; +import { useWorkspaceReadState } from "@/hooks/use-workspace-read-state"; import { SidebarWorkspaceRowFrame, SidebarWorkspaceRowContent, @@ -641,15 +641,21 @@ function StatusWorkspaceRowWithMenu({ const onTogglePin = canPin ? handleTogglePin : undefined; const archiveShortcutKeys = useShortcutKeys("archive-workspace"); - const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ - serverId: workspace.serverId, - workspaceId: workspace.workspaceId, - }); + const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } = + useWorkspaceReadState({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + }); const handleMarkAsRead = useCallback(() => { void clearAttention().catch((error) => { toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); }); }, [clearAttention, toast]); + const handleMarkAsUnread = useCallback(() => { + void markUnread().catch((error) => { + toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread"); + }); + }, [markUnread, toast]); useKeyboardActionHandler({ handlerId: `workspace-archive-${workspace.workspaceKey}`, @@ -682,6 +688,7 @@ function StatusWorkspaceRowWithMenu({ onCopyPath={handleCopyPath} onRename={handleOpenRename} onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} + onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} isPinned={isPinned} onTogglePin={onTogglePin} @@ -719,6 +726,7 @@ interface StatusWorkspaceRowInnerProps { onCopyPath?: () => void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; isPinned?: boolean; onTogglePin?: () => void; @@ -765,6 +773,7 @@ function StatusWorkspaceRowInnerContent({ onCopyPath, onRename, onMarkAsRead, + onMarkAsUnread, archiveShortcutKeys, isPinned, onTogglePin, @@ -860,6 +869,7 @@ function StatusWorkspaceRowInnerContent({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -907,6 +917,7 @@ function StatusWorkspaceRowInnerContent({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -937,6 +948,7 @@ function StatusWorkspaceActionSlot({ onCopyBranchName, onRename, onMarkAsRead, + onMarkAsUnread, onArchive, archiveLabel, archiveStatus, @@ -956,6 +968,7 @@ function StatusWorkspaceActionSlot({ onCopyBranchName?: () => void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; onArchive?: () => void; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; @@ -983,6 +996,7 @@ function StatusWorkspaceActionSlot({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} diff --git a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx index 66df32bdbb..115003a3f2 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx @@ -4,6 +4,7 @@ import { type PressableStateCallbackType } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Archive, + Circle, CircleCheck, Copy, MoreVertical, @@ -52,6 +53,7 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ const ThemedMoreVertical = withUnistyles(MoreVertical); const ThemedCopy = withUnistyles(Copy); const ThemedArchive = withUnistyles(Archive); +const ThemedCircle = withUnistyles(Circle); const ThemedPencil = withUnistyles(Pencil); const ThemedCircleCheck = withUnistyles(CircleCheck); const ThemedPin = withUnistyles(Pin); @@ -63,6 +65,7 @@ const renameLeadingIcon = ); +const markAsUnreadLeadingIcon = ; const archiveLeadingIcon = ; const pinLeadingIcon = ; const unpinLeadingIcon = ; @@ -85,6 +88,7 @@ export interface SidebarWorkspaceMenuProps { onCopyBranchName?: () => void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; onArchive: () => void; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; @@ -132,6 +136,7 @@ function SidebarWorkspaceMenuItems({ onCopyBranchName, onRename, onMarkAsRead, + onMarkAsUnread, onArchive, archiveLabel, archiveStatus, @@ -193,6 +198,16 @@ function SidebarWorkspaceMenuItems({ Mark as read ) : null} + {onMarkAsUnread ? ( + + Mark as unread + + ) : null} {onTogglePin ? ( { void clearAttention().catch((error) => { toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); }); }, [clearAttention, toast]); + const handleMarkAsUnread = useCallback(() => { + void markUnread().catch((error) => { + toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread"); + }); + }, [markUnread, toast]); useKeyboardActionHandler({ handlerId: `workspace-archive-${workspace.workspaceKey}`, @@ -166,6 +172,7 @@ export function SidebarWorkspaceRow({ onCopyPath={handleCopyPath} onRename={handleOpenRename} onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} + onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} /> void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; } @@ -221,6 +229,7 @@ function WorkspaceRowBody({ onCopyPath, onRename, onMarkAsRead, + onMarkAsUnread, archiveShortcutKeys, }: WorkspaceRowBodyProps) { const isCompact = useIsCompactFormFactor(); @@ -293,6 +302,7 @@ function WorkspaceRowBody({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -340,6 +350,7 @@ function WorkspaceRowBody({ onCopyPath={onCopyPath} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} /> @@ -365,6 +376,7 @@ function WorkspaceRowTrailingActions({ archiveShortcutKeys, onArchive, onMarkAsRead, + onMarkAsUnread, onCopyBranchName, onCopyPath, onRename, @@ -383,6 +395,7 @@ function WorkspaceRowTrailingActions({ archiveShortcutKeys?: ShortcutKey[][] | null; onArchive?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; onRename?: () => void; @@ -430,6 +443,7 @@ function WorkspaceRowTrailingActions({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} diff --git a/packages/app/src/hooks/use-clear-workspace-attention.ts b/packages/app/src/hooks/use-clear-workspace-attention.ts deleted file mode 100644 index 0ba74c97e6..0000000000 --- a/packages/app/src/hooks/use-clear-workspace-attention.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useCallback, useMemo } from "react"; -import { i18n } from "@/i18n/i18next"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; - -export interface ClearWorkspaceAttentionController { - hasClearableAttention: boolean; - clearAttention: () => Promise; -} - -export function useClearWorkspaceAttention({ - serverId, - workspaceId, -}: { - serverId: string; - workspaceId: string; -}): ClearWorkspaceAttentionController { - const hasClearableAttention = useSessionStore((state) => { - const workspace = state.sessions[serverId]?.workspaces.get(workspaceId); - return workspace?.status === "attention" || workspace?.status === "failed"; - }); - - const clearAttention = useCallback(async () => { - if (!hasClearableAttention) { - return; - } - const client = getHostRuntimeStore().getClient(serverId); - if (!client) { - throw new Error(i18n.t("workspace.terminal.hostDisconnected")); - } - await client.clearWorkspaceAttention(workspaceId); - }, [hasClearableAttention, serverId, workspaceId]); - - return useMemo( - () => ({ hasClearableAttention, clearAttention }), - [clearAttention, hasClearableAttention], - ); -} diff --git a/packages/app/src/hooks/use-workspace-read-state.ts b/packages/app/src/hooks/use-workspace-read-state.ts new file mode 100644 index 0000000000..2e7b092956 --- /dev/null +++ b/packages/app/src/hooks/use-workspace-read-state.ts @@ -0,0 +1,49 @@ +import { useCallback, useMemo } from "react"; +import { i18n } from "@/i18n/i18next"; +import { useHostFeature } from "@/runtime/host-features"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useWorkspaceFields } from "@/stores/session-store-hooks"; +import { markWorkspaceUnread } from "@/workspace/mark-unread"; + +export interface WorkspaceReadStateController { + hasClearableAttention: boolean; + canMarkUnread: boolean; + clearAttention: () => Promise; + markUnread: () => Promise; +} + +export function useWorkspaceReadState({ + serverId, + workspaceId, +}: { + serverId: string; + workspaceId: string; +}): WorkspaceReadStateController { + const status = useWorkspaceFields(serverId, workspaceId, (workspace) => workspace.status); + const supportsMarkUnread = useHostFeature(serverId, "workspaceMarkUnread"); + const hasClearableAttention = status === "attention" || status === "failed"; + const canMarkUnread = supportsMarkUnread && status === "done"; + + const clearAttention = useCallback(async () => { + if (!hasClearableAttention) { + return; + } + const client = getHostRuntimeStore().getClient(serverId); + if (!client) { + throw new Error(i18n.t("workspace.terminal.hostDisconnected")); + } + await client.clearWorkspaceAttention(workspaceId); + }, [hasClearableAttention, serverId, workspaceId]); + + const markUnread = useCallback(async () => { + if (!canMarkUnread) { + return; + } + await markWorkspaceUnread(serverId, workspaceId); + }, [canMarkUnread, serverId, workspaceId]); + + return useMemo( + () => ({ hasClearableAttention, canMarkUnread, clearAttention, markUnread }), + [canMarkUnread, clearAttention, hasClearableAttention, markUnread], + ); +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index a27a1ed418..4227e5c0cc 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -782,6 +782,7 @@ interface MobileMountedTabSlotProps { isWorkspaceFocused: boolean; isPaneFocused: boolean; paneId: string | null; + onFocusPane: (paneId: string) => void; buildPaneContentModel: (input: { paneId: string | null; tab: WorkspaceTabDescriptor; @@ -794,6 +795,7 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({ isWorkspaceFocused, isPaneFocused, paneId, + onFocusPane, buildPaneContentModel, }: MobileMountedTabSlotProps) { const content = useMemo( @@ -804,15 +806,21 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({ }), [buildPaneContentModel, paneId, tabDescriptor], ); + const handleTouch = useCallback(() => { + if (!isPaneFocused && paneId) onFocusPane(paneId); + return false; + }, [isPaneFocused, onFocusPane, paneId]); return ( - + + + ); @@ -1079,6 +1087,8 @@ interface RenderWorkspaceContentInput { focusedPaneTabDescriptorMap: Map; isRouteFocused: boolean; focusedPaneId: string | null; + paneFocusSuspended: boolean; + onFocusPane: (paneId: string) => void; buildMobilePaneContentModel: (input: { paneId: string | null; tab: WorkspaceTabDescriptor; @@ -1095,6 +1105,8 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React focusedPaneTabDescriptorMap, isRouteFocused, focusedPaneId, + paneFocusSuspended, + onFocusPane, buildMobilePaneContentModel, } = input; @@ -1134,8 +1146,9 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React tabDescriptor={tabDescriptor} isVisible={isRouteFocused && tabId === activeTabDescriptor.tabId} isWorkspaceFocused={isRouteFocused} - isPaneFocused={tabId === activeTabDescriptor.tabId} + isPaneFocused={!paneFocusSuspended && tabId === activeTabDescriptor.tabId} paneId={focusedPaneId} + onFocusPane={onFocusPane} buildPaneContentModel={buildMobilePaneContentModel} /> ); @@ -1799,6 +1812,9 @@ function WorkspaceScreenContent({ const workspaceLayout = useWorkspaceLayoutStore((state) => persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null, ); + const unfocusedPaneId = useWorkspaceLayoutStore((state) => + persistenceKey ? state.focusRestorationByWorkspace[persistenceKey]?.restorePaneId : undefined, + ); const explorerSidebarPaneId = useWorkspaceLayoutStore((state) => persistenceKey ? selectExplorerSidebarPaneId(state, persistenceKey) : null, ); @@ -1898,8 +1914,9 @@ function WorkspaceScreenContent({ deriveWorkspacePaneState({ layout: workspaceLayout, tabs: uiTabs, + paneId: workspaceLayout?.focusedPaneId ?? unfocusedPaneId, }), - [uiTabs, workspaceLayout], + [uiTabs, workspaceLayout, unfocusedPaneId], ); const viewedTimelineSync = useSessionStore( (state) => state.sessions[normalizedServerId]?.viewedTimelineSync ?? null, @@ -3621,6 +3638,12 @@ function WorkspaceScreenContent({ }, [buildPaneContentModel], ); + const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) { + if (!persistenceKey || paneFocusSuppressedRef.current) { + return; + } + focusWorkspacePane(persistenceKey, paneId); + }); const content = renderWorkspaceContent({ isMissingWorkspaceDirectory, activeTabDescriptor, @@ -3630,6 +3653,8 @@ function WorkspaceScreenContent({ focusedPaneTabDescriptorMap, isRouteFocused, focusedPaneId, + paneFocusSuspended: Boolean(unfocusedPaneId), + onFocusPane: handleFocusPane, buildMobilePaneContentModel, }); @@ -3655,13 +3680,6 @@ function WorkspaceScreenContent({ [activeTabDescriptor?.tabId, closingTabIds, hoveredCloseTabKey, tabs], ); - const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) { - if (!persistenceKey || paneFocusSuppressedRef.current) { - return; - } - focusWorkspacePane(persistenceKey, paneId); - }); - const handleSplitPane = useCallback( function handleSplitPane(input: { tabId: string; diff --git a/packages/app/src/workspace/mark-unread.ts b/packages/app/src/workspace/mark-unread.ts new file mode 100644 index 0000000000..0cb3310e19 --- /dev/null +++ b/packages/app/src/workspace/mark-unread.ts @@ -0,0 +1,26 @@ +import { i18n } from "@/i18n/i18next"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; + +export async function markWorkspaceUnread(serverId: string, workspaceId: string): Promise { + const client = getHostRuntimeStore().getClient(serverId); + if (!client) { + throw new Error(i18n.t("workspace.terminal.hostDisconnected")); + } + + const workspaceKey = `${serverId}:${workspaceId}`; + const layout = useWorkspaceLayoutStore.getState(); + // End viewing before restoring attention. The next pane focus reads it normally; + // leaving the workspace must not consume the mark the user just made. + const focusToken = layout.layoutByWorkspace[workspaceKey]?.focusedPaneId + ? layout.unfocusPane(workspaceKey) + : null; + try { + await client.markWorkspaceUnread(workspaceId); + } catch (error) { + if (focusToken) { + layout.restorePaneFocus(workspaceKey, focusToken); + } + throw error; + } +} diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index bfb582e5ab..d03fd7c754 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -3005,6 +3005,45 @@ test("sends project.add.request without creating a workspace", async () => { }); }); +test("marks a workspace unread through the dotted RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const markPromise = client.markWorkspaceUnread("workspace-1", "req-mark-unread"); + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "workspace.mark_unread.request", + workspaceId: "workspace-1", + requestId: "req-mark-unread", + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "workspace.mark_unread.response", + payload: { + requestId: "req-mark-unread", + workspaceId: "workspace-1", + markedAgentId: "agent-1", + success: true, + error: null, + }, + }), + ); + + await expect(markPromise).resolves.toBeUndefined(); +}); + test("searches GitHub repositories through the dotted RPC", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index aa2a410ab0..e22534da35 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1893,6 +1893,20 @@ export class DaemonClient { } } + async markWorkspaceUnread(workspaceId: string, requestId?: string): Promise { + const response = + await this.sendNamespacedCorrelatedSessionRequest<"workspace.mark_unread.response">({ + requestId, + message: { + type: "workspace.mark_unread.request", + workspaceId, + }, + }); + if (!response.success) { + throw new Error(response.error ?? "Failed to mark workspace unread"); + } + } + sendHeartbeat(params: { deviceType: "web" | "mobile"; focusedAgentId: string | null; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 6faceb4139..ed36eeb1ad 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2593,6 +2593,12 @@ export const WorkspaceClearAttentionRequestSchema = z.object({ requestId: z.string(), }); +export const WorkspaceMarkUnreadRequestSchema = z.object({ + type: z.literal("workspace.mark_unread.request"), + workspaceId: z.string(), + requestId: z.string(), +}); + // Highlighted diff token schema // Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter const HighlightTokenSchema = z.object({ @@ -3203,6 +3209,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ArchiveWorkspaceRequestSchema, WorkspaceCreateRequestSchema, WorkspaceClearAttentionRequestSchema, + WorkspaceMarkUnreadRequestSchema, FileExplorerRequestSchema, FileSubscribeRequestSchema, FileUnsubscribeRequestSchema, @@ -3529,6 +3536,8 @@ export const ServerInfoStatusPayloadSchema = z providerSubagentNesting: z.boolean().optional(), // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. workspacePinning: z.boolean().optional(), + // COMPAT(workspaceMarkUnread): added in v0.5.0, remove after 2027-08-20. + workspaceMarkUnread: z.boolean().optional(), // COMPAT(hubRelationship): added in v0.1.X, drop the gate when floor >= v0.1.X. hubRelationship: z.boolean().optional(), // COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15. @@ -4677,6 +4686,17 @@ export const WorkspaceClearAttentionResponseSchema = z.object({ }), }); +export const WorkspaceMarkUnreadResponseSchema = z.object({ + type: z.literal("workspace.mark_unread.response"), + payload: z.object({ + requestId: z.string(), + workspaceId: z.string(), + markedAgentId: z.string().nullable(), + success: z.boolean(), + error: z.string().nullable(), + }), +}); + export const SendAgentMessageResponseMessageSchema = z.object({ type: z.literal("send_agent_message_response"), payload: z.object({ @@ -6526,6 +6546,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ClearAgentAttentionResponseMessageSchema, WorkspaceCreateResponseSchema, WorkspaceClearAttentionResponseSchema, + WorkspaceMarkUnreadResponseSchema, SendAgentMessageResponseMessageSchema, SetVoiceModeResponseMessageSchema, DaemonGetStatusResponseSchema, @@ -7026,6 +7047,7 @@ export type ProjectGithubCloneRequest = z.infer; export type ArchiveWorkspaceRequest = z.infer; export type WorkspaceClearAttentionRequest = z.infer; +export type WorkspaceMarkUnreadRequest = z.infer; export type FileExplorerRequest = z.infer; export type FileExplorerResponse = z.infer; export type FileVersion = z.infer; diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index a9e8f87c9a..3cb9b0d5bd 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -10,6 +10,42 @@ import { } from "./messages.js"; describe("workspace message schemas", () => { + test("parses mark-unread request and response", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "workspace.mark_unread.request", + workspaceId: "workspace-1", + requestId: "req-mark-unread", + }), + ).toEqual({ + type: "workspace.mark_unread.request", + workspaceId: "workspace-1", + requestId: "req-mark-unread", + }); + + expect( + SessionOutboundMessageSchema.parse({ + type: "workspace.mark_unread.response", + payload: { + requestId: "req-mark-unread", + workspaceId: "workspace-1", + markedAgentId: "agent-1", + success: true, + error: null, + }, + }), + ).toEqual({ + type: "workspace.mark_unread.response", + payload: { + requestId: "req-mark-unread", + workspaceId: "workspace-1", + markedAgentId: "agent-1", + success: true, + error: null, + }, + }); + }); + test("parses blocked setup status and the explicit setup run RPC", () => { expect( SessionInboundMessageSchema.parse({ diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index fdd3b5f763..1e18ba9de4 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -4919,6 +4919,56 @@ test("archiveSnapshot dispatches archived state for stored-only agents", async ( expect(last.lifecycle).toBe("closed"); }); +test("markAgentUnread dispatches stored attention to every subscriber", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-mark-unread-dispatch-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + }); + const created = await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Stored unread dispatch" }, + undefined, + { workspaceId: "workspace-1" }, + ); + await manager.closeAgent(created.id); + + const firstClientEvents: ManagedAgent[] = []; + const secondClientEvents: ManagedAgent[] = []; + for (const events of [firstClientEvents, secondClientEvents]) { + manager.subscribe( + (event) => { + if (event.type === "agent_state" && event.agent.id === created.id) { + events.push(event.agent); + } + }, + { replayState: false }, + ); + } + + await manager.markAgentUnread(created.id); + + for (const events of [firstClientEvents, secondClientEvents]) { + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + id: created.id, + lifecycle: "closed", + workspaceId: "workspace-1", + attention: { + requiresAttention: true, + attentionReason: "finished", + attentionTimestamp: expect.any(Date), + }, + }); + } + expect(await storage.get(created.id)).toMatchObject({ + requiresAttention: true, + attentionReason: "finished", + attentionTimestamp: expect.any(String), + }); +}); + test("reloadAgentSession cancels active run and resumes existing session once thread_started is observed", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-active-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index c1f7a781d4..d5dc1ab52b 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1766,7 +1766,7 @@ export class AgentManager { if (this.agents.has(record.id)) { this.notifyAgentState(record.id); } else if (!archivedRecord.internal) { - this.dispatchArchivedStoredAgent(archivedRecord); + this.dispatchStoredAgentState(archivedRecord); } await this.fireAgentArchived(record.id); @@ -1801,8 +1801,16 @@ export class AgentManager { } } - private dispatchArchivedStoredAgent(record: StoredAgentRecord): void { + private dispatchStoredAgentState(record: StoredAgentRecord): void { const updatedAt = new Date(record.updatedAt); + const attention: AttentionState = + record.requiresAttention && record.attentionReason && record.attentionTimestamp + ? { + requiresAttention: true, + attentionReason: record.attentionReason, + attentionTimestamp: new Date(record.attentionTimestamp), + } + : { requiresAttention: false }; this.dispatch({ type: "agent_state", agent: { @@ -1836,7 +1844,7 @@ export class AgentManager { lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null, lastUsage: undefined, lastError: record.lastError ?? undefined, - attention: { requiresAttention: false }, + attention, internal: record.internal, labels: record.labels, }, @@ -2046,6 +2054,46 @@ export class AgentManager { } } + async markAgentUnread(agentId: string): Promise { + const liveAgent = this.agents.get(agentId); + if (liveAgent) { + const isFinished = liveAgent.lifecycle === "idle"; + const hasPendingPermissions = liveAgent.pendingPermissions.size > 0; + const canMarkUnread = + isFinished && !liveAgent.attention.requiresAttention && !hasPendingPermissions; + if (!canMarkUnread) { + throw new Error(`Agent is no longer finished and read: ${agentId}`); + } + liveAgent.attention = { + requiresAttention: true, + attentionReason: "finished", + attentionTimestamp: new Date(), + }; + await this.persistSnapshot(liveAgent); + this.emitState(liveAgent, { persist: false }); + return; + } + + const registry = this.requireRegistry(); + const record = await registry.get(agentId); + const hasFinishedStatus = record?.lastStatus === "idle" || record?.lastStatus === "closed"; + const canMarkUnread = + record && !record.internal && !record.archivedAt && !record.requiresAttention; + if (!canMarkUnread || !hasFinishedStatus) { + throw new Error(`Agent is no longer finished and read: ${agentId}`); + } + const updatedAt = this.nextStoredUpdatedAt(record); + const nextRecord: StoredAgentRecord = { + ...record, + updatedAt, + requiresAttention: true, + attentionReason: "finished", + attentionTimestamp: updatedAt, + }; + await registry.upsert(nextRecord); + this.dispatchStoredAgentState(nextRecord); + } + async archiveSnapshot(agentId: string, archivedAt: string): Promise { const registry = this.requireRegistry(); const liveAgent = this.getAgent(agentId); @@ -2069,7 +2117,7 @@ export class AgentManager { } else { this.discardRetainedAgentState(agentId); if (!nextRecord.internal) { - this.dispatchArchivedStoredAgent(nextRecord); + this.dispatchStoredAgentState(nextRecord); } } diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 1f8b9e1d1f..ec6c4db2dd 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -185,6 +185,7 @@ const INBOUND_PERMISSION = { voice_audio_chunk: "workspace.write", wait_for_finish_request: "workspace.read", "workspace.clear_attention.request": "workspace.write", + "workspace.mark_unread.request": "workspace.write", "workspace.create.request": "workspace.manage", "workspace.github.search_repositories.request": "workspace.read", "workspace.label.assignment.set.request": "workspace.manage", @@ -396,6 +397,7 @@ const OUTBOUND_PERMISSION = { voice_input_state: "workspace.write", wait_for_finish_response: "workspace.read", "workspace.clear_attention.response": "workspace.write", + "workspace.mark_unread.response": "workspace.write", "workspace.create.response": "workspace.manage", "workspace.github.search_repositories.response": "workspace.read", "workspace.label.assignment.set.response": "workspace.manage", diff --git a/packages/server/src/server/file-observer/internal/native-recursive.test.ts b/packages/server/src/server/file-observer/internal/native-recursive.test.ts index c97048ab47..b37e12657d 100644 --- a/packages/server/src/server/file-observer/internal/native-recursive.test.ts +++ b/packages/server/src/server/file-observer/internal/native-recursive.test.ts @@ -69,3 +69,54 @@ test("shallow parent scans retain nested change scopes", async () => { await rm(root, { recursive: true, force: true }); } }); + +test("a file announced only as changed remains visible to coalesced deletion scans", async () => { + const root = await mkdtemp(join(tmpdir(), "native-change-only-")); + const directory = join(root, "nested"); + await mkdir(directory); + const paths = createObserverPaths(process.platform); + const events: FileChange[] = []; + const notifications = new EventEmitter(); + let active = true; + const observer = createFileObserver(); + const backend = createNativeRecursiveBackend( + { + root, + metrics: observer.getDiagnostics(), + isActive: () => active, + isIgnored: () => false, + isPathInside: paths.isInside, + queueEvent: (type, path) => events.push({ type, path }), + fail: (error) => { + throw error; + }, + }, + paths, + (_root, listener) => { + notifications.on("change", listener); + return { + close: () => notifications.removeAllListeners(), + on: (event, onError) => notifications.on(event, onError), + }; + }, + ); + try { + await backend.start(); + const path = join(directory, "changed.txt"); + await writeFile(path, "created"); + notifications.emit("change", "change", path); + await expect.poll(() => backend.getDiagnostics().nativeTrackedFileCount).toBe(1); + + await rm(path); + notifications.emit("change", "change", directory); + await expect + .poll(() => events.filter((event) => event.type === "delete"), { timeout: 10_000 }) + .toEqual([{ path, type: "delete" }]); + } finally { + vi.useRealTimers(); + active = false; + await backend.close(); + await observer.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/file-observer/internal/native-recursive.ts b/packages/server/src/server/file-observer/internal/native-recursive.ts index 02450503d3..10dece67cc 100644 --- a/packages/server/src/server/file-observer/internal/native-recursive.ts +++ b/packages/server/src/server/file-observer/internal/native-recursive.ts @@ -139,15 +139,19 @@ class NativeRecursiveBackend implements ObservationBackend { const path = resolve(this.host.root, filename.toString()); if (this.host.isIgnored(path)) return; const scope = path === this.host.root ? this.host.root : dirname(path); + const knownDirectory = this.directories.has(path); + this.requestChangeAudit(scope); if (eventType === "change") { this.host.metrics.nativeChangeEventCount += 1; this.host.queueEvent("update", path); - this.requestChangeAudit(scope); - if (this.directories.has(path)) this.requestAudit(path, true); - return; + if (knownDirectory) this.requestAudit(path, true); + if (knownDirectory || this.files.has(path)) return; + // A coalesced creation can arrive only as a change. Classify unknown + // paths so later deletion scans can find them in the inventory. + } else { + this.host.metrics.nativeRenameEventCount += 1; + if (knownDirectory) this.requestAudit(scope); } - this.host.metrics.nativeRenameEventCount += 1; - const knownDirectory = this.directories.has(path); this.classify(path, (isDirectory) => { if (!isDirectory) { // Remember files as soon as we announce them. A coalesced delete must @@ -159,8 +163,6 @@ class NativeRecursiveBackend implements ObservationBackend { if (knownDirectory) this.requestAudit(path, true); else this.requestAudit(scope); }); - this.requestChangeAudit(scope); - if (knownDirectory) this.requestAudit(scope); }); watcher.on("error", (error) => { if (this.host.isActive()) this.host.fail(toError(error)); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index ae7a105aed..c1e149ee5e 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -230,6 +230,7 @@ import { import type { ForgeService } from "../services/forge-service.js"; import type { ProviderUsageService } from "../services/quota-fetcher/service.js"; import { + resolveWorkspaceRootAgent, summarizeFetchWorkspacesEntries, workspaceIdsOnCheckout, WorkspaceDirectory, @@ -2018,7 +2019,7 @@ export class Session { private dispatchWorkspaceLifecycleMessage(msg: SessionInboundMessage): Promise | undefined { return ( - this.dispatchWorkspaceRecoveryMessage(msg) ?? + this.dispatchWorkspaceStateMessage(msg) ?? this.dispatchWorkspaceLabelMessage(msg) ?? this.dispatchWorkspaceSetupMessage(msg) ?? this.dispatchWorkspaceAndProjectMessage(msg) @@ -2582,8 +2583,6 @@ export class Session { return this.handleProjectRemoveRequest(msg); case "workspace.create.request": return this.handleWorkspaceCreateRequest(msg); - case "workspace.clear_attention.request": - return this.handleWorkspaceClearAttentionRequest(msg); case "workspace.title.set.request": return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId); case "workspace.pin.set.request": @@ -2656,12 +2655,16 @@ export class Session { } } - private dispatchWorkspaceRecoveryMessage(msg: SessionInboundMessage): Promise | undefined { + private dispatchWorkspaceStateMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "workspace.recovery.inspect.request": return this.handleWorkspaceRecoveryInspectRequest(msg); case "workspace.recovery.restore.request": return this.handleWorkspaceRecoveryRestoreRequest(msg); + case "workspace.clear_attention.request": + return this.handleWorkspaceClearAttentionRequest(msg); + case "workspace.mark_unread.request": + return this.handleWorkspaceMarkUnreadRequest(msg); default: return undefined; } @@ -7026,6 +7029,64 @@ export class Session { }); } + private async handleWorkspaceMarkUnreadRequest( + request: Extract, + ): Promise { + const { requestId, workspaceId } = request; + let markedAgentId: string | null = null; + try { + const workspace = await this.workspaceRegistry.get(workspaceId); + if (!workspace || workspace.archivedAt) { + throw new Error(`Workspace not found: ${workspaceId}`); + } + + const agents = (await this.listAgentPayloads()).filter((agent) => + this.isProviderVisibleToClient(agent.provider), + ); + const agentsById = new Map(agents.map((agent) => [agent.id, agent] as const)); + const candidates = agents + .filter((agent) => !agent.archivedAt && agent.workspaceId === workspace.workspaceId) + .filter((agent) => resolveWorkspaceRootAgent(agent, agentsById)?.id === agent.id) + .filter((agent) => agent.status === "idle" || agent.status === "closed") + .filter((agent) => agent.requiresAttention !== true) + .filter((agent) => (agent.pendingPermissions?.length ?? 0) === 0) + .sort( + (left, right) => + right.updatedAt.localeCompare(left.updatedAt) || left.id.localeCompare(right.id), + ); + const candidate = candidates[0]; + if (!candidate) { + throw new Error(`Workspace has no finished agent to mark unread: ${workspaceId}`); + } + + await this.agentManager.markAgentUnread(candidate.id); + markedAgentId = candidate.id; + this.emit({ + type: "workspace.mark_unread.response", + payload: { + requestId, + workspaceId, + markedAgentId, + success: true, + error: null, + }, + }); + } catch (error) { + const message = getErrorMessage(error); + this.sessionLogger.error({ err: error, workspaceId }, "Failed to mark workspace unread"); + this.emit({ + type: "workspace.mark_unread.response", + payload: { + requestId, + workspaceId, + markedAgentId, + success: false, + error: message, + }, + }); + } + } + private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise { const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier); if (!resolved.ok) { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index ac3a928ac1..2914e58215 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -233,6 +233,7 @@ function makeAgent(input: { requiresAttention?: boolean; attentionReason?: AgentSnapshotPayload["attentionReason"]; attentionTimestamp?: string | null; + labels?: Record; }): AgentSnapshotPayload { const pendingPermissionCount = input.pendingPermissions ?? 0; return { @@ -269,7 +270,7 @@ function makeAgent(input: { sessionId: null, }, title: null, - labels: {}, + labels: input.labels ?? {}, requiresAttention: input.requiresAttention ?? false, attentionReason: input.attentionReason ?? null, attentionTimestamp: input.attentionTimestamp ?? null, @@ -283,17 +284,20 @@ function makeStoredAgent(input: { updatedAt: string; requiresAttention?: boolean; attentionReason?: StoredAgentRecord["attentionReason"]; + workspaceId?: string; + labels?: Record; }): StoredAgentRecord { return { id: input.id, provider: "codex", cwd: input.cwd, + ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), createdAt: input.updatedAt, updatedAt: input.updatedAt, lastActivityAt: input.updatedAt, lastUserMessageAt: null, title: null, - labels: {}, + labels: input.labels ?? {}, lastStatus: "closed", lastModeId: null, config: { provider: "codex", cwd: input.cwd }, @@ -583,6 +587,7 @@ function createSessionForWorkspaceTests( archiveSnapshot: async () => ({}), unarchiveSnapshot: async () => true, clearAgentAttention: async () => {}, + markAgentUnread: async () => {}, notifyAgentState: () => {}, ...options.agentManager, }); @@ -1771,6 +1776,123 @@ test("workspace clear attention responds with an error instead of timing out", a }); }); +test("workspace mark unread selects the newest finished workspace root", async () => { + const emitted: SessionOutboundMessage[] = []; + const workspace = createPersistedWorkspaceRecord({ + workspaceId: REPO_CWD, + projectId: REPO_CWD, + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + }); + const project = createPersistedProjectRecord({ + projectId: REPO_CWD, + rootPath: REPO_CWD, + kind: "non_git", + displayName: "repo", + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + }); + const storedRecords = new Map([ + [ + "root-agent", + makeStoredAgent({ + id: "root-agent", + cwd: REPO_CWD, + workspaceId: workspace.workspaceId, + updatedAt: "2026-03-30T16:00:00.000Z", + }), + ], + [ + "newer-child", + makeStoredAgent({ + id: "newer-child", + cwd: REPO_CWD, + workspaceId: workspace.workspaceId, + updatedAt: "2026-03-30T17:00:00.000Z", + labels: { "paseo.parent-agent-id": "root-agent" }, + }), + ], + ]); + const markedAgentIds: string[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + agentManager: { + markAgentUnread: async (agentId: string) => { + markedAgentIds.push(agentId); + }, + }, + }); + + session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.get = async (id: string) => + id === workspace.workspaceId ? workspace : null; + session.projectRegistry.list = async () => [project]; + session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null); + session.listAgentPayloads = async () => + Array.from(storedRecords.values()).map((record) => + makeAgent({ + id: record.id, + cwd: record.cwd, + workspaceId: record.workspaceId, + status: record.lastStatus, + updatedAt: record.updatedAt, + requiresAttention: record.requiresAttention, + attentionReason: record.attentionReason, + attentionTimestamp: record.attentionTimestamp, + labels: record.labels, + }), + ); + + await session.handleMessage({ + type: "workspace.mark_unread.request", + workspaceId: workspace.workspaceId, + requestId: "req-mark-unread", + }); + + expect(markedAgentIds).toEqual(["root-agent"]); + expect(findByType(emitted, "workspace.mark_unread.response").payload).toEqual({ + requestId: "req-mark-unread", + workspaceId: workspace.workspaceId, + markedAgentId: "root-agent", + success: true, + error: null, + }); +}); + +test("workspace mark unread rejects workspaces without a finished root agent", async () => { + const emitted: SessionOutboundMessage[] = []; + const workspace = createPersistedWorkspaceRecord({ + workspaceId: REPO_CWD, + projectId: REPO_CWD, + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + }); + const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); + session.workspaceRegistry.get = async (id: string) => + id === workspace.workspaceId ? workspace : null; + session.listAgentPayloads = async () => []; + + await session.handleMessage({ + type: "workspace.mark_unread.request", + workspaceId: workspace.workspaceId, + requestId: "req-mark-unread", + }); + + expect(findByType(emitted, "workspace.mark_unread.response").payload).toEqual({ + requestId: "req-mark-unread", + workspaceId: workspace.workspaceId, + markedAgentId: null, + success: false, + error: `Workspace has no finished agent to mark unread: ${workspace.workspaceId}`, + }); +}); + test("workspace clear attention can clear multiple workspaces in one request", async () => { const emitted: SessionOutboundMessage[] = []; const workspaces = [ diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 3839263f52..c470476913 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -1008,6 +1008,7 @@ describe("relay external socket reconnect behavior", () => { expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true); expect(serverInfo.features?.providersSnapshotCwd).toBe(true); expect(serverInfo.features?.pluginLogs).toBe(true); + expect(serverInfo.features?.workspaceMarkUnread).toBe(true); expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true); expect(serverInfo.features?.["terminal-size-ownership"]).toBe(true); expect(serverInfo.features?.agentTurnIdentity).toBeUndefined(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 4d56418152..30fce35b13 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1732,6 +1732,8 @@ export class VoiceAssistantWebSocketServer { providerSubagentNesting: true, // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. workspacePinning: true, + // COMPAT(workspaceMarkUnread): added in v0.5.0, remove after 2027-08-20. + workspaceMarkUnread: true, // COMPAT(hubRelationship): added in v0.1.X, drop the gate when floor >= v0.1.X. hubRelationship: true, // COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15. diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index a50713e16f..8817576885 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -693,7 +693,7 @@ function groupAgentsByWorkspaceId( return byWorkspaceId; } -function resolveWorkspaceRootAgent( +export function resolveWorkspaceRootAgent( agent: AgentSnapshotPayload, activeAgentsById: ReadonlyMap, ): AgentSnapshotPayload | null {