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

@ -22,9 +22,12 @@ Thanks for helping improve Mike. Please keep contributions small, focused, and e
## System Workflows
System workflows live in `mike-workflows/system/`. Put structured metadata in
the YAML frontmatter at the top of `SKILL.md`, put workflow instructions in the
body of `SKILL.md`, and use `table-config.yaml` for tabular review columns.
System workflows live in the sibling
[`Open-Legal-Products/mike-workflows`](https://github.com/Open-Legal-Products/mike-workflows)
repository under `assistant-workflows/` and `tabular-review-workflows/`. Put
structured metadata in the YAML frontmatter at the top of `SKILL.md`, set
`metadata.mike-availability` to `system`, put workflow instructions in the body
of `SKILL.md`, and use `table-columns.yaml` for tabular review columns.
After changing system workflows, regenerate the app files:
@ -34,7 +37,7 @@ node scripts/build-workflows.js
## Security
Do not open a public issue for security vulnerabilities. Use [GitHub's private vulnerability reporting](https://github.com/willchen96/mike/security/advisories/new) instead.
Do not open a public issue for security vulnerabilities. Use [GitHub's private vulnerability reporting](https://github.com/Open-Legal-Products/mike/security/advisories/new) instead.
We will aim to respond promptly and coordinate a disclosure timeline with you.

View file

@ -15,6 +15,12 @@ Website: [mikeoss.com](https://mikeoss.com)
- `backend/schema.sql` - Supabase schema for fresh databases
- `backend/migrations/` - dated, incremental schema migrations; on an existing database, apply the files dated after the Mike version you deployed
## System Workflows
Mike's system assistant and tabular review workflows are maintained in the
[`Open-Legal-Products/mike-workflows`](https://github.com/Open-Legal-Products/mike-workflows)
repository.
## Prerequisites
- Node.js 20 or newer

File diff suppressed because one or more lines are too long

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();

View file

@ -6,7 +6,10 @@ const path = require("path");
const ROOT_DIR = path.resolve(__dirname, "..");
const WORKSPACE_DIR = path.resolve(ROOT_DIR, "..");
const WORKFLOWS_DIR = path.join(WORKSPACE_DIR, "mike-workflows");
const SYSTEM_WORKFLOWS_DIR = path.join(WORKFLOWS_DIR, "system");
const WORKFLOW_COLLECTIONS = [
{ directory: "assistant-workflows", type: "assistant" },
{ directory: "tabular-review-workflows", type: "tabular" },
];
const BACKEND_OUT = path.join(ROOT_DIR, "backend/src/lib/systemWorkflows.ts");
const LANDING_OUT = path.join(ROOT_DIR, "landing/app/generated-workflows.ts");
@ -75,6 +78,7 @@ function parseSimpleYaml(source, label) {
const scalarItems = [];
const objectItems = [];
const properties = {};
let mode = null;
i++;
for (; i < lines.length; i++) {
@ -116,6 +120,19 @@ function parseSimpleYaml(source, label) {
continue;
}
const childPropMatch = child.match(/^ ([A-Za-z_][A-Za-z0-9_-]*):(.*)$/);
if (childPropMatch) {
mode ??= "properties";
if (mode !== "properties") {
fail(`${label}.${key} mixes mapping and list values`);
}
properties[childPropMatch[1]] = parseScalar(
childPropMatch[2],
`${label}.${key}.${childPropMatch[1]}`,
);
continue;
}
const propMatch = child.match(/^ ([A-Za-z_][A-Za-z0-9_-]*):(.*)$/);
if (!propMatch || mode !== "objects" || objectItems.length === 0) {
fail(`${label}:${i + 1} has unsupported frontmatter structure`);
@ -126,7 +143,12 @@ function parseSimpleYaml(source, label) {
);
}
result[key] = mode === "objects" ? objectItems : scalarItems;
result[key] =
mode === "objects"
? objectItems
: mode === "properties"
? properties
: scalarItems;
}
return result;
@ -152,7 +174,7 @@ function readSkillFile(filePath) {
};
}
function parseTableConfigYaml(filePath) {
function parseTableColumnsYaml(filePath) {
const lines = readText(filePath).replace(/\r\n/g, "\n").split("\n");
const result = { columns_config: [] };
let i = 0;
@ -171,8 +193,8 @@ function parseTableConfigYaml(filePath) {
continue;
}
if (line !== "columns_config:") {
fail(`${relative(filePath)}:${i + 1} is not valid table config YAML`);
if (line !== "columns:") {
fail(`${relative(filePath)}:${i + 1} is not valid table columns YAML`);
}
i++;
break;
@ -300,9 +322,8 @@ function assertColumnConfig(columns, label) {
});
}
function readWorkflow(category, dirent) {
const slug = dirent.name;
const workflowDir = path.join(SYSTEM_WORKFLOWS_DIR, category, slug);
function readWorkflow(category, workflowDir) {
const slug = path.basename(workflowDir);
const metadataPath = path.join(workflowDir, "metadata.json");
if (fs.existsSync(metadataPath)) {
fail(`${relative(metadataPath)} is no longer supported; use SKILL.md frontmatter`);
@ -311,103 +332,93 @@ function readWorkflow(category, dirent) {
if (!fs.existsSync(skillPath)) {
fail(`${relative(skillPath)} is required`);
}
const { metadata, body: skillMd, fullText: sourceSkillMd } = readSkillFile(skillPath);
const { metadata: frontmatter, body: skillMd, fullText: sourceSkillMd } =
readSkillFile(skillPath);
const label = `${relative(skillPath)} frontmatter`;
const metadata = frontmatter.metadata;
const id = `builtin-${slug}`;
if (metadata.id !== undefined) {
fail(`${label}.id is not supported; the ID is generated from the directory name`);
}
if (metadata.$schema !== undefined) {
fail(`${label}.$schema is not supported in SKILL.md frontmatter`);
}
if (metadata.title !== undefined) {
fail(`${label}.title is not supported; use name`);
}
if (metadata.order !== undefined) {
fail(`${label}.order is not supported`);
}
assertString(metadata.name, `${label}.name`);
if (metadata.name !== slug) {
assertString(frontmatter.name, `${label}.name`);
if (frontmatter.name !== slug) {
fail(`${label}.name must match the folder name "${slug}"`);
}
assertString(metadata.display_name, `${label}.display_name`);
assertString(metadata.description, `${label}.description`);
const contributors = normalizeContributors(
metadata.contributors,
`${label}.contributors`,
);
assertString(frontmatter.description, `${label}.description`);
assertString(frontmatter.license, `${label}.license`);
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
fail(`${label}.metadata must be a mapping`);
}
assertString(metadata.author, `${label}.metadata.author`);
assertString(metadata.language, `${label}.language`);
assertString(metadata.version, `${label}.version`);
if (metadata.type !== category) {
fail(`${label}.type must be "${category}"`);
assertString(metadata["mike-display-name"], `${label}.metadata.mike-display-name`);
if (metadata["mike-type"] !== category) {
fail(`${label}.metadata.mike-type must be "${category}"`);
}
if (metadata.category !== undefined) {
fail(`${label}.category is not supported`);
if (!["system", "add-on"].includes(metadata["mike-availability"])) {
fail(`${label}.metadata.mike-availability must be "system" or "add-on"`);
}
if (metadata.action !== undefined) {
fail(`${label}.action is not supported`);
}
assertOptionalString(metadata.practice, `${label}.practice`);
assertOptionalStringArray(metadata.jurisdictions, `${label}.jurisdictions`);
assertString(metadata.practice, `${label}.metadata.practice`);
assertString(metadata.jurisdictions, `${label}.metadata.jurisdictions`);
const normalizedMetadata = {
title: metadata["mike-display-name"],
description: frontmatter.description,
type: metadata["mike-type"],
contributors: [
{
name: metadata.author.trim(),
organisation: null,
role: null,
linkedin: null,
},
],
language: metadata.language,
version: metadata.version,
practice: metadata.practice,
jurisdictions: metadata.jurisdictions
.split(",")
.map((item) => item.trim())
.filter(Boolean),
};
if (category === "assistant") {
if (!skillMd.trim()) {
fail(`${relative(skillPath)} must include instructions after frontmatter`);
}
const tableConfigPath = path.join(workflowDir, "table-config.yaml");
if (fs.existsSync(tableConfigPath)) {
fail(`${relative(tableConfigPath)} is only supported for tabular workflows`);
const tableColumnsPath = path.join(workflowDir, "table-columns.yaml");
if (fs.existsSync(tableColumnsPath)) {
fail(`${relative(tableColumnsPath)} is only supported for tabular workflows`);
}
return {
id,
metadata: {
title: metadata.display_name,
description: metadata.description,
type: metadata.type,
contributors,
language: metadata.language,
version: metadata.version,
practice: metadata.practice ?? null,
jurisdictions: metadata.jurisdictions ?? null,
},
availability: metadata["mike-availability"],
metadata: normalizedMetadata,
skill_md: skillMd,
source_skill_md: sourceSkillMd,
columns_config: null,
};
}
if (metadata.columns_config !== undefined) {
fail(`${label}.columns_config is not supported; use table-config.yaml`);
const tableColumnsPath = path.join(workflowDir, "table-columns.yaml");
if (!fs.existsSync(tableColumnsPath)) {
fail(`${relative(tableColumnsPath)} is required for tabular workflows`);
}
const legacyTableConfigPath = path.join(workflowDir, "table-config.json");
if (fs.existsSync(legacyTableConfigPath)) {
fail(`${relative(legacyTableConfigPath)} is no longer supported; use table-config.yaml`);
}
const tableConfigPath = path.join(workflowDir, "table-config.yaml");
if (!fs.existsSync(tableConfigPath)) {
fail(`${relative(tableConfigPath)} is required for tabular workflows`);
}
const tableConfig = parseTableConfigYaml(tableConfigPath);
const tableConfigLabel = relative(tableConfigPath);
const expectedTableConfigSchema = "../../../schema/table-config.schema.yaml";
if (tableConfig.$schema !== expectedTableConfigSchema) {
fail(`${tableConfigLabel}.$schema must be "${expectedTableConfigSchema}"`);
const tableConfig = parseTableColumnsYaml(tableColumnsPath);
const tableConfigLabel = relative(tableColumnsPath);
const expectedSchemaPath = path.join(
WORKFLOWS_DIR,
"workflow-schema/table-columns.schema.yaml",
);
const actualSchemaPath = path.resolve(workflowDir, tableConfig.$schema ?? "");
if (actualSchemaPath !== expectedSchemaPath) {
fail(`${tableConfigLabel}.$schema must point to workflow-schema/table-columns.schema.yaml`);
}
assertColumnConfig(tableConfig.columns_config, tableConfigLabel);
return {
id,
metadata: {
title: metadata.display_name,
description: metadata.description,
type: metadata.type,
contributors,
language: metadata.language,
version: metadata.version,
practice: metadata.practice ?? null,
jurisdictions: metadata.jurisdictions ?? null,
},
availability: metadata["mike-availability"],
metadata: normalizedMetadata,
skill_md: skillMd || null,
source_skill_md: sourceSkillMd,
columns_config: tableConfig.columns_config,
@ -418,17 +429,30 @@ function loadWorkflows() {
const workflows = [];
const seenIds = new Set();
for (const category of ["assistant", "tabular"]) {
const categoryDir = path.join(SYSTEM_WORKFLOWS_DIR, category);
if (!fs.existsSync(categoryDir)) continue;
for (const collection of WORKFLOW_COLLECTIONS) {
const collectionDir = path.join(WORKFLOWS_DIR, collection.directory);
if (!fs.existsSync(collectionDir)) continue;
const workflowDirs = fs
.readdirSync(collectionDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
.flatMap((entry) => {
const entryDir = path.join(collectionDir, entry.name);
if (fs.existsSync(path.join(entryDir, "SKILL.md"))) return [entryDir];
if (!fs.existsSync(path.join(entryDir, "pack.json"))) return [];
return fs
.readdirSync(entryDir, { withFileTypes: true })
.filter(
(child) =>
child.isDirectory() &&
fs.existsSync(path.join(entryDir, child.name, "SKILL.md")),
)
.map((child) => path.join(entryDir, child.name));
})
.sort((a, b) => a.localeCompare(b));
const entries = fs
.readdirSync(categoryDir, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory() && !dirent.name.startsWith("."))
.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const workflow = readWorkflow(category, entry);
for (const workflowDir of workflowDirs) {
const workflow = readWorkflow(collection.type, workflowDir);
if (workflow.availability !== "system") continue;
if (seenIds.has(workflow.id)) {
fail(`Duplicate workflow id: ${workflow.id}`);
}
@ -483,8 +507,11 @@ function main() {
if (!fs.existsSync(WORKFLOWS_DIR)) {
fail(`Workflow source directory not found: ${relative(WORKFLOWS_DIR)}`);
}
if (!fs.existsSync(SYSTEM_WORKFLOWS_DIR)) {
fail(`System workflow source directory not found: ${relative(SYSTEM_WORKFLOWS_DIR)}`);
for (const collection of WORKFLOW_COLLECTIONS) {
const collectionDir = path.join(WORKFLOWS_DIR, collection.directory);
if (!fs.existsSync(collectionDir)) {
fail(`Workflow collection not found: ${relative(collectionDir)}`);
}
}
const workflows = loadWorkflows();