Skip to content
48 changes: 48 additions & 0 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ import {
} from "../../native/SelectableMarkdownText";

import { AppText as Text } from "../../components/AppText";
import {
delegationNoticeHeadline,
delegationNoticeSummary,
parseDelegationNotice,
} from "@t3tools/client-runtime/state/delegation-notice";
import { VideoPreviewModal, type VideoPreviewSource } from "../../components/VideoPreviewModal";
import { VideoAttachmentTile } from "../../components/VideoAttachmentTile";
import { MediaVideoPlayer } from "../../components/MediaVideoPlayer";
Expand Down Expand Up @@ -1376,6 +1381,7 @@ function renderFeedEntry(
readonly onToggleTurnFold: (turnId: TurnId) => void;
readonly onPressPreview: (source: FilePreviewSource) => void;
readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
readonly onOpenThread?: (threadId: string) => void;
readonly markdownLinkHandlers: MarkdownLinkHandlers;
readonly renderMarkdownImage: MarkdownImageRenderer;
readonly renderViewedImage: MarkdownImageRenderer;
Expand Down Expand Up @@ -1508,6 +1514,36 @@ function renderFeedEntry(
!message.streaming;

if (isUser) {
const notice = parseDelegationNotice(message);
if (notice !== null) {
return (
<View
accessibilityRole="summary"
accessibilityLabel={delegationNoticeSummary(notice)}
className="my-2 rounded-xl border border-border bg-subtle p-3 gap-1.5"
style={{ borderWidth: StyleSheet.hairlineWidth }}
>
{notice.updates.map((update) => (
<Pressable
key={update.threadId}
onPress={
props.onOpenThread ? () => props.onOpenThread?.(update.threadId) : undefined
}
accessibilityRole="button"
className="py-1 active:opacity-70"
>
<Text className="font-t3-medium text-sm">{delegationNoticeHeadline(update)}</Text>
<Text className="text-xs text-foreground-muted" numberOfLines={1}>
{update.title}
</Text>
{update.reason !== null && (
<Text className="text-xs text-danger-foreground">{update.reason}</Text>
)}
</Pressable>
))}
</View>
);
}
const referenceIds = new Set(
collectComposerContextReferences(message.text).map((reference) => reference.contextId),
);
Expand Down Expand Up @@ -2059,6 +2095,16 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const iconSubtleColor = theme["--color-icon-subtle"];
const screenColor = theme["--color-screen"];
const userBubbleColor = theme["--color-user-bubble"];
const onOpenThread = useCallback(
(threadId: string) => {
navigation.navigate("Thread", {
environmentId: String(props.environmentId),
threadId: String(threadId),
});
},
[navigation, props.environmentId],
);

const onMarkdownLinkPress = useCallback(
(href: string) => {
const presentation = resolveMarkdownLinkPresentation(href);
Expand Down Expand Up @@ -2720,6 +2766,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
onToggleTurnFold,
onPressPreview,
onPressVideo,
onOpenThread,
markdownLinkHandlers,
renderMarkdownImage,
renderViewedImage,
Expand Down Expand Up @@ -2774,6 +2821,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
markdownLinkHandlers,
onPressPreview,
onPressVideo,
onOpenThread,
onToggleTurnFold,
onToggleWorkGroup,
onToggleWorkRow,
Expand Down
91 changes: 91 additions & 0 deletions apps/web/src/components/chat/DelegationNoticeRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { DelegationNotice } from "@t3tools/client-runtime/state/delegation-notice";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vite-plus/test";

import { DelegationNoticeRow } from "./DelegationNoticeRow";

vi.mock("@tanstack/react-router", () => ({
Link: ({
children,
params,
}: {
children: React.ReactNode;
params: { environmentId: string; threadId: string };
}) => <a href={`/${params.environmentId}/${params.threadId}`}>{children}</a>,
}));

const ENV = EnvironmentId.make("env-1");
const EXECUTOR = ThreadId.make("delegated:lead:0123456789abcdef");
const CHILD = ThreadId.make("delegated:lead:fedcba9876543210");

const finished: DelegationNotice = {
updates: [
{
threadId: EXECUTOR,
title: "Executor · Fix the parser",
status: "completed",
reason: null,
isExecutor: true,
},
],
needsUser: false,
};

describe("DelegationNoticeRow", () => {
it("says who finished and links to that thread, with none of the agent-facing text", () => {
const html = renderToStaticMarkup(
<DelegationNoticeRow notice={finished} environmentId={ENV} />,
);
expect(html).toContain("data-delegation-notice");
expect(html).toContain('data-needs-user="false"');
expect(html).toContain("Executor finished");
expect(html).toContain("Executor · Fix the parser");
expect(html).toContain(`href="/${ENV}/${EXECUTOR}"`);
expect(html).not.toContain("JSON");
expect(html).not.toContain("threadId");
expect(html).not.toContain("authorized scope");
});

it("marks a notice that is waiting on the user and shows a failure's reason", () => {
const html = renderToStaticMarkup(
<DelegationNoticeRow
environmentId={ENV}
notice={{
needsUser: true,
updates: [
{
threadId: CHILD,
title: "Review auth",
status: "needs-approval",
reason: null,
isExecutor: false,
},
{
threadId: EXECUTOR,
title: "Executor",
status: "error",
reason: "quota exceeded",
isExecutor: true,
},
],
}}
/>,
);
expect(html).toContain('data-needs-user="true"');
expect(html).toContain("Delegated thread needs your approval");
expect(html).toContain("Executor failed");
expect(html).toContain("quota exceeded");
expect(html).toContain(`href="/${ENV}/${CHILD}"`);
expect(html).toContain(`href="/${ENV}/${EXECUTOR}"`);
// One item per update, so a screen reader hears a list.
expect(html.match(/data-delegation-notice-item/g)).toHaveLength(2);
});

it("is announced as a status, not as something the user said", () => {
const html = renderToStaticMarkup(
<DelegationNoticeRow notice={finished} environmentId={ENV} />,
);
expect(html).toContain('role="status"');
});
});
68 changes: 68 additions & 0 deletions apps/web/src/components/chat/DelegationNoticeRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
delegationNoticeHeadline,
type DelegationNotice,
} from "@t3tools/client-runtime/state/delegation-notice";
import type { EnvironmentId } from "@t3tools/contracts";
import { Link } from "@tanstack/react-router";
import { cn } from "~/lib/utils";

/**
* Replaces the raw automatic wake message in the timeline: who finished or
* needs the user, and a link to that thread.
*/
export function DelegationNoticeRow(props: {
readonly notice: DelegationNotice;
readonly environmentId: EnvironmentId;
}) {
return (
<div
role="status"
data-delegation-notice=""
data-needs-user={props.notice.needsUser ? "true" : "false"}
className={cn(
"w-full rounded-lg border border-border/70 bg-muted/30 px-3 py-2 text-xs",
props.notice.needsUser && "border-info/60",
)}
>
<ul className="space-y-1.5">
{props.notice.updates.map((update) => (
<li
key={update.threadId}
data-delegation-notice-item=""
className="flex flex-col gap-0.5"
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
<span
aria-hidden
className={cn(
"size-1.5 shrink-0 rounded-full",
update.status === "error"
? "bg-destructive"
: update.status === "completed"
? "bg-success"
: update.status === "interrupted"
? "bg-muted-foreground/60"
: "bg-info",
)}
/>
<span className="font-medium text-foreground">
{delegationNoticeHeadline(update)}
</span>
<span className="truncate text-muted-foreground">{update.title}</span>
</div>
<Link
to="/$environmentId/$threadId"
params={{ environmentId: props.environmentId, threadId: update.threadId }}
className="shrink-0 text-muted-foreground hover:text-foreground hover:underline"
>
Open
</Link>
</div>
{update.reason !== null && <div className="text-destructive pl-3">{update.reason}</div>}
</li>
))}
</ul>
</div>
);
}
17 changes: 17 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { delegationActivity } from "../../delegationActivity";
import { usePreparedConnection } from "~/state/session";
import { MediaVideoPlayer } from "../media/MediaVideoPlayer";
import { PierreEntryIcon } from "./PierreEntryIcon";
import { DelegationNoticeRow } from "./DelegationNoticeRow";
import { parseDelegationNotice } from "@t3tools/client-runtime/state/delegation-notice";
import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview";
import { useRightPanelStore } from "~/rightPanelStore";
import {
Expand Down Expand Up @@ -1662,7 +1664,22 @@ function ContextCompactionTimelineRow({
);
}

/**
* The automatic wake message Pylon sends a parent is not something the user
* said, so it renders as a notice and offers none of a message's actions. The
* split keeps every hook of the message row unconditional.
*/
function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
const ctx = use(TimelineRowCtx);
const notice = parseDelegationNotice(row.message);
return notice === null ? (
<UserMessageTimelineRow row={row} />
) : (
<DelegationNoticeRow notice={notice} environmentId={ctx.activeThreadEnvironmentId} />
);
}

function UserMessageTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
const ctx = use(TimelineRowCtx);
const { onImageExpand, onFileOpen } = ctx;
const resources = useMemo(
Expand Down
8 changes: 8 additions & 0 deletions packages/client-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,14 @@
"types": "./src/state/delegatedThreads.ts",
"default": "./src/state/delegatedThreads.ts"
},
"./state/pair": {
"types": "./src/state/pair.ts",
"default": "./src/state/pair.ts"
},
"./state/delegation-notice": {
"types": "./src/state/delegationNotice.ts",
"default": "./src/state/delegationNotice.ts"
},
"./state/thread-sort": {
"types": "./src/state/threadSort.ts",
"default": "./src/state/threadSort.ts"
Expand Down
4 changes: 3 additions & 1 deletion packages/client-runtime/src/state/delegatedThreads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export function nestedRowContainsThread<T extends ScopedThread>(
}

/** Shell-only lifecycle; keep admission/terminal precedence aligned with server delegation. */
function delegatedThreadStatus(shell: EnvironmentThreadShell) {
export function delegatedThreadStatus(shell: EnvironmentThreadShell) {
if (shell.archivedAt !== null) return "archived";
const { session, latestTurn: turn } = shell;
if (session?.status === "error" || turn?.state === "error") return "error";
Expand All @@ -129,6 +129,8 @@ function delegatedThreadStatus(shell: EnvironmentThreadShell) {
return "running";
}

export type DelegatedThreadStatus = ReturnType<typeof delegatedThreadStatus>;

/**
* Compact delegated children from the existing environment shell subscription.
* The parent's session is deliberately irrelevant: children can outlive it.
Expand Down
Loading
Loading