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
17 changes: 17 additions & 0 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import type { HandoffEndpoints } from "@t3tools/client-runtime/handoff";
import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
import type { WorkflowOpening } from "@t3tools/client-runtime/state/subagentRuntime";
import {
type OrchestrationMessageContext,
ThreadId,
Expand Down Expand Up @@ -217,6 +218,7 @@ import { composerDocumentAttachmentRecord } from "../../lib/composerContext";
import * as Option from "effect/Option";
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentThreadShells, threadEnvironment } from "../../state/threads";
import { ThreadWorkflowOpening, useWorkflowOpening } from "./thread-workflow-opening";
import { useAtomCommand } from "../../state/use-atom-command";
import { useV2ItemSupport } from "../../state/v2-item-support";
import {
Expand Down Expand Up @@ -1005,6 +1007,7 @@ function renderFeedEntry(
readonly markdownContentWidth: number;
readonly contentWidth: number;
readonly threadTitle: string;
readonly workflowOpening: WorkflowOpening | null;
},
) {
const entry = info.item;
Expand Down Expand Up @@ -1152,6 +1155,15 @@ function renderFeedEntry(
!assistantTurnStillInProgress &&
!message.streaming;

if (isUser && props.workflowOpening?.messageId === message.id) {
return (
<ThreadWorkflowOpening
environmentId={props.environmentId}
threadId={props.threadId}
opening={props.workflowOpening}
/>
);
}
if (isUser) {
const enterAnimated = isFreshTimestamp(message.createdAt);
const intentBadge = resolveUserMessageIntentBadge(message.inputIntent);
Expand Down Expand Up @@ -1661,6 +1673,7 @@ function ThreadFeedPlaceholder(props: {
}

export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const workflowOpening = useWorkflowOpening(props.environmentId, props.threadId);
const deviceType = useDeviceType();
const duoColumns = useNativeWorkspaceColumnsSupported() && isDuoDevice(deviceType);
const openPullRequest = useOpenPullRequest();
Expand Down Expand Up @@ -2010,6 +2023,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
themeAppearance,
userBubbleColor,
viewportWidth,
workflowOpening,
}),
[
props.worktreeSetup,
Expand All @@ -2026,6 +2040,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
themeAppearance,
userBubbleColor,
viewportWidth,
workflowOpening,
],
);
const reportHeaderMaterialVisibility = useCallback(
Expand Down Expand Up @@ -2476,6 +2491,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
threadTitle: props.threadTitle,
skills: props.skills,
workspaceRoot: props.workspaceRoot,
workflowOpening,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})}
{props.worktreeSetup && info.index === setupAnchorIndex ? (
<WorktreeSetupCard key={props.threadId} {...props.worktreeSetup} />
Expand Down Expand Up @@ -2525,6 +2541,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
props.threadTitle,
props.skills,
props.workspaceRoot,
workflowOpening,
renderMarkdownImage,
renderViewedImage,
renderReasoning,
Expand Down
202 changes: 202 additions & 0 deletions apps/mobile/src/features/threads/thread-workflow-opening.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { useAtomValue } from "@effect/atom-react";
import { StackActions, useNavigation } from "@react-navigation/native";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import {
resolveWorkflowOpening,
workflowOpeningHeadline,
workflowOpeningParentThreadId,
type RuntimeSubagent,
} from "@t3tools/client-runtime/state/subagentRuntime";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { useState } from "react";
import { Pressable, View } from "react-native";

import { AppText as Text } from "../../components/ui/AppText";
import { environmentThreadDetails } from "../../state/threads";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
import { SubagentRow } from "./SubagentRow";
import { WorkLogBlock } from "./work-log-layout";

// A script longer than this many lines shows its head until opened.
const FOLDED_SCRIPT_LINES = 10;

/** The workflow opening for a thread, or null; the parent's detail loads only for a workflow. */
export function useWorkflowOpening(environmentId: EnvironmentId, threadId: ThreadId) {
const threadAtom = environmentThreadDetails.threadAtom(scopeThreadRef(environmentId, threadId));
// Selected as a primitive so an ordinary thread's updates never change the result.
const parentId = useAtomValue(threadAtom, (thread) =>
thread === null
? null
: workflowOpeningParentThreadId({
thread: thread.projection.thread,
messages: thread.projection.messages,
}),
);
const projection = useAtomValue(threadAtom, (thread) =>
parentId === null ? null : (thread?.projection ?? null),
);
const parentSubagents = useAtomValue(
environmentThreadDetails.threadAtom(scopeThreadRef(environmentId, parentId ?? threadId)),
(parent) => (parentId === null ? null : (parent?.projection.subagents ?? null)),
);
return projection === null
? null
: resolveWorkflowOpening({
thread: projection.thread,
messages: projection.messages,
parentSubagents,
});
}

function memberRowSubagent(
member: RuntimeSubagent,
opening: WorkflowOpeningValue,
coordinatorThreadId: ThreadId,
) {
return {
// A member's parent thread is the coordinator's thread, the one showing this opening.
threadId: coordinatorThreadId,
childThreadId: member.childThreadId as ThreadId | null,
model: member.model,
driver: opening.driver!,
providerInstanceId: opening.providerInstanceId!,
title: member.title,
prompt: member.progress ?? member.title,
status: member.status,
progress: undefined,
result: member.result ?? member.error,
};
}

type WorkflowOpeningValue = NonNullable<ReturnType<typeof useWorkflowOpening>>;

/** Replaces a workflow coordinator's script message: the plan, its agents, then the script. */
export function ThreadWorkflowOpening(props: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
readonly opening: WorkflowOpeningValue;
}) {
const navigation = useNavigation();
const { opening } = props;
const headline = workflowOpeningHeadline(opening);
const canListMembers = opening.driver !== null && opening.providerInstanceId !== null;
const phases = (opening.group?.phases ?? []).filter((phase) => phase.members.length > 0);
const unphased = opening.group?.unphasedMembers ?? [];
const renderMember = (member: RuntimeSubagent) => {
const threadId = member.childThreadId;
return (
<Pressable
key={member.id}
accessibilityRole={threadId === null ? undefined : "link"}
accessibilityHint={threadId === null ? undefined : "Opens this agent's thread"}
disabled={threadId === null}
onPress={() => {
if (threadId !== null)
navigation.dispatch(
StackActions.push("Thread", {
environmentId: String(props.environmentId),
threadId,
}),
);
}}
className="rounded-lg px-3 py-3 active:bg-subtle"
>
<SubagentRow
environmentId={props.environmentId}
subagent={memberRowSubagent(member, opening, props.threadId)}
elapsed={null}
/>
</Pressable>
);
};
return (
<WorkLogBlock>
<View className="mb-5 gap-3">
<View className="flex-row items-start gap-2">
<View className="min-w-0 flex-1 gap-0.5">
<Text className="font-t3-medium text-2xs text-foreground-muted">Workflow started</Text>
<Text numberOfLines={1} className="font-t3-medium text-sm text-foreground">
{headline.title}
</Text>
{headline.description ? (
<Text className="text-xs text-foreground-muted">{headline.description}</Text>
) : null}
</View>
{headline.agents ? (
<Text className="shrink-0 text-2xs tabular-nums text-foreground-muted">
{headline.agents}
</Text>
) : null}
</View>
{canListMembers
? phases.map((phase) => (
<View key={phase.index} className="gap-px">
<Text className="px-1 pt-1 font-t3-medium text-2xs text-foreground-muted">
{phase.title} · {phase.settledCount}/{phase.members.length}
</Text>
{phase.members.map(renderMember)}
</View>
))
: null}
{canListMembers ? unphased.map(renderMember) : null}
<WorkflowScript script={opening.script} />
</View>
</WorkLogBlock>
);
}

/** The script as highlighted code; a long one shows its head until opened. */
function WorkflowScript(props: { readonly script: string }) {
const [expanded, setExpanded] = useState(false);
const { themeAppearance } = useAppearancePreferences();
const script = props.script.trimEnd();
const lines = useMarkdownCodeHighlight({
code: script,
language: "javascript",
theme: themeAppearance,
enabled: true,
});
const lineCount = script.split("\n").length;
const canFold = lineCount > FOLDED_SCRIPT_LINES;
const shownLines = canFold && !expanded ? FOLDED_SCRIPT_LINES : lineCount;
// Tokens are keyed by their offset into the script, like the work log's REPL code.
let offset = 0;
const highlighted = lines?.slice(0, shownLines).flatMap((line, lineIndex) => {
const separator = lineIndex > 0 ? "\n" : "";
offset += separator.length;
return [
separator,
...line.map((token) => {
const start = offset;
offset += token.content.length;
return (
<Text key={start} style={token.color ? { color: token.color } : undefined}>
{token.content}
</Text>
);
}),
];
});
return (
<View className="gap-1">
<View className="rounded-lg bg-subtle px-3 py-2.5">
<Text selectable className="font-mono text-2xs leading-normal text-foreground">
{highlighted ?? script.split("\n").slice(0, shownLines).join("\n")}
</Text>
</View>
{canFold ? (
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded }}
onPress={() => setExpanded((value) => !value)}
className="self-start py-1 active:opacity-70"
>
<Text className="font-t3-medium text-xs text-foreground-muted">
{expanded ? "Show less" : "Show full script"}
</Text>
</Pressable>
) : null}
</View>
);
}
89 changes: 89 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11663,6 +11663,95 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

