Repository navigation
fix(web): a workflow thread opens with its phases and agents instead of its script #20857
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
juliusmarminge
merged 13 commits into
fix/workflow-member-transcript-stream
from
fix/workflow-thread-opening
Oct 18, 2026
+1,080
−5
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
d0aa4bf
fix(server): a Claude workflow's script reaches its thread marked as …
juliusmarminge 65e2884
fix(web): a workflow thread opens with its phases and agents instead …
juliusmarminge 4bef96a
fix(mobile): a workflow thread opens with its phases and agents inste…
juliusmarminge 728fd17
Merge branch 'fix/workflow-member-transcript-stream' into fix/workflo…
juliusmarminge a634206
Merge branch 'fix/workflow-member-transcript-stream' into fix/workflo…
juliusmarminge bfd0337
fix(client-runtime): a subagent brief that looks like a script stays …
juliusmarminge 0748181
Merge branch 'fix/workflow-member-transcript-stream' into fix/workflo…
juliusmarminge 5631b49
Merge branch 'fix/workflow-member-transcript-stream' into fix/workflo…
juliusmarminge da8da4c
Merge branch 'fix/workflow-member-transcript-stream' into fix/workflo…
juliusmarminge 670af26
fix(server): a workflow coordinator's script stays in its thread's bo…
juliusmarminge 7abc450
fix(web): Thread Find reaches a workflow opening's name and collapsed…
juliusmarminge c0c9629
Merge branch 'fix/workflow-member-transcript-stream' into fix/workflo…
juliusmarminge 1d2160a
fix(web,mobile): a workflow's opening reads like the rest of the thread
juliusmarminge File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
202 changes: 202 additions & 0 deletions
202
apps/mobile/src/features/threads/thread-workflow-opening.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.