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