it.effect("marks a workflow coordinator's script prompt and leaves a subagent brief plain", () =>
Effect.scoped(
Effect.gen(function* () {
const harness = yield* makeWorkflowHarness;
const script =
'export const meta = { name: "probe-wf", description: "Run the alpha workflow" };';
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("attempt-workflow-script-prompt"),
text: "Run the workflow and an auditor.",
attachments: [],
}),
);
yield* harness.offerAndWait(workflowToolUse);
yield* harness.offerAndWait(claudeSdkFrame({ ...workflowTaskStarted, prompt: script }));
yield* harness.offerAndWait(workflowLaunchAck(harness.transcriptDir));
yield* harness.offerAndWait(
workflowSnapshot({ uuid: "00000000-0000-4000-8000-000000001289", state: "start" }),
);
yield* harness.offerAndWait(
claudeSdkFrame({
type: "system",
subtype: "task_started",
task_id: "task-plain-auditor",
description: "Audit recent commits",
task_type: "local_agent",
prompt: "Audit the last five commits.",
uuid: "00000000-0000-4000-8000-000000001290",
session_id: WAKE_NATIVE_SESSION,
}),
);
yield* awaitUntil(
() =>
workflowCoordinatorEvents(harness.events).length > 0 &&
harness.events.some(
(event) =>
event.type === "subagent.updated" &&
event.subagent.nativeTaskRef?.nativeId === "task-plain-auditor",
),
"workflow coordinator and auditor projected",
);
const subagentPrompt = (nativeTaskId: string) => {
const subagent = harness.events.flatMap((event) =>
event.type === "subagent.updated" &&
event.subagent.nativeTaskRef?.nativeId === nativeTaskId
? [event.subagent]
: [],
)[0];
assert.isDefined(subagent);
const message = threadMessages(harness.events, subagent?.childThreadId).find(
(event) => event.message.role === "user",
)?.message;
const turnItem = harness.events.findLast(
(event) =>
event.type === "turn_item.updated" &&
event.turnItem.type === "user_message" &&
event.turnItem.messageId === message?.id,
);
return {
text: message?.text,
messageMark: message?.workflowScript,
itemMark:
turnItem?.type === "turn_item.updated" && turnItem.turnItem.type === "user_message"
? turnItem.turnItem.workflowScript
: undefined,
};
};
assert.deepEqual(subagentPrompt(WORKFLOW_TASK_ID), {
text: script,
messageMark: true,
itemMark: true,
});
// A brief is marked as not a script, so its text is never read as one.
assert.deepEqual(subagentPrompt("task-plain-auditor"), {
text: "Audit the last five commits.",
messageMark: false,
itemMark: false,
});
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
),
);

it.effect("scopes workflow member artifacts to the provider thread", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
Loading
Loading