Allow finished workspaces to be marked unread (#3603)

Restore unread attention on finished workspaces and preserve the reminder when leaving the current workspace. Cover desktop and compact read flows, and retain change-only file paths for deletion observation.

Co-authored-by: Edi Hasaj <34984294+edihasaj@users.noreply.github.com>
This commit is contained in:
Edi Hasaj
2026-09-09 11:01:08 +02:00
committed by GitHub
parent c172076bff
commit 85cd969321
26 changed files with 930 additions and 85 deletions
+4
View File
@@ -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`.
@@ -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);
});
@@ -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);
@@ -45,6 +45,7 @@ export interface SeedDaemonClient {
entries: SeedWorkspaceDescriptor[];
}>;
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
clearWorkspaceAttention(workspaceId: string): Promise<void>;
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?: {
@@ -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}
@@ -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}
@@ -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 = <ThemedPencil size={14} uniProps={foregroundMutedColor
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const markAsUnreadLeadingIcon = <ThemedCircle size={14} uniProps={foregroundMutedColorMapping} />;
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMapping} />;
const unpinLeadingIcon = <ThemedPinOff size={14} uniProps={foregroundMutedColorMapping} />;
@@ -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
</WorkspaceMenuItem>
) : null}
{onMarkAsUnread ? (
<WorkspaceMenuItem
surface={surface}
testID={`sidebar-workspace-menu-mark-as-unread-${workspaceKey}`}
leading={markAsUnreadLeadingIcon}
onSelect={onMarkAsUnread}
>
Mark as unread
</WorkspaceMenuItem>
) : null}
{onTogglePin ? (
<WorkspaceMenuItem
surface={surface}
@@ -243,6 +258,7 @@ export function SidebarWorkspaceMenu({
onCopyBranchName,
onRename,
onMarkAsRead,
onMarkAsUnread,
onArchive,
archiveLabel,
archiveStatus,
@@ -288,6 +304,7 @@ export function SidebarWorkspaceMenu({
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onMarkAsUnread={onMarkAsUnread}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
@@ -320,6 +337,7 @@ export function SidebarWorkspaceContextMenu({
onCopyBranchName,
onRename,
onMarkAsRead,
onMarkAsUnread,
onArchive,
archiveLabel,
archiveStatus,
@@ -399,6 +417,7 @@ export function SidebarWorkspaceContextMenu({
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onMarkAsUnread={onMarkAsUnread}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
@@ -14,7 +14,7 @@ import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
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 { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { isNative as platformIsNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -123,15 +123,21 @@ export function SidebarWorkspaceRow({
}, []);
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}`,
@@ -166,6 +172,7 @@ export function SidebarWorkspaceRow({
onCopyPath={handleCopyPath}
onRename={handleOpenRename}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<WorkspaceRenameModal
@@ -198,6 +205,7 @@ interface WorkspaceRowBodyProps {
onCopyPath?: () => 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}
/>
</SidebarWorkspaceRowContent>
</SidebarWorkspaceContextMenu>
@@ -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}
@@ -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<void>;
}
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],
);
}
@@ -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<void>;
markUnread: () => Promise<void>;
}
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],
);
}
@@ -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 (
<RenderProfile id={`MobileMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
<RetainedPanel active={isVisible} style={styles.mobileMountedTabSlot}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
<View style={styles.mobileMountedTabSlot} onStartShouldSetResponderCapture={handleTouch}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
</RetainedPanel>
</RenderProfile>
);
@@ -1079,6 +1087,8 @@ interface RenderWorkspaceContentInput {
focusedPaneTabDescriptorMap: Map<string, WorkspaceTabDescriptor>;
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;
+26
View File
@@ -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<void> {
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;
}
}
+39
View File
@@ -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();
+14
View File
@@ -1893,6 +1893,20 @@ export class DaemonClient {
}
}
async markWorkspaceUnread(workspaceId: string, requestId?: string): Promise<void> {
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;
+22
View File
@@ -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<typeof ProjectGithubCloneRequest
export type ProjectGithubCloneProtocol = z.infer<typeof ProjectGithubCloneProtocolSchema>;
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
export type WorkspaceClearAttentionRequest = z.infer<typeof WorkspaceClearAttentionRequestSchema>;
export type WorkspaceMarkUnreadRequest = z.infer<typeof WorkspaceMarkUnreadRequestSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
export type FileVersion = z.infer<typeof FileVersionSchema>;
@@ -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({
@@ -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");
@@ -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<void> {
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<StoredAgentRecord> {
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);
}
}
@@ -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",
@@ -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 });
}
});
@@ -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));
+65 -4
View File
@@ -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<void> | 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<void> | undefined {
private dispatchWorkspaceStateMessage(msg: SessionInboundMessage): Promise<void> | 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<SessionInboundMessage, { type: "workspace.mark_unread.request" }>,
): Promise<void> {
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<void> {
const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier);
if (!resolved.ok) {
@@ -233,6 +233,7 @@ function makeAgent(input: {
requiresAttention?: boolean;
attentionReason?: AgentSnapshotPayload["attentionReason"];
attentionTimestamp?: string | null;
labels?: Record<string, string>;
}): 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<string, string>;
}): 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<string, StoredAgentRecord>([
[
"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 = [
@@ -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();
@@ -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.
@@ -693,7 +693,7 @@ function groupAgentsByWorkspaceId(
return byWorkspaceId;
}
function resolveWorkspaceRootAgent(
export function resolveWorkspaceRootAgent(
agent: AgentSnapshotPayload,
activeAgentsById: ReadonlyMap<string, AgentSnapshotPayload>,
): AgentSnapshotPayload | null {