@workos/quickstudy 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/package.json +17 -3
- package/src/build-info.generated.ts +1 -1
- package/ui/dist/index.html +3 -3
- package/ui/src/components/DiffViewer.tsx +66 -0
- package/ui/src/components/TranscriptViewer.tsx +217 -0
- package/ui/src/components/viewers.css +168 -0
- package/ui/src/viewers.ts +18 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal hand-rolled unified-diff renderer. Chosen over diff2html on bundle
|
|
3
|
+
* budget (spec open item): the diffs here are plain `diff.patch` artifacts
|
|
4
|
+
* and only need add/del/hunk/file coloring plus line-range highlighting —
|
|
5
|
+
* ~60 lines beats a dependency, and nothing loads from a CDN.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useRef, type ReactNode } from "react";
|
|
9
|
+
|
|
10
|
+
type DiffLineKind = "file" | "hunk" | "add" | "del" | "context";
|
|
11
|
+
|
|
12
|
+
function classify(line: string): DiffLineKind {
|
|
13
|
+
if (line.startsWith("--- ") || line.startsWith("+++ ") || line.startsWith("diff ")) return "file";
|
|
14
|
+
if (line.startsWith("@@")) return "hunk";
|
|
15
|
+
if (line.startsWith("+")) return "add";
|
|
16
|
+
if (line.startsWith("-")) return "del";
|
|
17
|
+
return "context";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const KIND_CLASS: Record<DiffLineKind, string> = {
|
|
21
|
+
file: "diff-file",
|
|
22
|
+
hunk: "diff-hunk",
|
|
23
|
+
add: "diff-add",
|
|
24
|
+
del: "diff-del",
|
|
25
|
+
context: "",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export interface DiffViewerProps {
|
|
29
|
+
text: string;
|
|
30
|
+
/** 1-indexed inclusive highlight range within the raw patch file. */
|
|
31
|
+
lines?: [number, number];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function DiffViewer({ text, lines }: DiffViewerProps): ReactNode {
|
|
35
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
36
|
+
const rows = text.split("\n");
|
|
37
|
+
if (rows.at(-1) === "") rows.pop();
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!lines) return;
|
|
41
|
+
const target = containerRef.current?.querySelector(`#L${lines[0]}`);
|
|
42
|
+
target?.scrollIntoView({ block: "center" });
|
|
43
|
+
}, [lines, text]);
|
|
44
|
+
|
|
45
|
+
const inRange = (line: number): boolean => lines !== undefined && line >= lines[0] && line <= lines[1];
|
|
46
|
+
|
|
47
|
+
if (rows.length === 0) {
|
|
48
|
+
return <div className="viewer-note">This attempt produced an empty diff.</div>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="quickstudy-viewer viewer diff" ref={containerRef} data-testid="diff-viewer">
|
|
53
|
+
{rows.map((row, index) => {
|
|
54
|
+
const line = index + 1;
|
|
55
|
+
const kind = classify(row);
|
|
56
|
+
const highlight = inRange(line) ? " highlight" : "";
|
|
57
|
+
return (
|
|
58
|
+
<div key={line} id={`L${line}`} className={`diff-line ${KIND_CLASS[kind]}${highlight}`}>
|
|
59
|
+
<span className="line-no">{line}</span>
|
|
60
|
+
<span className="text">{row === "" ? " " : row}</span>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript viewer: JSONL parsed into collapsible turn blocks. One JSONL
|
|
3
|
+
* line = one turn = one line number, which is exactly the coordinate system
|
|
4
|
+
* attempt deep links use (`L<start>-<end>` ranges), so a
|
|
5
|
+
* `#…/transcript/L120-140` link highlights lines 120-140 here.
|
|
6
|
+
*
|
|
7
|
+
* Huge transcripts stay responsive via lazy collapse: blocks render collapsed
|
|
8
|
+
* and only a capped window renders initially (centered on the anchor when one
|
|
9
|
+
* is present), with an explicit "load all" action.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
13
|
+
|
|
14
|
+
/** Initial render cap — beyond this, the viewer offers "load all". */
|
|
15
|
+
const INITIAL_TURN_CAP = 250;
|
|
16
|
+
/** Turns rendered around an anchor when the transcript is capped. */
|
|
17
|
+
const ANCHOR_CONTEXT_TURNS = 120;
|
|
18
|
+
|
|
19
|
+
type Role = "system" | "user" | "assistant" | "tool" | "raw";
|
|
20
|
+
|
|
21
|
+
interface Turn {
|
|
22
|
+
/** 1-indexed transcript line number. */
|
|
23
|
+
line: number;
|
|
24
|
+
role: Role;
|
|
25
|
+
summary: string;
|
|
26
|
+
detail: string;
|
|
27
|
+
isError: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ContentItem {
|
|
31
|
+
type?: string;
|
|
32
|
+
text?: string;
|
|
33
|
+
name?: string;
|
|
34
|
+
content?: unknown;
|
|
35
|
+
is_error?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function contentItems(value: unknown): ContentItem[] {
|
|
39
|
+
if (!Array.isArray(value)) return [];
|
|
40
|
+
return value.filter((item): item is ContentItem => typeof item === "object" && item !== null);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function truncate(text: string, max = 160): string {
|
|
44
|
+
const single = text.replace(/\s+/g, " ").trim();
|
|
45
|
+
return single.length > max ? `${single.slice(0, max)}…` : single;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Tolerant per-line classification: vendor stream-json, simple role events, or raw text. */
|
|
49
|
+
export function parseTurn(line: string, lineNumber: number): Turn {
|
|
50
|
+
let parsed: Record<string, unknown>;
|
|
51
|
+
try {
|
|
52
|
+
parsed = JSON.parse(line) as Record<string, unknown>;
|
|
53
|
+
} catch {
|
|
54
|
+
return { line: lineNumber, role: "raw", summary: truncate(line), detail: line, isError: false };
|
|
55
|
+
}
|
|
56
|
+
const detail = JSON.stringify(parsed, null, 2);
|
|
57
|
+
const isError = /"is_error"\s*:\s*true/.test(line) || parsed["subtype"] === "error";
|
|
58
|
+
|
|
59
|
+
// Simple TranscriptEvent shape ({role, content, at}) from in-process adapters.
|
|
60
|
+
const role = parsed["role"];
|
|
61
|
+
if (typeof role === "string" && typeof parsed["content"] === "string") {
|
|
62
|
+
const known: Role =
|
|
63
|
+
role === "system" || role === "user" || role === "assistant" || role === "tool" ? role : "raw";
|
|
64
|
+
return { line: lineNumber, role: known, summary: truncate(parsed["content"]), detail, isError };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const type = parsed["type"];
|
|
68
|
+
if (type === "system") {
|
|
69
|
+
const subtype = typeof parsed["subtype"] === "string" ? parsed["subtype"] : "event";
|
|
70
|
+
return { line: lineNumber, role: "system", summary: `system · ${subtype}`, detail, isError };
|
|
71
|
+
}
|
|
72
|
+
if (type === "assistant" || type === "user") {
|
|
73
|
+
const message = parsed["message"] as Record<string, unknown> | undefined;
|
|
74
|
+
const items = contentItems(message?.["content"]);
|
|
75
|
+
const toolUse = items.find((item) => item.type === "tool_use");
|
|
76
|
+
const toolResult = items.find((item) => item.type === "tool_result");
|
|
77
|
+
const text = items.find((item) => item.type === "text" && typeof item.text === "string");
|
|
78
|
+
if (toolResult) {
|
|
79
|
+
const flagged = toolResult.is_error === true;
|
|
80
|
+
return {
|
|
81
|
+
line: lineNumber,
|
|
82
|
+
role: "tool",
|
|
83
|
+
summary: flagged ? "tool_result · ERROR" : "tool_result",
|
|
84
|
+
detail,
|
|
85
|
+
isError: isError || flagged,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (toolUse) {
|
|
89
|
+
return {
|
|
90
|
+
line: lineNumber,
|
|
91
|
+
role: type === "assistant" ? "assistant" : "user",
|
|
92
|
+
summary: `tool_use · ${toolUse.name ?? "?"}`,
|
|
93
|
+
detail,
|
|
94
|
+
isError,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (text?.text !== undefined) {
|
|
98
|
+
return { line: lineNumber, role: type, summary: truncate(text.text), detail, isError };
|
|
99
|
+
}
|
|
100
|
+
return { line: lineNumber, role: type, summary: `${type} message`, detail, isError };
|
|
101
|
+
}
|
|
102
|
+
if (type === "result") {
|
|
103
|
+
return { line: lineNumber, role: "system", summary: "result", detail, isError };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Codex `exec --json`: `item.*` events carry the payload (item.type splits
|
|
107
|
+
// text from tool calls); `thread.*`/`turn.*`/`error` are lifecycle.
|
|
108
|
+
if (typeof type === "string" && type.startsWith("item.")) {
|
|
109
|
+
const item = (parsed["item"] ?? {}) as Record<string, unknown>;
|
|
110
|
+
const itemType = typeof item["type"] === "string" ? item["type"] : "item";
|
|
111
|
+
const text = typeof item["text"] === "string" ? item["text"] : "";
|
|
112
|
+
if (itemType === "agent_message") {
|
|
113
|
+
return { line: lineNumber, role: "assistant", summary: truncate(text), detail, isError };
|
|
114
|
+
}
|
|
115
|
+
if (itemType === "reasoning") {
|
|
116
|
+
return { line: lineNumber, role: "assistant", summary: `reasoning · ${truncate(text)}`, detail, isError };
|
|
117
|
+
}
|
|
118
|
+
if (itemType === "error") {
|
|
119
|
+
return { line: lineNumber, role: "system", summary: `error · ${truncate(String(item["message"] ?? ""))}`, detail, isError: true };
|
|
120
|
+
}
|
|
121
|
+
const target = item["command"] ?? item["tool"] ?? item["query"];
|
|
122
|
+
const failed = (typeof item["exit_code"] === "number" && item["exit_code"] !== 0) || item["status"] === "failed";
|
|
123
|
+
return {
|
|
124
|
+
line: lineNumber,
|
|
125
|
+
role: "tool",
|
|
126
|
+
summary: `${itemType}${failed ? " · ERROR" : ""}${target === undefined ? "" : ` · ${truncate(String(target), 120)}`}`,
|
|
127
|
+
detail,
|
|
128
|
+
isError: isError || failed,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (type === "error" || (typeof type === "string" && /^(thread|turn)\./.test(type))) {
|
|
132
|
+
const failed = type === "error" || type === "turn.failed";
|
|
133
|
+
return { line: lineNumber, role: "system", summary: type, detail, isError: isError || failed };
|
|
134
|
+
}
|
|
135
|
+
return { line: lineNumber, role: "raw", summary: truncate(line), detail, isError };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function parseTranscript(text: string): Turn[] {
|
|
139
|
+
const lines = text.split("\n");
|
|
140
|
+
if (lines.at(-1) === "") lines.pop();
|
|
141
|
+
return lines.map((line, index) => parseTurn(line, index + 1));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function TurnBlock({ turn, highlighted, defaultExpanded }: { turn: Turn; highlighted: boolean; defaultExpanded: boolean }): ReactNode {
|
|
145
|
+
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
146
|
+
return (
|
|
147
|
+
<div className={highlighted ? "turn highlight" : "turn"} id={`L${turn.line}`} data-line={turn.line}>
|
|
148
|
+
<button type="button" className="turn-head" onClick={() => setExpanded(!expanded)} aria-expanded={expanded}>
|
|
149
|
+
<span className="line-no">L{turn.line}</span>
|
|
150
|
+
<span className={`role role-${turn.role}`}>{turn.role}</span>
|
|
151
|
+
<span className="summary">
|
|
152
|
+
{turn.isError && "⚠ "}
|
|
153
|
+
{turn.summary}
|
|
154
|
+
</span>
|
|
155
|
+
<span aria-hidden="true">{expanded ? "▾" : "▸"}</span>
|
|
156
|
+
</button>
|
|
157
|
+
{expanded && (
|
|
158
|
+
<div className="turn-body">
|
|
159
|
+
<pre>{turn.detail}</pre>
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface TranscriptViewerProps {
|
|
167
|
+
text: string;
|
|
168
|
+
/** 1-indexed inclusive highlight range (from an L<start>-<end> anchor). */
|
|
169
|
+
lines?: [number, number];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function TranscriptViewer({ text, lines }: TranscriptViewerProps): ReactNode {
|
|
173
|
+
const turns = useMemo(() => parseTranscript(text), [text]);
|
|
174
|
+
const [loadAll, setLoadAll] = useState(false);
|
|
175
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
176
|
+
|
|
177
|
+
const capped = !loadAll && turns.length > INITIAL_TURN_CAP;
|
|
178
|
+
let visible = turns;
|
|
179
|
+
let windowNote: string | null = null;
|
|
180
|
+
if (capped) {
|
|
181
|
+
if (lines) {
|
|
182
|
+
const anchorIndex = Math.min(Math.max(lines[0] - 1, 0), turns.length - 1);
|
|
183
|
+
const start = Math.max(0, anchorIndex - ANCHOR_CONTEXT_TURNS);
|
|
184
|
+
const end = Math.min(turns.length, Math.max(anchorIndex + ANCHOR_CONTEXT_TURNS, lines[1]) + 1);
|
|
185
|
+
visible = turns.slice(start, end);
|
|
186
|
+
windowNote = `showing turns ${start + 1}–${end} of ${turns.length} (window around L${lines[0]})`;
|
|
187
|
+
} else {
|
|
188
|
+
visible = turns.slice(0, INITIAL_TURN_CAP);
|
|
189
|
+
windowNote = `showing the first ${INITIAL_TURN_CAP} of ${turns.length} turns`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Scroll the anchor into view once the (possibly re-windowed) list renders.
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (!lines) return;
|
|
196
|
+
const target = containerRef.current?.querySelector(`#L${lines[0]}`);
|
|
197
|
+
target?.scrollIntoView({ block: "center" });
|
|
198
|
+
}, [lines, loadAll, text]);
|
|
199
|
+
|
|
200
|
+
const inRange = (line: number): boolean => lines !== undefined && line >= lines[0] && line <= lines[1];
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<div className="quickstudy-viewer viewer" ref={containerRef} data-testid="transcript-viewer">
|
|
204
|
+
{windowNote !== null && (
|
|
205
|
+
<div className="viewer-note">
|
|
206
|
+
<span>{windowNote}</span>
|
|
207
|
+
<button type="button" onClick={() => setLoadAll(true)} data-testid="load-all">
|
|
208
|
+
Load all {turns.length} turns
|
|
209
|
+
</button>
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
{visible.map((turn) => (
|
|
213
|
+
<TurnBlock key={turn.line} turn={turn} highlighted={inRange(turn.line)} defaultExpanded={inRange(turn.line)} />
|
|
214
|
+
))}
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The transcript and diff viewers' styles (components/TranscriptViewer.tsx and
|
|
3
|
+
* DiffViewer.tsx), also published as the package's `./ui/viewers.css` export for hosts that
|
|
4
|
+
* embed the viewers. Every rule is scoped under the components' root class
|
|
5
|
+
* `.quickstudy-viewer`, so importing this file restyles nothing else in a host. The theme
|
|
6
|
+
* comes through custom properties the host defines on any ancestor:
|
|
7
|
+
* --surface --bg --line --muted --accent --green --amber --amber-bg --green-bg --red-bg
|
|
8
|
+
* --gray-bg --mono
|
|
9
|
+
* (styles.css sets them on :root for this app).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
.quickstudy-viewer.viewer {
|
|
13
|
+
background: var(--surface);
|
|
14
|
+
border: 1px solid var(--line);
|
|
15
|
+
border-radius: 10px;
|
|
16
|
+
overflow: hidden;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.quickstudy-viewer .turn {
|
|
20
|
+
border-bottom: 1px solid var(--line);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.quickstudy-viewer .turn-head {
|
|
24
|
+
display: flex;
|
|
25
|
+
align-items: center;
|
|
26
|
+
gap: 8px;
|
|
27
|
+
width: 100%;
|
|
28
|
+
font: inherit;
|
|
29
|
+
text-align: left;
|
|
30
|
+
padding: 6px 12px;
|
|
31
|
+
background: var(--surface);
|
|
32
|
+
border: 0;
|
|
33
|
+
cursor: pointer;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.quickstudy-viewer .turn-head:hover {
|
|
37
|
+
background: var(--bg);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.quickstudy-viewer .turn-head .line-no {
|
|
41
|
+
font-family: var(--mono);
|
|
42
|
+
font-size: 11px;
|
|
43
|
+
color: var(--muted);
|
|
44
|
+
min-width: 46px;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.quickstudy-viewer .turn-head .role {
|
|
48
|
+
font-size: 11px;
|
|
49
|
+
font-weight: 700;
|
|
50
|
+
text-transform: uppercase;
|
|
51
|
+
letter-spacing: 0.04em;
|
|
52
|
+
min-width: 72px;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.quickstudy-viewer .role-assistant {
|
|
56
|
+
color: var(--accent);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.quickstudy-viewer .role-user {
|
|
60
|
+
color: var(--green);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.quickstudy-viewer .role-tool {
|
|
64
|
+
color: var(--amber);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.quickstudy-viewer .role-system {
|
|
68
|
+
color: var(--muted);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.quickstudy-viewer .turn-head .summary {
|
|
72
|
+
color: var(--muted);
|
|
73
|
+
font-size: 12px;
|
|
74
|
+
white-space: nowrap;
|
|
75
|
+
overflow: hidden;
|
|
76
|
+
text-overflow: ellipsis;
|
|
77
|
+
flex: 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.quickstudy-viewer .turn-body {
|
|
81
|
+
padding: 8px 12px 12px 70px;
|
|
82
|
+
background: var(--bg);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.quickstudy-viewer .turn-body pre {
|
|
86
|
+
margin: 0;
|
|
87
|
+
white-space: pre-wrap;
|
|
88
|
+
word-break: break-word;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.quickstudy-viewer .turn.highlight {
|
|
92
|
+
background: var(--amber-bg);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.quickstudy-viewer .turn.highlight .turn-head {
|
|
96
|
+
background: var(--amber-bg);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.quickstudy-viewer .viewer-note {
|
|
100
|
+
padding: 10px 14px;
|
|
101
|
+
color: var(--muted);
|
|
102
|
+
font-size: 12px;
|
|
103
|
+
border-bottom: 1px solid var(--line);
|
|
104
|
+
display: flex;
|
|
105
|
+
justify-content: space-between;
|
|
106
|
+
align-items: center;
|
|
107
|
+
gap: 12px;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.quickstudy-viewer .viewer-note button {
|
|
111
|
+
font: inherit;
|
|
112
|
+
border: 1px solid var(--line);
|
|
113
|
+
background: var(--surface);
|
|
114
|
+
border-radius: 6px;
|
|
115
|
+
padding: 3px 10px;
|
|
116
|
+
cursor: pointer;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* --- diff --- */
|
|
120
|
+
|
|
121
|
+
.quickstudy-viewer.diff {
|
|
122
|
+
font-family: var(--mono);
|
|
123
|
+
font-size: 12px;
|
|
124
|
+
line-height: 1.45;
|
|
125
|
+
overflow-x: auto;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.quickstudy-viewer .diff-line {
|
|
129
|
+
display: flex;
|
|
130
|
+
white-space: pre;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.quickstudy-viewer .diff-line .line-no {
|
|
134
|
+
min-width: 46px;
|
|
135
|
+
padding: 0 8px;
|
|
136
|
+
color: var(--muted);
|
|
137
|
+
text-align: right;
|
|
138
|
+
user-select: none;
|
|
139
|
+
flex-shrink: 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.quickstudy-viewer .diff-line .text {
|
|
143
|
+
flex: 1;
|
|
144
|
+
padding-right: 12px;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.quickstudy-viewer .diff-add {
|
|
148
|
+
background: var(--green-bg);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.quickstudy-viewer .diff-del {
|
|
152
|
+
background: var(--red-bg);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.quickstudy-viewer .diff-hunk {
|
|
156
|
+
background: var(--gray-bg);
|
|
157
|
+
color: var(--accent);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.quickstudy-viewer .diff-file {
|
|
161
|
+
background: var(--gray-bg);
|
|
162
|
+
font-weight: 700;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.quickstudy-viewer .diff-line.highlight {
|
|
166
|
+
outline: 2px solid var(--amber);
|
|
167
|
+
outline-offset: -2px;
|
|
168
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transcript and diff viewers as a library entry point — the package's `./ui/viewers`
|
|
3
|
+
* export, with styles at `./ui/viewers.css`.
|
|
4
|
+
*
|
|
5
|
+
* A host that already renders a run's data in its own design (a control plane over
|
|
6
|
+
* exported bundles, say) can render an attempt's artifacts with the same turn parsing,
|
|
7
|
+
* roles, collapsing and L<n> anchors this UI uses, instead of copying the components. Both
|
|
8
|
+
* components import React only; the host fetches the text (the bundle lazy-loads it the
|
|
9
|
+
* same way) and defines the CSS custom properties viewers.css reads.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
TranscriptViewer,
|
|
14
|
+
type TranscriptViewerProps,
|
|
15
|
+
parseTranscript,
|
|
16
|
+
parseTurn,
|
|
17
|
+
} from "./components/TranscriptViewer.tsx";
|
|
18
|
+
export { DiffViewer, type DiffViewerProps } from "./components/DiffViewer.tsx";
|