Skip to content
Source
Sign inSign up

thread-command-output.tsx

92 lines3.3 KB

1import type { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts";
2import { TERMINAL_PALETTES, terminalOutputSpans } from "@t3tools/shared/terminalOutput";
3import type React from "react";
4import { useMemo, useRef } from "react";
5import { ScrollView, View } from "react-native";
6​
7import { AppText as Text } from "../../components/ui/AppText";
8import { useServerConfigs } from "../../state/entities";
9import { orchestrationEnvironment } from "../../state/orchestration";
10import { useEnvironmentQuery } from "../../state/query";
11​
12const FOLLOW_SLACK = 8;
13​
14export function useCommandOutputStreaming(environmentId: EnvironmentId): boolean {
15 return (
16 useServerConfigs().get(environmentId)?.environment.capabilities.commandOutputStreaming === true
17 );
19​
20/** Mount only while useCommandOutputStreaming holds; unmount to stop subscriptions for collapsed rows or other threads. */
21export function ThreadCommandOutput(props: {
22 readonly environmentId: EnvironmentId;
23 readonly threadId: ThreadId;
24 readonly itemId: TurnItemId;
25 readonly themeAppearance: "light" | "dark";
26}) {
27 const { data, error } = useEnvironmentQuery(
28 orchestrationEnvironment.v2.commandOutput({
29 environmentId: props.environmentId,
30 input: { threadId: props.threadId, itemId: props.itemId },
31 }),
32 );
33 const scrollRef = useRef<React.ComponentRef<typeof ScrollView>>(null);
34 const followRef = useRef(true);
35 const text = data?.output.text ?? "";
36 const spans = useMemo(
37 () => terminalOutputSpans(text, TERMINAL_PALETTES[props.themeAppearance]),
38 [text, props.themeAppearance],
39 );
40 if (text.length === 0) {
41 const message = error
42 ? `Couldn't load output: ${error}`
43 : data === null
44 ? "Loading output…"
45 : data.running
46 ? null
47 : "No output.";
48 return message === null ? null : (
49 <Text className="pt-1.5 font-mono text-2xs leading-normal text-foreground-muted">
50 {message}
51 </Text>
52 );
53 }
54 return (
55 <View className="pt-1.5">
56 <ScrollView
57 ref={scrollRef}
58 nestedScrollEnabled
59 directionalLockEnabled
60 showsVerticalScrollIndicator
61 className="max-h-60"
62 contentContainerStyle={{ paddingRight: 8 }}
63 scrollEventThrottle={100}
64 onScroll={(event) => {
65 const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
66 followRef.current =
67 contentSize.height - contentOffset.y - layoutMeasurement.height <= FOLLOW_SLACK;
68 }}
69 onContentSizeChange={() => {
70 if (followRef.current) scrollRef.current?.scrollToEnd({ animated: false });
71 }}
72 >
73 {data?.output.truncated ? (
74 <Text className="pb-0.5 text-3xs text-foreground-subtle">Earlier output not shown</Text>
75 ) : null}
76 <Text selectable className="font-mono text-2xs leading-normal text-foreground-muted">
77 {spans.map((span, index) =>
78 span.style === null ? (
79 span.text
80 ) : (
81 // Spans are rebuilt from scratch on every update and never reorder.
82 // oxlint-disable-next-line react/no-array-index-key
83 <Text key={index} style={span.style}>
84 {span.text}
85 </Text>
86 ),
87 )}
88 </Text>
89 </ScrollView>
90 </View>
91 );