feat: refresh workflows and workflow editor UI

This commit is contained in:
willchen96 2026-07-20 03:04:47 +08:00
parent 0ed5050b21
commit fa21ac8fd7
11 changed files with 745 additions and 585 deletions

View file

@ -44,13 +44,10 @@
<stop offset="0.5" stop-color="#D9A94E"/>
<stop offset="1" stop-color="#B07E2C"/>
</linearGradient>
<filter id="bookShadow" x="-30%" y="-10%" width="170%" height="130%">
<feDropShadow dx="1.2" dy="1.4" stdDeviation="1.1" flood-color="#111827" flood-opacity="0.28"/>
</filter>
</defs>
<!-- brown book -->
<g filter="url(#bookShadow)">
<g>
<rect x="6" y="3.8" width="11" height="49.2" rx="3" fill="url(#leatherBrown)" stroke="url(#rim)" stroke-width="1.4"/>
<rect x="6.7" y="4.5" width="9.6" height="47.8" rx="2.4" fill="url(#spineGloss)"/>
<path d="M8.1 8V49" stroke="#F0C7A2" stroke-opacity="0.28" stroke-width="0.65"/>
@ -62,7 +59,7 @@
</g>
<!-- blue book -->
<g filter="url(#bookShadow)">
<g>
<rect x="19" y="8" width="11.5" height="45" rx="3" fill="url(#leatherBlue)" stroke="url(#rim)" stroke-width="1.4"/>
<rect x="19.7" y="8.7" width="10.1" height="43.6" rx="2.4" fill="url(#spineGloss)"/>
<path d="M21.2 12V49" stroke="#B9DCFF" stroke-opacity="0.3" stroke-width="0.65"/>
@ -74,7 +71,7 @@
</g>
<!-- red book -->
<g filter="url(#bookShadow)">
<g>
<rect x="32.5" y="5" width="11" height="48" rx="3" fill="url(#leatherRed)" stroke="url(#rim)" stroke-width="1.4"/>
<rect x="33.2" y="5.7" width="9.6" height="46.6" rx="2.4" fill="url(#spineGloss)"/>
<path d="M34.6 9V49" stroke="#FFD0D2" stroke-opacity="0.3" stroke-width="0.65"/>
@ -86,7 +83,7 @@
</g>
<!-- green book, leaning against the red book -->
<g transform="rotate(-9 62.1 52)" filter="url(#bookShadow)">
<g transform="rotate(-9 62.1 52)">
<rect x="51.6" y="5.2" width="10.5" height="46.8" rx="3" fill="url(#leatherGreen)" stroke="url(#rim)" stroke-width="1.4"/>
<rect x="52.3" y="5.9" width="9.1" height="45.4" rx="2.4" fill="url(#spineGloss)"/>
<path d="M53.7 9.5V48.5" stroke="#FFFFFF" stroke-opacity="0.24" stroke-width="0.65"/>

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Before After
Before After

View file

@ -110,6 +110,18 @@ export function MarkdownContent({
{...withoutMarkdownNode(props)}
/>
),
h5: (props) => (
<h5
className="text-base font-semibold mt-3 mb-2"
{...withoutMarkdownNode(props)}
/>
),
h6: (props) => (
<h6
className="text-sm font-semibold mt-3 mb-2"
{...withoutMarkdownNode(props)}
/>
),
p: ({ node, ...props }) => {
const parent =
node && typeof node === "object" && "parent" in node
@ -275,4 +287,3 @@ export function MarkdownContent({
</div>
);
}

View file

@ -55,6 +55,11 @@ export function useSmoothedReveal(text: string, active: boolean): string {
};
}, [text.length, active]);
// Once the stream ends, render the authoritative text immediately. The
// effect above keeps the animation cursor in sync, but updating a ref does
// not trigger a render; slicing with the last animated state here could
// otherwise leave the final few characters permanently hidden.
if (!active) return text;
return text.slice(0, Math.min(revealedInt, text.length));
}

View file

