mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
feat: refresh workflows and workflow editor UI
This commit is contained in:
parent
0ed5050b21
commit
fa21ac8fd7
11 changed files with 745 additions and 585 deletions
|
|
@ -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 |
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue