Modal, header, mobile display and workflow UI updates

This commit is contained in:
willchen96 2026-06-11 22:43:13 +08:00
parent 8a2dc05181
commit 3132e04ac0
34 changed files with 1635 additions and 1076 deletions

View file

@ -11,6 +11,7 @@ import { getUserApiKeys as getStoredUserApiKeys } from "./userApiKeys";
export type UserModelSettings = {
title_model: string;
tabular_model: string;
legal_research_us: boolean;
api_keys: UserApiKeys;
};
@ -32,7 +33,7 @@ export async function getUserModelSettings(
const client = db ?? createServerSupabase();
const { data } = await client
.from("user_profiles")
.select("title_model, tabular_model")
.select("title_model, tabular_model, legal_research_us")
.eq("user_id", userId)
.single();
const api_keys = await getStoredUserApiKeys(userId, client);
@ -40,6 +41,9 @@ export async function getUserModelSettings(
return {
title_model: resolveModel(data?.title_model, resolveTitleModel(api_keys)),
tabular_model: resolveModel(data?.tabular_model, DEFAULT_TABULAR_MODEL),
legal_research_us:
(data as { legal_research_us?: boolean | null } | null)
?.legal_research_us !== false,
api_keys,
};
}
@ -51,31 +55,3 @@ export async function getUserApiKeys(
const client = db ?? createServerSupabase();
return getStoredUserApiKeys(userId, client);
}
/**
* Whether the user has US legal research (CourtListener) tools enabled in
* chat. Controlled by the Features > Legal Research > Jurisdiction > US
* toggle in account settings. Defaults to enabled both when the user has
* no profile row yet and when the column is missing (migration not applied),
* so existing behaviour is preserved on partially-migrated deployments.
*/
export async function getLegalResearchUsEnabled(
userId: string,
db?: ReturnType<typeof createServerSupabase>,
): Promise<boolean> {
const client = db ?? createServerSupabase();
try {
const { data, error } = await client
.from("user_profiles")
.select("legal_research_us")
.eq("user_id", userId)
.maybeSingle();
if (error || !data) return true;
return (
(data as { legal_research_us?: boolean | null })
.legal_research_us !== false
);
} catch {
return true;
}
}

View file

@ -16,8 +16,6 @@ import {
} from "../lib/chatTools";
import { completeText } from "../lib/llm";
import {
getLegalResearchUsEnabled,
getUserApiKeys,
getUserModelSettings,
} from "../lib/userSettings";
import { checkProjectAccess } from "../lib/access";
@ -556,7 +554,10 @@ chatRouter.post("/", requireAuth, async (req, res) => {
db,
docIndex,
);
const legalResearchUs = await getLegalResearchUsEnabled(userId, db);
const {
api_keys: apiKeys,
legal_research_us: legalResearchUs,
} = await getUserModelSettings(userId, db);
const apiMessages = buildMessages(
enrichedMessages,
docAvailability,
@ -586,8 +587,6 @@ chatRouter.post("/", requireAuth, async (req, res) => {
if (!streamFinished) streamAbort.abort();
});
const apiKeys = await getUserApiKeys(userId, db);
try {
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);

View file

@ -16,8 +16,7 @@ import {
type ChatMessage,
} from "../lib/chatTools";
import {
getLegalResearchUsEnabled,
getUserApiKeys,
getUserModelSettings,
} from "../lib/userSettings";
import { checkProjectAccess } from "../lib/access";
import { safeErrorLog, safeErrorMessage } from "../lib/safeError";
@ -144,7 +143,10 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
systemPromptExtra += `\n\nUSER-ATTACHED DOCUMENTS FOR THIS TURN:\nThe user has attached the following document(s) directly to their latest message. Treat these as the primary focus of the request unless their message clearly says otherwise.\n${lines.join("\n")}`;
}
const legalResearchUs = await getLegalResearchUsEnabled(userId, db);
const {
api_keys: apiKeys,
legal_research_us: legalResearchUs,
} = await getUserModelSettings(userId, db);
const apiMessages = buildMessages(
messagesForLLM,
docAvailability,
@ -168,8 +170,6 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
if (!streamFinished) streamAbort.abort();
});
const apiKeys = await getUserApiKeys(userId, db);
try {
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);

View file

@ -70,20 +70,6 @@ async function attachDocumentOwnerLabels(
.filter((id, index, arr) => arr.indexOf(id) === index);
if (ownerIds.length === 0) return;
const emailByUserId = new Map<string, string>();
const userResults = await Promise.allSettled(
ownerIds.map(async (id) => {
const { data, error } = await db.auth.admin.getUserById(id);
if (error) throw error;
return { id, email: data.user?.email ?? null };
}),
);
for (const result of userResults) {
if (result.status === "fulfilled" && result.value.email) {
emailByUserId.set(result.value.id, result.value.email);
}
}
const displayNameByUserId = new Map<string, string>();
const { data: profiles, error: profilesError } = await db
.from("user_profiles")
@ -108,7 +94,7 @@ async function attachDocumentOwnerLabels(
owner_display_name?: string | null;
})[]) {
if (!doc.user_id) continue;
doc.owner_email = emailByUserId.get(doc.user_id) ?? null;
doc.owner_email = null;
doc.owner_display_name = displayNameByUserId.get(doc.user_id) ?? null;
}
}

View file