@ -164,14 +164,33 @@ interface Props {
onClose: () => void;
onCreated: (workflow: Workflow) => void;
editWorkflow?: Workflow;
readOnly?: boolean;
onUpdated?: (workflow: Workflow) => void;
}
function getWorkflowSourceLabel(workflow: Workflow) {
if (workflow.is_system) return "System";
if (workflow.is_owner === false) {
return workflow.shared_by_name?.trim() || "Shared";
}
return "User";
}
const OPEN_SOURCE_STATUS_LABELS: Record<
NonNullable<Workflow["open_source_submission"]>["status"],
string
> = {
pending: "Pending review",
approved: "Approved",
rejected: "Rejected",
};
export function NewWorkflowModal({
open,
onClose,
onCreated,
editWorkflow,
readOnly = false,
onUpdated,
}: Props) {
const [title, setTitle] = useState("");
@ -199,6 +218,41 @@ export function NewWorkflowModal({
const markdownInputRef = useRef<HTMLInputElement>(null);
const isEditing = !!editWorkflow;
const viewOnly = isEditing && readOnly;
const workflowDetails = editWorkflow
? [
{
label: "Type",
value:
editWorkflow.metadata.type === "tabular"
? "Tabular"
: "Assistant",
},
{
label: "Source",
value: getWorkflowSourceLabel(editWorkflow),
},
...(editWorkflow.metadata.version
? [
{
label: "Version",
value: editWorkflow.metadata.version,
},
]
: []),
...(editWorkflow.open_source_submission
? [
{
label: "Open source",
value:
OPEN_SOURCE_STATUS_LABELS[
editWorkflow.open_source_submission.status
],
},
]
: []),
]
: [];
const isOtherLanguage = language === "Other";
const isOtherPractice = practice === "Other";
const isOtherJurisdiction = jurisdiction === "Other";
@ -332,6 +386,7 @@ export function NewWorkflowModal({
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (viewOnly) return;
if (!title.trim()) return;
setLoading(true);
setError("");
@ -417,20 +472,24 @@ export function NewWorkflowModal({
onClose={handleClose}
breadcrumbs={[
"Workflows",
isEditing ? "Edit workflow" : "New workflow",
isEditing ? "View and Edit details" : "New workflow",
]}
primaryAction={{
label: loading
? isEditing
? "Saving…"
: "Creating…"
: isEditing
? "Save changes"
: "Create workflow",
type: "submit",
form: formId,
disabled: !title.trim() || loading,
}}
primaryAction={
viewOnly
? undefined
: {
label: loading
? isEditing
? "Saving…"
: "Creating…"
: isEditing
? "Save changes"
: "Create workflow",
type: "submit",
form: formId,
disabled: !title.trim() || loading,
}
}
secondaryAction={
!isEditing && type === "assistant"
? {
@ -445,9 +504,23 @@ export function NewWorkflowModal({
<form
id={formId}
onSubmit={handleSubmit}
className="flex min-h-0 flex-1 flex-col"
className="flex min-h-0 flex-1 flex-col overflow-y-auto pb-5"
>
<div className="space-y-6">
{workflowDetails.length > 0 && (
<dl className="grid grid-cols-2 gap-x-5 gap-y-4 rounded-2xl border border-white/70 bg-white/45 p-4 text-sm shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] backdrop-blur-xl">
{workflowDetails.map((detail) => (
<div key={detail.label} className="min-w-0">
<dt className="text-xs text-gray-400">
{detail.label}
</dt>
<dd className="mt-0.5 truncate text-gray-700">
{detail.value}
</dd>
</div>
))}
</dl>
)}
<div>
<ModalFieldLabel htmlFor="workflow-title">
Title
@ -459,7 +532,8 @@ export function NewWorkflowModal({
onChange={(e) => setTitle(e.target.value)}
placeholder="Add workflow name"
variant="minimal"
autoFocus
disabled={viewOnly}
autoFocus={!viewOnly}
/>
</div>
@ -494,6 +568,7 @@ export function NewWorkflowModal({
id="workflow-language"
value={language}
options={languageOptions}
disabled={viewOnly}
open={openDropdown === "language"}
onOpenChange={(nextOpen) =>
setOpenDropdown((current) =>
@ -517,6 +592,7 @@ export function NewWorkflowModal({
ref={customLanguageInputRef}
type="text"
value={customLanguage}
disabled={viewOnly}
onChange={(e) =>
setCustomLanguage(e.target.value)
}
@ -534,6 +610,7 @@ export function NewWorkflowModal({
id="workflow-practice"
value={practice}
options={PRACTICE_OPTIONS}
disabled={viewOnly}
open={openDropdown === "practice"}
onOpenChange={(nextOpen) =>
setOpenDropdown((current) =>
@ -557,6 +634,7 @@ export function NewWorkflowModal({
ref={customInputRef}
type="text"
value={customPractice}
disabled={viewOnly}
onChange={(e) =>
setCustomPractice(e.target.value)
}
@ -575,6 +653,7 @@ export function NewWorkflowModal({
id="workflow-jurisdiction"
value={jurisdiction}
options={jurisdictionOptions}
disabled={viewOnly}
open={openDropdown === "jurisdiction"}
onOpenChange={(nextOpen) =>
setOpenDropdown((current) =>
@ -600,6 +679,7 @@ export function NewWorkflowModal({
className="mt-2"
value={jurisdictionRegion}
options={jurisdictionRegionOptions}
disabled={viewOnly}
placeholder={
jurisdiction === "United States"
? "Select state..."
@ -626,6 +706,7 @@ export function NewWorkflowModal({
ref={customJurisdictionInputRef}
type="text"
value={customJurisdiction}
disabled={viewOnly}
onChange={(e) =>
setCustomJurisdiction(e.target.value)
}

View file

@ -47,8 +47,25 @@ import { PeopleModal } from "@/app/components/modals/PeopleModal";
import { OpenSourceWorkflowModal } from "@/app/components/workflows/OpenSourceWorkflowModal";
import { PageHeader } from "@/app/components/shared/PageHeader";
import { PillButton } from "@/app/components/ui/pill-button";
import { TabPillButton } from "@/app/components/ui/tab-pill-button";
import { LIQUID_TABLE_SURFACE_CLASS } from "@/app/components/ui/liquid-surface";
import { NewWorkflowModal } from "@/app/components/workflows/NewWorkflowModal";
import { TabularReviewSkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons";
import {
TABLE_CHECKBOX_CLASS,
SkeletonDot,
SkeletonLine,
TableBody,
TableCell,
TableEmptyState,
TableHeaderCell,
TableHeaderRow,
TablePrimaryCell,
TableRow,
TableScrollArea,
TableStickyCell,
} from "@/app/components/shared/TablePrimitive";
import { TableToolbar } from "@/app/components/shared/TableToolbar";
import { useAuth } from "@/app/contexts/AuthContext";
import { useUserProfile } from "@/app/contexts/UserProfileContext";
import { downloadWorkflowZip } from "./workflowZipExport";
@ -71,7 +88,6 @@ type DeleteStatus = "idle" | "loading" | "complete";
type WorkflowShare = Awaited<ReturnType<typeof listWorkflowShares>>[number];
const NAME_COL_W = "w-[332px] shrink-0";
const CHECKBOX_GUTTER = "h-2.5 w-2.5 shrink-0";
const WORKFLOW_CONTRIBUTIONS_ENABLED =
process.env.NEXT_PUBLIC_WORKFLOW_CONTRIBUTIONS_ENABLED === "true";
@ -82,8 +98,6 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
const router = useRouter();
const { user } = useAuth();
const { profile } = useUserProfile();
const stickyCellBg = "bg-[#fafbfc]";
const [workflow, setWorkflow] = useState<Workflow | null>(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
@ -308,6 +322,16 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
setAddColumnOpen(false);
}
function handleDeleteSelectedColumns() {
const next = columns
.filter((column) => !selectedColIndices.includes(column.index))
.map((column, index) => ({ ...column, index }));
setColumns(next);
saveColumns(next);
setSelectedColIndices([]);
setColActionsOpen(false);
}
function handleColumnSaved(updated: ColumnConfig) {
const next = columns.map((c) =>
c.index === updated.index ? updated : c,
@ -335,7 +359,6 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
]}
/>
<div className="flex min-h-0 flex-1 flex-col">
<WorkflowMetadataSkeleton />
{workflowType === "tabular" ? (
<TabularWorkflowEditorSkeleton />
) : (
@ -364,15 +387,14 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
icon: Download,
onSelect: () => downloadWorkflowZip(workflow, promptMd, columns),
},
{
label: "View and Edit details",
icon: Pencil,
onSelect: () => setDetailsOpen(true),
},
];
if (!readOnly) {
workflowActionItems.push({
label: "Edit details",
icon: Pencil,
onSelect: () => setDetailsOpen(true),
});
if (canOpenSource) {
workflowActionItems.push({
label: "Open source this",
@ -467,6 +489,7 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
<NewWorkflowModal
open={detailsOpen}
editWorkflow={workflow}
readOnly={readOnly}
onClose={() => setDetailsOpen(false)}
onCreated={() => undefined}
onUpdated={(updated) => {
@ -537,12 +560,9 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
{/* Body */}
<div className="flex-1 min-h-0 flex flex-col">
{/* Metadata */}
<WorkflowMetadata workflow={workflow} />
{workflow.metadata.type === "assistant" ? (
/* ── Assistant: WYSIWYG editor ── */
<div className="flex-1 min-h-0 px-4 pb-2 pt-4 md:px-10 md:pb-3">
<div className="flex-1 min-h-0 px-4 pb-2 pt-4 md:px-6 md:pb-3">
<WorkflowPromptEditor
value={promptMd}
onChange={readOnly ? undefined : handlePromptChange}
@ -552,51 +572,62 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
) : (
/* ── Tabular: Column table ── */
<div className="flex flex-col flex-1 min-h-0 pt-2">
{/* Toolbar */}
{!readOnly && (
<div className="flex items-center justify-between h-10 shrink-0 border-b border-gray-200 px-4 md:px-10">
{visibleColumns.length > 0 &&
selectedColIndices.length > 0 && (
<div ref={colActionsRef} className="relative">
<button
onClick={() => setColActionsOpen((v) => !v)}
className="flex items-center gap-1 text-xs font-medium text-gray-700 hover:text-gray-900 transition-colors"
<TableToolbar
actions={
<div className="flex items-center gap-2">
{visibleColumns.length > 0 &&
selectedColIndices.length > 0 && (
<>
<div
ref={colActionsRef}
className="relative max-md:hidden"
>
<TabPillButton
onClick={() =>
setColActionsOpen(
(open) =>
!open,
)
}
>
Actions
<ChevronDown className="h-3.5 w-3.5" />
</TabPillButton>
{colActionsOpen && (
<div className="absolute top-full right-0 mt-1 w-36 rounded-lg border border-gray-100 bg-white shadow-lg z-50 overflow-hidden">
<button
onClick={
handleDeleteSelectedColumns
}
className="w-full px-3 py-1.5 text-left text-xs text-red-600 hover:bg-red-50 transition-colors"
>
Delete
</button>
</div>
)}
</div>
<TabPillButton
onClick={
handleDeleteSelectedColumns
}
className="text-red-600 md:hidden"
>
Delete
</TabPillButton>
</>
)}
<TabPillButton
onClick={() =>
setAddColumnOpen(true)
}
>
Actions
<ChevronDown className="h-3.5 w-3.5" />
</button>
{colActionsOpen && (
<div className="absolute top-full right-0 mt-1 w-36 rounded-lg border border-gray-100 bg-white shadow-lg z-50 overflow-hidden">
<button
onClick={() => {
const next = columns
.filter((c) => !selectedColIndices.includes(c.index))
.map((c, i) => ({ ...c, index: i }));
setColumns(next);
saveColumns(next);
setSelectedColIndices([]);
setColActionsOpen(false);
}}
className="w-full px-3 py-1.5 text-left text-xs text-red-600 hover:bg-red-50 transition-colors"
>
Delete
</button>
</div>
)}
<Plus className="h-3.5 w-3.5" />
Add Column
</TabPillButton>
</div>
)}
{(visibleColumns.length === 0 ||
selectedColIndices.length === 0) && (
<span aria-hidden="true" />
)}
<button
onClick={() => setAddColumnOpen(true)}
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Add Column
</button>
</div>
}
/>
)}
{readOnly && (
<div className="flex h-10 shrink-0 items-center bg-gray-50 px-4 md:px-10">
@ -606,36 +637,63 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
</div>
)}
<div className="flex-1 min-h-0 overflow-auto">
<div className="min-w-max flex min-h-full flex-col">
{/* Table header */}
<div className={`flex items-center h-8 pr-3 md:pr-10 border-b border-gray-200 text-xs text-gray-500 font-medium shrink-0 select-none ${readOnly ? "border-t" : ""}`}>
<div className={`sticky left-0 z-[60] ${NAME_COL_W} ${stickyCellBg} flex items-center gap-4 self-stretch pl-4 pr-2 text-left`}>
{visibleColumns.length > 0 ? (
<input
type="checkbox"
checked={selectedColIndices.length === visibleColumns.length}
ref={(el) => { if (el) el.indeterminate = selectedColIndices.length > 0 && selectedColIndices.length < visibleColumns.length; }}
onChange={() => setSelectedColIndices(selectedColIndices.length === visibleColumns.length ? [] : visibleColumns.map((c) => c.index))}
className={`${CHECKBOX_GUTTER} rounded border-gray-200 cursor-pointer accent-black`}
/>
) : (
<span
className={CHECKBOX_GUTTER}
aria-hidden="true"
/>
)}
<span>Column Title</span>
</div>
<div className="ml-auto w-36 shrink-0">Format</div>
<div className="flex-1 min-w-0">Prompt</div>
{!readOnly && <div className="w-8 shrink-0" />}
</div>
{/* Rows */}
<div className="flex-1">
<TableScrollArea
header={
<TableHeaderRow className="md:pr-10">
<TableStickyCell
header
widthClassName={NAME_COL_W}
>
{visibleColumns.length > 0 ? (
<input
type="checkbox"
checked={
selectedColIndices.length ===
visibleColumns.length
}
ref={(el) => {
if (el)
el.indeterminate =
selectedColIndices.length >
0 &&
selectedColIndices.length <
visibleColumns.length;
}}
onChange={() =>
setSelectedColIndices(
selectedColIndices.length ===
visibleColumns.length
? []
: visibleColumns.map(
(column) =>
column.index,
),
)
}
className={TABLE_CHECKBOX_CLASS}
/>
) : (
<span
className="mr-4 h-2.5 w-2.5 shrink-0"
aria-hidden="true"
/>
)}
<span>Column Title</span>
</TableStickyCell>
<TableHeaderCell className="ml-auto w-36">
Format
</TableHeaderCell>
<TableHeaderCell className="min-w-[240px] flex-1">
Prompt
</TableHeaderCell>
{!readOnly && (
<TableHeaderCell className="w-8" />
)}
</TableHeaderRow>
}
>
{visibleColumns.length === 0 ? (
<div className="flex flex-col items-start py-24 w-full max-w-xs mx-auto">
<TableEmptyState>
<TabularReviewSkeuoIcon className="mb-4 h-8 w-8" />
<p className="text-2xl font-medium font-serif text-gray-900">
Columns
@ -654,68 +712,100 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
Add Column
</PillButton>
)}
</div>
</TableEmptyState>
) : (
visibleColumns.map((col) => {
const FormatIcon = formatIcon(col.format ?? "text");
const isChecked = selectedColIndices.includes(col.index);
return (
<div
key={col.index}
onClick={() => readOnly ? setViewingColumn(col) : setEditingColumn(col)}
className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-100/70 cursor-pointer transition-colors"
>
<div className={`sticky left-0 z-[60] ${NAME_COL_W} py-2 pl-4 pr-2 ${isChecked ? "bg-gray-50" : stickyCellBg} transition-colors group-hover:bg-gray-100/70`}>
<div className="flex min-w-0 items-center gap-4">
<input
type="checkbox"
checked={isChecked}
onChange={() => setSelectedColIndices((prev) => prev.includes(col.index) ? prev.filter((i) => i !== col.index) : [...prev, col.index])}
onClick={(e) => e.stopPropagation()}
className={`${CHECKBOX_GUTTER} rounded border-gray-200 cursor-pointer accent-black`}
/>
<span className="min-w-0 flex-1 truncate text-sm text-gray-800">
{col.name}
<TableBody>
{visibleColumns.map((col) => {
const FormatIcon = formatIcon(
col.format ?? "text",
);
const isChecked =
selectedColIndices.includes(
col.index,
);
return (
<TableRow
key={col.index}
selected={isChecked}
onClick={() =>
readOnly
? setViewingColumn(col)
: setEditingColumn(col)
}
className="md:pr-10"
>
<TablePrimaryCell
widthClassName={NAME_COL_W}
selected={isChecked}
onSelectionChange={() =>
setSelectedColIndices(
(previous) =>
previous.includes(
col.index,
)
? previous.filter(
(index) =>
index !==
col.index,
)
: [
...previous,
col.index,
],
)
}
label={col.name}
/>
<TableCell className="ml-auto w-36">
<span className="inline-flex items-center gap-1.5 text-xs text-gray-600">
<FormatIcon
className={`h-3.5 w-3.5 ${formatIconClassName(col.format ?? "text")}`}
/>
{formatLabel(
col.format ??
"text",
)}
</span>
</div>
</div>
<div className="ml-auto w-36 shrink-0">
<span className="inline-flex items-center gap-1.5 text-xs text-gray-600">
<FormatIcon
className={`h-3.5 w-3.5 ${formatIconClassName(col.format ?? "text")}`}
/>
{formatLabel(col.format ?? "text")}
</span>
</div>
<div className="flex-1 min-w-0 pr-4">
<span className="text-xs text-gray-500 truncate block">
</TableCell>
<TableCell className="min-w-[240px] flex-1 pr-4 text-xs">
{col.prompt}
</span>
</div>
{!readOnly && (
<div className="w-8 shrink-0 flex justify-end">
<button
onClick={(e) => {
e.stopPropagation();
const next = columns
.filter((c) => c.index !== col.index)
.map((c, i) => ({ ...c, index: i }));
setColumns(next);
saveColumns(next);
}}
className="p-1 text-gray-300 hover:text-red-500 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
);
})
</TableCell>
{!readOnly && (
<div className="w-8 shrink-0 flex justify-end">
<button
onClick={(e) => {
e.stopPropagation();
const next =
columns
.filter(
(column) =>
column.index !==
col.index,
)
.map(
(
column,
index,
) => ({
...column,
index,
}),
);
setColumns(next);
saveColumns(next);
}}
className="p-1 text-gray-300 hover:text-red-500 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</TableRow>
);
})}
</TableBody>
)}
</div>
</div>
</div>
</TableScrollArea>
</div>
)}
</div>
@ -753,81 +843,12 @@ export function WorkflowDetailPage({ id, workflowType }: Props) {
);
}
function WorkflowMetadata({ workflow }: { workflow: Workflow }) {
const fields: { label: string; value: string }[] = [
{ label: "Type", value: workflow.metadata.type === "tabular" ? "Tabular" : "Assistant" },
{ label: "Source", value: getWorkflowSourceLabel(workflow) },
];
if (workflow.metadata.language) fields.push({ label: "Language", value: workflow.metadata.language });
if (workflow.metadata.version) fields.push({ label: "Version", value: workflow.metadata.version });
if (workflow.metadata.practice) fields.push({ label: "Practice", value: workflow.metadata.practice });
if (workflow.metadata.jurisdictions?.length) {
fields.push({ label: "Jurisdiction", value: workflow.metadata.jurisdictions.join(", ") });
}
if (workflow.open_source_submission) {
const statusLabels: Record<
NonNullable<Workflow["open_source_submission"]>["status"],
string
> = {
pending: "Pending review",
approved: "Approved",
rejected: "Rejected",
};
fields.push({
label: "Open source",
value: statusLabels[workflow.open_source_submission.status],
});
}
return (
<div className="flex flex-wrap gap-x-8 gap-y-3 px-4 pb-3 pt-1 text-xs shrink-0 md:px-10">
{fields.map(({ label, value }) => (
<div key={label} className="flex flex-col gap-0.5">
<span className="text-gray-400">{label}</span>
<span className="text-gray-700">{value}</span>
</div>
))}
</div>
);
}
function WorkflowMetadataSkeleton() {
const fields = [
{ labelWidth: "w-8", valueWidth: "w-16" },
{ labelWidth: "w-10", valueWidth: "w-14" },
{ labelWidth: "w-12", valueWidth: "w-20" },
{ labelWidth: "w-10", valueWidth: "w-12" },
{ labelWidth: "w-12", valueWidth: "w-24" },
];
return (
<div className="flex shrink-0 flex-wrap gap-x-8 gap-y-3 px-4 pb-3 pt-1 md:px-10">
{fields.map((field, index) => (
<div key={index} className="flex flex-col gap-0.5">
<div
className={`h-4 ${field.labelWidth} animate-pulse rounded bg-gray-100`}
/>
<div
className={`h-4 ${field.valueWidth} animate-pulse rounded bg-gray-100`}
/>
</div>
))}
</div>
);
}
function getWorkflowSourceLabel(workflow: Workflow) {
if (workflow.is_system) return "System";
if (workflow.is_owner === false) {
return workflow.shared_by_name?.trim() || "Shared";
}
return "User";
}
function AssistantWorkflowEditorSkeleton() {
return (
<div className="min-h-0 flex-1 px-4 pb-2 pt-4 md:px-10 md:pb-3">
<div className="h-full rounded-md border border-gray-200 bg-gray-50 px-5 py-4">
<div className="min-h-0 flex-1 px-4 pb-2 pt-4 md:px-6 md:pb-3">
<div
className={`h-full px-5 py-4 ${LIQUID_TABLE_SURFACE_CLASS}`}
>
<div className="space-y-3">
<div className="h-3 w-24 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-5/6 animate-pulse rounded bg-gray-100" />
@ -851,60 +872,68 @@ function AssistantWorkflowEditorSkeleton() {
}
function TabularWorkflowEditorSkeleton() {
const titleWidths = ["w-36", "w-44", "w-40", "w-52", "w-48"];
const promptWidths = ["w-64", "w-80", "w-72", "w-96", "w-60"];
return (
<div className="flex min-h-0 flex-1 flex-col pt-2">
<div className="flex h-10 shrink-0 items-center justify-end border-b border-gray-200 px-4 md:px-10">
<div className="h-3 w-20 animate-pulse rounded bg-gray-100" />
</div>
<div className="flex h-8 shrink-0 items-center border-b border-gray-200 pr-3 md:pr-10">
<div
className={`${NAME_COL_W} flex shrink-0 items-center gap-4 self-stretch pl-4 pr-2`}
>
<div
className={`${CHECKBOX_GUTTER} animate-pulse rounded bg-gray-100`}
/>
<div className="h-2.5 w-20 animate-pulse rounded bg-gray-100" />
</div>
<div className="w-36 shrink-0">
<div className="h-2.5 w-14 animate-pulse rounded bg-gray-100" />
</div>
<div className="flex-1">
<div className="h-2.5 w-12 animate-pulse rounded bg-gray-100" />
</div>
<div className="w-8 shrink-0" />
</div>
<div className="flex-1 overflow-hidden">
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="flex h-10 items-center border-b border-gray-50 pr-3 md:pr-10"
>
<div
className={`${NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}
<TableToolbar
actions={
<SkeletonLine className="h-7 w-24 rounded-full" />
}
/>
<TableScrollArea
header={
<TableHeaderRow className="md:pr-10">
<TableStickyCell
header
hover={false}
widthClassName={NAME_COL_W}
>
<div
className={`${CHECKBOX_GUTTER} animate-pulse rounded bg-gray-100`}
/>
<div
className="h-3 animate-pulse rounded bg-gray-100"
style={{ width: `${40 + (i * 13) % 35}%` }}
/>
</div>
<div className="w-36 shrink-0">
<div className="h-3 w-16 animate-pulse rounded bg-gray-100" />
</div>
<div className="flex-1 pr-4">
<div
className="h-3 animate-pulse rounded bg-gray-100"
style={{ width: `${50 + (i * 17) % 35}%` }}
/>
</div>
<div className="w-8 shrink-0" />
</div>
))}
</div>
<SkeletonDot className="mr-4" />
<SkeletonLine className="h-2.5 w-20" />
</TableStickyCell>
<TableHeaderCell className="ml-auto w-36">
<SkeletonLine className="h-2.5 w-14" />
</TableHeaderCell>
<TableHeaderCell className="min-w-[240px] flex-1">
<SkeletonLine className="h-2.5 w-12" />
</TableHeaderCell>
<TableHeaderCell className="w-8" />
</TableHeaderRow>
}
>
<TableBody>
{[1, 2, 3, 4, 5].map((i) => (
<TableRow
key={i}
interactive={false}
className="md:pr-10"
>
<TableStickyCell
hover={false}
widthClassName={NAME_COL_W}
>
<div className="flex min-w-0 flex-1 items-center">
<SkeletonDot className="mr-4" />
<SkeletonLine
className={`h-3 ${titleWidths[i - 1]}`}
/>
</div>
</TableStickyCell>
<TableCell className="ml-auto w-36">
<SkeletonLine className="w-16" />
</TableCell>
<TableCell className="min-w-[240px] flex-1 pr-4">
<SkeletonLine
className={promptWidths[i - 1]}
/>
</TableCell>
<TableCell className="w-8" />
</TableRow>
))}
</TableBody>
</TableScrollArea>
</div>
);
}

View file

@ -17,6 +17,7 @@ import {
Table2,
} from "lucide-react";
import { Button } from "@/app/components/ui/button";
import { LIQUID_TABLE_SURFACE_CLASS } from "@/app/components/ui/liquid-surface";
interface Props {
value: string;
@ -352,14 +353,10 @@ export function WorkflowPromptEditor({
return (
<div
className={`flex h-full flex-col overflow-hidden bg-white ${
readOnly
? "rounded-md border border-gray-200"
: "rounded-md border border-gray-200"
}`}
className={`flex h-full flex-col overflow-hidden ${LIQUID_TABLE_SURFACE_CLASS}`}
>
{!readOnly && editor && (
<div className="flex items-center gap-0.5 px-2 py-1.5 border-b border-gray-100 bg-gray-50 shrink-0">
<div className="flex shrink-0 items-center gap-0.5 border-b border-white/70 bg-app-surface px-2 py-1.5 backdrop-blur-xl">
<AppToolbarButton
onClick={() =>
rawMode
@ -558,7 +555,7 @@ export function WorkflowPromptEditor({
</div>
)}
{readOnly && (
<div className="flex h-9 shrink-0 items-center justify-between bg-gray-50 px-5">
<div className="flex h-9 shrink-0 items-center justify-between bg-app-surface px-5 backdrop-blur-xl">
<span className="text-xs font-medium text-gray-500">
Read-only
</span>
@ -591,7 +588,7 @@ export function WorkflowPromptEditor({
}
readOnly={readOnly}
spellCheck={false}
className="h-full min-h-full w-full resize-none bg-white px-5 py-4 font-mono text-xs leading-6 text-gray-800 outline-none placeholder:text-gray-400 read-only:cursor-default"
className="h-full min-h-full w-full resize-none bg-transparent px-5 py-4 font-mono text-xs leading-6 text-gray-800 outline-none placeholder:text-gray-400 read-only:cursor-default"
aria-label="Raw Markdown"
/>
) : (

View file

@ -380,11 +380,16 @@ export function useAssistantChat({
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
if (done) {
// Flush any bytes still held by TextDecoder. A response is allowed
// to close without a final newline, so the remaining buffer must be
// parsed as the last SSE record instead of being discarded.
buffer += decoder.decode();
} else {
buffer += decoder.decode(value, { stream: true });
}
const lines = buffer.split("\n");
buffer = lines.pop() || "";
buffer = done ? "" : lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
@ -1181,6 +1186,8 @@ export function useAssistantChat({
);
}
}
if (done) break;
}
finalizeStreamingReasoning();