@anchrd/intel-ui 0.3.0 → 0.5.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/components.json +7 -1
- package/package.json +3 -1
- package/src/app/action-slot/action-slot.tsx +23 -0
- package/src/app/app-sidebar/app-sidebar.tsx +71 -0
- package/src/app/app-tree/app-tree.tsx +798 -0
- package/src/app/app.tsx +99 -54
- package/src/app/header-search/header-search.tsx +291 -0
- package/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
- package/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
- package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
- package/src/app/tree-move/tree-move.tsx +197 -0
- package/src/app/user-footer/user-footer.tsx +103 -0
- package/src/app/view-toggle/view-toggle.tsx +77 -0
- package/src/blocknote-view/blocknote-view.tsx +19 -2
- package/src/branding/favicon.default.svg +2 -2
- package/src/branding/favicon.svg +2 -2
- package/src/components/ui/breadcrumb.tsx +102 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/collapsible.tsx +20 -0
- package/src/components/ui/command.tsx +160 -0
- package/src/components/ui/dialog.tsx +143 -0
- package/src/components/ui/dropdown-menu.tsx +162 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/separator.tsx +26 -0
- package/src/components/ui/sheet.tsx +136 -0
- package/src/components/ui/sidebar.tsx +693 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +170 -85
- package/src/data/intel-data-provider/intel-data-provider.types.ts +64 -20
- package/src/document-link/document-link.tsx +132 -0
- package/src/flow-runs/flow-runs.tsx +225 -0
- package/src/flows/flows.tsx +684 -368
- package/src/flows/node-icon/node-icon.ts +28 -0
- package/src/flows/node-palette/node-palette.tsx +174 -0
- package/src/flows/node-palette/node-palette.types.ts +15 -0
- package/src/graph-pane/graph-pane.tsx +44 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/i18n/en.json +188 -52
- package/src/knowledge/knowledge.tsx +133 -709
- package/src/knowledge-editor/knowledge-editor.tsx +169 -21
- package/src/knowledge-graph/knowledge-graph.ts +26 -24
- package/src/knowledge-graph/knowledge-graph.tsx +33 -24
- package/src/knowledge-table/knowledge-table.tsx +129 -0
- package/src/main.tsx +2 -2
- package/src/resource-menu/resource-menu.tsx +580 -0
- package/src/router/router.tsx +4 -0
- package/src/router/selection-search.ts +43 -0
- package/src/save-button/save-button.tsx +103 -0
- package/src/styles.css +91 -51
- package/src/theme/theme.ts +24 -0
- package/src/tools/tools.tsx +175 -158
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { DocumentLinkInlineType } from "@anchrd/intel-contract";
|
|
2
|
+
import { BlockNoteSchema } from "@blocknote/core";
|
|
3
|
+
import { createReactInlineContentSpec } from "@blocknote/react";
|
|
4
|
+
import { FileText, Link2Off } from "lucide-react";
|
|
5
|
+
import { createContext, useContext } from "react";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a document link resolves to for the person reading it.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ `titles` holds only the documents this reader may see. A missing entry is the whole answer for
|
|
11
|
+
* both of the reasons an entry can be missing — the target is gone, or it is not theirs to reach —
|
|
12
|
+
* and nothing here may ever be given a way to tell those apart. `pending` exists so the editor can
|
|
13
|
+
* hold back the difference between "not resolved yet" and "does not resolve", which would otherwise
|
|
14
|
+
* flash a broken link at every reader for one render.
|
|
15
|
+
*/
|
|
16
|
+
export interface DocumentLinkResolution {
|
|
17
|
+
titles: ReadonlyMap<string, string>;
|
|
18
|
+
pending: boolean;
|
|
19
|
+
// The one word an unresolved link says. It travels in the context because a BlockNote inline
|
|
20
|
+
// element is rendered by the editor rather than by a view that could reach the i18n catalog.
|
|
21
|
+
unresolvedLabel: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DocumentLinkContext = createContext<DocumentLinkResolution>({
|
|
25
|
+
titles: new Map(),
|
|
26
|
+
pending: false,
|
|
27
|
+
unresolvedLabel: "Unavailable document",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const DocumentLinkProvider = DocumentLinkContext.Provider;
|
|
31
|
+
|
|
32
|
+
export function useDocumentLinkResolution(): DocumentLinkResolution {
|
|
33
|
+
return useContext(DocumentLinkContext);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One document link inside the text (#41).
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ The element stores the target's ID and nothing else. A stored title would be wrong the moment
|
|
40
|
+
* the target is renamed and a stored path the moment it is moved — and both would put a name into a
|
|
41
|
+
* document that any reader of it could then read, whether or not they may reach the target.
|
|
42
|
+
*
|
|
43
|
+
* ⚠️ An unresolved link says that it does not resolve and nothing more: no title, no path, no ID on
|
|
44
|
+
* screen, no hint of which of the two reasons applies. That is the whole of the leak this feature
|
|
45
|
+
* could have introduced, and the reason the broken form carries no data at all.
|
|
46
|
+
*/
|
|
47
|
+
export function DocumentLinkText({ nodeId }: { nodeId: string }) {
|
|
48
|
+
const { titles, pending, unresolvedLabel } = useDocumentLinkResolution();
|
|
49
|
+
const title = titles.get(nodeId);
|
|
50
|
+
if (title === undefined) {
|
|
51
|
+
return (
|
|
52
|
+
<span
|
|
53
|
+
data-document-link={pending ? "pending" : "unresolved"}
|
|
54
|
+
className={
|
|
55
|
+
pending
|
|
56
|
+
? "inline-flex items-center gap-1 rounded-sm px-1 text-muted-foreground"
|
|
57
|
+
: "inline-flex items-center gap-1 rounded-sm bg-destructive/10 px-1 text-destructive line-through"
|
|
58
|
+
}
|
|
59
|
+
>
|
|
60
|
+
{pending ? null : <Link2Off aria-hidden="true" className="size-3.5" />}
|
|
61
|
+
{pending ? "…" : unresolvedLabel}
|
|
62
|
+
</span>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return (
|
|
66
|
+
<span
|
|
67
|
+
data-document-link="resolved"
|
|
68
|
+
data-node-id={nodeId}
|
|
69
|
+
className="inline-flex items-center gap-1 rounded-sm bg-muted px-1 text-foreground"
|
|
70
|
+
>
|
|
71
|
+
<FileText aria-hidden="true" className="size-3.5" />
|
|
72
|
+
{title}
|
|
73
|
+
</span>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const documentLinkSpec = createReactInlineContentSpec(
|
|
78
|
+
{
|
|
79
|
+
type: DocumentLinkInlineType,
|
|
80
|
+
propSchema: { nodeId: { default: "" } },
|
|
81
|
+
// The link has no editable text of its own: what it reads is the target's title, and a title
|
|
82
|
+
// somebody could type over would stop following the target the first time they did (#41).
|
|
83
|
+
content: "none",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
render: (props) => <DocumentLinkText nodeId={String(props.inlineContent.props.nodeId)} />,
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// One schema for the editor and for everything that reads its documents back: BlockNote's defaults
|
|
91
|
+
// plus the one inline element #41 adds.
|
|
92
|
+
//
|
|
93
|
+
// ⚠️ `extend` rather than `create({ inlineContentSpecs: { ...defaults, documentLink } })`. Both
|
|
94
|
+
// build the same schema at run time, but only `extend` keeps the added type in the schema's own
|
|
95
|
+
// generics under this repo's `exactOptionalPropertyTypes` — with the spread form the editor infers
|
|
96
|
+
// BlockNote's default schema, typechecks, and then refuses to insert a document link.
|
|
97
|
+
export const intelEditorSchema = BlockNoteSchema.create().extend({
|
|
98
|
+
inlineContentSpecs: { documentLink: documentLinkSpec },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// The schema carries its own editor and block types as declaration-only properties, which is the
|
|
102
|
+
// only way to name them here: writing `BlockNoteEditor<typeof schema.blockSchema, ...>` by hand
|
|
103
|
+
// fails BlockNote's own constraint under `exactOptionalPropertyTypes`.
|
|
104
|
+
export type IntelEditor = (typeof intelEditorSchema)["BlockNoteEditor"];
|
|
105
|
+
export type IntelEditorPartialBlock = (typeof intelEditorSchema)["PartialBlock"];
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The IDs a stored BlockNote document links to.
|
|
109
|
+
*
|
|
110
|
+
* The server reads the same thing out of the same content when the document is saved; this side
|
|
111
|
+
* needs it too, to ask which of them it may show a title for.
|
|
112
|
+
*/
|
|
113
|
+
export function documentLinkIds(blocks: unknown): string[] {
|
|
114
|
+
const found = new Set<string>();
|
|
115
|
+
const walk = (value: unknown): void => {
|
|
116
|
+
if (Array.isArray(value)) {
|
|
117
|
+
for (const entry of value) walk(entry);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (typeof value !== "object" || value === null) return;
|
|
121
|
+
const record = value as Record<string, unknown>;
|
|
122
|
+
if (record.type === DocumentLinkInlineType) {
|
|
123
|
+
const props = record.props as Record<string, unknown> | undefined;
|
|
124
|
+
const nodeId = props?.nodeId;
|
|
125
|
+
if (typeof nodeId === "string" && nodeId.length > 0) found.add(nodeId);
|
|
126
|
+
}
|
|
127
|
+
walk(record.content);
|
|
128
|
+
walk(record.children);
|
|
129
|
+
};
|
|
130
|
+
walk(blocks);
|
|
131
|
+
return [...found];
|
|
132
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { FlowRunSummary } from "@anchrd/intel-contract";
|
|
2
|
+
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { ChevronDown, ChevronRight, CornerDownRight } from "lucide-react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
6
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
7
|
+
|
|
8
|
+
// One page is what a person reads before deciding, not what a database can return. The server caps
|
|
9
|
+
// it at fifty; twenty is what fits on a screen without scrolling past the answer.
|
|
10
|
+
const PageSize = 20;
|
|
11
|
+
|
|
12
|
+
function formatDuration(ms: number, i18n: I18n): string {
|
|
13
|
+
if (ms < 1_000) return i18n.t("runs.durationMs", { value: ms });
|
|
14
|
+
if (ms < 60_000) return i18n.t("runs.durationSeconds", { value: (ms / 1_000).toFixed(1) });
|
|
15
|
+
return i18n.t("runs.durationMinutes", { value: Math.round(ms / 60_000) });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The third view of the flow screen: what this flow has done (#35).
|
|
20
|
+
*
|
|
21
|
+
* **A view, not a dialog.** Comparing runs and looking things up is what fault-finding is, and that
|
|
22
|
+
* needs the room and the working memory of a whole surface — a modal would block everything else on
|
|
23
|
+
* the screen while doing exactly the task that needs the rest of it. So it joins the switch #19
|
|
24
|
+
* already built rather than opening somewhere new.
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ What it shows is what a run *did*. Neither the input a run was given nor the result it
|
|
27
|
+
* produced is in the answer it reads, because a run reaches Knowledge and tools with the rights of
|
|
28
|
+
* whoever started it — and the person reading this list is not necessarily that person.
|
|
29
|
+
*/
|
|
30
|
+
export function FlowRuns({ flowId }: { flowId: string }) {
|
|
31
|
+
const { data, i18n } = useIntelRouterContext();
|
|
32
|
+
const [failedOnly, setFailedOnly] = useState(false);
|
|
33
|
+
const [openRunId, setOpenRunId] = useState<string | null>(null);
|
|
34
|
+
// The filter belongs in the key: "only the failed ones" is a different question with a different
|
|
35
|
+
// answer, and reusing one cache entry for both would show the wrong one for a frame.
|
|
36
|
+
const runs = useInfiniteQuery({
|
|
37
|
+
queryKey: ["flow-runs", flowId, failedOnly],
|
|
38
|
+
queryFn: ({ pageParam }) =>
|
|
39
|
+
data.listFlowRuns({ flowId, failedOnly, limit: PageSize, cursor: pageParam }),
|
|
40
|
+
initialPageParam: null as string | null,
|
|
41
|
+
getNextPageParam: (last) => last.nextCursor,
|
|
42
|
+
});
|
|
43
|
+
const items = runs.data?.pages.flatMap((page) => page.items) ?? [];
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<section aria-label={i18n.t("runs.title")} className="flex min-h-0 flex-1 flex-col">
|
|
47
|
+
<div className="flex items-center gap-3 border-b px-5 py-3">
|
|
48
|
+
<h2 className="text-sm font-semibold">{i18n.t("runs.title")}</h2>
|
|
49
|
+
{/* The one filter. Everything beyond it is a report rather than a search for a fault. */}
|
|
50
|
+
<label className="ml-auto flex cursor-pointer items-center gap-2 text-sm">
|
|
51
|
+
<input
|
|
52
|
+
type="checkbox"
|
|
53
|
+
checked={failedOnly}
|
|
54
|
+
onChange={(event) => {
|
|
55
|
+
setFailedOnly(event.target.checked);
|
|
56
|
+
setOpenRunId(null);
|
|
57
|
+
}}
|
|
58
|
+
className="size-4 accent-primary"
|
|
59
|
+
/>
|
|
60
|
+
{i18n.t("runs.onlyFailed")}
|
|
61
|
+
</label>
|
|
62
|
+
</div>
|
|
63
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-5">
|
|
64
|
+
{runs.isPending && (
|
|
65
|
+
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
66
|
+
)}
|
|
67
|
+
{runs.isError && (
|
|
68
|
+
<div role="alert" className="space-y-3 text-sm">
|
|
69
|
+
<p className="text-destructive">{i18n.t("runs.failedToLoad")}</p>
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={() => void runs.refetch()}
|
|
73
|
+
className="rounded-md border px-3 py-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
74
|
+
>
|
|
75
|
+
{i18n.t("common.retry")}
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
{/* ⚠️ Two different answers, kept apart: this flow has never run, and this flow has never
|
|
80
|
+
failed. Saying the first when the filter is on would be wrong for exactly the person who
|
|
81
|
+
came here worried. */}
|
|
82
|
+
{runs.data && items.length === 0 && (
|
|
83
|
+
<p className="text-sm text-muted-foreground">
|
|
84
|
+
{i18n.t(failedOnly ? "runs.emptyFailed" : "runs.empty")}
|
|
85
|
+
</p>
|
|
86
|
+
)}
|
|
87
|
+
{items.length > 0 && (
|
|
88
|
+
<ul className="space-y-2">
|
|
89
|
+
{items.map((run) => (
|
|
90
|
+
<RunRow
|
|
91
|
+
key={run.id}
|
|
92
|
+
run={run}
|
|
93
|
+
open={openRunId === run.id}
|
|
94
|
+
toggle={() => setOpenRunId(openRunId === run.id ? null : run.id)}
|
|
95
|
+
/>
|
|
96
|
+
))}
|
|
97
|
+
</ul>
|
|
98
|
+
)}
|
|
99
|
+
{runs.hasNextPage && (
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
onClick={() => void runs.fetchNextPage()}
|
|
103
|
+
disabled={runs.isFetchingNextPage}
|
|
104
|
+
className="mt-4 rounded-md border px-3 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
105
|
+
>
|
|
106
|
+
{runs.isFetchingNextPage ? i18n.t("common.loading") : i18n.t("runs.more")}
|
|
107
|
+
</button>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
</section>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function RunRow({ run, open, toggle }: { run: FlowRunSummary; open: boolean; toggle(): void }) {
|
|
115
|
+
const { i18n } = useIntelRouterContext();
|
|
116
|
+
const failed = run.status === "failed";
|
|
117
|
+
const Chevron = open ? ChevronDown : ChevronRight;
|
|
118
|
+
return (
|
|
119
|
+
<li className="rounded-lg border bg-card">
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
onClick={toggle}
|
|
123
|
+
aria-expanded={open}
|
|
124
|
+
className="flex w-full items-start gap-3 rounded-lg px-4 py-3 text-left outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
125
|
+
>
|
|
126
|
+
<Chevron aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
127
|
+
<span className="min-w-0 flex-1">
|
|
128
|
+
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
|
129
|
+
<span
|
|
130
|
+
className={`rounded-full border px-2 py-0.5 text-xs ${failed ? "border-destructive/40 text-destructive" : "text-muted-foreground"}`}
|
|
131
|
+
>
|
|
132
|
+
{i18n.t(`runs.status.${run.status}`)}
|
|
133
|
+
</span>
|
|
134
|
+
<span>{new Date(run.startedAt).toLocaleString(i18n.locale)}</span>
|
|
135
|
+
<span className="text-muted-foreground">
|
|
136
|
+
{run.durationMs === null
|
|
137
|
+
? i18n.t("runs.stillRunning")
|
|
138
|
+
: formatDuration(run.durationMs, i18n)}
|
|
139
|
+
</span>
|
|
140
|
+
<span className="text-muted-foreground">{i18n.t(`runs.trigger.${run.trigger}`)}</span>
|
|
141
|
+
</span>
|
|
142
|
+
{/* The step that ended it, in the words the failure itself used. The list does not
|
|
143
|
+
summarize them into "failed" — that is the sentence #20 replaced. */}
|
|
144
|
+
{run.failure && (
|
|
145
|
+
<span className="mt-1.5 block text-sm text-destructive">
|
|
146
|
+
<span className="font-medium">
|
|
147
|
+
{i18n.t("runs.failedAt", { step: run.failure.nodeLabel })}
|
|
148
|
+
</span>{" "}
|
|
149
|
+
{run.failure.detail}
|
|
150
|
+
</span>
|
|
151
|
+
)}
|
|
152
|
+
</span>
|
|
153
|
+
</button>
|
|
154
|
+
{open && <RunSteps runId={run.id} />}
|
|
155
|
+
</li>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The drill-down: every step this run took, in order. The failed node is reported rather than drawn
|
|
160
|
+
// onto the canvas because a run took an immutable version and the editor shows the current one —
|
|
161
|
+
// marking a node in the wrong graph would point confidently at the wrong step.
|
|
162
|
+
function RunSteps({ runId }: { runId: string }) {
|
|
163
|
+
const { data, i18n } = useIntelRouterContext();
|
|
164
|
+
const history = useQuery({
|
|
165
|
+
queryKey: ["flow-run-steps", runId],
|
|
166
|
+
queryFn: () => data.getFlowRunSteps(runId),
|
|
167
|
+
});
|
|
168
|
+
return (
|
|
169
|
+
<div className="border-t px-4 py-3">
|
|
170
|
+
{history.isPending && (
|
|
171
|
+
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
172
|
+
)}
|
|
173
|
+
{history.isError && (
|
|
174
|
+
<p role="alert" className="text-sm text-destructive">
|
|
175
|
+
{i18n.t("runs.stepsFailedToLoad")}
|
|
176
|
+
</p>
|
|
177
|
+
)}
|
|
178
|
+
{history.data && (
|
|
179
|
+
<>
|
|
180
|
+
{/* Which step of which flow this run is, outermost caller first (#17). Only worth saying
|
|
181
|
+
when there is more than one link in the chain. */}
|
|
182
|
+
{history.data.trail.length > 1 && (
|
|
183
|
+
<p className="mb-3 flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
|
|
184
|
+
{history.data.trail.map((entry, index) => (
|
|
185
|
+
<span key={entry.runId} className="flex items-center gap-1">
|
|
186
|
+
{index > 0 && <CornerDownRight aria-hidden="true" className="size-3" />}
|
|
187
|
+
<span>{entry.flowTitle}</span>
|
|
188
|
+
{entry.nodeLabel && <span>· {entry.nodeLabel}</span>}
|
|
189
|
+
</span>
|
|
190
|
+
))}
|
|
191
|
+
</p>
|
|
192
|
+
)}
|
|
193
|
+
{history.data.steps.length === 0 ? (
|
|
194
|
+
<p className="text-sm text-muted-foreground">{i18n.t("runs.stepsEmpty")}</p>
|
|
195
|
+
) : (
|
|
196
|
+
<ol aria-label={i18n.t("runs.stepsTitle")} className="space-y-2">
|
|
197
|
+
{history.data.steps.map((step) => (
|
|
198
|
+
<li key={step.nodeId} className="text-sm">
|
|
199
|
+
<span
|
|
200
|
+
className={
|
|
201
|
+
step.outcome === "failed" ? "font-medium text-destructive" : "font-medium"
|
|
202
|
+
}
|
|
203
|
+
>
|
|
204
|
+
{step.nodeLabel}
|
|
205
|
+
</span>
|
|
206
|
+
<span className="ml-2 text-xs text-muted-foreground">
|
|
207
|
+
{i18n.t(`runs.outcome.${step.outcome}`)}
|
|
208
|
+
{step.branch ? ` · ${step.branch}` : ""}
|
|
209
|
+
</span>
|
|
210
|
+
{step.detail && (
|
|
211
|
+
<span
|
|
212
|
+
className={`mt-0.5 block text-xs ${step.outcome === "failed" ? "text-destructive" : "text-muted-foreground"}`}
|
|
213
|
+
>
|
|
214
|
+
{step.detail}
|
|
215
|
+
</span>
|
|
216
|
+
)}
|
|
217
|
+
</li>
|
|
218
|
+
))}
|
|
219
|
+
</ol>
|
|
220
|
+
)}
|
|
221
|
+
</>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|