mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 03:03:04 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user