mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
feat(app): show consistent agent and workspace status in search (#7690)
Share tab agent presentation and effective workspace status with Command Center. Preserve unchanged result identities so live directory updates only rerender the changed row, with bounded retention and browser regression coverage.
This commit is contained in:
@@ -1,5 +1,15 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
seedSearchableCommandCenter,
|
||||
observeCommandCenterRowRenders,
|
||||
searchCommandCenter,
|
||||
warmCommandCenterRows,
|
||||
expectOnlyCommandCenterRowRendered,
|
||||
expectCommandCenterAgentWindow,
|
||||
scrollToOldestCommandCenterAgent,
|
||||
} from "../support/helpers/command-center-performance";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
expectCommandCenterToRemainAtTheTop,
|
||||
@@ -65,3 +75,44 @@ test("Command Center preserves manual scrolling through live updates and resumes
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("live search updates render only the changed row and keep agents virtualized", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const seeded = await seedSearchableCommandCenter();
|
||||
try {
|
||||
await observeCommandCenterRowRenders(page);
|
||||
await gotoAppShell(page);
|
||||
const panel = await openCommandCenterWithKeyboard(page);
|
||||
await searchCommandCenter(panel, "Search agent");
|
||||
await expect(panel.getByText("Search agent 059", { exact: true })).toBeVisible();
|
||||
await warmCommandCenterRows(page);
|
||||
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
|
||||
|
||||
await seeded.client.updateAgent(seeded.agents[59].id, { name: "Search agent 059 revised" });
|
||||
await expect(panel.getByText("Search agent 059 revised", { exact: true })).toBeVisible();
|
||||
await expectOnlyCommandCenterRowRendered(
|
||||
page,
|
||||
`agent:${getServerId()}:${seeded.agents[59].id}`,
|
||||
);
|
||||
|
||||
await searchCommandCenter(panel, "Search workspace");
|
||||
await expect(panel.getByText("Search workspace 039", { exact: true })).toBeVisible();
|
||||
await warmCommandCenterRows(page);
|
||||
await seeded.client.setWorkspaceTitle(seeded.workspaces[39].id, "Search workspace 039 revised");
|
||||
await expect(panel.getByText("Search workspace 039 revised", { exact: true })).toBeVisible();
|
||||
await expectOnlyCommandCenterRowRendered(
|
||||
page,
|
||||
`workspace:${getServerId()}:${seeded.workspaces[39].id}`,
|
||||
);
|
||||
|
||||
await searchCommandCenter(panel, "Search agent");
|
||||
await warmCommandCenterRows(page);
|
||||
await scrollToOldestCommandCenterAgent(panel);
|
||||
await expect(panel.getByText("Search agent 000", { exact: true })).toBeVisible();
|
||||
await expectCommandCenterAgentWindow(panel, seeded.agents.length);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { addOfflineHostAndReload } from "../support/helpers/hosts";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedRunningMockAgentWorkspace, openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
const PRIMARY_HOST_LABEL = "Primary Host";
|
||||
@@ -20,6 +21,34 @@ const AGENT_TITLE = "Fix checkout retries";
|
||||
test.describe("Command center workspaces", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("agent and workspace results retain their icons and overlay live status", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedRunningMockAgentWorkspace({
|
||||
repoPrefix: "command-center-status-",
|
||||
title: "Review checkout retries",
|
||||
model: "five-minute-stream",
|
||||
initialPrompt: "Keep working on checkout retries",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, workspace);
|
||||
await expectAgentTabActive(page, workspace.agentId);
|
||||
const panel = await openCommandCenter(page);
|
||||
const agent = panel.getByTestId(`command-center-agent-${getServerId()}:${workspace.agentId}`);
|
||||
const row = panel.getByTestId(
|
||||
`command-center-workspace-${getServerId()}:${workspace.workspaceId}`,
|
||||
);
|
||||
await expect(agent.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
|
||||
await expect(row.getByRole("progressbar", { name: "Agent running" })).toBeVisible();
|
||||
await expect(agent.getByRole("img", { name: "Mock agent" })).toBeVisible();
|
||||
await workspace.client.cancelAgent(workspace.agentId);
|
||||
await expect(agent.getByRole("progressbar")).toHaveCount(0);
|
||||
await expect(row.getByRole("progressbar")).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace results show their title, host, and branch and open the workspace", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { createMockIdleAgent } from "./archive-tab";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
|
||||
export async function seedSearchableCommandCenter() {
|
||||
const seed = await seedWorkspace({
|
||||
repoPrefix: "command-center-performance-",
|
||||
title: "Search workspace",
|
||||
git: false,
|
||||
});
|
||||
try {
|
||||
const agents = [];
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
agents.push(
|
||||
await createMockIdleAgent(seed.client, {
|
||||
cwd: seed.repoPath,
|
||||
workspaceId: seed.workspaceId,
|
||||
title: `Search agent ${String(index).padStart(3, "0")}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const workspaces = [];
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
const path = `${seed.repoPath}/workspace-${index}`;
|
||||
mkdirSync(path);
|
||||
const result = await seed.client.createWorkspace({
|
||||
source: { kind: "directory", path, projectId: seed.projectId },
|
||||
title: `Search workspace ${String(index).padStart(3, "0")}`,
|
||||
});
|
||||
if (!result.workspace) throw new Error(result.error ?? "Workspace creation failed");
|
||||
workspaces.push(result.workspace);
|
||||
}
|
||||
return { ...seed, agents, workspaces };
|
||||
} catch (error) {
|
||||
await seed.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function observeCommandCenterRowRenders(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
globalThis.__PASEO_RENDER_PROFILE_ENABLED__ = true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function searchCommandCenter(panel: Locator, query: string) {
|
||||
await panel.getByTestId("command-center-input").fill(query);
|
||||
}
|
||||
|
||||
export async function warmCommandCenterRows(page: Page) {
|
||||
// FlatList fills its overscan window in timed batches after the first visible frame.
|
||||
await page.waitForTimeout(600);
|
||||
await page.evaluate(() => {
|
||||
globalThis.__PASEO_RENDER_PROFILE_REASONS__ = {};
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectOnlyCommandCenterRowRendered(page: Page, resultId: string) {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
const renders = await page.evaluate(() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {}).filter(([id]) =>
|
||||
id.startsWith("CommandCenterResultRow:"),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(renders).toEqual({ [`CommandCenterResultRow:${resultId}`]: { render: 1 } });
|
||||
}
|
||||
|
||||
export async function expectCommandCenterAgentWindow(panel: Locator, total: number) {
|
||||
const rows = panel.locator(
|
||||
'[data-testid^="command-center-agent-"]:not([data-testid="command-center-agent-subtitle"])',
|
||||
);
|
||||
const count = await rows.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
expect(count).toBeLessThan(total);
|
||||
}
|
||||
|
||||
export async function scrollToOldestCommandCenterAgent(panel: Locator) {
|
||||
await panel.getByTestId("command-center-results").evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
}
|
||||
@@ -148,6 +148,7 @@ export interface SeedDaemonClient {
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
cancelAgent(agentId: string): Promise<void>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import type { PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { useSessionStore, selectAgentTurnPresentation, type Agent } from "@/stores/session-store";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
|
||||
function formatProviderLabel(provider: Agent["provider"]): string {
|
||||
if (!provider) {
|
||||
return "Agent";
|
||||
}
|
||||
return provider
|
||||
.split(/[-_\s]+/)
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function resolveAgentLabel(title: string | null | undefined): string | null {
|
||||
if (typeof title !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = title.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.toLowerCase() === "new agent") {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function useAgentDescriptor(
|
||||
target: { kind: "agent"; agentId: string },
|
||||
context: { serverId: string },
|
||||
): PanelDescriptor {
|
||||
const descriptorState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const session = state.sessions[context.serverId];
|
||||
const agent =
|
||||
session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null;
|
||||
return {
|
||||
provider: agent?.provider ?? "codex",
|
||||
title: agent?.title ?? null,
|
||||
status: agent?.status ?? null,
|
||||
pendingPermissionCount: agent?.pendingPermissions.length ?? 0,
|
||||
requiresAttention: agent?.requiresAttention ?? false,
|
||||
attentionReason: agent?.attentionReason ?? null,
|
||||
isTurnActive: selectAgentTurnPresentation(session, target.agentId).isActive,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const icon = useProviderIcon(descriptorState.provider, context.serverId);
|
||||
return buildAgentDescriptor({ ...descriptorState, icon });
|
||||
}
|
||||
|
||||
export function buildAgentDescriptor(input: {
|
||||
provider: Agent["provider"];
|
||||
title: string | null;
|
||||
status: Agent["status"] | null;
|
||||
pendingPermissionCount: number;
|
||||
requiresAttention: boolean;
|
||||
attentionReason: Agent["attentionReason"];
|
||||
isTurnActive: boolean;
|
||||
icon: PanelDescriptor["icon"];
|
||||
}): PanelDescriptor {
|
||||
const provider = input.provider;
|
||||
const label = resolveAgentLabel(input.title);
|
||||
|
||||
return {
|
||||
label: label ?? "",
|
||||
subtitle: `${formatProviderLabel(provider)} agent`,
|
||||
tooltip: label ?? `${formatProviderLabel(provider)} agent`,
|
||||
titleState: label ? "ready" : "loading",
|
||||
icon: input.icon,
|
||||
statusBucket: input.status
|
||||
? deriveSidebarStateBucket({
|
||||
status: input.isTurnActive ? "running" : input.status,
|
||||
pendingPermissionCount: input.pendingPermissionCount,
|
||||
requiresAttention: input.requiresAttention,
|
||||
attentionReason: input.attentionReason,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||
import {
|
||||
FlatList,
|
||||
Modal,
|
||||
@@ -24,7 +26,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronRight, Folder, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { BottomSheetFlatList, type BottomSheetFlatListMethods } from "@gorhom/bottom-sheet";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import { useAgentDescriptor, resolveAgentLabel } from "@/agent-presentation";
|
||||
import { StatusIcon } from "@/components/status-icon";
|
||||
import { useWorkspaceStatus } from "@/workspace-status";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { HighlightedText, type HighlightedTextProps } from "@/components/ui/highlighted-text";
|
||||
import { findHighlightRanges } from "@/components/ui/highlighted-text-segments";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
@@ -114,6 +120,20 @@ function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearc
|
||||
return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] };
|
||||
}
|
||||
|
||||
function createWorkspaceResultAction(serverId: string, workspaceId: string) {
|
||||
return () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToWorkspace({ serverId, workspaceId });
|
||||
};
|
||||
}
|
||||
|
||||
function createAgentResultAction(serverId: string, agentId: string) {
|
||||
return () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToAgent({ serverId, agentId });
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build every pinned row. Deliberately not keyed on `query`: none of this work depends on what
|
||||
* was typed. Ordering belongs to `filterAndRankBuiltInResults`, which ranks on every keystroke.
|
||||
@@ -123,6 +143,9 @@ function useBuiltInRows(open: boolean): {
|
||||
agents: CommandCenterAgentResult[];
|
||||
} {
|
||||
const { t } = useTranslation();
|
||||
const previousRows = useRef(
|
||||
new Map<string, CommandCenterAgentResult | CommandCenterWorkspaceResult>(),
|
||||
);
|
||||
const { agents } = useAggregatedAgents({ demand: open });
|
||||
const { projects } = useProjects({ enabled: open });
|
||||
const showHost = useHosts().length > 1;
|
||||
@@ -144,7 +167,22 @@ function useBuiltInRows(open: boolean): {
|
||||
const iconDataByProjectViewKey = useProjectIcons({ projects: iconTargets });
|
||||
|
||||
return useMemo(() => {
|
||||
if (!open) return { workspaces: [], agents: [] };
|
||||
if (!open) {
|
||||
previousRows.current.clear();
|
||||
return { workspaces: [], agents: [] };
|
||||
}
|
||||
// Built-in IDs identify immutable navigation targets. Reuse their callbacks and unchanged
|
||||
// row data so a directory update does not invalidate every mounted ResultRow.
|
||||
const nextRows = new Map<string, CommandCenterAgentResult | CommandCenterWorkspaceResult>();
|
||||
const preserve = <T extends CommandCenterAgentResult | CommandCenterWorkspaceResult>(
|
||||
row: T,
|
||||
): T => {
|
||||
const previous = previousRows.current.get(row.id);
|
||||
if (previous) row.run = previous.run;
|
||||
const stable = previous && equal(previous, row) ? (previous as T) : row;
|
||||
nextRows.set(row.id, stable);
|
||||
return stable;
|
||||
};
|
||||
const allWorkspaces: CommandCenterWorkspaceResult[] = [];
|
||||
for (const project of projects) {
|
||||
const workspaceProject = {
|
||||
@@ -155,23 +193,24 @@ function useBuiltInRows(open: boolean): {
|
||||
for (const host of project.hosts) {
|
||||
for (const workspace of host.workspaces) {
|
||||
if (workspace.archivingAt) continue;
|
||||
allWorkspaces.push({
|
||||
kind: "workspace",
|
||||
id: `workspace:${host.serverId}:${workspace.id}`,
|
||||
title: workspace.title ?? workspace.name,
|
||||
subtitle: joinSubtitleParts([
|
||||
showHost ? host.serverName : null,
|
||||
project.projectName,
|
||||
workspace.currentBranch,
|
||||
]),
|
||||
project: workspaceProject,
|
||||
changeRequestNumber: workspace.changeRequestNumber,
|
||||
activityAt: workspace.statusEnteredAt,
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id });
|
||||
},
|
||||
});
|
||||
allWorkspaces.push(
|
||||
preserve({
|
||||
kind: "workspace",
|
||||
id: `workspace:${host.serverId}:${workspace.id}`,
|
||||
title: workspace.title ?? workspace.name,
|
||||
subtitle: joinSubtitleParts([
|
||||
showHost ? host.serverName : null,
|
||||
project.projectName,
|
||||
workspace.currentBranch,
|
||||
]),
|
||||
serverId: host.serverId,
|
||||
workspaceId: workspace.id,
|
||||
project: workspaceProject,
|
||||
changeRequestNumber: workspace.changeRequestNumber,
|
||||
activityAt: workspace.statusEnteredAt,
|
||||
run: createWorkspaceResultAction(host.serverId, workspace.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,22 +221,20 @@ function useBuiltInRows(open: boolean): {
|
||||
const workspaceTitle = agent.workspaceId
|
||||
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
|
||||
: undefined;
|
||||
return {
|
||||
return preserve({
|
||||
kind: "agent",
|
||||
id: `agent:${agent.serverId}:${agent.id}`,
|
||||
agent,
|
||||
title: agent.title || t("shell.commandCenter.newAgent"),
|
||||
title: resolveAgentLabel(agent.title) ?? t("workspace.tabs.loading"),
|
||||
subtitle: joinSubtitleParts([
|
||||
showHost ? agent.serverLabel : null,
|
||||
workspaceTitle ?? shortenPath(agent.cwd),
|
||||
]),
|
||||
activityAt: agent.lastActivityAt,
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
|
||||
},
|
||||
};
|
||||
run: createAgentResultAction(agent.serverId, agent.id),
|
||||
});
|
||||
});
|
||||
previousRows.current = nextRows;
|
||||
return { workspaces: allWorkspaces, agents: agentRows };
|
||||
}, [agents, iconDataByProjectViewKey, open, projects, showHost, t]);
|
||||
}
|
||||
@@ -402,6 +439,7 @@ const ResultRow = memo(function ResultRow({
|
||||
active,
|
||||
onSelect,
|
||||
}: ResultRowProps) {
|
||||
recordRenderProfileReasons(`CommandCenterResultRow:${result.id}`, ["render"]);
|
||||
const press = useCallback(() => onSelect(result), [onSelect, result]);
|
||||
const choice =
|
||||
result.kind === "contribution" && result.contribution.presentation.kind === "choice"
|
||||
@@ -435,7 +473,19 @@ const ResultRow = memo(function ResultRow({
|
||||
result.kind === "file" ? `command-center-file-row-${result.filePath}` : choice?.testId
|
||||
}
|
||||
>
|
||||
<ResultContent result={result} query={query} />
|
||||
{(state) => (
|
||||
<ResultContent
|
||||
result={result}
|
||||
query={query}
|
||||
backdrop={
|
||||
active ||
|
||||
state.pressed ||
|
||||
Boolean((state as PressableStateCallbackType & { hovered?: boolean }).hovered)
|
||||
? "surface1"
|
||||
: "surface0"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
@@ -489,6 +539,72 @@ function SubtitleWithTimestamp({
|
||||
);
|
||||
}
|
||||
|
||||
const providerIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function AgentResultContent({
|
||||
result,
|
||||
query,
|
||||
backdrop,
|
||||
}: {
|
||||
result: CommandCenterAgentResult;
|
||||
query: string;
|
||||
backdrop: SurfaceBackdrop;
|
||||
}) {
|
||||
const agent = result.agent;
|
||||
const descriptor = useAgentDescriptor(
|
||||
{ kind: "agent", agentId: agent.id },
|
||||
{ serverId: agent.serverId },
|
||||
);
|
||||
const Icon = useMemo(
|
||||
() => withUnistyles(descriptor.icon, providerIconMapping),
|
||||
[descriptor.icon],
|
||||
);
|
||||
return (
|
||||
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.iconSlot}>
|
||||
<StatusIcon statusBucket={descriptor.statusBucket} size={16} backdrop={backdrop}>
|
||||
<View accessibilityRole="image" accessibilityLabel={descriptor.subtitle}>
|
||||
<Icon size={16} />
|
||||
</View>
|
||||
</StatusIcon>
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={result.title}
|
||||
style={styles.title}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
<SubtitleWithTimestamp
|
||||
query={query}
|
||||
subtitle={result.subtitle}
|
||||
activityAt={result.activityAt}
|
||||
testID="command-center-agent-subtitle"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceResultIcon({
|
||||
result,
|
||||
backdrop,
|
||||
}: {
|
||||
result: CommandCenterWorkspaceResult;
|
||||
backdrop: SurfaceBackdrop;
|
||||
}) {
|
||||
const statusBucket = useWorkspaceStatus(result.serverId, result.workspaceId);
|
||||
return (
|
||||
<StatusIcon statusBucket={statusBucket} size={16} backdrop={backdrop}>
|
||||
<View accessibilityElementsHidden importantForAccessibility="no-hide-descendants" aria-hidden>
|
||||
<WorkspaceProjectIcon project={result.project} />
|
||||
</View>
|
||||
</StatusIcon>
|
||||
);
|
||||
}
|
||||
|
||||
/** The same project icon the sidebar's workspace rows lead with: its image, or a lettered square. */
|
||||
function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResult["project"] }) {
|
||||
const initial = projectIconPlaceholderLabelFromDisplayName(project.name).charAt(0).toUpperCase();
|
||||
@@ -503,7 +619,15 @@ function WorkspaceProjectIcon({ project }: { project: CommandCenterWorkspaceResu
|
||||
);
|
||||
}
|
||||
|
||||
function ResultContent({ result, query }: { result: CommandCenterResult; query: string }) {
|
||||
function ResultContent({
|
||||
result,
|
||||
query,
|
||||
backdrop,
|
||||
}: {
|
||||
result: CommandCenterResult;
|
||||
query: string;
|
||||
backdrop: SurfaceBackdrop;
|
||||
}) {
|
||||
if (result.kind === "file") {
|
||||
return (
|
||||
<View style={styles.rowContent}>
|
||||
@@ -530,34 +654,7 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query:
|
||||
);
|
||||
}
|
||||
if (result.kind === "agent") {
|
||||
const agent = result.agent;
|
||||
return (
|
||||
<View style={styles.rowContent} testID={`command-center-agent-${agent.serverId}:${agent.id}`}>
|
||||
<View style={styles.rowMain}>
|
||||
<View style={styles.iconSlot}>
|
||||
<AgentStatusDot
|
||||
status={agent.status}
|
||||
requiresAttention={agent.requiresAttention}
|
||||
showInactive
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<QueryHighlightedText
|
||||
query={query}
|
||||
text={result.title}
|
||||
style={styles.title}
|
||||
numberOfLines={1}
|
||||
/>
|
||||
<SubtitleWithTimestamp
|
||||
query={query}
|
||||
subtitle={result.subtitle}
|
||||
activityAt={result.activityAt}
|
||||
testID="command-center-agent-subtitle"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
return <AgentResultContent result={result} query={query} backdrop={backdrop} />;
|
||||
}
|
||||
if (result.kind === "workspace") {
|
||||
const key = result.id.slice("workspace:".length);
|
||||
@@ -565,13 +662,8 @@ function ResultContent({ result, query }: { result: CommandCenterResult; query:
|
||||
<View style={styles.rowContent} testID={`command-center-workspace-${key}`}>
|
||||
<View style={styles.rowMain}>
|
||||
{/* Decorative: the fallback icon's initial must not prefix the row's accessible name. */}
|
||||
<View
|
||||
style={styles.iconSlot}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
aria-hidden
|
||||
>
|
||||
<WorkspaceProjectIcon project={result.project} />
|
||||
<View style={styles.iconSlot}>
|
||||
<WorkspaceResultIcon result={result} backdrop={backdrop} />
|
||||
</View>
|
||||
<View style={styles.textContent}>
|
||||
<QueryHighlightedText
|
||||
|
||||
@@ -110,6 +110,8 @@ function workspace(
|
||||
): CommandCenterWorkspaceResult {
|
||||
return {
|
||||
kind: "workspace",
|
||||
serverId: "server",
|
||||
workspaceId: "workspace",
|
||||
id,
|
||||
title,
|
||||
subtitle,
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface CommandCenterWorkspaceResult {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
project: CommandCenterWorkspaceProject;
|
||||
/** PR/MR number backing this workspace, so a number query can match it exactly. */
|
||||
changeRequestNumber: number | null;
|
||||
|
||||
@@ -11,6 +11,8 @@ function workspace(input: {
|
||||
}): CommandCenterWorkspaceResult {
|
||||
return {
|
||||
kind: "workspace",
|
||||
serverId: "server",
|
||||
workspaceId: "workspace",
|
||||
id: input.id,
|
||||
title: input.title ?? input.id,
|
||||
subtitle: input.subtitle ?? "",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { CircleAlert } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
import { getStatusRingOffset } from "@/components/status-ring/geometry";
|
||||
import {
|
||||
STATUS_INDICATOR_ALERT_SIZE,
|
||||
STATUS_INDICATOR_DOT_SIZE,
|
||||
} from "@/utils/status-indicator-geometry";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
interface StatusIconProps {
|
||||
children: ReactNode;
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
size?: number;
|
||||
statusDotBorderColor?: string;
|
||||
backdrop: SurfaceBackdrop;
|
||||
}
|
||||
const DEFAULT_STATUS_DOT_OFFSET = -2;
|
||||
const STATUS_ALERT_OFFSET = -3;
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const needsInputAlertMapping = (theme: Theme) => ({
|
||||
color: theme.colors.surface0,
|
||||
fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
|
||||
});
|
||||
|
||||
export function StatusIcon({
|
||||
children,
|
||||
statusBucket,
|
||||
size = 14,
|
||||
statusDotBorderColor,
|
||||
backdrop,
|
||||
}: StatusIconProps): ReactElement {
|
||||
const bucket = statusBucket;
|
||||
const isRunning = bucket === "running";
|
||||
let statusDotColor: string | undefined;
|
||||
if (bucket === "failed") statusDotColor = styles.statusDotFailed.color;
|
||||
else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color;
|
||||
const showNeedsInputAlert = bucket === "needs_input";
|
||||
const agentIconWrapperStyle = useMemo(
|
||||
() => [styles.agentIconWrapper, { width: size, height: size }],
|
||||
[size],
|
||||
);
|
||||
const statusDotStyle = useMemo(
|
||||
() => [
|
||||
styles.statusDot,
|
||||
{
|
||||
backgroundColor: statusDotColor,
|
||||
borderColor: statusDotBorderColor ?? styles[backdrop].borderColor,
|
||||
},
|
||||
],
|
||||
[statusDotColor, statusDotBorderColor, backdrop],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={agentIconWrapperStyle}>
|
||||
{children}
|
||||
{isRunning ? (
|
||||
<View
|
||||
style={styles.statusRing}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel="Agent running"
|
||||
>
|
||||
<StatusRing backdrop={backdrop} />
|
||||
</View>
|
||||
) : null}
|
||||
{statusDotColor ? <View style={statusDotStyle} /> : null}
|
||||
{showNeedsInputAlert ? (
|
||||
<View style={styles.statusAlertOverlay}>
|
||||
<ThemedCircleAlert size={STATUS_INDICATOR_ALERT_SIZE} uniProps={needsInputAlertMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
agentIconWrapper: {
|
||||
position: "relative",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
statusDot: {
|
||||
position: "absolute",
|
||||
right: DEFAULT_STATUS_DOT_OFFSET,
|
||||
bottom: DEFAULT_STATUS_DOT_OFFSET,
|
||||
width: STATUS_INDICATOR_DOT_SIZE,
|
||||
height: STATUS_INDICATOR_DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
},
|
||||
statusRing: {
|
||||
position: "absolute",
|
||||
right: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE),
|
||||
bottom: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE),
|
||||
},
|
||||
surface0: { borderColor: theme.colors.surface0 },
|
||||
surface1: { borderColor: theme.colors.surface1 },
|
||||
surface2: { borderColor: theme.colors.surface2 },
|
||||
surfaceSidebar: { borderColor: theme.colors.surfaceSidebar },
|
||||
surfaceSidebarHover: { borderColor: theme.colors.surfaceSidebarHover },
|
||||
surfaceSidebarSelected: { borderColor: theme.colors.surfaceSidebarSelected },
|
||||
statusAlertOverlay: {
|
||||
position: "absolute",
|
||||
right: STATUS_ALERT_OFFSET,
|
||||
bottom: STATUS_ALERT_OFFSET,
|
||||
},
|
||||
statusDotFailed: {
|
||||
color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined,
|
||||
},
|
||||
statusDotAttention: {
|
||||
color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
|
||||
},
|
||||
}));
|
||||
@@ -1,3 +1,4 @@
|
||||
import { deriveEffectiveWorkspaceStatus } from "@/workspace-status";
|
||||
import equal from "fast-deep-equal";
|
||||
import type { PrHint } from "@/git/pr-hint";
|
||||
import { selectPrHintFromStatus } from "@/git/pr-hint";
|
||||
@@ -124,11 +125,6 @@ export function areSidebarWorkspaceSessionsEqual(
|
||||
return true;
|
||||
}
|
||||
|
||||
interface EffectiveWorkspaceStatus {
|
||||
status: WorkspaceDescriptor["status"];
|
||||
enteredAt: Date | null;
|
||||
}
|
||||
|
||||
function projectNameForWorkspace(workspace: WorkspaceDescriptor): string {
|
||||
return (
|
||||
workspace.projectCustomName ??
|
||||
@@ -189,51 +185,6 @@ export function createSidebarWorkspaceEntry(input: {
|
||||
|
||||
const EMPTY_WORKSPACE_LABELS: string[] = [];
|
||||
|
||||
function deriveEffectiveWorkspaceStatus(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||
}): EffectiveWorkspaceStatus {
|
||||
if (input.workspace.status !== "done") {
|
||||
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
|
||||
}
|
||||
|
||||
const pendingStartedAt = getPendingInitialAgentCreateStartedAt({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
pendingCreateAttempts: input.pendingCreateAttempts,
|
||||
});
|
||||
if (pendingStartedAt) {
|
||||
return { status: "running", enteredAt: pendingStartedAt };
|
||||
}
|
||||
|
||||
const rootAgentActivity = input.workspaceAgentActivity?.get(input.workspace.id);
|
||||
if (rootAgentActivity && rootAgentActivity.status !== "done") {
|
||||
return rootAgentActivity;
|
||||
}
|
||||
|
||||
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
|
||||
}
|
||||
|
||||
function getPendingInitialAgentCreateStartedAt(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
pendingCreateAttempts: Record<string, PendingCreateAttempt> | undefined;
|
||||
}): Date | null {
|
||||
let latestStartedAt: Date | null = null;
|
||||
for (const pending of Object.values(input.pendingCreateAttempts ?? {})) {
|
||||
if (pending.serverId !== input.serverId) continue;
|
||||
if (pending.workspaceId !== input.workspaceId) continue;
|
||||
if (pending.lifecycle === "abandoned") continue;
|
||||
const startedAt = new Date(pending.timestamp);
|
||||
if (!latestStartedAt || startedAt > latestStartedAt) {
|
||||
latestStartedAt = startedAt;
|
||||
}
|
||||
}
|
||||
return latestStartedAt;
|
||||
}
|
||||
|
||||
export interface ProjectStatusSession {
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentDescriptor, resolveAgentLabel } from "@/agent-presentation";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor";
|
||||
|
||||
@@ -66,3 +67,57 @@ describe("buildDraftPanelDescriptor", () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared agent descriptor", () => {
|
||||
function describeAgent(overrides: Partial<Parameters<typeof buildAgentDescriptor>[0]> = {}) {
|
||||
return buildAgentDescriptor({
|
||||
provider: "codex",
|
||||
title: " Review checkout retries ",
|
||||
status: "idle",
|
||||
pendingPermissionCount: 0,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
isTurnActive: false,
|
||||
icon: TestIcon,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps the normalized title and provider identity together", () => {
|
||||
expect(describeAgent()).toMatchObject({
|
||||
label: "Review checkout retries",
|
||||
subtitle: "Codex agent",
|
||||
icon: TestIcon,
|
||||
titleState: "ready",
|
||||
statusBucket: "done",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([null, "", " ", "New Agent", " new agent "])(
|
||||
"leaves an untitled agent loading: %s",
|
||||
(title) => {
|
||||
expect(resolveAgentLabel(title)).toBeNull();
|
||||
expect(describeAgent({ title })).toMatchObject({ label: "", titleState: "loading" });
|
||||
},
|
||||
);
|
||||
|
||||
it("shows an active turn as running before the lifecycle update arrives", () => {
|
||||
expect(describeAgent({ isTurnActive: true }).statusBucket).toBe("running");
|
||||
});
|
||||
|
||||
it("surfaces pending permissions even while the turn is running", () => {
|
||||
expect(describeAgent({ isTurnActive: true, pendingPermissionCount: 1 }).statusBucket).toBe(
|
||||
"needs_input",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["finished", "attention"],
|
||||
["error", "failed"],
|
||||
["permission", "needs_input"],
|
||||
] as const)("preserves %s attention", (attentionReason, statusBucket) => {
|
||||
expect(describeAgent({ requiresAttention: true, attentionReason }).statusBucket).toBe(
|
||||
statusBucket,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
} from "@/composer/pill-styles";
|
||||
import { getActiveMessageSubmissions } from "@/composer/submission/model";
|
||||
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { useAgentDescriptor } from "@/agent-presentation";
|
||||
import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
@@ -62,7 +62,7 @@ import { TimelineSyncStatus } from "@/timeline/sync-status";
|
||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||
import { useAgentFactsLine } from "@/panels/agent-facts-line";
|
||||
import { PaneSubtitle } from "@/panels/pane-subtitle";
|
||||
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { definePanel } from "@/panels/panel-registry";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { useHasPluginComposerPills } from "@/plugins";
|
||||
import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor";
|
||||
@@ -101,7 +101,6 @@ import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { buildDraftAgentSetup, type ClientSlashCommand } from "@/client-slash-commands";
|
||||
|
||||
interface ChatAgentStateShape {
|
||||
@@ -256,31 +255,6 @@ function renderChatAgentNonReadyView(args: {
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatProviderLabel(provider: Agent["provider"]): string {
|
||||
if (!provider) {
|
||||
return "Agent";
|
||||
}
|
||||
return provider
|
||||
.split(/[-_\s]+/)
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string | null {
|
||||
if (typeof title !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = title.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.toLowerCase() === "new agent") {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function shouldStoreFetchedAgentInActiveDirectory(agent: Agent): boolean {
|
||||
return !agent.archivedAt && Boolean(agent.projectPlacement);
|
||||
}
|
||||
@@ -328,47 +302,6 @@ function storeFetchedAgentDetail(input: {
|
||||
return hydrated;
|
||||
}
|
||||
|
||||
function useAgentPanelDescriptor(
|
||||
target: { kind: "agent"; agentId: string },
|
||||
context: { serverId: string },
|
||||
): PanelDescriptor {
|
||||
const descriptorState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const session = state.sessions[context.serverId];
|
||||
const agent =
|
||||
session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null;
|
||||
return {
|
||||
provider: agent?.provider ?? "codex",
|
||||
title: agent?.title ?? null,
|
||||
status: agent?.status ?? null,
|
||||
pendingPermissionCount: agent?.pendingPermissions.length ?? 0,
|
||||
requiresAttention: agent?.requiresAttention ?? false,
|
||||
attentionReason: agent?.attentionReason ?? null,
|
||||
isTurnActive: selectAgentTurnPresentation(session, target.agentId).isActive,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const provider = descriptorState.provider;
|
||||
const label = resolveWorkspaceAgentTabLabel(descriptorState.title);
|
||||
const icon = useProviderIcon(provider, context.serverId);
|
||||
|
||||
return {
|
||||
label: label ?? "",
|
||||
subtitle: `${formatProviderLabel(provider)} agent`,
|
||||
tooltip: label ?? `${formatProviderLabel(provider)} agent`,
|
||||
titleState: label ? "ready" : "loading",
|
||||
icon,
|
||||
statusBucket: descriptorState.status
|
||||
? deriveSidebarStateBucket({
|
||||
status: descriptorState.isTurnActive ? "running" : descriptorState.status,
|
||||
pendingPermissionCount: descriptorState.pendingPermissionCount,
|
||||
requiresAttention: descriptorState.requiresAttention,
|
||||
attentionReason: descriptorState.attentionReason,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function AgentPanel() {
|
||||
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
|
||||
const { isInteractive } = usePaneFocus();
|
||||
@@ -438,7 +371,7 @@ export function AgentConversationPanel() {
|
||||
|
||||
export const agentPanelRegistration = definePanel("agent", {
|
||||
component: AgentConversationPanel,
|
||||
useDescriptor: useAgentPanelDescriptor,
|
||||
useDescriptor: useAgentDescriptor,
|
||||
});
|
||||
|
||||
export function useDraftPanelDescriptor(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { StatusIcon } from "@/components/status-icon";
|
||||
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { Check, CircleAlert } from "lucide-react-native";
|
||||
import { Check } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import invariant from "tiny-invariant";
|
||||
@@ -9,14 +11,6 @@ import { getPanelRegistration, type PanelIconProps } from "@/panels/panel-regist
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
import { getStatusRingOffset } from "@/components/status-ring/geometry";
|
||||
import {
|
||||
STATUS_INDICATOR_ALERT_SIZE,
|
||||
STATUS_INDICATOR_DOT_SIZE,
|
||||
} from "@/utils/status-indicator-geometry";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { usePanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
||||
|
||||
export interface WorkspaceTabPresentation {
|
||||
@@ -32,9 +26,6 @@ export interface WorkspaceTabPresentation {
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
}
|
||||
|
||||
const DEFAULT_STATUS_DOT_OFFSET = -2;
|
||||
const STATUS_ALERT_OFFSET = -3;
|
||||
|
||||
interface WorkspaceTabPresentationResolverProps {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
serverId: string;
|
||||
@@ -129,13 +120,7 @@ interface WorkspaceTabIconProps {
|
||||
}
|
||||
|
||||
const ThemedCheckIcon = withUnistyles(Check);
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const needsInputAlertMapping = (theme: Theme) => ({
|
||||
color: theme.colors.surface0,
|
||||
fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
|
||||
});
|
||||
|
||||
export function WorkspaceTabIcon({
|
||||
presentation,
|
||||
active = false,
|
||||
@@ -144,48 +129,17 @@ export function WorkspaceTabIcon({
|
||||
statusDotBorderColor,
|
||||
backdrop,
|
||||
}: WorkspaceTabIconProps): ReactElement {
|
||||
const iconColor = active ? styles.iconActive.color : styles.iconInactive.color;
|
||||
const bucket = presentation.statusBucket;
|
||||
const isRunning = bucket === "running";
|
||||
let statusDotColor: string | undefined;
|
||||
if (bucket === "failed") statusDotColor = styles.statusDotFailed.color;
|
||||
else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color;
|
||||
const showNeedsInputAlert = bucket === "needs_input";
|
||||
const Icon = presentation.icon;
|
||||
const agentIconWrapperStyle = useMemo(
|
||||
() => [styles.agentIconWrapper, { width: size, height: size }],
|
||||
[size],
|
||||
);
|
||||
const statusDotStyle = useMemo(
|
||||
() => [
|
||||
styles.statusDot,
|
||||
{
|
||||
backgroundColor: statusDotColor,
|
||||
borderColor: statusDotBorderColor ?? styles.statusDotBorderDefault.borderColor,
|
||||
},
|
||||
],
|
||||
[statusDotColor, statusDotBorderColor],
|
||||
);
|
||||
|
||||
const iconColor = active ? styles.iconActive.color : styles.iconInactive.color;
|
||||
return (
|
||||
<View style={agentIconWrapperStyle}>
|
||||
<StatusIcon
|
||||
statusBucket={presentation.statusBucket}
|
||||
size={size}
|
||||
statusDotBorderColor={statusDotBorderColor}
|
||||
backdrop={backdrop}
|
||||
>
|
||||
<Icon size={size} color={iconColor} strokeWidth={strokeWidth} />
|
||||
{isRunning ? (
|
||||
<View
|
||||
style={styles.statusRing}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel="Agent running"
|
||||
>
|
||||
<StatusRing backdrop={backdrop} />
|
||||
</View>
|
||||
) : null}
|
||||
{statusDotColor ? <View style={statusDotStyle} /> : null}
|
||||
{showNeedsInputAlert ? (
|
||||
<View style={styles.statusAlertOverlay}>
|
||||
<ThemedCircleAlert size={STATUS_INDICATOR_ALERT_SIZE} uniProps={needsInputAlertMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</StatusIcon>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -262,43 +216,6 @@ export function WorkspaceTabOptionRow({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
agentIconWrapper: {
|
||||
position: "relative",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
statusDot: {
|
||||
position: "absolute",
|
||||
right: DEFAULT_STATUS_DOT_OFFSET,
|
||||
bottom: DEFAULT_STATUS_DOT_OFFSET,
|
||||
width: STATUS_INDICATOR_DOT_SIZE,
|
||||
height: STATUS_INDICATOR_DOT_SIZE,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
},
|
||||
statusRing: {
|
||||
position: "absolute",
|
||||
right: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE),
|
||||
bottom: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE),
|
||||
},
|
||||
statusDotBorderDefault: {
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
statusAlertOverlay: {
|
||||
position: "absolute",
|
||||
right: STATUS_ALERT_OFFSET,
|
||||
bottom: STATUS_ALERT_OFFSET,
|
||||
},
|
||||
statusDotFailed: {
|
||||
color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined,
|
||||
},
|
||||
statusDotRunning: {
|
||||
color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
|
||||
},
|
||||
statusDotAttention: {
|
||||
color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
|
||||
},
|
||||
iconActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
|
||||
interface EffectiveWorkspaceStatus {
|
||||
status: WorkspaceDescriptor["status"];
|
||||
enteredAt: Date | null;
|
||||
}
|
||||
|
||||
export function deriveEffectiveWorkspaceStatus(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
workspaceAgentActivity?: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||
}): EffectiveWorkspaceStatus {
|
||||
if (input.workspace.status !== "done") {
|
||||
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
|
||||
}
|
||||
|
||||
const pendingStartedAt = getPendingInitialAgentCreateStartedAt({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
pendingCreateAttempts: input.pendingCreateAttempts,
|
||||
});
|
||||
if (pendingStartedAt) {
|
||||
return { status: "running", enteredAt: pendingStartedAt };
|
||||
}
|
||||
|
||||
const rootAgentActivity = input.workspaceAgentActivity?.get(input.workspace.id);
|
||||
if (rootAgentActivity && rootAgentActivity.status !== "done") {
|
||||
return rootAgentActivity;
|
||||
}
|
||||
|
||||
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
|
||||
}
|
||||
|
||||
function getPendingInitialAgentCreateStartedAt(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
pendingCreateAttempts: Record<string, PendingCreateAttempt> | undefined;
|
||||
}): Date | null {
|
||||
let latestStartedAt: Date | null = null;
|
||||
for (const pending of Object.values(input.pendingCreateAttempts ?? {})) {
|
||||
if (pending.serverId !== input.serverId) continue;
|
||||
if (pending.workspaceId !== input.workspaceId) continue;
|
||||
if (pending.lifecycle === "abandoned") continue;
|
||||
const startedAt = new Date(pending.timestamp);
|
||||
if (!latestStartedAt || startedAt > latestStartedAt) {
|
||||
latestStartedAt = startedAt;
|
||||
}
|
||||
}
|
||||
return latestStartedAt;
|
||||
}
|
||||
|
||||
export function useWorkspaceStatus(serverId: string, workspaceId: string) {
|
||||
const pendingCreateAttempts = useCreateFlowStore((state) => state.pendingByDraftId);
|
||||
return useSessionStore(
|
||||
useShallow((state) => {
|
||||
const session = state.sessions[serverId];
|
||||
if (!session) return null;
|
||||
const key = resolveWorkspaceMapKeyByIdentity({ workspaces: session.workspaces, workspaceId });
|
||||
const workspace = key ? session.workspaces.get(key) : null;
|
||||
if (!workspace) return null;
|
||||
return deriveEffectiveWorkspaceStatus({
|
||||
serverId,
|
||||
workspace,
|
||||
pendingCreateAttempts,
|
||||
workspaceAgentActivity: session.workspaceAgentActivity,
|
||||
}).status;
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user