Show activity indicators for terminals and their workspaces (#1507)
* feat: show activity indicators for terminals and their workspaces A terminal surfaces whether the agent running inside it is working, idle, or waiting for input as a tab dot, and that state rolls up into the owning workspace status — so an agent run inside a terminal reads the same as a native agent. Finishing a turn raises a "Terminal finished" notification that routes back to the workspace terminal tab. Activity is reported by the agents, not parsed from terminal output: Claude and Codex command hooks and an OpenCode plugin post coarse state to a local daemon endpoint, and a source-agnostic per-terminal tracker maps it to working/idle/attention. Hook installation is opt-in and off by default because it edits your real agent config files; enable it under a host's Terminal settings and Paseo installs the hooks globally, runs them via `paseo hooks <agent> <event>`, and removes its own hooks when you opt back out. Installation and reporting are idempotent and fail-open, so a missing or failing hook never breaks the user's shell. * Fix terminal activity routing and CI test Test drift: anchor the provider-leak test to the repo root instead of process.cwd() so it passes under the server workspace CI runner. Also restore auth-first JSON parsing outside the terminal-activity route and route subdirectory terminal activity to the deepest active workspace. * Fix workspace resolver regression Code drift: terminal activity routing needs parent-workspace prefix matching, but agent directory and historical fetches keep exact registered workspace semantics, including archived exact matches. * Narrow terminal/draft workspace subscriptions to the fields they use terminal-panel and the draft agent tab subscribed to the whole workspace descriptor via useWorkspace but read only workspaceDirectory (plus projectKind in TerminalPanel, plus id in the draft tab), so they re-rendered on any field change of that workspace (status, diffStat, scripts). Switch them to the existing useWorkspaceDirectory / useWorkspaceFields hooks so they re-render only when the fields they actually read change. No behavior change. * Make TerminalActivity.state forward-compatible A strict z.enum rejects the whole payload when a newer daemon reports a terminal activity state this client predates, breaking the protocol's old-client-parses-new-daemon contract (the activity field has no .catch at its embedding in messages.ts, so the failure bubbles up). Degrade unknown states to "idle" (no indicator, no notification) via .catch, matching the enum-with-catch pattern used elsewhere in the protocol. Add a forward-compat regression test.
This commit is contained in:
@@ -193,6 +193,7 @@ export function TerminalPane({
|
||||
const supportsTerminalRestoreModes = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-restore-modes"] === true,
|
||||
);
|
||||
const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId);
|
||||
|
||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||
const terminalStreamKey = useMemo(() => `${scopeKey}:${terminalId}`, [scopeKey, terminalId]);
|
||||
@@ -297,9 +298,19 @@ export function TerminalPane({
|
||||
}, [isPaneFocused, isWorkspaceFocused, requestTerminalReflow, scopeKey, terminalId]);
|
||||
|
||||
const handleTerminalFocus = useCallback(() => {
|
||||
if (isWorkspaceFocused && isPaneFocused) {
|
||||
setFocusedTerminalId(serverId, terminalId);
|
||||
}
|
||||
lastSentTerminalSizeRef.current = null;
|
||||
requestTerminalReflow();
|
||||
}, [requestTerminalReflow]);
|
||||
}, [
|
||||
isPaneFocused,
|
||||
isWorkspaceFocused,
|
||||
requestTerminalReflow,
|
||||
serverId,
|
||||
setFocusedTerminalId,
|
||||
terminalId,
|
||||
]);
|
||||
|
||||
const clearKeyboardRefitTimeouts = useCallback(() => {
|
||||
if (keyboardRefitTimeoutsRef.current.length === 0) {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
@@ -317,8 +317,11 @@ export function WorkspaceDraftAgentTab({
|
||||
const insets = useSafeAreaInsets();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const workspace = useWorkspace(serverId, workspaceId);
|
||||
const workspaceDirectory = workspace?.workspaceDirectory || null;
|
||||
const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({
|
||||
workspaceDirectory: w.workspaceDirectory,
|
||||
id: w.id,
|
||||
}));
|
||||
const workspaceDirectory = workspaceFields?.workspaceDirectory || null;
|
||||
const draftSetup = initialSetup ?? null;
|
||||
const draftWorkingDirectory = resolveDraftWorkingDirectory({
|
||||
workspaceDirectory,
|
||||
@@ -470,7 +473,7 @@ export function WorkspaceDraftAgentTab({
|
||||
attachments,
|
||||
client,
|
||||
workspaceDirectory: draftWorkingDirectory,
|
||||
workspaceId: workspace?.id ?? null,
|
||||
workspaceId: workspaceFields?.id ?? null,
|
||||
autoSubmitConfig,
|
||||
composerState,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
|
||||
@@ -490,6 +490,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const focusedAgentId = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.focusedAgentId ?? null,
|
||||
);
|
||||
const focusedTerminalId = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.focusedTerminalId ?? null,
|
||||
);
|
||||
const sessionAgents = useSessionStore((state) => state.sessions[serverId]?.agents);
|
||||
|
||||
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
|
||||
@@ -815,7 +818,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
);
|
||||
|
||||
// Client activity tracking (heartbeat, push token registration)
|
||||
useClientActivity({ client, focusedAgentId, onAppResumed: handleAppResumed });
|
||||
useClientActivity({ client, focusedAgentId, focusedTerminalId, onAppResumed: handleAppResumed });
|
||||
usePushTokenRegistration({ client, serverId });
|
||||
|
||||
const notifyAgentAttention = useCallback(
|
||||
@@ -1648,6 +1651,27 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
});
|
||||
});
|
||||
|
||||
const unsubTerminalAttention = client.on("terminal_attention_required", (message) => {
|
||||
if (message.type !== "terminal_attention_required") {
|
||||
return;
|
||||
}
|
||||
if (!message.payload.shouldNotify) {
|
||||
return;
|
||||
}
|
||||
void sendOsNotification({
|
||||
title: message.payload.title,
|
||||
body: message.payload.body,
|
||||
// serverId + workspaceId + terminalId route a tap to the terminal tab; cwd is
|
||||
// carried as a fallback identifier when the daemon resolved no workspace.
|
||||
data: {
|
||||
serverId: message.payload.serverId ?? serverId,
|
||||
terminalId: message.payload.terminalId,
|
||||
cwd: message.payload.cwd,
|
||||
...(message.payload.workspaceId ? { workspaceId: message.payload.workspaceId } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubAgentUpdate();
|
||||
unsubAgentStream();
|
||||
@@ -1667,6 +1691,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
unsubVoiceInputState();
|
||||
unsubAgentDeleted();
|
||||
unsubAgentArchived();
|
||||
unsubTerminalAttention();
|
||||
agentStreamReducerQueue.dispose({ flush: true });
|
||||
};
|
||||
}, [
|
||||
|
||||
@@ -65,6 +65,7 @@ function buildTracker(
|
||||
client,
|
||||
deviceType: overrides.deviceType ?? "web",
|
||||
initialFocusedAgentId: overrides.initialFocusedAgentId ?? "agent-1",
|
||||
initialFocusedTerminalId: overrides.initialFocusedTerminalId ?? null,
|
||||
initialAppVisible: overrides.initialAppVisible ?? true,
|
||||
now: clock.now,
|
||||
onAppResumed: overrides.onAppResumed,
|
||||
@@ -130,6 +131,20 @@ describe("client activity tracker", () => {
|
||||
expect(client.recordedHeartbeats).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sends one immediate heartbeat when the focused terminal changes", () => {
|
||||
const { tracker, client, clock } = buildTracker({ initialFocusedTerminalId: null });
|
||||
|
||||
clock.advance(5_000);
|
||||
tracker.setFocusedTerminalId("terminal-1");
|
||||
|
||||
expect(client.recordedHeartbeats).toHaveLength(1);
|
||||
expect(client.latest()).toMatchObject({
|
||||
focusedAgentId: "agent-1",
|
||||
focusedTerminalId: "terminal-1",
|
||||
lastActivityAt: new Date(START_MS + 5_000).toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it("drives lastActivityAt forward from system idle polling", () => {
|
||||
const { tracker, client, clock } = buildTracker();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export const DESKTOP_IDLE_POLL_INTERVAL_MS = 5_000;
|
||||
export interface HeartbeatPayload {
|
||||
deviceType: "web" | "mobile";
|
||||
focusedAgentId: string | null;
|
||||
focusedTerminalId: string | null;
|
||||
lastActivityAt: string;
|
||||
appVisible: boolean;
|
||||
appVisibilityChangedAt?: string;
|
||||
@@ -19,6 +20,7 @@ export interface ClientActivityTrackerInput {
|
||||
client: HeartbeatClient;
|
||||
deviceType: "web" | "mobile";
|
||||
initialFocusedAgentId: string | null;
|
||||
initialFocusedTerminalId: string | null;
|
||||
initialAppVisible: boolean;
|
||||
now: () => number;
|
||||
onAppResumed?: (awayMs: number) => void;
|
||||
@@ -28,6 +30,7 @@ export interface ClientActivityTracker {
|
||||
recordUserActivity(): void;
|
||||
maybeSendImmediateHeartbeat(): void;
|
||||
setFocusedAgentId(id: string | null): void;
|
||||
setFocusedTerminalId(id: string | null): void;
|
||||
notifyAppVisibility(visible: boolean): { changed: boolean };
|
||||
notifySystemIdleMs(idleMs: number | null): void;
|
||||
sendHeartbeat(): void;
|
||||
@@ -42,6 +45,7 @@ export function createClientActivityTracker(
|
||||
let appVisibilityChangedAtMs = now();
|
||||
let backgroundedAtMs: number | null = appVisible ? null : now();
|
||||
let focusedAgentId = input.initialFocusedAgentId;
|
||||
let focusedTerminalId = input.initialFocusedTerminalId;
|
||||
let lastImmediateHeartbeatAtMs = 0;
|
||||
|
||||
function sendHeartbeat(): void {
|
||||
@@ -49,6 +53,7 @@ export function createClientActivityTracker(
|
||||
client.sendHeartbeat({
|
||||
deviceType,
|
||||
focusedAgentId,
|
||||
focusedTerminalId,
|
||||
lastActivityAt: new Date(lastActivityAtMs).toISOString(),
|
||||
appVisible,
|
||||
appVisibilityChangedAt: new Date(appVisibilityChangedAtMs).toISOString(),
|
||||
@@ -76,6 +81,12 @@ export function createClientActivityTracker(
|
||||
recordUserActivity();
|
||||
sendHeartbeat();
|
||||
},
|
||||
setFocusedTerminalId(id) {
|
||||
if (focusedTerminalId === id) return;
|
||||
focusedTerminalId = id;
|
||||
recordUserActivity();
|
||||
sendHeartbeat();
|
||||
},
|
||||
notifyAppVisibility(nextVisible) {
|
||||
if (appVisible === nextVisible) return { changed: false };
|
||||
appVisible = nextVisible;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
interface ClientActivityOptions {
|
||||
client: DaemonClient;
|
||||
focusedAgentId: string | null;
|
||||
focusedTerminalId: string | null;
|
||||
onAppResumed?: (awayMs: number) => void;
|
||||
}
|
||||
|
||||
@@ -26,6 +27,7 @@ interface ClientActivityOptions {
|
||||
export function useClientActivity({
|
||||
client,
|
||||
focusedAgentId,
|
||||
focusedTerminalId,
|
||||
onAppResumed,
|
||||
}: ClientActivityOptions): void {
|
||||
const onAppResumedRef = useRef(onAppResumed);
|
||||
@@ -37,6 +39,7 @@ export function useClientActivity({
|
||||
client,
|
||||
deviceType: isWeb ? "web" : "mobile",
|
||||
initialFocusedAgentId: focusedAgentId,
|
||||
initialFocusedTerminalId: focusedTerminalId,
|
||||
initialAppVisible: AppState.currentState === "active",
|
||||
now: () => Date.now(),
|
||||
onAppResumed: (awayMs) => onAppResumedRef.current?.(awayMs),
|
||||
@@ -114,6 +117,11 @@ export function useClientActivity({
|
||||
tracker.setFocusedAgentId(focusedAgentId);
|
||||
}, [focusedAgentId, tracker]);
|
||||
|
||||
// Send heartbeat on focused terminal change.
|
||||
useEffect(() => {
|
||||
tracker.setFocusedTerminalId(focusedTerminalId);
|
||||
}, [focusedTerminalId, tracker]);
|
||||
|
||||
// Periodic heartbeat gated by connection status.
|
||||
useEffect(() => {
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
@@ -11,7 +11,8 @@ import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { terminalActivityToStatusBucket } from "@/utils/terminal-activity-bucket";
|
||||
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
@@ -37,8 +38,7 @@ function useTerminalPanelDescriptor(
|
||||
): PanelDescriptor {
|
||||
const { t } = useTranslation();
|
||||
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
|
||||
const workspace = useWorkspace(context.serverId, context.workspaceId);
|
||||
const workspaceDirectory = workspace?.workspaceDirectory || null;
|
||||
const workspaceDirectory = useWorkspaceDirectory(context.serverId, context.workspaceId);
|
||||
const terminalsQuery = useQuery(
|
||||
{
|
||||
queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
|
||||
@@ -63,16 +63,19 @@ function useTerminalPanelDescriptor(
|
||||
subtitle: t("workspace.tabs.fallback.terminal"),
|
||||
titleState: "ready",
|
||||
icon: Terminal,
|
||||
statusBucket: null,
|
||||
statusBucket: terminalActivityToStatusBucket(terminal?.activity?.state),
|
||||
};
|
||||
}
|
||||
|
||||
function TerminalPanel() {
|
||||
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
|
||||
const { isWorkspaceFocused, isPaneFocused } = usePaneFocus();
|
||||
const workspace = useWorkspace(serverId, workspaceId);
|
||||
const workspaceDirectory = workspace?.workspaceDirectory || null;
|
||||
const isGitCheckout = workspace?.projectKind === "git";
|
||||
const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({
|
||||
workspaceDirectory: w.workspaceDirectory,
|
||||
isGitCheckout: w.projectKind === "git",
|
||||
}));
|
||||
const workspaceDirectory = workspaceFields?.workspaceDirectory || null;
|
||||
const isGitCheckout = workspaceFields?.isGitCheckout ?? false;
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const handleOpenFileExplorer = useCallback(() => {
|
||||
if (!workspaceDirectory) {
|
||||
|
||||
@@ -767,6 +767,46 @@ function AutoArchiveMergedWorkspacesCard({ serverId }: { serverId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function EnableTerminalAgentHooksCard({ serverId }: { serverId: string }) {
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(next: boolean) => {
|
||||
void patchConfig({ enableTerminalAgentHooks: next }).catch((error) => {
|
||||
console.error("[HostPage] Failed to update terminal agent hooks", error);
|
||||
Alert.alert(
|
||||
"Unable to update terminal agent hooks",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
},
|
||||
[patchConfig],
|
||||
);
|
||||
|
||||
if (!isConnected) return null;
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.card} testID="host-page-terminal-agent-hooks-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Enable terminal agent hooks</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Get notifications and status from terminal agents. This installs hooks in your agent
|
||||
config files.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={config?.enableTerminalAgentHooks === true}
|
||||
onValueChange={handleValueChange}
|
||||
accessibilityLabel="Enable terminal agent hooks"
|
||||
testID="host-page-terminal-agent-hooks-switch"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -1449,6 +1489,9 @@ export function HostTerminalsPage({ serverId }: { serverId: string }) {
|
||||
|
||||
return (
|
||||
<View>
|
||||
<SettingsSection title="Terminal agents">
|
||||
<EnableTerminalAgentHooksCard serverId={serverId} />
|
||||
</SettingsSection>
|
||||
<TerminalProfilesSection serverId={serverId} />
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -223,6 +223,7 @@ function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDa
|
||||
providers,
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
enableTerminalAgentHooks: false,
|
||||
appendSystemPrompt: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -474,8 +474,13 @@ function TabHandleContent({
|
||||
tabLabelSkeletonStyle: React.ComponentProps<typeof View>["style"];
|
||||
tabLabelStyle: React.ComponentProps<typeof Text>["style"];
|
||||
}) {
|
||||
const tabHandleDataSet = useMemo(
|
||||
() => ({ statusBucket: presentation.statusBucket ?? "none" }),
|
||||
[presentation.statusBucket],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.tabHandle}>
|
||||
<View style={styles.tabHandle} dataSet={tabHandleDataSet}>
|
||||
<View style={styles.tabIcon}>
|
||||
<WorkspaceTabIcon presentation={presentation} active={isHighlighted} />
|
||||
</View>
|
||||
|
||||
@@ -2030,6 +2030,7 @@ function WorkspaceScreenContent({
|
||||
[uiTabs, workspaceLayout],
|
||||
);
|
||||
const setFocusedAgentId = useSessionStore((state) => state.setFocusedAgentId);
|
||||
const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId);
|
||||
const focusedPaneAgentId = useMemo(() => {
|
||||
const target = focusedPaneTabState.activeTab?.descriptor.target;
|
||||
if (target?.kind !== "agent") {
|
||||
@@ -2037,13 +2038,28 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
return target.agentId;
|
||||
}, [focusedPaneTabState.activeTab]);
|
||||
const focusedPaneTerminalId = useMemo(() => {
|
||||
const target = focusedPaneTabState.activeTab?.descriptor.target;
|
||||
if (target?.kind !== "terminal") {
|
||||
return null;
|
||||
}
|
||||
return target.terminalId;
|
||||
}, [focusedPaneTabState.activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRouteFocused) {
|
||||
return;
|
||||
}
|
||||
setFocusedAgentId(normalizedServerId, focusedPaneAgentId);
|
||||
}, [focusedPaneAgentId, isRouteFocused, normalizedServerId, setFocusedAgentId]);
|
||||
setFocusedTerminalId(normalizedServerId, focusedPaneTerminalId);
|
||||
}, [
|
||||
focusedPaneAgentId,
|
||||
focusedPaneTerminalId,
|
||||
isRouteFocused,
|
||||
normalizedServerId,
|
||||
setFocusedAgentId,
|
||||
setFocusedTerminalId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRouteFocused) {
|
||||
@@ -2051,8 +2067,9 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
return () => {
|
||||
setFocusedAgentId(normalizedServerId, null);
|
||||
setFocusedTerminalId(normalizedServerId, null);
|
||||
};
|
||||
}, [isRouteFocused, normalizedServerId, setFocusedAgentId]);
|
||||
}, [isRouteFocused, normalizedServerId, setFocusedAgentId, setFocusedTerminalId]);
|
||||
|
||||
const openWorkspaceDraftTab = useCallback(
|
||||
function openWorkspaceDraftTab(input?: { draftId?: string; focus?: boolean }) {
|
||||
|
||||
@@ -282,6 +282,7 @@ export interface SessionState {
|
||||
|
||||
// Focus
|
||||
focusedAgentId: string | null;
|
||||
focusedTerminalId: string | null;
|
||||
|
||||
// Messages
|
||||
messages: MessageEntry[];
|
||||
@@ -340,6 +341,7 @@ interface SessionStoreActions {
|
||||
|
||||
// Focus
|
||||
setFocusedAgentId: (serverId: string, agentId: string | null) => void;
|
||||
setFocusedTerminalId: (serverId: string, terminalId: string | null) => void;
|
||||
|
||||
// Messages
|
||||
setMessages: (
|
||||
@@ -480,6 +482,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess
|
||||
hasHydratedWorkspaces: false,
|
||||
isPlayingAudio: false,
|
||||
focusedAgentId: null,
|
||||
focusedTerminalId: null,
|
||||
messages: [],
|
||||
currentAssistantMessage: "",
|
||||
agentStreamTail: new Map(),
|
||||
@@ -724,6 +727,25 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setFocusedTerminalId: (serverId, terminalId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session || session.focusedTerminalId === terminalId) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
focusedTerminalId: terminalId,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Messages
|
||||
setMessages: (serverId, messages) => {
|
||||
set((prev) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ describe("resolveNotificationTarget", () => {
|
||||
serverId: "server-123",
|
||||
agentId: "agent-456",
|
||||
workspaceId: null,
|
||||
terminalId: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,11 +22,13 @@ describe("resolveNotificationTarget", () => {
|
||||
serverId: null,
|
||||
agentId: null,
|
||||
workspaceId: null,
|
||||
terminalId: null,
|
||||
});
|
||||
expect(resolveNotificationTarget(undefined)).toEqual({
|
||||
serverId: null,
|
||||
agentId: null,
|
||||
workspaceId: null,
|
||||
terminalId: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +43,7 @@ describe("resolveNotificationTarget", () => {
|
||||
serverId: "srv-1",
|
||||
agentId: "agent-1",
|
||||
workspaceId: null,
|
||||
terminalId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -61,6 +65,26 @@ describe("buildNotificationRoute", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("routes to the workspace terminal tab when workspace and terminal ids are present", () => {
|
||||
expect(
|
||||
buildNotificationRoute({
|
||||
serverId: "srv-1",
|
||||
workspaceId: "ws-main",
|
||||
terminalId: "term-1",
|
||||
}),
|
||||
).toBe("/h/srv-1/workspace/ws-main?open=terminal%3Aterm-1");
|
||||
});
|
||||
|
||||
it("falls back to host root for a terminal without a workspace id", () => {
|
||||
expect(
|
||||
buildNotificationRoute({
|
||||
serverId: "srv-1",
|
||||
terminalId: "term-1",
|
||||
cwd: "/tmp/repo",
|
||||
}),
|
||||
).toBe("/h/srv-1");
|
||||
});
|
||||
|
||||
it("falls back to host root when only serverId is present", () => {
|
||||
expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/h/srv-only");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Href } from "expo-router";
|
||||
import { buildHostAgentDetailRoute, buildHostRootRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceOpenRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
type NotificationData = Record<string, unknown> | null | undefined;
|
||||
type NotificationRoute = Extract<Href, string>;
|
||||
@@ -17,19 +21,24 @@ export function resolveNotificationTarget(data: NotificationData): {
|
||||
serverId: string | null;
|
||||
agentId: string | null;
|
||||
workspaceId: string | null;
|
||||
terminalId: string | null;
|
||||
} {
|
||||
return {
|
||||
serverId: readNonEmptyString(data, "serverId"),
|
||||
agentId: readNonEmptyString(data, "agentId"),
|
||||
workspaceId: readNonEmptyString(data, "workspaceId"),
|
||||
terminalId: readNonEmptyString(data, "terminalId"),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNotificationRoute(data: NotificationData): NotificationRoute {
|
||||
const { serverId, agentId } = resolveNotificationTarget(data);
|
||||
const { serverId, agentId, workspaceId, terminalId } = resolveNotificationTarget(data);
|
||||
if (serverId && agentId) {
|
||||
return buildHostAgentDetailRoute(serverId, agentId);
|
||||
}
|
||||
if (serverId && workspaceId && terminalId) {
|
||||
return buildHostWorkspaceOpenRoute(serverId, workspaceId, `terminal:${terminalId}`);
|
||||
}
|
||||
if (serverId) {
|
||||
return buildHostRootRoute(serverId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { terminalActivityToStatusBucket } from "./terminal-activity-bucket";
|
||||
|
||||
describe("terminalActivityToStatusBucket", () => {
|
||||
it("maps working to running", () => {
|
||||
expect(terminalActivityToStatusBucket("working")).toBe("running");
|
||||
});
|
||||
|
||||
it("maps idle to null", () => {
|
||||
expect(terminalActivityToStatusBucket("idle")).toBeNull();
|
||||
});
|
||||
|
||||
it("maps null to null", () => {
|
||||
expect(terminalActivityToStatusBucket(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("maps undefined to null", () => {
|
||||
expect(terminalActivityToStatusBucket(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
|
||||
export function terminalActivityToStatusBucket(
|
||||
state: TerminalActivityState | null | undefined,
|
||||
): SidebarStateBucket | null {
|
||||
if (state === "working") return "running";
|
||||
if (state === "attention") return "needs_input";
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user