1import { useAtomValue } from "@effect/atom-react";
2import { scopeThreadRef } from "@t3tools/client-runtime/environment";
3import {
4 resolveWorkflowOpening,
5 workflowMemberModelSelection,
6 workflowOpeningHeadline,
7 workflowOpeningParentThreadId,
8 type RuntimeSubagent,
9} from "@t3tools/client-runtime/state/subagentRuntime";
10import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts";
11import type { EnvironmentThread } from "@t3tools/client-runtime/state/shell";
12import { Atom } from "effect/reactivity";
13import { useState } from "react";
14import { Pressable, View } from "react-native";
15
16import { AppText as Text } from "../../components/ui/AppText";
17import { environmentThreadDetails } from "../../state/threads";
18import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
19import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
20import { SubagentRow } from "./SubagentRow";
21import { WorkLogBlock } from "./work-log-layout";
22import { useOpenLinkedThread } from "./useOpenLinkedThread";
23
24const FOLDED_SCRIPT_LINES = 10;
25
26const NO_THREAD_ATOM = Atom.make<EnvironmentThread | null>(null);
27
28export function useWorkflowOpening(ref: ScopedThreadRef | null) {
29 const threadAtom = ref === null ? NO_THREAD_ATOM : environmentThreadDetails.threadAtom(ref);
30 // Selected as a primitive so an ordinary thread's updates never change the result.
31 const parentId = useAtomValue(threadAtom, (thread) =>
32 thread === null
33 ? null
34 : workflowOpeningParentThreadId({
35 thread: thread.projection.thread,
36 messages: thread.projection.messages,
37 }),
38 );
39 const projection = useAtomValue(threadAtom, (thread) =>
40 parentId === null ? null : (thread?.projection ?? null),
41 );
42 const parentSubagents = useAtomValue(
43 ref === null || parentId === null
44 ? NO_THREAD_ATOM
45 : environmentThreadDetails.threadAtom(scopeThreadRef(ref.environmentId, parentId)),
46 (parent) => (parentId === null ? null : (parent?.projection.subagents ?? null)),
47 );
48 return projection === null
49 ? null
50 : resolveWorkflowOpening({
51 thread: projection.thread,
52 messages: projection.messages,
53 parentSubagents,
54 });
55}
56
57function memberRowSubagent(
58 member: RuntimeSubagent,
59 opening: WorkflowOpeningValue,
60 coordinatorThreadId: ThreadId,
61) {
62 return {
63 // A member's parent thread is the coordinator's thread, the one showing this opening.
64 threadId: coordinatorThreadId,
65 childThreadId: member.childThreadId as ThreadId | null,
66 model: member.model,
67 modelSelection: workflowMemberModelSelection(member, opening.providerInstanceId!),
68 driver: opening.driver!,
69 providerInstanceId: opening.providerInstanceId!,
70 title: member.title,
71 prompt: member.progress ?? member.title,
72 status: member.status,
73 progress: undefined,
74 result: member.result ?? member.error,
75 };
76}
77
78type WorkflowOpeningValue = NonNullable<ReturnType<typeof useWorkflowOpening>>;
79
80export function ThreadWorkflowOpening(props: {
81 readonly environmentId: EnvironmentId;
82 readonly threadId: ThreadId;
83 readonly opening: WorkflowOpeningValue;
84}) {
85 const openLinkedThread = useOpenLinkedThread();
86 const { opening } = props;
87 const headline = workflowOpeningHeadline(opening);
88 const canListMembers = opening.driver !== null && opening.providerInstanceId !== null;
89 const phases = (opening.group?.phases ?? []).filter((phase) => phase.members.length > 0);
90 const unphased = opening.group?.unphasedMembers ?? [];
91 const renderMember = (member: RuntimeSubagent) => {
92 const threadId = member.childThreadId;
93 return (
94 <Pressable
95 key={member.id}
96 accessibilityRole={threadId === null ? undefined : "link"}
97 accessibilityHint={threadId === null ? undefined : "Opens this agent's thread"}
98 disabled={threadId === null}
99 onPress={() => {
100 if (threadId !== null)
101 openLinkedThread({ environmentId: String(props.environmentId), threadId });
102 }}
103 className="rounded-lg px-3 py-3 active:bg-subtle"
104 >
105 <SubagentRow
106 environmentId={props.environmentId}
107 subagent={memberRowSubagent(member, opening, props.threadId)}
108 elapsed={null}
109 />
110 </Pressable>
111 );
112 };
113 return (
114 <WorkLogBlock>
115 <View className="mb-5 gap-3">
116 <View className="flex-row items-start gap-2">
117 <View className="min-w-0 flex-1 gap-0.5">
118 <Text className="font-t3-medium text-2xs text-foreground-muted">Workflow started</Text>
119 <Text numberOfLines={1} className="font-t3-medium text-sm text-foreground">
120 {headline.title}
121 </Text>
122 {headline.description ? (
123 <Text className="text-xs text-foreground-muted">{headline.description}</Text>
124 ) : null}
125 </View>
126 {headline.agents ? (
127 <Text className="shrink-0 text-2xs tabular-nums text-foreground-muted">
128 {headline.agents}
129 </Text>
130 ) : null}
131 </View>
132 {canListMembers
133 ? phases.map((phase) => (
134 <View key={phase.index} className="gap-px">
135 <Text className="px-1 pt-1 font-t3-medium text-2xs text-foreground-muted">
136 {phase.title} · {phase.settledCount}/{phase.members.length}
137 </Text>
138 {phase.members.map(renderMember)}
139 </View>
140 ))
141 : null}
142 {canListMembers ? unphased.map(renderMember) : null}
143 <WorkflowScript script={opening.script} />
144 </View>
145 </WorkLogBlock>
146 );
147}
148
149function WorkflowScript(props: { readonly script: string }) {
150 const [expanded, setExpanded] = useState(false);
151 const { themeAppearance } = useAppearancePreferences();
152 const script = props.script.trimEnd();
153 const lines = useMarkdownCodeHighlight({
154 code: script,
155 language: "javascript",
156 theme: themeAppearance,
157 enabled: true,
158 });
159 const lineCount = script.split("\n").length;
160 const canFold = lineCount > FOLDED_SCRIPT_LINES;
161 const shownLines = canFold && !expanded ? FOLDED_SCRIPT_LINES : lineCount;
162 let offset = 0;
163 const highlighted = lines?.slice(0, shownLines).flatMap((line, lineIndex) => {
164 const separator = lineIndex > 0 ? "\n" : "";
165 offset += separator.length;
166 return [
167 separator,
168 ...line.map((token) => {
169 const start = offset;
170 offset += token.content.length;
171 return (
172 <Text key={start} style={token.color ? { color: token.color } : undefined}>
173 {token.content}
174 </Text>
175 );
176 }),
177 ];
178 });
179 return (
180 <View className="gap-1">
181 <View className="rounded-lg bg-subtle px-3 py-2.5">
182 <Text selectable className="font-mono text-2xs leading-normal text-foreground">
183 {highlighted ?? script.split("\n").slice(0, shownLines).join("\n")}
184 </Text>
185 </View>
186 {canFold ? (
187 <Pressable
188 accessibilityRole="button"
189 accessibilityState={{ expanded }}
190 onPress={() => setExpanded((value) => !value)}
191 className="self-start py-1 active:opacity-70"
192 >
193 <Text className="font-t3-medium text-xs text-foreground-muted">
194 {expanded ? "Show less" : "Show full script"}
195 </Text>
196 </Pressable>
197 ) : null}
198 </View>
199 );
200}