@ -1,11 +1,12 @@
"use client";
import { useState, useEffect } from "react";
import { useCallback, useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { PanelLeft } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { ChatHistoryProvider } from "@/app/contexts/ChatHistoryContext";
import { SidebarContext } from "@/app/contexts/SidebarContext";
import { PageChromeContext } from "@/app/contexts/PageChromeContext";
import { AppSidebar } from "@/app/components/shared/AppSidebar";
export default function MikeLayout({
@ -15,6 +16,8 @@ export default function MikeLayout({
}) {
const { isAuthenticated, authLoading } = useAuth();
const router = useRouter();
const [mobileActionsContainer, setMobileActionsContainer] =
useState<HTMLDivElement | null>(null);
const [isSidebarOpenDesktop, setIsSidebarOpenDesktop] = useState(() => {
if (typeof window !== "undefined") {
@ -58,6 +61,13 @@ export default function MikeLayout({
}
};
const handleMobileActionsContainerRef = useCallback(
(node: HTMLDivElement | null) => {
setMobileActionsContainer(node);
},
[],
);
useEffect(() => {
if (!authLoading && !isAuthenticated) {
router.push("/login");
@ -76,46 +86,52 @@ export default function MikeLayout({
return (
<ChatHistoryProvider>
<SidebarContext.Provider
value={{
setSidebarOpen: (open) => {
const isSmall =
typeof window !== "undefined" &&
window.innerWidth < 768;
if (isSmall) {
if (!open) setIsSidebarOpen(false);
return;
}
setIsSidebarOpen(open);
setIsSidebarOpenDesktop(open);
},
}}
>
<div className="h-dvh flex flex-col bg-gray-50/80">
<div className="flex-1 flex min-w-0 overflow-visible">
<AppSidebar
isOpen={isSidebarOpen}
onToggle={handleSidebarToggle}
/>
<div className="flex-1 flex flex-col h-dvh md:overflow-hidden relative w-full">
{/* Mobile header */}
<div className="flex md:hidden items-center gap-3 px-4 pt-3 pb-1 shrink-0">
<button
onClick={handleSidebarToggle}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/70 text-gray-700 shadow-[0_8px_24px_rgba(15,23,42,0.12)] ring-1 ring-white/70 backdrop-blur-md transition-all hover:bg-white/90 active:scale-95"
title="Open sidebar"
aria-label="Open sidebar"
>
<PanelLeft className="h-4 w-4" />
</button>
<PageChromeContext.Provider value={{ mobileActionsContainer }}>
<SidebarContext.Provider
value={{
setSidebarOpen: (open) => {
const isSmall =
typeof window !== "undefined" &&
window.innerWidth < 768;
if (isSmall) {
if (!open) setIsSidebarOpen(false);
return;
}
setIsSidebarOpen(open);
setIsSidebarOpenDesktop(open);
},
}}
>
<div className="h-dvh flex flex-col bg-gray-50/80">
<div className="flex-1 flex min-w-0 overflow-visible">
<AppSidebar
isOpen={isSidebarOpen}
onToggle={handleSidebarToggle}
/>
<div className="flex-1 flex flex-col h-dvh md:overflow-hidden relative w-full">
{/* Mobile header */}
<div className="relative z-20 flex md:hidden items-center gap-3 overflow-visible px-4 pt-3 pb-2 shrink-0">
<button
onClick={handleSidebarToggle}
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/70 text-gray-700 shadow-[0_8px_24px_rgba(15,23,42,0.12)] ring-1 ring-white/70 backdrop-blur-md transition-all hover:bg-white/90 active:scale-95"
title="Open sidebar"
aria-label="Open sidebar"
>
<PanelLeft className="h-4 w-4" />
</button>
<div
ref={handleMobileActionsContainerRef}
className="ml-auto flex min-w-0 flex-1 items-center justify-end"
/>
</div>
<main className="flex-1 overflow-y-auto md:overflow-hidden w-full h-full">
{children}
</main>
</div>
<main className="flex-1 overflow-y-auto md:overflow-hidden w-full h-full">
{children}
</main>
</div>
</div>
</div>
</SidebarContext.Provider>
</SidebarContext.Provider>
</PageChromeContext.Provider>
</ChatHistoryProvider>
);
}

View file

@ -782,18 +782,15 @@ export default function ProjectAssistantChatPage({ params }: Props) {
project
? {
label: project.name,
suffix: project.cm_number ? (
<span className="ml-1 text-gray-400">
(#{project.cm_number})
</span>
) : null,
onClick: () => router.push(`/projects/${projectId}`),
onClick: () =>
router.push(`/projects/${projectId}?tab=assistant`),
title: "Back to project",
}
: {
loading: true,
skeletonClassName: "w-32",
onClick: () => router.push(`/projects/${projectId}`),
onClick: () =>
router.push(`/projects/${projectId}?tab=assistant`),
title: "Back to project",
},
chatLoaded

View file

@ -55,7 +55,7 @@ export default function TabularReviewsPage() {
const actionsRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const { user } = useAuth();
const stickyCellBg = "bg-[#fcfcfd]";
const stickyCellBg = "bg-[#fafbfc]";
useEffect(() => {
Promise.all([

View file

@ -0,0 +1,13 @@
"use client";
import { use } from "react";
import { WorkflowDetailPage } from "@/app/components/workflows/WorkflowDetailPage";
interface Props {
params: Promise<{ id: string }>;
}
export default function AssistantWorkflowPage({ params }: Props) {
const { id } = use(params);
return <WorkflowDetailPage id={id} workflowType="assistant" />;
}

View file

@ -0,0 +1,13 @@
"use client";
import { use } from "react";
import { WorkflowDetailPage } from "@/app/components/workflows/WorkflowDetailPage";
interface Props {
params: Promise<{ id: string }>;
}
export default function TabularReviewWorkflowPage({ params }: Props) {
const { id } = use(params);
return <WorkflowDetailPage id={id} workflowType="tabular" />;
}

View file

@ -1,18 +1,12 @@
"use client";
import { useEffect, useState } from "react";
import { ChevronLeft, Search, X } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Workflow } from "../shared/types";
import { listWorkflows } from "@/app/lib/mikeApi";
import { BUILT_IN_WORKFLOWS } from "../workflows/builtinWorkflows";
import { Modal } from "../shared/Modal";
import { WorkflowPickerModal } from "../workflows/WorkflowPickerModal";
interface Props {
open: boolean;
onClose: () => void;
onSelect: (workflow: Workflow) => void;
onSelect: (workflow: Workflow) => Promise<void> | void;
projectName?: string;
projectCmNumber?: string | null;
initialWorkflowId?: string;
@ -26,70 +20,6 @@ export function AssistantWorkflowModal({
projectCmNumber,
initialWorkflowId,
}: Props) {
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<Workflow | null>(null);
const [search, setSearch] = useState("");
useEffect(() => {
if (!open) return;
let cancelled = false;
const builtins = BUILT_IN_WORKFLOWS.filter(
(w) => w.type === "assistant",
);
const frame = requestAnimationFrame(() => {
if (cancelled) return;
setWorkflows(builtins);
setLoading(true);
if (initialWorkflowId) {
const match = builtins.find((w) => w.id === initialWorkflowId);
if (match) setSelected(match);
}
});
listWorkflows("assistant")
.then((custom) => {
if (cancelled) return;
const all = [...builtins, ...custom];
setWorkflows(all);
if (initialWorkflowId) {
const match = all.find((w) => w.id === initialWorkflowId);
if (match) setSelected(match);
}
})
.catch(() => {
if (cancelled) return;
if (initialWorkflowId) {
const match = builtins.find((w) => w.id === initialWorkflowId);
if (match) setSelected(match);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
cancelAnimationFrame(frame);
};
}, [open, initialWorkflowId]);
if (!open) return null;
const filteredWorkflows = search
? workflows.filter((w) => w.title.toLowerCase().includes(search.toLowerCase()))
: workflows;
function handleClose() {
setSelected(null);
setSearch("");
onClose();
}
function handleUse() {
if (!selected) return;
onSelect(selected);
handleClose();
}
const breadcrumbs = projectName
? [
"Projects",
@ -100,162 +30,14 @@ export function AssistantWorkflowModal({
: ["Assistant", "Add workflow"];
return (
<Modal
<WorkflowPickerModal
open={open}
onClose={handleClose}
size={selected ? "xl" : "lg"}
onClose={onClose}
onSelect={onSelect}
workflowType="assistant"
breadcrumbs={breadcrumbs}
primaryAction={{
label: "Use",
type: "button",
onClick: handleUse,
disabled: !selected,
}}
>
{/* Content */}
<div className="flex flex-row flex-1 min-h-0 overflow-hidden gap-3">
{/* Left panel — workflow list */}
<div
className={`flex flex-col overflow-hidden ${selected ? "w-80 shrink-0" : "flex-1"}`}
>
{/* Search */}
<div className="px-2 pt-3 pb-2 shrink-0">
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
<Search className="h-3 w-3 text-gray-400 shrink-0" />
<input
type="text"
placeholder="Search workflows…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 bg-transparent text-xs text-gray-700 placeholder:text-gray-400 outline-none"
/>
{search && (
<button onClick={() => setSearch("")} className="text-gray-400 hover:text-gray-600">
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
{loading ? (
<div className="space-y-1 px-2 pb-2 pt-1">
{[60, 45, 75, 50, 65, 40, 55].map((w, i) => (
<div
key={i}
className="flex items-center justify-between gap-3 rounded-lg px-3 py-2.5"
>
<div
className="h-3 rounded bg-gray-100 animate-pulse"
style={{ width: `${w}%` }}
/>
<div className="h-3 w-10 rounded bg-gray-100 animate-pulse shrink-0" />
</div>
))}
</div>
) : filteredWorkflows.length === 0 ? (
<p className="py-8 text-sm text-center text-gray-400">
{search ? "No matches found" : "No assistant workflows found"}
</p>
) : (
<div className="overflow-y-auto">
{filteredWorkflows.map((wf) => (
<button
key={wf.id}
type="button"
onClick={() =>
setSelected((prev) =>
prev?.id === wf.id ? null : wf,
)
}
className={`w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-xs text-left transition-colors ${
selected?.id === wf.id
? "bg-gray-100"
: "hover:bg-gray-50"
}`}
>
<span className="flex-1 truncate text-gray-800">
{wf.title}
</span>
<span className="shrink-0 text-xs text-gray-400">
{wf.is_system ? "Built-in" : "Custom"}
</span>
</button>
))}
</div>
)}
</div>
{/* Right panel — prompt preview */}
{selected && (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex items-center justify-between py-3 shrink-0">
<p className="text-xs font-medium text-gray-700">
Workflow Prompt
</p>
<button
onClick={() => setSelected(null)}
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3 text-sm border border-gray-200 rounded-md text-gray-600 leading-relaxed font-serif bg-gray-50">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="text-base font-semibold text-gray-900 mt-4 mb-1 first:mt-0">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-sm font-semibold text-gray-900 mt-3 mb-1 first:mt-0">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-xs font-semibold text-gray-900 mt-2 mb-0.5 first:mt-0">
{children}
</h3>
),
p: ({ children }) => (
<p className="mb-2 last:mb-0">
{children}
</p>
),
ul: ({ children }) => (
<ul className="list-disc pl-4 mb-2 space-y-0.5">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal pl-4 mb-2 space-y-0.5">
{children}
</ol>
),
li: ({ children }) => (
<li>{children}</li>
),
strong: ({ children }) => (
<strong className="font-semibold text-gray-800">
{children}
</strong>
),
em: ({ children }) => (
<em className="italic">
{children}
</em>
),
}}
>
{selected.prompt_md ??
"_No prompt defined._"}
</ReactMarkdown>
</div>
</div>
)}
</div>
</Modal>
primaryLabel="Use"
initialWorkflowId={initialWorkflowId}
/>
);
}

View file

@ -41,7 +41,7 @@ export function ProjectAssistantTab({
setRenamingChatId: Dispatch<SetStateAction<string | null>>;
setRenameChatValue: Dispatch<SetStateAction<string>>;
}) {
const stickyCellBg = "bg-[#fcfcfd]";
const stickyCellBg = "bg-[#fafbfc]";
return (
<>

View file

@ -0,0 +1,216 @@
"use client";
import { type ReactNode, useEffect, useMemo, useState } from "react";
import { Loader2, Users } from "lucide-react";
import { Modal } from "@/app/components/shared/Modal";
import type { Project } from "@/app/components/shared/types";
import type { ProjectPeople } from "@/app/lib/mikeApi";
interface ProjectDetailsModalProps {
open: boolean;
project: Project | null;
canEdit: boolean;
currentUserDisplayName?: string | null;
currentUserEmail?: string | null;
fetchPeople: (projectId: string) => Promise<ProjectPeople>;
onClose: () => void;
onSave: (values: { name: string; cmNumber: string }) => Promise<void>;
onShareProject: () => void;
}
export function ProjectDetailsModal({
open,
project,
canEdit,
currentUserDisplayName,
currentUserEmail,
fetchPeople,
onClose,
onSave,
onShareProject,
}: ProjectDetailsModalProps) {
const [nameDraft, setNameDraft] = useState("");
const [cmDraft, setCmDraft] = useState("");
const [people, setPeople] = useState<ProjectPeople | null>(null);
const [peopleLoading, setPeopleLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !project) return;
setNameDraft(project.name);
setCmDraft(project.cm_number ?? "");
setSaved(false);
setError(null);
}, [open, project]);
useEffect(() => {
if (!open || !project) return;
const isPrivateOwnedProject =
project.is_owner !== false &&
(!Array.isArray(project.shared_with) ||
project.shared_with.length === 0);
if (isPrivateOwnedProject) {
setPeople(null);
setPeopleLoading(false);
return;
}
let cancelled = false;
setPeopleLoading(true);
fetchPeople(project.id)
.then((data) => {
if (!cancelled) setPeople(data);
})
.catch(() => {
if (!cancelled) setPeople(null);
})
.finally(() => {
if (!cancelled) setPeopleLoading(false);
});
return () => {
cancelled = true;
};
}, [open, project, fetchPeople]);
const trimmedName = nameDraft.trim();
const trimmedCm = cmDraft.trim();
const hasChanges = useMemo(() => {
if (!project) return false;
return (
trimmedName !== project.name ||
trimmedCm !== (project.cm_number ?? "")
);
}, [project, trimmedCm, trimmedName]);
if (!project) return null;
const accessLabel =
Array.isArray(project.shared_with) && project.shared_with.length > 0
? "Shared"
: "Private";
const isPrivateOwnedProject =
project.is_owner !== false && accessLabel === "Private";
const ownerLabel =
people?.owner.display_name?.trim() ||
people?.owner.email?.trim() ||
(isPrivateOwnedProject ? currentUserDisplayName?.trim() : "") ||
(isPrivateOwnedProject ? currentUserEmail?.trim() : "") ||
"Unknown";
async function handleSave() {
if (!canEdit || saving || !hasChanges || !trimmedName) return;
setSaving(true);
setSaved(false);
setError(null);
try {
await onSave({ name: trimmedName, cmNumber: trimmedCm });
setSaved(true);
} catch {
setError("Could not update project details.");
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
breadcrumbs={["Projects", project.name, "Details"]}
secondaryAction={{
label: "Share Project",
icon: <Users className="h-4 w-4" />,
onClick: onShareProject,
}}
footerStatus={
error ? (
<span className="text-sm text-red-600">{error}</span>
) : saved ? (
<span className="text-sm text-gray-400">Updated</span>
) : null
}
primaryAction={
canEdit
? {
label: saving ? "Updating..." : "Update",
onClick: () => void handleSave(),
disabled: saving || !hasChanges || !trimmedName,
}
: undefined
}
cancelAction={canEdit ? undefined : false}
>
<div className="flex flex-col gap-5 py-1">
<div className="flex flex-col gap-3">
<label
htmlFor="project-details-name"
className="text-xs font-medium text-gray-700"
>
Project Name
</label>
<input
id="project-details-name"
value={nameDraft}
onChange={(e) => {
setNameDraft(e.target.value);
setSaved(false);
setError(null);
}}
disabled={!canEdit || saving}
className="h-9 w-full rounded-md border border-gray-200 bg-gray-50 px-3 text-sm text-gray-900 outline-none transition-colors focus:border-gray-300 disabled:cursor-not-allowed disabled:text-gray-400"
/>
</div>
<div className="flex flex-col gap-3">
<label
htmlFor="project-details-cm"
className="text-xs font-medium text-gray-700"
>
CM
</label>
<input
id="project-details-cm"
value={cmDraft}
onChange={(e) => {
setCmDraft(e.target.value);
setSaved(false);
setError(null);
}}
disabled={!canEdit || saving}
placeholder="No CM"
className="h-9 w-full rounded-md border border-gray-200 bg-gray-50 px-3 text-sm text-gray-900 outline-none transition-colors focus:border-gray-300 disabled:cursor-not-allowed disabled:text-gray-400"
/>
</div>
<div className="divide-y divide-gray-100 text-sm">
<DetailRow label="Ownership" value={accessLabel} />
<DetailRow
label="Owner"
value={
peopleLoading && !isPrivateOwnedProject ? (
<span className="inline-flex items-center gap-1.5 text-gray-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading
</span>
) : (
ownerLabel
)
}
/>
</div>
</div>
</Modal>
);
}
function DetailRow({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="flex items-center justify-between gap-4 py-3">
<span className="text-gray-500">{label}</span>
<span className="min-w-0 truncate text-right text-gray-900">
{value}
</span>
</div>
);
}

View file

@ -63,6 +63,7 @@ import {
import { PeopleModal } from "@/app/components/shared/PeopleModal";
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { AddNewTRModal } from "@/app/components/tabular/AddNewTRModal";
import { WarningPopup } from "@/app/components/shared/WarningPopup";
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
@ -83,6 +84,7 @@ import {
type ProjectTab,
} from "./ProjectPageParts";
import { DocumentSidePanel } from "./DocumentSidePanel";
import { ProjectDetailsModal } from "./ProjectDetailsModal";
import { ProjectAssistantTab } from "./ProjectAssistantTab";
import { ProjectReviewsTab } from "./ProjectReviewsTab";
@ -274,9 +276,11 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
: initialTab;
const [addDocsOpen, setAddDocsOpen] = useState(false);
const [peopleModalOpen, setPeopleModalOpen] = useState(false);
const [projectDetailsOpen, setProjectDetailsOpen] = useState(false);
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
const { user } = useAuth();
const stickyCellBg = "bg-[#fcfcfd]";
const { profile } = useUserProfile();
const stickyCellBg = "bg-[#fafbfc]";
const [viewingDoc, setViewingDoc] = useState<Document | null>(null);
const [viewingDocVersion, setViewingDocVersion] = useState<{
id: string;
@ -1133,33 +1137,30 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
}
}
async function handleTitleCommit(newName: string) {
if (!newName || newName === project?.name) return;
// Server-side this would 404 silently for non-owners; surface a
// clear permission warning instead.
async function handleProjectDetailsSave(values: {
name: string;
cmNumber: string;
}) {
if (project && project.is_owner === false) {
setOwnerOnlyAction("rename this project");
setOwnerOnlyAction("edit project details");
return;
}
setProject((prev) => (prev ? { ...prev, name: newName } : prev));
await updateProject(projectId, { name: newName });
}
async function handleCmNumberCommit(newCmNumber: string) {
if (project && project.is_owner === false) {
setOwnerOnlyAction("rename this project's CM number");
return;
}
const trimmed = newCmNumber.trim();
if (trimmed === (project?.cm_number ?? "")) return;
setProject((prev) =>
prev ? { ...prev, cm_number: trimmed || null } : prev,
);
const name = values.name.trim();
const cmNumber = values.cmNumber.trim();
if (!name) return;
const updated = await updateProject(projectId, {
cm_number: trimmed,
name,
cm_number: cmNumber,
});
setProject((prev) =>
prev ? { ...prev, cm_number: updated.cm_number } : prev,
prev
? {
...prev,
name: updated.name,
cm_number: updated.cm_number,
updated_at: updated.updated_at,
}
: prev,
);
}
@ -2519,9 +2520,8 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
docsCount={docs.length}
isOwner={project?.is_owner !== false}
onBackToProjects={() => router.push("/projects")}
onRenameProject={handleTitleCommit}
onRenameCmNumber={handleCmNumberCommit}
onOwnerOnly={setOwnerOnlyAction}
onOpenDetails={() => setProjectDetailsOpen(true)}
onDeleteProject={requestProjectDelete}
onSearchChange={setSearch}
onOpenPeople={() => setPeopleModalOpen(true)}
@ -3426,6 +3426,21 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
onClose={() => setOwnerOnlyAction(null)}
/>
<ProjectDetailsModal
open={projectDetailsOpen}
project={project}
canEdit={project?.is_owner !== false}
currentUserDisplayName={profile?.displayName ?? null}
currentUserEmail={user?.email ?? null}
fetchPeople={getProjectPeople}
onClose={() => setProjectDetailsOpen(false)}
onSave={handleProjectDetailsSave}
onShareProject={() => {
setProjectDetailsOpen(false);
setPeopleModalOpen(true);
}}
/>
<ConfirmPopup
open={deleteProjectConfirmOpen}
title="Delete project?"

View file

@ -1,11 +1,11 @@
"use client";
import { type CSSProperties, type KeyboardEvent, useState } from "react";
import { type CSSProperties, useRef, useState } from "react";
import {
CornerDownRight,
File,
FileText,
Hash,
Info,
Loader2,
MessageSquare,
Pencil,
@ -108,8 +108,10 @@ export function DocVersionHistory({
null,
);
const [editingValue, setEditingValue] = useState("");
const committingVersionId = useRef<string | null>(null);
const commit = async (versionId: string) => {
if (committingVersionId.current === versionId) return;
const trimmed = editingValue.trim();
const previousFilename = versions
.find((version) => version.id === versionId)
@ -123,6 +125,7 @@ export function DocVersionHistory({
return;
}
committingVersionId.current = versionId;
setEditingVersionId(null);
const next = trimmed.length > 0 ? trimmed : null;
await onRenameVersion?.(versionId, next);
@ -260,6 +263,7 @@ export function DocVersionHistory({
e.preventDefault();
void commit(v.id);
} else if (e.key === "Escape") {
committingVersionId.current = null;
setEditingVersionId(null);
}
}}
@ -322,6 +326,7 @@ export function DocVersionHistory({
onRename={
onRenameVersion
? () => {
committingVersionId.current = null;
setEditingVersionId(v.id);
setEditingValue(
v.filename ?? "",
@ -355,9 +360,8 @@ export function ProjectPageHeader({
docsCount,
isOwner,
onBackToProjects,
onRenameProject,
onRenameCmNumber,
onOwnerOnly,
onOpenDetails,
onDeleteProject,
onSearchChange,
onOpenPeople,
@ -371,69 +375,26 @@ export function ProjectPageHeader({
docsCount: number;
isOwner: boolean;
onBackToProjects: () => void;
onRenameProject: (name: string) => void;
onRenameCmNumber: (cmNumber: string) => void;
onOwnerOnly: (action: string) => void;
onOpenDetails: () => void;
onDeleteProject: () => void;
onSearchChange: (search: string) => void;
onOpenPeople: () => void;
onNewChat: () => void;
onNewReview: () => void;
}) {
const [editingField, setEditingField] = useState<"name" | "cm" | null>(
null,
);
const [draft, setDraft] = useState("");
const startEdit = (field: "name" | "cm") => {
const requestRename = () => {
if (!project) return;
if (!isOwner) {
onOwnerOnly(
field === "name"
? "rename this project"
: "rename this project's CM number",
);
onOwnerOnly("rename this project");
return;
}
setDraft(field === "name" ? project.name : project.cm_number ?? "");
setEditingField(field);
onOpenDetails();
};
const commitEdit = () => {
if (!editingField) return;
const value = draft.trim();
if (editingField === "name") onRenameProject(value);
else onRenameCmNumber(value);
setEditingField(null);
};
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
commitEdit();
} else if (e.key === "Escape") {
e.preventDefault();
setEditingField(null);
}
};
const editInputClassName =
"min-w-0 cursor-text border-0 border-b border-gray-200 bg-transparent font-serif text-2xl font-medium outline-none transition-colors focus:border-gray-300";
const titleLabel = !project ? undefined : editingField === "name" ? (
<input
autoFocus
value={draft}
size={Math.max(draft.length + 1, 3)}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={commitEdit}
className={`${editInputClassName} text-gray-900`}
aria-label="Rename project"
/>
) : (
const titleLabel = !project ? undefined : (
<span
onClick={() => startEdit("name")}
onClick={requestRename}
className="inline-block cursor-text"
title="Rename"
>
@ -441,31 +402,6 @@ export function ProjectPageHeader({
</span>
);
const cmSuffix = !project ? null : editingField === "cm" ? (
<span className="ml-1 inline-flex items-center text-gray-400">
(#
<input
autoFocus
value={draft}
size={Math.max(draft.length + 1, 3)}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={commitEdit}
className={`${editInputClassName} text-gray-400`}
aria-label="Rename CM number"
/>
)
</span>
) : project.cm_number ? (
<span
onClick={() => startEdit("cm")}
className="ml-1 inline-block cursor-text text-gray-400"
title="Rename CM"
>
(#{project.cm_number})
</span>
) : null;
return (
<PageHeader
breadcrumbs={[
@ -478,7 +414,6 @@ export function ProjectPageHeader({
...(project
? {
label: titleLabel,
suffix: cmSuffix,
cursor: "text",
}
: {
@ -511,12 +446,12 @@ export function ProjectPageHeader({
{
label: "Rename",
icon: Pencil,
onSelect: () => startEdit("name"),
onSelect: requestRename,
},
{
label: "Rename CM",
icon: Hash,
onSelect: () => startEdit("cm"),
label: "Project Details",
icon: Info,
onSelect: onOpenDetails,
},
{
label: "Delete",
@ -535,6 +470,7 @@ export function ProjectPageHeader({
{
onClick: onNewChat,
disabled: creatingChat,
compact: true,
icon: creatingChat ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
@ -549,6 +485,7 @@ export function ProjectPageHeader({
{
onClick: onNewReview,
disabled: docsCount === 0 || creatingReview,
compact: true,
icon: creatingReview ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (

View file

@ -45,7 +45,7 @@ export function ProjectReviewsTab({
setRenamingReviewId: Dispatch<SetStateAction<string | null>>;
setRenameReviewValue: Dispatch<SetStateAction<string>>;
}) {
const stickyCellBg = "bg-[#fcfcfd]";
const stickyCellBg = "bg-[#fafbfc]";
return (
<>

View file

@ -41,7 +41,7 @@ export function ProjectsOverview() {
const actionsRef = useRef<HTMLDivElement>(null);
const router = useRouter();
const { user, isAuthenticated, authLoading } = useAuth();
const stickyCellBg = "bg-[#fcfcfd]";
const stickyCellBg = "bg-[#fafbfc]";
useEffect(() => {
if (authLoading) {

View file

@ -9,12 +9,13 @@ import {
type ButtonHTMLAttributes,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { ChevronLeft, Loader2, Plus, Search, Trash2 } from "lucide-react";
import { usePageChrome } from "@/app/contexts/PageChromeContext";
import { cn } from "@/lib/utils";
export interface PageHeaderBreadcrumb {
label?: ReactNode;
suffix?: ReactNode;
onClick?: () => void;
cursor?: "text";
loading?: boolean;
@ -31,6 +32,7 @@ type PageHeaderButtonAction = {
title?: string;
variant?: "default" | "danger";
iconOnly?: boolean;
compact?: boolean;
tooltip?: ReactNode;
};
@ -108,6 +110,7 @@ export function PageHeader({
breadcrumbs,
loading = false,
}: PageHeaderProps) {
const { mobileActionsContainer } = usePageChrome();
const headerContent = breadcrumbs?.length ? (
<PageHeaderBreadcrumbs items={breadcrumbs} />
) : (
@ -124,6 +127,7 @@ export function PageHeader({
? [{ actions: actionItems, gap: actionGap }]
: [])
);
const hasActions = groupedActionItems.length > 0;
return (
<div
@ -131,39 +135,70 @@ export function PageHeader({
"flex justify-between",
align === "start" ? "items-start" : "items-center",
"px-4 md:px-10",
"pb-4 pt-5.5",
"min-h-[76px] pb-4 pt-5.5",
shrink && "shrink-0",
className,
)}
>
{headerContent}
{groupedActionItems.length > 0 && (
<div className="ml-4 flex shrink-0 items-center gap-3">
{groupedActionItems.map((group, groupIndex) => (
<div
key={groupIndex}
className={cn(
"flex shrink-0 items-center",
actionGapClassName[group.gap],
"rounded-full border border-white/70 bg-white px-1 py-1 shadow-[0_-1px_3px_rgba(15,23,42,0.03),0_2px_7px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.82),inset_0_-3px_7px_rgba(255,255,255,0.13)] backdrop-blur-2xl",
)}
>
{group.actions.map((action, index) => (
<Fragment key={index}>
<PageHeaderActionRenderer
action={action}
disabled={actionsDisabled}
/>
</Fragment>
))}
</div>
))}
{hasActions && (
<div className="ml-4 hidden shrink-0 items-center gap-3 md:flex">
<PageHeaderActionGroups
groupedActionItems={groupedActionItems}
actionsDisabled={actionsDisabled}
/>
</div>
)}
{hasActions &&
mobileActionsContainer &&
createPortal(
<div className="flex min-w-0 items-center justify-end gap-3 overflow-visible py-2 -my-2">
<PageHeaderActionGroups
groupedActionItems={groupedActionItems}
actionsDisabled={actionsDisabled}
/>
</div>,
mobileActionsContainer,
)}
</div>
);
}
function PageHeaderActionGroups({
groupedActionItems,
actionsDisabled,
}: {
groupedActionItems: {
actions: PageHeaderAction[];
gap: PageHeaderActionGap;
}[];
actionsDisabled: boolean;
}) {
return (
<>
{groupedActionItems.map((group, groupIndex) => (
<div
key={groupIndex}
className={cn(
"flex shrink-0 items-center",
actionGapClassName[group.gap],
"rounded-full border border-white/70 bg-white px-1 py-1 shadow-[0_8px_24px_rgba(15,23,42,0.06)] backdrop-blur-2xl",
)}
>
{group.actions.map((action, index) => (
<Fragment key={index}>
<PageHeaderActionRenderer
action={action}
disabled={actionsDisabled}
/>
</Fragment>
))}
</div>
))}
</>
);
}
function normalizeActionGroup(
group: PageHeaderActionGroup,
fallbackGap: PageHeaderActionGap,
@ -264,6 +299,7 @@ function PageHeaderButtonActionControl({
aria-label={action.title}
variant={action.variant}
iconOnly={iconOnly}
compact={action.compact}
>
{action.icon}
{action.label}
@ -394,11 +430,13 @@ type PageHeaderActionButtonProps = Omit<
> & {
variant?: "default" | "danger";
iconOnly?: boolean;
compact?: boolean;
};
type PageHeaderActionControlClassNameOptions = {
variant?: "default" | "danger";
iconOnly?: boolean;
compact?: boolean;
disabled?: boolean;
className?: string;
};
@ -406,12 +444,13 @@ type PageHeaderActionControlClassNameOptions = {
function pageHeaderActionControlClassName({
variant = "default",
iconOnly = false,
compact = false,
disabled = false,
className,
}: PageHeaderActionControlClassNameOptions = {}) {
return cn(
"flex h-7 items-center justify-center rounded-full text-sm transition-colors hover:bg-gray-100 active:bg-gray-100 disabled:cursor-default disabled:text-gray-300 disabled:hover:bg-transparent disabled:hover:text-gray-300",
iconOnly ? "w-7" : "gap-1.5 px-3",
iconOnly ? "w-7" : compact ? "gap-1.5 px-2" : "gap-1.5 px-3",
disabled ? "cursor-default" : "cursor-pointer",
"hover:bg-gray-100 active:bg-gray-100",
variant === "danger"
@ -425,6 +464,7 @@ function PageHeaderActionButton({
children,
variant = "default",
iconOnly = false,
compact = false,
disabled,
...props
}: PageHeaderActionButtonProps) {
@ -434,6 +474,7 @@ function PageHeaderActionButton({
className={pageHeaderActionControlClassName({
variant,
iconOnly,
compact,
disabled,
})}
{...props}
@ -444,7 +485,6 @@ function PageHeaderActionButton({
}
function PageHeaderBreadcrumbs({ items }: { items: PageHeaderBreadcrumb[] }) {
const current = items[items.length - 1];
const parent = [...items]
.slice(0, -1)
.reverse()
@ -462,21 +502,15 @@ function PageHeaderBreadcrumbs({ items }: { items: PageHeaderBreadcrumb[] }) {
<ChevronLeft className="h-5 w-5" />
</button>
)}
<div className="hidden min-w-0 items-center gap-1.5 sm:flex">
<div className="flex min-w-0 items-center gap-1.5">
{items.map((item, index) => (
<BreadcrumbItem
key={index}
item={item}
current={index === items.length - 1}
showSuffix
/>
))}
</div>
<div className="min-w-0 sm:hidden">
{current ? (
<BreadcrumbItem item={current} current showSuffix={false} />
) : null}
</div>
</div>
);
}
@ -484,11 +518,9 @@ function PageHeaderBreadcrumbs({ items }: { items: PageHeaderBreadcrumb[] }) {
function BreadcrumbItem({
item,
current,
showSuffix,
}: {
item: PageHeaderBreadcrumb;
current: boolean;
showSuffix: boolean;
}) {
const content = item.loading ? (
<div
@ -507,7 +539,6 @@ function BreadcrumbItem({
>
{item.label}
</span>
{showSuffix && item.suffix}
</>
);
@ -520,9 +551,13 @@ function BreadcrumbItem({
? "text-gray-500 hover:text-gray-700"
: "text-gray-500",
);
const wrapperClassName = cn(
"min-w-0 items-center gap-1.5",
current ? "flex" : "hidden sm:flex",
);
return (
<>
<span className={wrapperClassName}>
{current ? (
<span className={className}>{content}</span>
) : item.onClick ? (
@ -533,6 +568,6 @@ function BreadcrumbItem({
<span className={className}>{content}</span>
)}
{!current && <span className="shrink-0 text-gray-300"></span>}
</>
</span>
);
}

View file

@ -207,9 +207,10 @@ export function PeopleModal({
} with access.`
}
>
<div className="flex min-h-0 flex-1 flex-col gap-6">
{/* Add-member row */}
{onSharedWithChange && (
<div className="pt-1 pb-2">
<section className="space-y-2">
<div className="flex items-center gap-2">
<div className="flex flex-1 items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
<UserPlus className="h-3.5 w-3.5 text-gray-400 shrink-0" />
@ -269,90 +270,92 @@ export function PeopleModal({
{error}
</p>
)}
</div>
</section>
)}
{/* Section heading */}
<div className="pt-3 pb-1 flex items-center gap-2">
<h3 className="text-xs font-medium text-gray-500">
People with Access
</h3>
{peopleLoading && (
<Loader2 className="h-3 w-3 animate-spin text-gray-400" />
)}
</div>
{/* Member list */}
{roster.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-400">
No one has access yet.
<section className="min-h-0 flex-1">
<div className="mb-2 flex items-center gap-2">
<h3 className="text-xs font-medium text-gray-500">
People with Access
</h3>
{peopleLoading && (
<Loader2 className="h-3 w-3 animate-spin text-gray-400" />
)}
</div>
) : (
<ul className="divide-y divide-gray-100 [&>li:nth-child(2)]:border-t-0">
{roster.map((entry) => {
const isYou =
!!currentUserEmail &&
entry.email.toLowerCase() ===
currentUserEmail.toLowerCase();
const isRemoving =
busy === "remove" &&
removingEmail === entry.email;
const primary =
entry.display_name?.trim() || entry.email;
const showSecondary =
!!entry.display_name?.trim() &&
primary !== entry.email;
return (
<li
key={`${entry.role}-${entry.email}`}
className="flex items-center gap-3 py-3"
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-900 text-white">
<User className="h-3 w-3" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-gray-800">
{primary}
{isYou && (
<span className="ml-1.5 text-xs text-gray-400">
(You)
</span>
)}
{entry.role === "owner" && (
<span className="ml-1.5 text-[10px] text-gray-400">
Owner
</span>
)}
</p>
{showSecondary && (
<p className="truncate text-xs text-gray-400">
{entry.email}
</p>
)}
</div>
{entry.role === "member" &&
onSharedWithChange && (
<button
onClick={() =>
void handleRemove(
entry.email,
)
}
disabled={busy !== null}
title="Remove access"
className="self-center inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-gray-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
>
{isRemoving && (
<Loader2 className="h-3 w-3 animate-spin" />
{/* Member list */}
{roster.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-400">
No one has access yet.
</div>
) : (
<ul className="divide-y divide-gray-100 [&>li:nth-child(2)]:border-t-0">
{roster.map((entry) => {
const isYou =
!!currentUserEmail &&
entry.email.toLowerCase() ===
currentUserEmail.toLowerCase();
const isRemoving =
busy === "remove" &&
removingEmail === entry.email;
const primary =
entry.display_name?.trim() || entry.email;
const showSecondary =
!!entry.display_name?.trim() &&
primary !== entry.email;
return (
<li
key={`${entry.role}-${entry.email}`}
className="flex items-center gap-3 py-3"
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-900 text-white">
<User className="h-3 w-3" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-gray-800">
{primary}
{isYou && (
<span className="ml-1.5 text-xs text-gray-400">
(You)
</span>
)}
Remove
</button>
)}
</li>
);
})}
</ul>
)}
{entry.role === "owner" && (
<span className="ml-1.5 text-[10px] text-gray-400">
Owner
</span>
)}
</p>
{showSecondary && (
<p className="truncate text-xs text-gray-400">
{entry.email}
</p>
)}
</div>
{entry.role === "member" &&
onSharedWithChange && (
<button
onClick={() =>
void handleRemove(
entry.email,
)
}
disabled={busy !== null}
title="Remove access"
className="self-center inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-gray-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
>
{isRemoving && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
Remove
</button>
)}
</li>
);
})}
</ul>
)}
</section>
</div>
</Modal>
);

View file

@ -21,6 +21,7 @@ export function RenameableTitle({ value, onCommit, suffix }: Props) {
const [draft, setDraft] = useState("");
const caretPos = useRef<number | null>(null);
const escaped = useRef(false);
const committed = useRef(false);
function startEditing(e: React.MouseEvent) {
const doc = document as CaretDocument;
@ -32,15 +33,18 @@ export function RenameableTitle({ value, onCommit, suffix }: Props) {
? range.startOffset
: null;
escaped.current = false;
committed.current = false;
setDraft(value);
setEditing(true);
}
function commit() {
if (committed.current) return;
if (escaped.current) {
escaped.current = false;
return;
}
committed.current = true;
setEditing(false);
onCommit(draft.trim());
}
@ -58,9 +62,13 @@ export function RenameableTitle({ value, onCommit, suffix }: Props) {
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Enter") {
e.preventDefault();
commit();
}
if (e.key === "Escape") {
escaped.current = true;
committed.current = true;
setEditing(false);
}
}}

View file

@ -141,9 +141,12 @@ export function RowActionMenuItems({
)}
{onDelete && (
<button
onClick={() => { onClose(); onDelete(); }}
disabled={deleting}
aria-disabled={deleteDisabled}
onClick={() => {
if (deleteDisabled || deleting) return;
onClose();
onDelete();
}}
disabled={deleting || deleteDisabled}
className={`flex items-center gap-2 w-full px-3 py-2 text-xs text-red-500 transition-colors disabled:opacity-40 ${
deleteDisabled
? "cursor-not-allowed opacity-40 hover:bg-transparent"

View file

@ -1,155 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Check, Loader2, Search } from "lucide-react";
import { listWorkflows } from "@/app/lib/mikeApi";
import { Modal } from "@/app/components/shared/Modal";
import type { Workflow } from "@/app/components/shared/types";
import { BUILT_IN_WORKFLOWS } from "../workflows/builtinWorkflows";
import { cn } from "@/lib/utils";
interface Props {
open: boolean;
applying?: boolean;
onClose: () => void;
onApply: (workflow: Workflow) => Promise<void> | void;
}
export function ApplyWorkflowPresetModal({
open,
applying = false,
onClose,
onApply,
}: Props) {
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(
null,
);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) return;
const builtinTabular = BUILT_IN_WORKFLOWS.filter(
(workflow) => workflow.type === "tabular",
);
setLoading(true);
setSearch("");
setSelectedWorkflowId(null);
listWorkflows("tabular")
.then((custom) => setWorkflows([...builtinTabular, ...custom]))
.catch(() => setWorkflows(builtinTabular))
.finally(() => setLoading(false));
}, [open]);
const filteredWorkflows = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return workflows;
return workflows.filter((workflow) =>
[workflow.title, workflow.practice ?? ""]
.join(" ")
.toLowerCase()
.includes(q),
);
}, [search, workflows]);
const selectedWorkflow =
workflows.find((workflow) => workflow.id === selectedWorkflowId) ?? null;
const canApply =
!!selectedWorkflow &&
!applying &&
!loading &&
!!selectedWorkflow.columns_config?.length;
return (
<Modal
open={open}
onClose={onClose}
title="Apply preset workflow"
size="md"
primaryAction={{
label: applying ? "Applying..." : "Apply",
onClick: () => {
if (selectedWorkflow) void onApply(selectedWorkflow);
},
disabled: !canApply,
icon: applying ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : undefined,
}}
cancelAction={{ label: "Cancel", onClick: onClose }}
>
<div className="flex min-h-0 flex-1 flex-col gap-3">
<p className="text-sm text-gray-500">
Choose a tabular review workflow. Applying it will replace
the current review columns with the workflow preset.
</p>
<div className="flex h-9 items-center gap-2 rounded-xl bg-gray-100 px-3">
<Search className="h-3.5 w-3.5 shrink-0 text-gray-400" />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search workflows..."
className="min-w-0 flex-1 bg-transparent text-sm text-gray-800 outline-none placeholder:text-gray-400"
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto rounded-xl bg-gray-50 p-1.5">
{loading ? (
<div className="space-y-2 p-1">
{[1, 2, 3, 4].map((index) => (
<div
key={index}
className="h-14 animate-pulse rounded-xl bg-white"
/>
))}
</div>
) : filteredWorkflows.length === 0 ? (
<div className="flex h-32 items-center justify-center text-sm text-gray-400">
No workflows found
</div>
) : (
filteredWorkflows.map((workflow) => {
const selected = workflow.id === selectedWorkflowId;
const columnCount =
workflow.columns_config?.length ?? 0;
return (
<button
key={workflow.id}
type="button"
onClick={() =>
setSelectedWorkflowId(workflow.id)
}
disabled={columnCount === 0}
className={cn(
"flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2.5 text-left transition-colors",
selected
? "bg-white text-gray-950 shadow-[0_1px_4px_rgba(15,23,42,0.06)]"
: "text-gray-700 hover:bg-white/75",
columnCount === 0 &&
"cursor-not-allowed opacity-45",
)}
>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">
{workflow.title}
</span>
<span className="mt-0.5 block truncate text-xs text-gray-400">
{workflow.practice ?? "Tabular"} ·{" "}
{columnCount}{" "}
{columnCount === 1
? "column"
: "columns"}
</span>
</span>
{selected && (
<Check className="h-4 w-4 shrink-0 text-green-600" />
)}
</button>
);
})
)}
</div>
</div>
</Modal>
);
}

View file

@ -67,7 +67,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
},
ref,
) {
const stickyCellBg = "bg-[#fcfcfd]";
const stickyCellBg = "bg-[#fafbfc]";
const scrollContainerRef = useRef<HTMLDivElement>(null);
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
const totalContentWidth =

View file

@ -0,0 +1,36 @@
"use client";
import type { ReactNode } from "react";
import type { Workflow } from "../shared/types";
import { WorkflowPickerModal } from "../workflows/WorkflowPickerModal";
interface TRWorkflowModalProps {
open: boolean;
onClose: () => void;
onApply: (workflow: Workflow) => Promise<void> | void;
breadcrumbs: ReactNode[];
applying?: boolean;
}
export function TRWorkflowModal({
open,
onClose,
onApply,
breadcrumbs,
applying = false,
}: TRWorkflowModalProps) {
return (
<WorkflowPickerModal
open={open}
onClose={onClose}
onSelect={onApply}
workflowType="tabular"
breadcrumbs={breadcrumbs}
primaryLabel="Apply"
selectingLabel="Applying..."
selecting={applying}
closeOnSelect={false}
disabledWorkflow={(workflow) => !workflow.columns_config?.length}
/>
);
}

View file

@ -37,7 +37,7 @@ import type {
Workflow,
} from "../shared/types";
import { AddColumnModal } from "./AddColumnModal";
import { ApplyWorkflowPresetModal } from "./ApplyWorkflowPresetModal";
import { TRWorkflowModal } from "./TRWorkflowModal";
import { AddDocumentsModal } from "../shared/AddDocumentsModal";
import { AddProjectDocsModal } from "../shared/AddProjectDocsModal";
import { PeopleModal } from "../shared/PeopleModal";
@ -79,9 +79,8 @@ export function TRView({ reviewId, projectId }: Props) {
const [addColOpen, setAddColOpen] = useState(false);
const [addDocsOpen, setAddDocsOpen] = useState(false);
const [peopleModalOpen, setPeopleModalOpen] = useState(false);
const [workflowPresetModalOpen, setWorkflowPresetModalOpen] =
useState(false);
const [applyingWorkflowPreset, setApplyingWorkflowPreset] = useState(false);
const [workflowModalOpen, setWorkflowModalOpen] = useState(false);
const [applyingWorkflow, setApplyingWorkflow] = useState(false);
const [deleteReviewConfirmOpen, setDeleteReviewConfirmOpen] =
useState(false);
const [deleteReviewStatus, setDeleteReviewStatus] = useState<
@ -591,15 +590,15 @@ export function TRView({ reviewId, projectId }: Props) {
}
}
function requestWorkflowPreset() {
function requestWorkflow() {
if (review?.is_owner === false) {
setOwnerOnlyAction("apply a preset workflow");
setOwnerOnlyAction("apply a workflow");
return;
}
setWorkflowPresetModalOpen(true);
setWorkflowModalOpen(true);
}
async function handleApplyWorkflowPreset(workflow: Workflow) {
async function handleApplyWorkflow(workflow: Workflow) {
if (!workflow.columns_config?.length) return;
const nextColumns = workflow.columns_config.map((column, index) => ({
...column,
@ -607,7 +606,7 @@ export function TRView({ reviewId, projectId }: Props) {
}));
const previousColumns = columns;
const previousCells = cells;
setApplyingWorkflowPreset(true);
setApplyingWorkflow(true);
setColumns(nextColumns);
setCells([]);
try {
@ -622,13 +621,13 @@ export function TRView({ reviewId, projectId }: Props) {
console.error("Failed to clear old tabular cells", err);
}
}
setWorkflowPresetModalOpen(false);
setWorkflowModalOpen(false);
} catch (err) {
setColumns(previousColumns);
setCells(previousCells);
console.error("Failed to apply workflow preset", err);
console.error("Failed to apply workflow", err);
} finally {
setApplyingWorkflowPreset(false);
setApplyingWorkflow(false);
}
}
@ -657,18 +656,17 @@ export function TRView({ reviewId, projectId }: Props) {
loading: true,
skeletonClassName: "w-32",
onClick: () =>
router.push(`/projects/${projectId}`),
router.push(
`/projects/${projectId}?tab=reviews`,
),
title: "Back to project",
}
: {
label: project?.name ?? "",
suffix: project?.cm_number ? (
<span className="ml-1 text-gray-400">
(#{project.cm_number})
</span>
) : null,
onClick: () =>
router.push(`/projects/${projectId}`),
router.push(
`/projects/${projectId}?tab=reviews`,
),
title: "Back to project",
},
]
@ -716,10 +714,9 @@ export function TRView({ reviewId, projectId }: Props) {
onSelect: requestReviewRename,
},
{
label: "Apply preset workflow",
label: "Apply workflow",
icon: WandSparkles,
onSelect:
requestWorkflowPreset,
onSelect: requestWorkflow,
},
{
label: "Delete",
@ -1057,14 +1054,28 @@ export function TRView({ reviewId, projectId }: Props) {
}
/>
<ApplyWorkflowPresetModal
open={workflowPresetModalOpen}
applying={applyingWorkflowPreset}
<TRWorkflowModal
open={workflowModalOpen}
onClose={() => {
if (applyingWorkflowPreset) return;
setWorkflowPresetModalOpen(false);
if (applyingWorkflow) return;
setWorkflowModalOpen(false);
}}
onApply={handleApplyWorkflowPreset}
onApply={handleApplyWorkflow}
breadcrumbs={[
...(project
? [
"Projects",
project.name +
(project.cm_number
? ` (#${project.cm_number})`
: ""),
]
: []),
"Tabular Reviews",
review?.title || "Untitled Review",
"Add workflow",
]}
applying={applyingWorkflow}
/>
<ConfirmPopup

View file

@ -1,28 +1,17 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
ChevronDown,
Folder,
MessageSquare,
Search,
Table2,
X,
} from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type {
Document,
Workflow,
} from "../shared/types";
import { useEffect, useState } from "react";
import { Folder, Search, X } from "lucide-react";
import type { Document, Workflow } from "../shared/types";
import { createTabularReview } from "@/app/lib/mikeApi";
import { useRouter } from "next/navigation";
import { formatIcon, formatLabel } from "../tabular/columnFormat";
import { useDirectoryData } from "../shared/useDirectoryData";
import { FileDirectory } from "../shared/FileDirectory";
import type { Project } from "../shared/types";
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
import { Modal } from "../shared/Modal";
import { WorkflowPickerContent } from "./WorkflowPickerContent";
import { workflowDetailPath } from "./workflowRoutes";
interface Props {
workflows: Workflow[];
@ -122,165 +111,6 @@ function SimpleProjectPicker({
);
}
// ---------------------------------------------------------------------------
// Shared markdown renderer
// ---------------------------------------------------------------------------
function MarkdownBody({ content }: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="text-base font-semibold text-gray-900 mt-4 mb-1 first:mt-0">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-sm font-semibold text-gray-900 mt-3 mb-1 first:mt-0">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-xs font-semibold text-gray-900 mt-2 mb-0.5 first:mt-0">
{children}
</h3>
),
p: ({ children }) => (
<p className="mb-2 last:mb-0">{children}</p>
),
ul: ({ children }) => (
<ul className="list-disc pl-4 mb-2 space-y-0.5">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal pl-4 mb-2 space-y-0.5">
{children}
</ol>
),
li: ({ children }) => <li>{children}</li>,
strong: ({ children }) => (
<strong className="font-semibold text-gray-800">
{children}
</strong>
),
em: ({ children }) => <em className="italic">{children}</em>,
}}
>
{content}
</ReactMarkdown>
);
}
// ---------------------------------------------------------------------------
// Right panel for assistant workflows (select screen)
// ---------------------------------------------------------------------------
function AssistantPanel({ workflow }: { workflow: Workflow }) {
return (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="py-3 shrink-0">
<p className="text-xs font-medium text-gray-700">
Workflow Prompt
</p>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3 text-sm border border-gray-200 rounded-md text-gray-600 leading-relaxed font-serif bg-gray-50">
<MarkdownBody
content={workflow.prompt_md ?? "_No prompt defined._"}
/>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Right panel for tabular workflows — accordion column list (select screen)
// ---------------------------------------------------------------------------
function TabularPanel({ workflow }: { workflow: Workflow }) {
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
const columns = (workflow.columns_config ?? []).sort(
(a, b) => a.index - b.index,
);
return (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="py-3 shrink-0">
<p className="text-xs font-medium text-gray-700">Columns</p>
</div>
<div className="flex-1 overflow-y-auto border border-gray-200 rounded-md bg-gray-50">
{columns.length === 0 ? (
<p className="px-4 py-6 text-xs text-center text-gray-400">
No columns defined
</p>
) : (
columns.map((col) => {
const isExpanded = expandedIndex === col.index;
const FormatIcon = formatIcon(col.format ?? "text");
return (
<div
key={col.index}
className="border-b border-gray-200"
>
<button
type="button"
onClick={() =>
setExpandedIndex(
isExpanded ? null : col.index,
)
}
className="w-full flex items-center gap-2.5 px-3 py-2.5 text-xs text-left hover:bg-white transition-colors"
>
<FormatIcon className="h-3.5 w-3.5 shrink-0 text-gray-400" />
<span className="flex-1 truncate text-gray-800">
{col.name}
</span>
<span className="shrink-0 text-gray-400">
{formatLabel(col.format ?? "text")}
</span>
<ChevronDown
className={`h-3 w-3 shrink-0 text-gray-300 transition-transform duration-150 ${isExpanded ? "rotate-180" : ""}`}
/>
</button>
{isExpanded && (
<div className="px-4 py-3 bg-white border-t border-gray-200 text-sm text-gray-600 leading-relaxed font-serif space-y-3">
{col.tags && col.tags.length > 0 && (
<div>
<p className="text-xs font-medium text-gray-400 mb-1.5 font-sans">
Tags
</p>
<div className="flex flex-wrap gap-1.5">
{col.tags.map((tag) => (
<span
key={tag}
className="inline-block rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600 font-sans"
>
{tag}
</span>
))}
</div>
</div>
)}
<div>
<p className="text-xs font-medium text-gray-400 mb-1 font-sans">
Prompt
</p>
<MarkdownBody
content={
col.prompt ||
"_No prompt defined._"
}
/>
</div>
</div>
)}
</div>
);
})
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// DisplayWorkflowModal
// ---------------------------------------------------------------------------
@ -288,7 +118,6 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
const [screen, setScreen] = useState<"select" | "configure">("select");
const [selected, setSelected] = useState<Workflow | null>(workflow);
const [listSearch, setListSearch] = useState("");
const selectedRowRef = useRef<HTMLButtonElement>(null);
// Configure screen state
const [inProject, setInProject] = useState(false);
@ -320,12 +149,6 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
}
}, [workflow?.id]);
useEffect(() => {
if (selected && selectedRowRef.current) {
selectedRowRef.current.scrollIntoView({ block: "nearest" });
}
}, [selected?.id]);
// Reset configure state on back
useEffect(() => {
if (screen === "select") {
@ -467,7 +290,7 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
];
const selectPageAction = () => {
router.push(`/workflows/${wf.id}`);
router.push(workflowDetailPath(wf));
handleClose();
};
@ -525,59 +348,19 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) {
{/* ── SELECT SCREEN ── */}
{screen === "select" && (
<div className="flex flex-row flex-1 min-h-0 overflow-hidden gap-3">
{/* Left: workflow list */}
<div className="w-80 shrink-0 flex flex-col overflow-hidden">
{/* Search */}
<div className="px-2 py-3 shrink-0">
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5 py-1">
<Search className="h-3 w-3 text-gray-400 shrink-0" />
<input
type="text"
placeholder="Search…"
value={listSearch}
onChange={(e) => setListSearch(e.target.value)}
className="flex-1 bg-transparent text-xs text-gray-700 placeholder:text-gray-400 outline-none"
/>
{listSearch && (
<button onClick={() => setListSearch("")} className="text-gray-400 hover:text-gray-600">
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
{/* List */}
<div className="overflow-y-auto flex-1">
{workflows
.filter((wfItem) => !listSearch || wfItem.title.toLowerCase().includes(listSearch.toLowerCase()))
.map((wfItem) => {
const isSelected = selected?.id === wfItem.id;
const Icon = wfItem.type === "tabular" ? Table2 : MessageSquare;
return (
<button
key={wfItem.id}
ref={isSelected ? selectedRowRef : null}
type="button"
onClick={() => setSelected(wfItem)}
className={`w-full flex items-center gap-3 rounded-lg px-3 py-2.5 text-xs text-left transition-colors ${isSelected ? "bg-gray-100 text-gray-900" : "hover:bg-gray-50"}`}
>
<span className={`flex-1 truncate ${isSelected ? "text-gray-900 font-medium" : "text-gray-700"}`}>
{wfItem.title}
</span>
<Icon className="h-3.5 w-3.5 shrink-0 text-gray-400" />
</button>
);
})}
</div>
</div>
{/* Right: workflow detail */}
{wf.type === "assistant" ? (
<AssistantPanel key={wf.id} workflow={wf} />
) : (
<TabularPanel key={wf.id} workflow={wf} />
)}
</div>
<WorkflowPickerContent
workflows={workflows}
selected={wf}
onSelect={(next) => {
if (next) setSelected(next);
}}
search={listSearch}
onSearchChange={setListSearch}
workflowType="all"
previewMode="auto"
showTypeIcon
allowClearPreview={false}
/>
)}
{/* ── ASSISTANT CONFIGURE SCREEN ── */}

View file

@ -84,6 +84,8 @@ export function ShareWorkflowModal({
disabled: saving || pendingEmails.length === 0,
}}
>
<div className="flex min-h-0 flex-1 flex-col gap-6">
<section className="space-y-3">
<EmailPillInput
emails={pendingEmails}
onChange={setPendingEmails}
@ -101,9 +103,10 @@ export function ShareWorkflowModal({
{error}
</div>
) : null}
</section>
{/* Permission toggle */}
<div className="flex flex-col gap-2">
<section className="flex flex-col gap-3">
<span className="text-xs font-medium text-gray-700">Allow editing by share recipients</span>
<button
type="button"
@ -112,10 +115,10 @@ export function ShareWorkflowModal({
>
<span className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${allowEdit ? "translate-x-4" : "translate-x-0"}`} />
</button>
</div>
</section>
{/* Existing access */}
<div>
<section className="min-h-0 flex-1">
<p className="text-xs font-medium text-gray-700 mb-2">People with access</p>
{loading ? (
<div className="space-y-2">
@ -146,7 +149,8 @@ export function ShareWorkflowModal({
))}
</div>
)}
</div>
</section>
</div>
</Modal>
);
}

View file

@ -1,22 +1,32 @@
"use client";
import { use, useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import dynamic from "next/dynamic";
import { ChevronDown, Plus, Users, X } from "lucide-react";
import { getWorkflow, updateWorkflow } from "@/app/lib/mikeApi";
import {
Check,
ChevronDown,
Info,
Pencil,
Plus,
Trash2,
Users,
X,
} from "lucide-react";
import { deleteWorkflow, getWorkflow, updateWorkflow } from "@/app/lib/mikeApi";
import { ShareWorkflowModal } from "@/app/components/workflows/ShareWorkflowModal";
import { WFEditColumnModal } from "@/app/components/workflows/WFEditColumnModal";
import { WFColumnViewModal } from "@/app/components/workflows/WFColumnViewModal";
import { AddColumnModal } from "@/app/components/tabular/AddColumnModal";
import type { ColumnConfig, Workflow } from "@/app/components/shared/types";
import {
BUILT_IN_IDS,
BUILT_IN_WORKFLOWS,
} from "@/app/components/workflows/builtinWorkflows";
import { BUILT_IN_WORKFLOWS } from "@/app/components/workflows/builtinWorkflows";
import { formatIcon, formatLabel } from "@/app/components/tabular/columnFormat";
import { RenameableTitle } from "@/app/components/shared/RenameableTitle";
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
import { HeaderActionsMenu } from "@/app/components/shared/HeaderActionsMenu";
import { PageHeader } from "@/app/components/shared/PageHeader";
import { WorkflowDetailsModal } from "@/app/components/workflows/WorkflowDetailsModal";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
// dynamic import keeps Tiptap (browser-only) out of the SSR bundle
const WorkflowPromptEditor = dynamic(
() =>
@ -27,26 +37,32 @@ const WorkflowPromptEditor = dynamic(
);
interface Props {
params: Promise<{ id: string }>;
id: string;
workflowType: Workflow["type"];
}
type SaveStatus = "idle" | "saving" | "saved";
type DeleteStatus = "idle" | "loading" | "complete";
const NAME_COL_W = "w-[332px] shrink-0";
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function WorkflowDetailPage({ params }: Props) {
const { id } = use(params);
export function WorkflowDetailPage({ id, workflowType }: Props) {
const router = useRouter();
const stickyCellBg = "bg-[#fcfcfd]";
const { user } = useAuth();
const { profile } = useUserProfile();
const stickyCellBg = "bg-[#fafbfc]";
const builtinWorkflow =
BUILT_IN_WORKFLOWS.find((w) => w.id === id && w.type === workflowType) ??
null;
const isBuiltin = builtinWorkflow !== null;
const [workflow, setWorkflow] = useState<Workflow | null>(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
const isBuiltin = BUILT_IN_IDS.has(id);
const readOnly =
isBuiltin ||
(workflow?.is_system ?? false) ||
@ -71,6 +87,9 @@ export default function WorkflowDetailPage({ params }: Props) {
// Share popover
const [shareOpen, setShareOpen] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteStatus, setDeleteStatus] = useState<DeleteStatus>("idle");
// Column actions dropdown
const [colActionsOpen, setColActionsOpen] = useState(false);
@ -91,7 +110,7 @@ export default function WorkflowDetailPage({ params }: Props) {
// ---------------------------------------------------------------------------
useEffect(() => {
if (isBuiltin) {
const wf = BUILT_IN_WORKFLOWS.find((w) => w.id === id) ?? null;
const wf = builtinWorkflow;
if (!wf) {
setNotFound(true);
} else {
@ -105,6 +124,10 @@ export default function WorkflowDetailPage({ params }: Props) {
getWorkflow(id)
.then((wf) => {
if (wf.type !== workflowType) {
setNotFound(true);
return;
}
setWorkflow(wf);
setPromptMd(wf.prompt_md ?? "");
setColumns(
@ -115,7 +138,7 @@ export default function WorkflowDetailPage({ params }: Props) {
})
.catch(() => setNotFound(true))
.finally(() => setLoading(false));
}, [id, isBuiltin]);
}, [id, isBuiltin, builtinWorkflow, workflowType]);
// ---------------------------------------------------------------------------
// Debounced auto-save for prompt
@ -138,10 +161,27 @@ export default function WorkflowDetailPage({ params }: Props) {
[id, readOnly],
);
async function handleTitleCommit(newTitle: string) {
if (!newTitle || newTitle === workflow?.title) return;
const updated = await updateWorkflow(id, { title: newTitle });
setWorkflow(updated);
async function handleWorkflowDetailsSave(values: { title: string }) {
if (!workflow || readOnly || !values.title) return;
if (values.title === workflow.title) return;
const updated = await updateWorkflow(id, { title: values.title });
setWorkflow({
...updated,
shared_by_name:
updated.shared_by_name ?? workflow.shared_by_name ?? null,
});
}
async function handleDeleteWorkflow() {
if (!workflow || readOnly || workflow.is_owner === false) return;
setDeleteStatus("loading");
try {
await deleteWorkflow(id);
setDeleteStatus("complete");
setTimeout(() => router.push("/workflows"), 600);
} catch {
setDeleteStatus("idle");
}
}
function handlePromptChange(val: string | undefined) {
@ -190,53 +230,24 @@ export default function WorkflowDetailPage({ params }: Props) {
// ---------------------------------------------------------------------------
if (loading) {
return (
<div className="flex flex-col h-full">
{/* Header skeleton */}
<div className="flex h-full flex-col">
<PageHeader
shrink
breadcrumbs={[
{ label: "Workflows" },
{
label: "Workflows",
onClick: () => router.push("/workflows"),
title: "Back to Workflows",
},
{ loading: true, skeletonClassName: "w-40" },
]}
/>
{/* Toolbar skeleton */}
<div className="flex items-center px-8 h-10 border-b border-gray-200 shrink-0">
<div className="h-3 w-20 rounded bg-gray-100 animate-pulse" />
</div>
{/* Table header skeleton */}
<div className="flex items-center h-8 pr-8 border-b border-gray-200 shrink-0">
<div className={`${NAME_COL_W} flex shrink-0 items-center gap-4 self-stretch pl-4 pr-2`}>
<div className="h-2.5 w-2.5 rounded bg-gray-100 animate-pulse" />
<div className="h-2.5 w-20 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-36 shrink-0">
<div className="h-2.5 w-14 rounded bg-gray-100 animate-pulse" />
</div>
<div className="flex-1">
<div className="h-2.5 w-12 rounded bg-gray-100 animate-pulse" />
</div>
<div className="w-8 shrink-0" />
</div>
{/* Row skeletons */}
<div className="flex-1 overflow-hidden">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center h-10 pr-8 border-b border-gray-50">
<div className={`${NAME_COL_W} flex shrink-0 items-center gap-4 pl-4 pr-2`}>
<div className="h-2.5 w-2.5 shrink-0 rounded bg-gray-100 animate-pulse" />
<div className="h-3 rounded bg-gray-100 animate-pulse" style={{ width: `${40 + (i * 13) % 35}%` }} />
</div>
<div className="w-36 shrink-0">
<div className="h-3 w-16 rounded bg-gray-100 animate-pulse" />
</div>
<div className="flex-1 pr-4">
<div className="h-3 rounded bg-gray-100 animate-pulse" style={{ width: `${50 + (i * 17) % 35}%` }} />
</div>
<div className="w-8 shrink-0" />
</div>
))}
<div className="flex min-h-0 flex-1 flex-col">
{workflowType === "tabular" ? (
<TabularWorkflowEditorSkeleton />
) : (
<AssistantWorkflowEditorSkeleton />
)}
</div>
</div>
);
@ -263,31 +274,29 @@ export default function WorkflowDetailPage({ params }: Props) {
title: "Back to Workflows",
},
{
label: readOnly ? (
label: (
<span className="text-gray-900 truncate max-w-xs">
{workflow.title}
</span>
) : (
<RenameableTitle
value={workflow.title}
onCommit={handleTitleCommit}
/>
),
},
]}
actions={[
{
type: "custom",
render: (
<span className="text-xs text-gray-400">
{saveStatus === "saving"
? "Saving…"
: saveStatus === "saved"
? "Saved"
: ""}
</span>
),
},
saveStatus !== "idle"
? {
type: "custom",
render: (
<span className="inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-sm text-gray-500">
{saveStatus === "saved" ? (
<Check className="h-3.5 w-3.5 text-green-600" />
) : null}
{saveStatus === "saving"
? "Saving…"
: "Saved"}
</span>
),
}
: null,
canShare
? {
onClick: () => setShareOpen(true),
@ -296,8 +305,57 @@ export default function WorkflowDetailPage({ params }: Props) {
icon: <Users className="h-4 w-4" />,
}
: null,
!readOnly
? {
type: "custom",
render: (
<HeaderActionsMenu
title="Workflow actions"
items={[
{
label: "Rename",
icon: Pencil,
onSelect: () =>
setDetailsOpen(true),
},
{
label: "Workflow Details",
icon: Info,
onSelect: () =>
setDetailsOpen(true),
},
{
label: "Delete",
icon: Trash2,
variant: "danger",
disabled:
workflow.is_owner === false,
onSelect: () => {
setDeleteStatus("idle");
setDeleteOpen(true);
},
},
]}
/>
),
}
: null,
]}
/>
<WorkflowDetailsModal
open={detailsOpen}
workflow={workflow}
canEdit={!readOnly}
canShare={canShare}
currentUserDisplayName={profile?.displayName}
currentUserEmail={user?.email}
onClose={() => setDetailsOpen(false)}
onSave={handleWorkflowDetailsSave}
onShareWorkflow={() => {
setDetailsOpen(false);
setShareOpen(true);
}}
/>
{shareOpen && (
<ShareWorkflowModal
workflowId={id}
@ -305,19 +363,25 @@ export default function WorkflowDetailPage({ params }: Props) {
onClose={() => setShareOpen(false)}
/>
)}
{/* Read-only badge for built-in workflows */}
{readOnly && (
<div className="flex items-center h-10 px-8 border-b border-gray-200">
<span className="text-xs text-gray-400">Read-only</span>
</div>
)}
<ConfirmPopup
open={deleteOpen}
title="Delete workflow?"
message="This workflow will be permanently deleted."
confirmLabel="Delete"
confirmStatus={deleteStatus}
onConfirm={() => void handleDeleteWorkflow()}
onCancel={() => {
if (deleteStatus === "loading") return;
setDeleteOpen(false);
setDeleteStatus("idle");
}}
/>
{/* Body */}
<div className="flex-1 min-h-0 flex flex-col">
{workflow.type === "assistant" ? (
/* ── Assistant: WYSIWYG editor ── */
<div className="flex-1 min-h-0 p-6">
<div className="flex-1 min-h-0 px-4 pb-2 pt-0 md:px-10 md:pb-3">
<WorkflowPromptEditor
value={promptMd}
onChange={readOnly ? undefined : handlePromptChange}
@ -329,7 +393,7 @@ export default function WorkflowDetailPage({ params }: Props) {
<div className="flex flex-col flex-1 min-h-0">
{/* Toolbar */}
{!readOnly && (
<div className="flex items-center justify-between px-8 h-10 border-b border-gray-200 shrink-0">
<div className="flex items-center justify-between px-4 md:px-10 h-10 border-b border-gray-200 shrink-0">
<button
onClick={() => setAddColumnOpen(true)}
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 transition-colors"
@ -368,11 +432,18 @@ export default function WorkflowDetailPage({ params }: Props) {
)}
</div>
)}
{readOnly && (
<div className="flex h-10 shrink-0 items-center bg-gray-50 px-4 md:px-10">
<span className="text-xs font-medium text-gray-500">
Read-only
</span>
</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-8 border-b border-gray-200 text-xs text-gray-500 font-medium shrink-0 select-none">
<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`}>
{columns.length > 0 && (
<input
@ -418,7 +489,7 @@ export default function WorkflowDetailPage({ params }: Props) {
<div
key={col.index}
onClick={() => readOnly ? setViewingColumn(col) : setEditingColumn(col)}
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-100 cursor-pointer transition-colors"
className="group flex items-center h-10 pr-3 md:pr-10 border-b border-gray-50 hover:bg-gray-100 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`}>
<div className="flex min-w-0 items-center gap-4">
@ -505,3 +576,84 @@ export default function WorkflowDetailPage({ params }: Props) {
</div>
);
}
function AssistantWorkflowEditorSkeleton() {
return (
<div className="min-h-0 flex-1 px-4 pb-2 pt-0 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="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" />
<div className="h-3 w-3/4 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-4/5 animate-pulse rounded bg-gray-100" />
</div>
<div className="mt-8 space-y-3">
<div className="h-3 w-28 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-11/12 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-2/3 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-10/12 animate-pulse rounded bg-gray-100" />
</div>
<div className="mt-8 space-y-3">
<div className="h-3 w-20 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-4/6 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-5/6 animate-pulse rounded bg-gray-100" />
</div>
</div>
</div>
);
}
function TabularWorkflowEditorSkeleton() {
return (
<>
<div className="flex h-10 shrink-0 items-center 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="h-2.5 w-2.5 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`}
>
<div className="h-2.5 w-2.5 shrink-0 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>
</>
);
}

View file

@ -0,0 +1,190 @@
"use client";
import { type ReactNode, useEffect, useMemo, useState } from "react";
import { Users } from "lucide-react";
import { Modal } from "@/app/components/shared/Modal";
import type { Workflow } from "@/app/components/shared/types";
import { listWorkflowShares } from "@/app/lib/mikeApi";
interface WorkflowDetailsModalProps {
open: boolean;
workflow: Workflow | null;
canEdit: boolean;
canShare: boolean;
currentUserDisplayName?: string | null;
currentUserEmail?: string | null;
onClose: () => void;
onSave: (values: { title: string }) => Promise<void>;
onShareWorkflow: () => void;
}
export function WorkflowDetailsModal({
open,
workflow,
canEdit,
canShare,
currentUserDisplayName,
currentUserEmail,
onClose,
onSave,
onShareWorkflow,
}: WorkflowDetailsModalProps) {
const [titleDraft, setTitleDraft] = useState("");
const [shareCount, setShareCount] = useState<number | null>(null);
const [sharesLoading, setSharesLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !workflow) return;
setTitleDraft(workflow.title);
setShareCount(null);
setSaved(false);
setError(null);
}, [open, workflow]);
useEffect(() => {
if (!open || !workflow || !canShare) {
setSharesLoading(false);
return;
}
let cancelled = false;
setSharesLoading(true);
listWorkflowShares(workflow.id)
.then((shares) => {
if (!cancelled) setShareCount(shares.length);
})
.catch(() => {
if (!cancelled) setShareCount(null);
})
.finally(() => {
if (!cancelled) setSharesLoading(false);
});
return () => {
cancelled = true;
};
}, [canShare, open, workflow]);
const trimmedTitle = titleDraft.trim();
const hasChanges = useMemo(() => {
if (!workflow) return false;
return trimmedTitle !== workflow.title;
}, [trimmedTitle, workflow]);
if (!workflow) return null;
const typeLabel = workflow.type === "tabular" ? "Tabular" : "Assistant";
const ownershipLabel = workflow.is_system
? "Built-in"
: workflow.is_owner === false
? "Shared with you"
: shareCount && shareCount > 0
? "Shared"
: "Private";
const ownerLabel =
workflow.is_owner === false
? workflow.shared_by_name?.trim() || "Unknown"
: currentUserDisplayName?.trim() ||
currentUserEmail?.trim() ||
"You";
async function handleSave() {
if (!canEdit || saving || !hasChanges || !trimmedTitle) return;
setSaving(true);
setSaved(false);
setError(null);
try {
await onSave({ title: trimmedTitle });
setSaved(true);
} catch {
setError("Could not update workflow details.");
} finally {
setSaving(false);
}
}
return (
<Modal
open={open}
onClose={onClose}
breadcrumbs={["Workflows", workflow.title, "Details"]}
secondaryAction={
canShare
? {
label: "Share Workflow",
icon: <Users className="h-4 w-4" />,
onClick: onShareWorkflow,
}
: undefined
}
footerStatus={
error ? (
<span className="text-sm text-red-600">{error}</span>
) : saved ? (
<span className="text-sm text-gray-400">Updated</span>
) : null
}
primaryAction={
canEdit
? {
label: saving ? "Updating..." : "Update",
onClick: () => void handleSave(),
disabled: saving || !hasChanges || !trimmedTitle,
}
: undefined
}
cancelAction={canEdit ? undefined : false}
>
<div className="flex flex-col gap-5 py-1">
<div className="flex flex-col gap-3">
<label
htmlFor="workflow-details-title"
className="text-xs font-medium text-gray-700"
>
Workflow Name
</label>
<input
id="workflow-details-title"
value={titleDraft}
onChange={(e) => {
setTitleDraft(e.target.value);
setSaved(false);
setError(null);
}}
disabled={!canEdit || saving}
className="h-9 w-full rounded-md border border-gray-200 bg-gray-50 px-3 text-sm text-gray-900 outline-none transition-colors focus:border-gray-300 disabled:cursor-not-allowed disabled:text-gray-400"
/>
</div>
<div className="divide-y divide-gray-100 text-sm">
<DetailRow label="Type" value={typeLabel} />
<DetailRow
label="Ownership"
value={
sharesLoading ? (
<span className="inline-block h-4 w-14 rounded bg-gray-100 animate-pulse" />
) : (
ownershipLabel
)
}
/>
<DetailRow label="Owner" value={ownerLabel} />
</div>
</div>
</Modal>
);
}
function DetailRow({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="flex items-center justify-between gap-4 py-3">
<span className="text-gray-500">{label}</span>
<span className="min-w-0 truncate text-right text-gray-900">
{value}
</span>
</div>
);
}

View file

@ -26,6 +26,7 @@ import { RowActions } from "../shared/RowActions";
import { MikeIcon } from "@/components/chat/mike-icon";
import { useAuth } from "@/contexts/AuthContext";
import { PageHeader } from "@/app/components/shared/PageHeader";
import { workflowDetailPath } from "./workflowRoutes";
type Tab = "all" | "builtin" | "custom" | "hidden";
@ -41,7 +42,7 @@ const TABS: { id: Tab; label: string }[] = [
export function WorkflowList() {
const router = useRouter();
const { user } = useAuth();
const stickyCellBg = "bg-[#fcfcfd]";
const stickyCellBg = "bg-[#fafbfc]";
const [custom, setCustom] = useState<Workflow[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Workflow | null>(null);
@ -606,7 +607,7 @@ export function WorkflowList() {
onCreated={(wf) => {
setCustom((prev) => [wf, ...prev]);
setNewModalOpen(false);
router.push(`/workflows/${wf.id}`);
router.push(workflowDetailPath(wf));
}}
/>
</div>

View file

@ -0,0 +1,362 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
ChevronDown,
ChevronLeft,
MessageSquare,
Search,
Table2,
X,
} from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { ColumnConfig, Workflow } from "../shared/types";
import { formatIcon, formatLabel } from "../tabular/columnFormat";
import { TAG_COLORS } from "../tabular/pillUtils";
type WorkflowPreviewMode = "auto" | "prompt" | "columns";
interface WorkflowPickerContentProps {
workflows: Workflow[];
selected: Workflow | null;
onSelect: (workflow: Workflow | null) => void;
search: string;
onSearchChange: (value: string) => void;
loading?: boolean;
workflowType?: Workflow["type"] | "all";
emptyMessage?: string;
previewMode?: WorkflowPreviewMode;
disabledWorkflow?: (workflow: Workflow) => boolean;
showTypeIcon?: boolean;
allowClearPreview?: boolean;
}
export function WorkflowPickerContent({
workflows,
selected,
onSelect,
search,
onSearchChange,
loading = false,
workflowType = "all",
emptyMessage,
previewMode = "auto",
disabledWorkflow,
showTypeIcon = false,
allowClearPreview = true,
}: WorkflowPickerContentProps) {
const selectedRowRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (selectedRowRef.current) {
selectedRowRef.current.scrollIntoView({ block: "nearest" });
}
}, [selected?.id]);
const normalizedSearch = search.trim().toLowerCase();
const filteredWorkflows = normalizedSearch
? workflows.filter((workflow) =>
[
workflow.title,
workflow.practice ?? "",
workflow.is_system ? "Built-in" : "Custom",
]
.join(" ")
.toLowerCase()
.includes(normalizedSearch),
)
: workflows;
const resolvedEmptyMessage =
emptyMessage ??
(search
? "No matches found"
: workflowType === "all"
? "No workflows found"
: `No ${workflowType} workflows found`);
return (
<div className="flex min-h-0 flex-1 flex-row gap-3 overflow-hidden">
<div
className={`flex flex-col overflow-hidden ${selected ? "w-80 shrink-0" : "flex-1"}`}
>
<div className="shrink-0 px-2 pb-2 pt-3">
<div className="flex h-9 items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3">
<Search className="h-3.5 w-3.5 shrink-0 text-gray-400" />
<input
type="text"
placeholder="Search workflows..."
value={search}
onChange={(event) =>
onSearchChange(event.target.value)
}
className="flex-1 bg-transparent text-sm text-gray-700 outline-none placeholder:text-gray-400"
/>
{search && (
<button
type="button"
onClick={() => onSearchChange("")}
className="text-gray-400 hover:text-gray-600"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{loading ? (
<div className="space-y-1">
{[60, 45, 75, 50, 65, 40, 55].map((width, index) => (
<div
key={index}
className="flex items-center justify-between gap-3 rounded-lg px-3 py-2.5"
>
<div
className="h-3 animate-pulse rounded bg-gray-100"
style={{ width: `${width}%` }}
/>
<div className="h-3 w-10 shrink-0 animate-pulse rounded bg-gray-100" />
</div>
))}
</div>
) : filteredWorkflows.length === 0 ? (
<p className="py-8 text-center text-sm text-gray-400">
{resolvedEmptyMessage}
</p>
) : (
<div className="space-y-1 overflow-y-auto">
{filteredWorkflows.map((workflow) => {
const disabled = disabledWorkflow?.(workflow) ?? false;
const isSelected = selected?.id === workflow.id;
const TypeIcon =
workflow.type === "tabular"
? Table2
: MessageSquare;
return (
<button
key={workflow.id}
ref={isSelected ? selectedRowRef : null}
type="button"
disabled={disabled}
onClick={() =>
onSelect(isSelected ? null : workflow)
}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-xs transition-colors ${
isSelected
? "bg-gray-100 text-gray-900"
: "hover:bg-gray-50"
} ${disabled ? "cursor-not-allowed opacity-45" : ""}`}
>
<span
className={`flex-1 truncate ${
isSelected
? "font-medium text-gray-900"
: "text-gray-700"
}`}
>
{workflow.title}
</span>
{showTypeIcon ? (
<TypeIcon className="h-3.5 w-3.5 shrink-0 text-gray-400" />
) : (
<span className="shrink-0 text-xs text-gray-400">
{workflow.is_system
? "Built-in"
: "Custom"}
</span>
)}
</button>
);
})}
</div>
)}
</div>
{selected && (
<WorkflowPreview
workflow={selected}
mode={previewMode}
onClear={() => onSelect(null)}
allowClear={allowClearPreview}
/>
)}
</div>
);
}
function WorkflowPreview({
workflow,
mode,
onClear,
allowClear,
}: {
workflow: Workflow;
mode: WorkflowPreviewMode;
onClear: () => void;
allowClear: boolean;
}) {
const resolvedMode =
mode === "auto"
? workflow.type === "tabular"
? "columns"
: "prompt"
: mode;
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex h-14 shrink-0 items-center justify-between pb-2 pt-3">
<p className="text-sm font-medium text-gray-700">
Workflow Details
</p>
{allowClear ? (
<button
type="button"
onClick={onClear}
className="rounded-md p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
) : null}
</div>
{resolvedMode === "columns" ? (
<WorkflowColumnPreview columns={workflow.columns_config ?? []} />
) : (
<WorkflowPromptPreview
content={workflow.prompt_md ?? "_No prompt defined._"}
/>
)}
</div>
);
}
function WorkflowPromptPreview({ content }: { content: string }) {
return (
<div className="flex-1 overflow-y-auto rounded-md border border-gray-200 bg-gray-50 px-4 py-3 font-serif text-sm leading-relaxed text-gray-600">
<WorkflowPromptMarkdown content={content} />
</div>
);
}
function WorkflowPromptMarkdown({ content }: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 className="mb-1 mt-4 text-base font-semibold text-gray-900 first:mt-0">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="mb-1 mt-3 text-sm font-semibold text-gray-900 first:mt-0">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="mb-0.5 mt-2 text-xs font-semibold text-gray-900 first:mt-0">
{children}
</h3>
),
p: ({ children }) => (
<p className="mb-2 last:mb-0">{children}</p>
),
ul: ({ children }) => (
<ul className="mb-2 list-disc space-y-0.5 pl-4">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="mb-2 list-decimal space-y-0.5 pl-4">
{children}
</ol>
),
li: ({ children }) => <li>{children}</li>,
strong: ({ children }) => (
<strong className="font-semibold text-gray-800">
{children}
</strong>
),
em: ({ children }) => <em className="italic">{children}</em>,
}}
>
{content}
</ReactMarkdown>
);
}
function WorkflowColumnPreview({ columns }: { columns: ColumnConfig[] }) {
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
return (
<div className="flex-1 overflow-y-auto rounded-md border border-gray-200 bg-gray-50">
{sortedColumns.length === 0 ? (
<p className="px-4 py-6 text-center text-xs text-gray-400">
No columns defined
</p>
) : (
sortedColumns.map((column) => {
const isExpanded = expandedIndex === column.index;
const FormatIcon = formatIcon(column.format ?? "text");
return (
<div
key={column.index}
className="border-b border-gray-200 last:border-b-0"
>
<button
type="button"
onClick={() =>
setExpandedIndex(
isExpanded ? null : column.index,
)
}
className="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-xs transition-colors hover:bg-white"
>
<FormatIcon className="h-3.5 w-3.5 shrink-0 text-gray-400" />
<span className="flex-1 truncate text-gray-800">
{column.name}
</span>
<span className="shrink-0 text-gray-400">
{formatLabel(column.format ?? "text")}
</span>
<ChevronDown
className={`h-3 w-3 shrink-0 text-gray-300 transition-transform duration-150 ${isExpanded ? "rotate-180" : ""}`}
/>
</button>
{isExpanded ? (
<div className="space-y-3 border-t border-gray-200 bg-white px-4 py-3 font-serif text-sm leading-relaxed text-gray-600">
{column.tags && column.tags.length > 0 ? (
<div>
<p className="mb-1.5 font-sans text-[11px] font-medium text-gray-600">
Tags
</p>
<div className="flex flex-wrap gap-1.5">
{column.tags.map((tag, tagIdx) => (
<span
key={tag}
className={`inline-block rounded-full px-1.5 py-0.5 font-sans text-[10px] ${TAG_COLORS[tagIdx % TAG_COLORS.length]}`}
>
{tag}
</span>
))}
</div>
</div>
) : null}
<div>
<p className="mb-1 font-sans text-[11px] font-medium text-gray-600">
Prompt
</p>
<WorkflowPromptMarkdown
content={
column.prompt ||
"_No prompt defined._"
}
/>
</div>
</div>
) : null}
</div>
);
})
)}
</div>
);
}

View file

@ -0,0 +1,137 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { listWorkflows } from "@/app/lib/mikeApi";
import { Modal } from "../shared/Modal";
import type { Workflow } from "../shared/types";
import { BUILT_IN_WORKFLOWS } from "./builtinWorkflows";
import { WorkflowPickerContent } from "./WorkflowPickerContent";
interface WorkflowPickerModalProps {
open: boolean;
onClose: () => void;
onSelect: (workflow: Workflow) => Promise<void> | void;
workflowType: Workflow["type"];
breadcrumbs: ReactNode[];
primaryLabel?: string;
selectingLabel?: string;
selecting?: boolean;
closeOnSelect?: boolean;
initialWorkflowId?: string;
disabledWorkflow?: (workflow: Workflow) => boolean;
}
export function WorkflowPickerModal({
open,
onClose,
onSelect,
workflowType,
breadcrumbs,
primaryLabel = "Use",
selectingLabel,
selecting = false,
closeOnSelect = true,
initialWorkflowId,
disabledWorkflow,
}: WorkflowPickerModalProps) {
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<Workflow | null>(null);
const [search, setSearch] = useState("");
useEffect(() => {
if (!open) return;
let cancelled = false;
const builtins = BUILT_IN_WORKFLOWS.filter(
(workflow) => workflow.type === workflowType,
);
const frame = requestAnimationFrame(() => {
if (cancelled) return;
setWorkflows(builtins);
setLoading(true);
setSelected(
initialWorkflowId
? builtins.find((workflow) => workflow.id === initialWorkflowId) ??
null
: null,
);
setSearch("");
});
listWorkflows(workflowType)
.then((custom) => {
if (cancelled) return;
const all = [...builtins, ...custom];
setWorkflows(all);
if (initialWorkflowId) {
setSelected(
all.find((workflow) => workflow.id === initialWorkflowId) ??
null,
);
}
})
.catch(() => {
if (cancelled) return;
if (initialWorkflowId) {
setSelected(
builtins.find(
(workflow) => workflow.id === initialWorkflowId,
) ?? null,
);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
cancelAnimationFrame(frame);
};
}, [initialWorkflowId, open, workflowType]);
if (!open) return null;
const selectionDisabled =
!selected || selecting || (selected && disabledWorkflow?.(selected));
const resolvedPrimaryLabel =
selecting && selectingLabel ? selectingLabel : primaryLabel;
function handleClose() {
setSelected(null);
setSearch("");
onClose();
}
async function handleSelect() {
if (!selected || selectionDisabled) return;
await onSelect(selected);
if (closeOnSelect) handleClose();
}
return (
<Modal
open={open}
onClose={handleClose}
size={selected ? "xl" : "lg"}
breadcrumbs={breadcrumbs}
primaryAction={{
label: resolvedPrimaryLabel,
onClick: () => void handleSelect(),
disabled: selectionDisabled,
}}
>
<WorkflowPickerContent
workflows={workflows}
selected={selected}
onSelect={setSelected}
search={search}
onSearchChange={setSearch}
loading={loading}
workflowType={workflowType}
previewMode={workflowType === "tabular" ? "columns" : "prompt"}
disabledWorkflow={disabledWorkflow}
/>
</Modal>
);
}

View file

@ -99,7 +99,13 @@ export function WorkflowPromptEditor({
}, [value, editor]);
return (
<div className="flex flex-col h-full border border-gray-200 rounded-md overflow-hidden bg-white">
<div
className={`flex h-full flex-col overflow-hidden bg-white ${
readOnly
? "rounded-md border border-gray-200"
: "rounded-md border border-gray-200"
}`}
>
{!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">
<ToolbarBtn
@ -181,7 +187,18 @@ export function WorkflowPromptEditor({
</ToolbarBtn>
</div>
)}
<div className="flex-1 overflow-y-auto">
{readOnly && (
<div className="flex h-9 shrink-0 items-center bg-gray-50 px-5">
<span className="text-xs font-medium text-gray-500">
Read-only
</span>
</div>
)}
<div
className={`flex-1 overflow-y-auto ${
readOnly ? "border-t border-gray-100" : ""
}`}
>
<EditorContent editor={editor} />
</div>
</div>

View file

@ -0,0 +1,7 @@
import type { Workflow } from "../shared/types";
export function workflowDetailPath(workflow: Pick<Workflow, "id" | "type">) {
return workflow.type === "assistant"
? `/workflows/assistant/${workflow.id}`
: `/workflows/tabular-review/${workflow.id}`;
}

View file

@ -0,0 +1,15 @@
"use client";
import { createContext, useContext } from "react";
interface PageChromeContextValue {
mobileActionsContainer: HTMLElement | null;
}
export const PageChromeContext = createContext<PageChromeContextValue>({
mobileActionsContainer: null,
});
export function usePageChrome() {
return useContext(PageChromeContext);
}