Skip to content
Source
Sign inSign up
Open History to see the last commit

CommandOutputPanel.tsx

82 lines3.4 KB

1import type { CommandOutputView } from "@t3tools/client-runtime/state/command-output";
2import type { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts";
3import { TERMINAL_PALETTES, terminalOutputSpans } from "@t3tools/shared/terminalOutput";
4import { useLayoutEffect, useRef } from "react";
5​
6import { useTheme } from "../../hooks/useTheme";
7import { useServerConfigs } from "../../state/entities";
8import { orchestrationEnvironment } from "../../state/orchestration";
9import { useEnvironmentQuery } from "../../state/query";
10import { useInitialOutputReady, type TurnItemSource } from "./timelineRowContext";
11​
12const FOLLOW_SLACK_PX = 8;
13​
14export function useCommandOutputStreaming(environmentId: EnvironmentId): boolean {
15 return (
16 useServerConfigs().get(environmentId)?.environment.capabilities.commandOutputStreaming === true
17 );
19​
20/**
21 * Output of one expanded command row: streams while the command runs, then
22 * shows its final output. Mount it only when `useCommandOutputStreaming` holds
23 * or a `source` serves the output.
24 * It subscribes only while mounted, so collapsed rows and other threads receive nothing.
25 */
26export function CommandOutputPanel(props: {
27 readonly onInitialOutputReady?: (() => void) | undefined;
28 readonly environmentId: EnvironmentId;
29 readonly threadId: ThreadId;
30 readonly itemId: TurnItemId;
31 readonly source?: TurnItemSource["commandOutput"] | undefined;
32}) {
33 const input = { threadId: props.threadId, itemId: props.itemId };
34 const { data, error } = useEnvironmentQuery<CommandOutputView, unknown>(
35 props.source
36 ? props.source(input)
37 : orchestrationEnvironment.v2.commandOutput({ environmentId: props.environmentId, input }),
38 );
39 useInitialOutputReady(data === null && !error, props.onInitialOutputReady);
40 const scrollRef = useRef<HTMLDivElement>(null);
41 const followRef = useRef(true);
42 const text = data?.output.text ?? "";
43 const { resolvedTheme } = useTheme();
44​
45 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pins the output scroller's scrollTop to the bottom after each commit while the user follows
46 useLayoutEffect(() => {
47 const element = scrollRef.current;
48 if (element && followRef.current) element.scrollTop = element.scrollHeight;
49 });
50​
51 if (text.length === 0) {
52 if (error) return <div className="text-destructive">Couldn&apos;t load output: {error}</div>;
53 if (data === null) return <div className="text-muted-foreground italic">Loading output…</div>;
54 return data.running ? null : <div className="text-muted-foreground italic">No output.</div>;
55 }
56 // Parse after all hooks to preserve the text/theme compiler cache.
57 const spans = terminalOutputSpans(text, TERMINAL_PALETTES[resolvedTheme]);
58 return (
59 <div
60 ref={scrollRef}
61 onScroll={(event) => {
62 const element = event.currentTarget;
63 followRef.current =
64 element.scrollHeight - element.scrollTop - element.clientHeight <= FOLLOW_SLACK_PX;
65 }}
66 className="max-h-80 overflow-auto text-muted-foreground"
67 >
68 {data?.output.truncated ? <div className="italic">Earlier output not shown</div> : null}
69 {spans.map((span, index) =>
70 span.style === null ? (
71 span.text
72 ) : (
73 // Spans never reorder and are rebuilt on each update.
74 // oxlint-disable-next-line react/no-array-index-key
75 <span key={index} style={span.style}>
76 {span.text}
77 </span>
78 ),
79 )}
80 </div>
81 );