mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7db5385a96 |
@@ -242,7 +242,7 @@ parentAgentId === thisAgent.id AND !archivedAt
|
||||
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
|
||||
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
|
||||
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
|
||||
|
||||
|
||||
@@ -99,6 +99,16 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
await expectSubagentRowVisible(page, child.id);
|
||||
});
|
||||
|
||||
await test.step("the parent box in a subagent's sheet closes it onto the parent", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
const childSheet = sheet(page, child.title);
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await expect(childSheet.getByTestId("composer-parent-agent-pill")).toHaveCount(0);
|
||||
await childSheet.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(childSheet).toHaveCount(0);
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
|
||||
@@ -136,7 +136,8 @@ test.describe("real provider subagent timelines", () => {
|
||||
panel.getByText("Start chatting with this agent...", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click();
|
||||
// A provider subagent has no composer; its callout is the way back to the parent.
|
||||
await page.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
@@ -125,6 +125,29 @@ test("opening an already-tabbed subagent reveals its left tab without a duplicat
|
||||
}
|
||||
});
|
||||
|
||||
test("a subagent's parent pill returns to its parent", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-parent-pill-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Refactor auth flow",
|
||||
childTitle: "Write session tests",
|
||||
});
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, { workspaceId: pair.workspaceId, agentId: pair.child.id });
|
||||
|
||||
const pill = page.getByTestId("composer-parent-agent-pill");
|
||||
await expect(pill).toHaveAccessibleName("Back to Refactor auth flow");
|
||||
await pill.click();
|
||||
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${pair.parent.id}`)).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
|
||||
const target = "linked.ts:3";
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -276,7 +276,11 @@ export function ComposerTrackRow({
|
||||
* knockout for marks that sit on top of an icon, and nothing sits under this one, so it comes off
|
||||
* too — leaving every mark's visible edge on the same rail whatever state it is in.
|
||||
*/
|
||||
function ComposerTrackMark({ bucket }: { bucket: SidebarStateBucket | null }): ReactElement | null {
|
||||
export function ComposerTrackMark({
|
||||
bucket,
|
||||
}: {
|
||||
bucket: SidebarStateBucket | null;
|
||||
}): ReactElement | null {
|
||||
if (!bucket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1908,6 +1908,13 @@ export const ar: TranslationResources = {
|
||||
backdrop: "خلفية القائمة",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "العودة إلى الوكيل الأصلي",
|
||||
backTo: "العودة إلى {{label}}",
|
||||
parent: "الأصل",
|
||||
parentAgent: "الوكيل الأصلي",
|
||||
openParent: "فتح الوكيل الأصلي",
|
||||
},
|
||||
title: "الوكلاء الفرعيون",
|
||||
pillLabelOne: "وكيل فرعي واحد",
|
||||
pillLabelMany: "{{count}} وكلاء فرعيين",
|
||||
|
||||
@@ -1933,6 +1933,13 @@ export const en = {
|
||||
backdrop: "Menu backdrop",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Back to parent",
|
||||
backTo: "Back to {{label}}",
|
||||
parent: "Parent",
|
||||
parentAgent: "Parent agent",
|
||||
openParent: "Open parent",
|
||||
},
|
||||
title: "Subagents",
|
||||
pillLabelOne: "1 subagent",
|
||||
pillLabelMany: "{{count}} subagents",
|
||||
|
||||
@@ -1956,6 +1956,13 @@ export const es: TranslationResources = {
|
||||
backdrop: "Fondo del menú",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Volver al padre",
|
||||
backTo: "Volver a {{label}}",
|
||||
parent: "Padre",
|
||||
parentAgent: "Agente padre",
|
||||
openParent: "Abrir padre",
|
||||
},
|
||||
title: "Subagentes",
|
||||
pillLabelOne: "1 subagente",
|
||||
pillLabelMany: "{{count}} subagentes",
|
||||
|
||||
@@ -1954,6 +1954,13 @@ export const fr: TranslationResources = {
|
||||
backdrop: "Arrière-plan du menu",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Retour au parent",
|
||||
backTo: "Retour à {{label}}",
|
||||
parent: "Parent",
|
||||
parentAgent: "Agent parent",
|
||||
openParent: "Ouvrir le parent",
|
||||
},
|
||||
title: "Sous-agents",
|
||||
pillLabelOne: "1 sous-agent",
|
||||
pillLabelMany: "{{count}} sous-agents",
|
||||
|
||||
@@ -1927,6 +1927,13 @@ export const ja: TranslationResources = {
|
||||
backdrop: "メニューの背景",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "親に戻る",
|
||||
backTo: "{{label}} に戻る",
|
||||
parent: "親",
|
||||
parentAgent: "親エージェント",
|
||||
openParent: "親を開く",
|
||||
},
|
||||
title: "サブエージェント",
|
||||
pillLabelOne: "サブエージェント 1 件",
|
||||
pillLabelMany: "サブエージェント {{count}} 件",
|
||||
|
||||
@@ -1918,6 +1918,13 @@ export const ko: TranslationResources = {
|
||||
backdrop: "메뉴 배경",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "상위 에이전트로 돌아가기",
|
||||
backTo: "{{label}}(으)로 돌아가기",
|
||||
parent: "상위",
|
||||
parentAgent: "상위 에이전트",
|
||||
openParent: "상위 에이전트 열기",
|
||||
},
|
||||
title: "하위 에이전트",
|
||||
pillLabelOne: "하위 에이전트 1개",
|
||||
pillLabelMany: "하위 에이전트 {{count}}개",
|
||||
|
||||
@@ -1941,6 +1941,13 @@ export const ptBR: TranslationResources = {
|
||||
backdrop: "Fundo do menu",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Voltar ao pai",
|
||||
backTo: "Voltar para {{label}}",
|
||||
parent: "Pai",
|
||||
parentAgent: "Agente pai",
|
||||
openParent: "Abrir pai",
|
||||
},
|
||||
title: "Subagentes",
|
||||
pillLabelOne: "1 subagente",
|
||||
pillLabelMany: "{{count}} subagentes",
|
||||
|
||||
@@ -1940,6 +1940,13 @@ export const ru: TranslationResources = {
|
||||
backdrop: "Фон меню",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Назад к родителю",
|
||||
backTo: "Назад к {{label}}",
|
||||
parent: "Родитель",
|
||||
parentAgent: "Родительский агент",
|
||||
openParent: "Открыть родителя",
|
||||
},
|
||||
title: "Субагенты",
|
||||
pillLabelOne: "1 субагент",
|
||||
pillLabelMany: "Субагентов: {{count}}",
|
||||
|
||||
@@ -1886,6 +1886,13 @@ export const zhCN: TranslationResources = {
|
||||
backdrop: "菜单背景",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "返回父代理",
|
||||
backTo: "返回 {{label}}",
|
||||
parent: "父代理",
|
||||
parentAgent: "父代理",
|
||||
openParent: "打开父代理",
|
||||
},
|
||||
title: "Subagent",
|
||||
pillLabelOne: "1 个 subagent",
|
||||
pillLabelMany: "{{count}} 个 subagent",
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
||||
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
|
||||
import { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks";
|
||||
import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
@@ -1109,7 +1110,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
});
|
||||
const hasPluginComposerPills = useHasPluginComposerPills(serverId, workspaceId, agentId);
|
||||
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
|
||||
const parentAgentId = useParentAgentId(serverId, agentId);
|
||||
// With a composer the parent is a pill above it; without one, the box takes the composer's place.
|
||||
const parentPillAgentId = showsComposer ? parentAgentId : null;
|
||||
const hasVisibleAgentTracks = hasAgentTracks({
|
||||
parentAgentId: parentPillAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus: archiveFinishedSubagents.status,
|
||||
@@ -1161,6 +1166,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
composerState,
|
||||
],
|
||||
);
|
||||
const parentCallout = parentAgentId ? (
|
||||
<ParentAgentCallout serverId={serverId} parentAgentId={parentAgentId} />
|
||||
) : null;
|
||||
const composerSection = showsComposer ? (
|
||||
<RenderProfile id={`AgentComposerSection:${agentId}`}>
|
||||
<AgentComposerSection
|
||||
@@ -1179,7 +1187,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
onMessageSent={handleMessageSent}
|
||||
/>
|
||||
</RenderProfile>
|
||||
) : null;
|
||||
) : (
|
||||
parentCallout
|
||||
);
|
||||
const streamContent = (
|
||||
<View style={animatedStaticStyles.content}>
|
||||
{showsComposer ? null : <SheetAgentFacts serverId={serverId} agentId={agentId} />}
|
||||
@@ -1203,6 +1213,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
parentAgentId={parentPillAgentId}
|
||||
subagentRows={subagentRows}
|
||||
tasks={tasks}
|
||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
useSubagentOpensInSheet,
|
||||
type SubagentRow,
|
||||
} from "@/subagents";
|
||||
import { ParentAgentPill } from "@/subagents/parent";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
import type { TodoEntry } from "@/types/stream";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
@@ -34,6 +35,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
@@ -43,6 +45,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
parentAgentId: string | null;
|
||||
subagentRows: SubagentRow[];
|
||||
tasks: TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
@@ -88,8 +91,12 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
[openSubagentTarget, serverId, workspaceId],
|
||||
);
|
||||
const handleOpenProviderSubagent = useCallback(
|
||||
(parentAgentId: string, subagentId: string) => {
|
||||
openSubagentTarget({ kind: "provider_subagent", parentAgentId, subagentId });
|
||||
(ownerAgentId: string, subagentId: string) => {
|
||||
openSubagentTarget({
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: ownerAgentId,
|
||||
subagentId,
|
||||
});
|
||||
},
|
||||
[openSubagentTarget],
|
||||
);
|
||||
@@ -108,6 +115,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
if (
|
||||
!hasWorkspaceDiffStat &&
|
||||
!hasAgentTracks({
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
@@ -119,6 +127,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
|
||||
return (
|
||||
<ComposerTrackBar>
|
||||
{parentAgentId ? <ParentAgentPill serverId={serverId} parentAgentId={parentAgentId} /> : null}
|
||||
<AgentTaskList tasks={tasks} />
|
||||
<SubagentsTrack
|
||||
serverId={serverId}
|
||||
@@ -147,17 +156,20 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
});
|
||||
|
||||
export function hasAgentTracks({
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
hasPluginComposerPills = false,
|
||||
}: {
|
||||
parentAgentId: string | null;
|
||||
subagentRows: readonly SubagentRow[];
|
||||
tasks: readonly TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
hasPluginComposerPills?: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
parentAgentId !== null ||
|
||||
subagentRows.length > 0 ||
|
||||
Boolean(tasks?.length) ||
|
||||
archiveFinishedStatus.kind !== "idle" ||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { usePaneContext } from "@/panels/pane-context";
|
||||
import { PaneSubtitle } from "@/panels/pane-subtitle";
|
||||
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { ParentAgentCallout } from "@/subagents/parent";
|
||||
import { type SubagentRow, useSubagentsForParent } from "@/subagents/select";
|
||||
import { useSubagentOpensInSheet } from "@/subagents/opens-in-sheet";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
@@ -253,33 +254,37 @@ function ProviderSubagentPanel() {
|
||||
return (
|
||||
<View style={styles.container} testID="provider-subagent-panel">
|
||||
{subtitle ? <PaneSubtitle text={subtitle} testID="provider-subagent-pane-subtitle" /> : null}
|
||||
<AgentStreamView
|
||||
agentId={streamId}
|
||||
serverId={serverId}
|
||||
context={streamContext}
|
||||
streamItems={timeline?.tail ?? EMPTY_STREAM_ITEMS}
|
||||
streamHead={timeline?.head ?? EMPTY_STREAM_ITEMS}
|
||||
turnPresentation={turnPresentation}
|
||||
pendingPermissions={EMPTY_PERMISSIONS}
|
||||
isAuthoritativeHistoryReady
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
readOnly
|
||||
historyPagination={historyPagination}
|
||||
bottomOverlayTailClearance={childTrackClearance.tail}
|
||||
bottomOverlayControlClearance={childTrackClearance.controls}
|
||||
/>
|
||||
<ProviderSubagentChildTrack
|
||||
serverId={serverId}
|
||||
rows={childRows}
|
||||
onOpenProviderSubagent={openProviderChild}
|
||||
opensInSheet={opensInSheet}
|
||||
/>
|
||||
<View style={styles.stream}>
|
||||
<AgentStreamView
|
||||
agentId={streamId}
|
||||
serverId={serverId}
|
||||
context={streamContext}
|
||||
streamItems={timeline?.tail ?? EMPTY_STREAM_ITEMS}
|
||||
streamHead={timeline?.head ?? EMPTY_STREAM_ITEMS}
|
||||
turnPresentation={turnPresentation}
|
||||
pendingPermissions={EMPTY_PERMISSIONS}
|
||||
isAuthoritativeHistoryReady
|
||||
onOpenWorkspaceFile={openFileInWorkspace}
|
||||
readOnly
|
||||
historyPagination={historyPagination}
|
||||
bottomOverlayTailClearance={childTrackClearance.tail}
|
||||
bottomOverlayControlClearance={childTrackClearance.controls}
|
||||
/>
|
||||
<ProviderSubagentChildTrack
|
||||
serverId={serverId}
|
||||
rows={childRows}
|
||||
onOpenProviderSubagent={openProviderChild}
|
||||
opensInSheet={opensInSheet}
|
||||
/>
|
||||
</View>
|
||||
<ParentAgentCallout serverId={serverId} parentAgentId={target.parentAgentId} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: { flex: 1, minHeight: 0 },
|
||||
stream: { flex: 1, minHeight: 0 },
|
||||
unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
|
||||
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CornerUpLeft } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ComposerTrackMark } from "@/composer/tracks";
|
||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useParentAgent, type ParentAgent } from "./use-parent-agent";
|
||||
|
||||
function ParentIcon({
|
||||
icon: Icon,
|
||||
size,
|
||||
color = "",
|
||||
}: {
|
||||
icon: ParentAgent["icon"];
|
||||
size: number;
|
||||
color?: string;
|
||||
}) {
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedParentIcon = withUnistyles(ParentIcon);
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
/** Stands in the composer's place on a subagent shown without one: who the parent is, and the way back. */
|
||||
export function ParentAgentCallout({
|
||||
serverId,
|
||||
parentAgentId,
|
||||
}: {
|
||||
serverId: string;
|
||||
parentAgentId: string;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const parent = useParentAgent(serverId, parentAgentId);
|
||||
|
||||
return (
|
||||
<View style={styles.rail}>
|
||||
<View style={styles.callout} testID="parent-agent-callout">
|
||||
<View style={styles.avatar}>
|
||||
<ThemedParentIcon icon={parent.icon} size={18} uniProps={foregroundColorMapping} />
|
||||
</View>
|
||||
<View style={styles.body}>
|
||||
<Text style={styles.eyebrow}>{t("subagents.parent.parent")}</Text>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{parent.label ?? t("subagents.parent.parentAgent")}
|
||||
</Text>
|
||||
<ComposerTrackMark bucket={parent.statusBucket} />
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leftIcon={CornerUpLeft}
|
||||
onPress={parent.open}
|
||||
testID="parent-agent-callout-open"
|
||||
>
|
||||
{t("subagents.parent.openParent")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
rail: {
|
||||
minHeight: FOOTER_HEIGHT,
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
callout: {
|
||||
width: "100%",
|
||||
maxWidth: theme.contentMaxWidth,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
padding: theme.spacing[3],
|
||||
paddingRight: { xs: theme.spacing[3], md: theme.spacing[4] },
|
||||
},
|
||||
avatar: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[0.5],
|
||||
},
|
||||
eyebrow: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
title: {
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ParentAgentCallout } from "./callout";
|
||||
export { ParentAgentPill } from "./pill";
|
||||
export { useParentAgentId } from "./use-parent-agent";
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronLeft } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { composerPillStyles } from "@/composer/pill-styles";
|
||||
import { ComposerTrackMark } from "@/composer/tracks";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useParentAgent } from "./use-parent-agent";
|
||||
|
||||
const ThemedChevronLeft = withUnistyles(ChevronLeft);
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
/**
|
||||
* The way back from a subagent to the agent that started it. It leads the pill rail and names
|
||||
* the parent rather than saying "parent": in a stack of subagents the title is what tells you
|
||||
* where you will land.
|
||||
*/
|
||||
export function ParentAgentPill({
|
||||
serverId,
|
||||
parentAgentId,
|
||||
}: {
|
||||
serverId: string;
|
||||
parentAgentId: string;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const parent = useParentAgent(serverId, parentAgentId);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handleHoverIn = useCallback(() => setIsHovered(true), []);
|
||||
const handleHoverOut = useCallback(() => setIsHovered(false), []);
|
||||
const bodyStyle = useMemo(
|
||||
() => [composerPillStyles.body, styles.body, isHovered && composerPillStyles.bodyActive],
|
||||
[isHovered],
|
||||
);
|
||||
const labelStyle = useMemo(
|
||||
() => [composerPillStyles.label, styles.label, isHovered && composerPillStyles.labelActive],
|
||||
[isHovered],
|
||||
);
|
||||
const label = parent.label ?? t("subagents.parent.backToParent");
|
||||
|
||||
return (
|
||||
<PressHighlight
|
||||
testID="composer-parent-agent-pill"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("subagents.parent.backTo", { label })}
|
||||
onPress={parent.open}
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
style={bodyStyle}
|
||||
highlightStyle={composerPillStyles.bodyActive}
|
||||
>
|
||||
<ThemedChevronLeft
|
||||
size={14}
|
||||
uniProps={isHovered ? foregroundColorMapping : mutedColorMapping}
|
||||
/>
|
||||
<ComposerTrackMark bucket={parent.statusBucket} />
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</PressHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// The chevron's box is wider than its stroke, so the pill pulls it toward the edge to keep the
|
||||
// visible glyph on the same inset as other pills' leading marks.
|
||||
body: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[1.5],
|
||||
paddingLeft: theme.spacing[2],
|
||||
},
|
||||
label: {
|
||||
flexShrink: 1,
|
||||
maxWidth: { xs: 140, md: 240 },
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAgentDescriptor } from "@/agent-presentation";
|
||||
import type { PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
|
||||
export interface ParentAgent {
|
||||
/** The parent's title, or null while it has none — callers fall back to a generic name. */
|
||||
label: string | null;
|
||||
icon: PanelDescriptor["icon"];
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
/** The parent of a managed agent, or null for a root or detached agent. */
|
||||
export function useParentAgentId(serverId: string, agentId: string): string | null {
|
||||
return useSessionStore((state) => {
|
||||
const session = state.sessions[serverId];
|
||||
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
|
||||
return agent?.parentAgentId ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
/** The parent as its own tab presents it, and the way back to it. */
|
||||
export function useParentAgent(serverId: string, parentAgentId: string): ParentAgent {
|
||||
const descriptor = useAgentDescriptor({ kind: "agent", agentId: parentAgentId }, { serverId });
|
||||
// The parent may live in another workspace; navigateToAgent opens it wherever it is.
|
||||
const open = useCallback(() => {
|
||||
navigateToAgent({ serverId, agentId: parentAgentId });
|
||||
}, [parentAgentId, serverId]);
|
||||
return {
|
||||
label: descriptor.label || null,
|
||||
icon: descriptor.icon,
|
||||
statusBucket: descriptor.statusBucket,
|
||||
open,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user