Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
type CodexArtifactTemplate,
} from "@t3tools/client-runtime/codex-artifact-templates";
import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests";
import { presentPendingBackgroundWork } from "@t3tools/client-runtime/state/thread-execution";
import { resolveSubagentPillSegment } from "@t3tools/client-runtime/state/thread-subagents";
import {
formatModelSelectionEffort,
Expand Down Expand Up @@ -428,6 +429,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
// One floating pill above the composer: it reads the connection phase while
// disconnected, the sync state while messages load, then the working timer
// once the feed is settled.
// The shell's roster is the server's post-settlement view of what still runs.
const pendingBackgroundWork = presentPendingBackgroundWork(
props.selectedThread.pendingBackgroundTasks,
);
const floatingStatus = ((): FloatingWorkingStatus | null => {
const connectionStatus = connectionFloatingStatus({
connectionError: props.connectionError,
Expand Down Expand Up @@ -461,6 +466,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") {
return { kind: "working", startedAt: props.activeWorkStartedAt };
}
if (pendingBackgroundWork !== null && contentPresentationKind === "ready") {
return {
kind: "waiting",
label: pendingBackgroundWork.title,
accessibilityLabel: `${pendingBackgroundWork.title}: ${pendingBackgroundWork.items
.map((item) => item.label)
.join(", ")}`,
};
}
return null;
})();
const showWorkingControl = floatingStatus !== null;
Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/src/features/threads/floating-working-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,26 @@ function FloatingStatusLabel(props: {
</StatusLabelRow>
);
}
if (props.status.kind === "waiting") {
return (
<StatusLabelRow
key="waiting"
accessibilityLabel={props.status.accessibilityLabel}
className="gap-2"
onLayout={props.onLayout}
>
<SymbolView
name={{ ios: "bolt", android: "bolt" }}
size={13}
tintColorClassName="foreground"
type="monochrome"
/>
<Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
{props.status.label}
</Text>
</StatusLabelRow>
);
}
if (props.status.kind === "preparing") {
return (
<StatusLabelRow
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/floating-working-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export type FloatingWorkingStatus =
| { readonly kind: "working"; readonly startedAt: string }
| { readonly kind: "syncing"; readonly label: string }
| { readonly kind: "compacting" }
// The turn settled while background work it started still runs.
| { readonly kind: "waiting"; readonly label: string; readonly accessibilityLabel: string }
// A task whose thread the server has not created yet: the worktree may
// still be checking out, so there is no turn to time.
| { readonly kind: "preparing"; readonly label: string }
Expand Down
30 changes: 24 additions & 6 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
import { MaskedView } from "@expo/ui/community/masked-view";
import type { LegendListRef } from "@legendapp/list/react-native";
import { AnimatedLegendList } from "@legendapp/list/reanimated";
import { useIsFocused } from "@react-navigation/native";
import { useIsFocused, useNavigation } from "@react-navigation/native";
import {
memo,
useCallback,
Expand Down Expand Up @@ -64,6 +64,7 @@ import {
workEntryViewedImagePath,
} from "@t3tools/client-runtime/work-log/presentation";
import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution";
import type { MarkdownImageRenderer } from "../../native/SelectableMarkdownText";
import Animated, {
cancelAnimation,
Expand Down Expand Up @@ -786,6 +787,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
},
) {
const { row, expanded } = props;
const navigation = useNavigation();
const failureItem = row.projectedItem.item;
if (failureItem.type === "error" && failureItem.status === "failed") {
const warning = failureItem.failure.class === "usage_limit";
Expand Down Expand Up @@ -852,7 +854,12 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
</WorkLogPressable>
);
}
const canExpand = row.canExpand;
// A subagent's notification opens its thread, like the subagent's own row.
const notifiedSubagentThreadId =
row.projectedItem.item.type === "notification"
? notificationChildThreadId(row.projectedItem.item.source)
: undefined;
const canExpand = row.canExpand && notifiedSubagentThreadId === undefined;
const reasoning = row.projectedItem.item.type === "reasoning" ? row.projectedItem.item : null;
const fullDetail = expanded && !reasoning ? row.getFullDetail() : null;
const viewedImagePath = workEntryViewedImagePath(row.workEntry);
Expand Down Expand Up @@ -881,15 +888,26 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
{...(isFreshRow(row.createdAt) ? { entering: FadeIn.duration(200) } : {})}
>
<WorkLogPressable
accessibilityRole={canExpand ? "button" : undefined}
accessibilityRole={
notifiedSubagentThreadId !== undefined ? "link" : canExpand ? "button" : undefined
}
accessibilityLabel={failed ? `${accessiblePreview}, tool call failed` : accessiblePreview}
accessibilityHint={
canExpand
? `Double tap to ${expanded ? "hide" : "show"} full details. Long press to copy.`
: "Long press to copy."
notifiedSubagentThreadId !== undefined
? "Opens this agent's thread. Long press to copy."
: canExpand
? `Double tap to ${expanded ? "hide" : "show"} full details. Long press to copy.`
: "Long press to copy."
}
accessibilityState={canExpand ? { expanded } : undefined}
onPress={() => {
if (notifiedSubagentThreadId !== undefined) {
navigation.navigate("Thread", {
environmentId: String(props.environmentId),
threadId: String(notifiedSubagentThreadId),
});
return;
}
if (canExpand) {
void Haptics.selectionAsync();
props.onToggleRow(row.id, props.anchorKey);
Expand Down
18 changes: 17 additions & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,23 @@ function itemWorkLogTone(item: OrchestrationV2TurnItem): WorkLogPresentationEntr
}

function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] {
if (item.type === "notification") return "zap";
if (item.type === "notification") {
const source = item.source;
switch (source.kind) {
case "subagent":
case "delegated_task":
return "hammer";
case "command":
return "command";
case "monitor":
return "eye";
case "background_task":
return "zap";
default:
source satisfies never;
return "zap";
}
}
if (item.type === "dynamic_tool") {
const classified = classifyToolActivity({
itemType: "dynamic_tool_call",
Expand Down
62 changes: 46 additions & 16 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import {
deriveThreadActivityRun,
deriveLatestThreadRun,
deriveThreadRuntime,
presentPendingBackgroundWork,
} from "@t3tools/client-runtime/state/thread-execution";
import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows";
import {
Expand Down Expand Up @@ -133,6 +134,7 @@ import { Debouncer } from "@tanstack/react-pacer";
import { useAtomValue } from "@effect/atom-react";
import { Atom } from "effect/unstable/reactivity";
import {
Fragment,
lazy,
memo,
type SetStateAction,
Expand Down Expand Up @@ -533,7 +535,7 @@ import { assetEnvironment } from "../state/assets";
import { readPreparedConnection } from "../state/session";
import { useAtomCommand } from "../state/use-atom-command";
import { useAtomQueryRunner } from "../state/use-atom-query-runner";
import { Button } from "./ui/button";
import { Button, InlineButton } from "./ui/button";
import {
AlertDialog,
AlertDialogClose,
Expand Down Expand Up @@ -6878,11 +6880,21 @@ export default function ChatView(props: ChatViewProps) {
}
}
}, [activeThread, environmentId, interruptThreadTurn, setThreadError]);
const onOpenRelatedThread = useCallback(
(threadId: ThreadId) => {
void navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(scopeThreadRef(environmentId, threadId)),
});
},
[environmentId, navigate],
);

const backgroundWorkBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
if (activeBackgroundTasks.length === 0 || !activeThread) {
const presentation = presentPendingBackgroundWork(activeBackgroundTasks);
if (presentation === null || !activeThread) {
return null;
}
const count = activeBackgroundTasks.length;
return {
id: `background-work:${activeThread.id}`,
variant: "default",
Expand All @@ -6893,8 +6905,30 @@ export default function ChatView(props: ChatViewProps) {
aria-hidden="true"
/>
),
title: count === 1 ? "Waiting on background task" : `Waiting on ${count} background tasks`,
description: activeBackgroundTasks.map((task) => task.description || task.taskId).join(", "),
title: presentation.title,
// A single named item is already in the title.
description:
presentation.items.length === 1 && presentation.items[0]?.childThreadId === undefined
? undefined
: presentation.items.map((item, index) => {
const childThreadId = item.childThreadId;
return (
<Fragment key={item.taskId}>
{index > 0 ? ", " : null}
{childThreadId === undefined ? (
item.label
) : (
<InlineButton
tone="muted"
aria-label={`Open subagent ${item.label}`}
onClick={() => onOpenRelatedThread(childThreadId)}
>
{item.label}
</InlineButton>
)}
</Fragment>
);
}),
actions: (
<Button
size="xs"
Expand All @@ -6906,7 +6940,13 @@ export default function ChatView(props: ChatViewProps) {
</Button>
),
};
}, [activeBackgroundTasks, activeThread, handleStopBackgroundWork, isStoppingBackgroundWork]);
}, [
activeBackgroundTasks,
activeThread,
handleStopBackgroundWork,
isStoppingBackgroundWork,
onOpenRelatedThread,
]);
// A woken thread announces itself in the open view, not just the sidebar
// pill. Dismissing marks the wake as seen (same acknowledgment as the
// pill); sending a message clears it as a side effect of the send path.
Expand Down Expand Up @@ -7840,16 +7880,6 @@ export default function ChatView(props: ChatViewProps) {
],
);

const onOpenRelatedThread = useCallback(
(threadId: ThreadId) => {
void navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(scopeThreadRef(environmentId, threadId)),
});
},
[environmentId, navigate],
);

const onForkFromRun = useCallback(
async (input: { readonly sourceThreadId: ThreadId; readonly runId: RunId }) => {
if (!activeThread || activeEnvironmentUnavailable) return;
Expand Down
34 changes: 33 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { environmentThreadDetails } from "../../state/threads";
import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
import { Link } from "@tanstack/react-router";
import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution";
import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
import {
resolveWorkEntryToolPresentation,
Expand Down Expand Up @@ -4823,7 +4824,22 @@ const toolCallExpandedBodyClassName =

function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
if (workEntry.structuredPayload?.type === "notification") {
return workEntry.structuredPayload.outcome === "failed" ? "circle-alert" : "zap";
if (workEntry.structuredPayload.outcome === "failed") return "circle-alert";
const source = workEntry.structuredPayload.source;
switch (source.kind) {
case "subagent":
case "delegated_task":
return "bot";
case "command":
return "terminal";
case "monitor":
return "eye";
case "background_task":
return "zap";
default:
source satisfies never;
return "zap";
}
}
if (workEntry.itemType === "user_input_request" || workEntry.itemType === "approval_request") {
return "message-circle";
Expand Down Expand Up @@ -4907,6 +4923,10 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
workEntry.projectedItem?.item.type === "thread_created"
? workEntry.projectedItem.item
: undefined;
const notifiedSubagentThreadId =
workEntry.projectedItem?.item.type === "notification"
? notificationChildThreadId(workEntry.projectedItem.item.source)
: undefined;
const groupView = use(WorkGroupViewCtx);
const [expanded, setExpanded] = useState(
() => groupView?.state.expandedEntries.has(workEntry.id) ?? false,
Expand Down Expand Up @@ -5139,6 +5159,18 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
Open chat
</button>
) : null}
{notifiedSubagentThreadId ? (
<InlineButton
aria-label="Open subagent thread"
onClick={(event) => {
event.stopPropagation();
ctx.onOpenThread(notifiedSubagentThreadId);
}}
onKeyDown={stopRowToggle}
>
Open subagent
</InlineButton>
) : null}
{showFailedIndicator &&
!showDestructiveRowStyle &&
!toolIconAcceptsTint(entryIconName, entryToolIcon) ? (
Expand Down
47 changes: 47 additions & 0 deletions packages/client-runtime/src/state/threadExecution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { describe, expect, it } from "vite-plus/test";

import { v2Projection } from "./orchestrationV2TestFixtures.ts";
import {
presentPendingBackgroundWork,
deriveLatestThreadRun,
deriveProviderSubagentStatus,
formatModelSelectionEffort,
Expand Down Expand Up @@ -449,3 +450,49 @@ describe("threadRuntimeCanArchive", () => {
expect(threadRuntimeCanArchive(runtime("idle", null))).toBe(true);
});
});

describe("presentPendingBackgroundWork", () => {
it("names a single piece of work by kind", () => {
expect(
presentPendingBackgroundWork([
{ taskId: "a", kind: "subagent", description: "Review src/math.ts" },
])?.title,
).toBe("Waiting on subagent Review src/math.ts");
expect(presentPendingBackgroundWork([{ taskId: "a", kind: "command" }])?.title).toBe(
"Waiting on a command",
);
expect(presentPendingBackgroundWork([])).toBeNull();
});

it("groups work by kind, subagents first, and keeps each name", () => {
const presentation = presentPendingBackgroundWork([
{ taskId: "cmd", kind: "command", description: "npm test" },
{
taskId: "b",
kind: "subagent",
description: "Write tests",
childThreadId: ThreadId.make("thread:b"),
},
{ taskId: "a", kind: "subagent", description: "Review src/math.ts" },
]);
expect(presentation?.title).toBe("Waiting on 2 subagents and 1 command");
expect(presentation?.items.map((item) => [item.kind, item.label, item.childThreadId])).toEqual([
["subagent", "Write tests", "thread:b"],
["subagent", "Review src/math.ts", undefined],
["command", "npm test", undefined],
]);
});

it("names generic work, including rosters from servers that predate kinds", () => {
expect(
presentPendingBackgroundWork([
{ taskId: "bash", kind: "command", description: "Background sleep" },
{ taskId: "watch", kind: "monitor" },
{ taskId: "other", kind: "background_task" },
])?.title,
).toBe("Waiting on 1 command, 1 monitor and 1 background task");
expect(presentPendingBackgroundWork([{ taskId: "old", kind: "background_task" }])?.title).toBe(
"Waiting on a background task",
);
});
});
Loading
Loading