From 3132e04ac08952fb177dba4dfea2daa4563b2cc0 Mon Sep 17 00:00:00 2001 From: willchen96 Date: Thu, 11 Jun 2026 22:43:13 +0800 Subject: [PATCH] Modal, header, mobile display and workflow UI updates --- backend/src/lib/userSettings.ts | 34 +- backend/src/routes/chat.ts | 9 +- backend/src/routes/projectChat.ts | 10 +- backend/src/routes/projects.ts | 16 +- frontend/src/app/(pages)/layout.tsx | 92 +++-- .../[id]/assistant/chat/[chatId]/page.tsx | 11 +- .../src/app/(pages)/tabular-reviews/page.tsx | 2 +- .../(pages)/workflows/assistant/[id]/page.tsx | 13 + .../workflows/tabular-review/[id]/page.tsx | 13 + .../assistant/AssistantWorkflowModal.tsx | 236 +----------- .../projects/ProjectAssistantTab.tsx | 2 +- .../projects/ProjectDetailsModal.tsx | 216 +++++++++++ .../app/components/projects/ProjectPage.tsx | 63 +-- .../components/projects/ProjectPageParts.tsx | 103 +---- .../components/projects/ProjectReviewsTab.tsx | 2 +- .../components/projects/ProjectsOverview.tsx | 2 +- .../src/app/components/shared/PageHeader.tsx | 109 ++++-- .../src/app/components/shared/PeopleModal.tsx | 165 ++++---- .../app/components/shared/RenameableTitle.tsx | 10 +- .../src/app/components/shared/RowActions.tsx | 9 +- .../tabular/ApplyWorkflowPresetModal.tsx | 155 -------- .../src/app/components/tabular/TRTable.tsx | 2 +- .../components/tabular/TRWorkflowModal.tsx | 36 ++ .../components/tabular/TabularReviewView.tsx | 67 ++-- .../workflows/DisplayWorkflowModal.tsx | 255 +----------- .../workflows/ShareWorkflowModal.tsx | 12 +- .../workflows/WorkflowDetailPage.tsx} | 330 +++++++++++----- .../workflows/WorkflowDetailsModal.tsx | 190 +++++++++ .../app/components/workflows/WorkflowList.tsx | 5 +- .../workflows/WorkflowPickerContent.tsx | 362 ++++++++++++++++++ .../workflows/WorkflowPickerModal.tsx | 137 +++++++ .../workflows/WorkflowPromptEditor.tsx | 21 +- .../components/workflows/workflowRoutes.ts | 7 + .../src/app/contexts/PageChromeContext.tsx | 15 + 34 files changed, 1635 insertions(+), 1076 deletions(-) create mode 100644 frontend/src/app/(pages)/workflows/assistant/[id]/page.tsx create mode 100644 frontend/src/app/(pages)/workflows/tabular-review/[id]/page.tsx create mode 100644 frontend/src/app/components/projects/ProjectDetailsModal.tsx delete mode 100644 frontend/src/app/components/tabular/ApplyWorkflowPresetModal.tsx create mode 100644 frontend/src/app/components/tabular/TRWorkflowModal.tsx rename frontend/src/app/{(pages)/workflows/[id]/page.tsx => components/workflows/WorkflowDetailPage.tsx} (64%) create mode 100644 frontend/src/app/components/workflows/WorkflowDetailsModal.tsx create mode 100644 frontend/src/app/components/workflows/WorkflowPickerContent.tsx create mode 100644 frontend/src/app/components/workflows/WorkflowPickerModal.tsx create mode 100644 frontend/src/app/components/workflows/workflowRoutes.ts create mode 100644 frontend/src/app/contexts/PageChromeContext.tsx diff --git a/backend/src/lib/userSettings.ts b/backend/src/lib/userSettings.ts index e3b9826..92ac49a 100644 --- a/backend/src/lib/userSettings.ts +++ b/backend/src/lib/userSettings.ts @@ -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, -): Promise { - 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; - } -} diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index fe82bdf..ecf2dfe 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -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`); diff --git a/backend/src/routes/projectChat.ts b/backend/src/routes/projectChat.ts index e682aaf..1a3a9ea 100644 --- a/backend/src/routes/projectChat.ts +++ b/backend/src/routes/projectChat.ts @@ -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`); diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index d0fdd58..6eea085 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -70,20 +70,6 @@ async function attachDocumentOwnerLabels( .filter((id, index, arr) => arr.indexOf(id) === index); if (ownerIds.length === 0) return; - const emailByUserId = new Map(); - 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(); 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; } } diff --git a/frontend/src/app/(pages)/layout.tsx b/frontend/src/app/(pages)/layout.tsx index 37c8258..4e8e85c 100644 --- a/frontend/src/app/(pages)/layout.tsx +++ b/frontend/src/app/(pages)/layout.tsx @@ -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(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 ( - { - const isSmall = - typeof window !== "undefined" && - window.innerWidth < 768; - if (isSmall) { - if (!open) setIsSidebarOpen(false); - return; - } - setIsSidebarOpen(open); - setIsSidebarOpenDesktop(open); - }, - }} - > -
-
- -
- {/* Mobile header */} -
- + + { + const isSmall = + typeof window !== "undefined" && + window.innerWidth < 768; + if (isSmall) { + if (!open) setIsSidebarOpen(false); + return; + } + setIsSidebarOpen(open); + setIsSidebarOpenDesktop(open); + }, + }} + > +
+
+ +
+ {/* Mobile header */} +
+ +
+
+
+ {children} +
-
- {children} -
-
-
+ +
); } diff --git a/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx b/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx index f15f5d1..d53a22a 100644 --- a/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx +++ b/frontend/src/app/(pages)/projects/[id]/assistant/chat/[chatId]/page.tsx @@ -782,18 +782,15 @@ export default function ProjectAssistantChatPage({ params }: Props) { project ? { label: project.name, - suffix: project.cm_number ? ( - - (#{project.cm_number}) - - ) : 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 diff --git a/frontend/src/app/(pages)/tabular-reviews/page.tsx b/frontend/src/app/(pages)/tabular-reviews/page.tsx index 08937be..54e50d9 100644 --- a/frontend/src/app/(pages)/tabular-reviews/page.tsx +++ b/frontend/src/app/(pages)/tabular-reviews/page.tsx @@ -55,7 +55,7 @@ export default function TabularReviewsPage() { const actionsRef = useRef(null); const router = useRouter(); const { user } = useAuth(); - const stickyCellBg = "bg-[#fcfcfd]"; + const stickyCellBg = "bg-[#fafbfc]"; useEffect(() => { Promise.all([ diff --git a/frontend/src/app/(pages)/workflows/assistant/[id]/page.tsx b/frontend/src/app/(pages)/workflows/assistant/[id]/page.tsx new file mode 100644 index 0000000..c5347f6 --- /dev/null +++ b/frontend/src/app/(pages)/workflows/assistant/[id]/page.tsx @@ -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 ; +} diff --git a/frontend/src/app/(pages)/workflows/tabular-review/[id]/page.tsx b/frontend/src/app/(pages)/workflows/tabular-review/[id]/page.tsx new file mode 100644 index 0000000..406eaf8 --- /dev/null +++ b/frontend/src/app/(pages)/workflows/tabular-review/[id]/page.tsx @@ -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 ; +} diff --git a/frontend/src/app/components/assistant/AssistantWorkflowModal.tsx b/frontend/src/app/components/assistant/AssistantWorkflowModal.tsx index c8c7314..3272528 100644 --- a/frontend/src/app/components/assistant/AssistantWorkflowModal.tsx +++ b/frontend/src/app/components/assistant/AssistantWorkflowModal.tsx @@ -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; projectName?: string; projectCmNumber?: string | null; initialWorkflowId?: string; @@ -26,70 +20,6 @@ export function AssistantWorkflowModal({ projectCmNumber, initialWorkflowId, }: Props) { - const [workflows, setWorkflows] = useState([]); - const [loading, setLoading] = useState(false); - const [selected, setSelected] = useState(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 ( - - {/* Content */} -
- {/* Left panel — workflow list */} -
- {/* Search */} -
-
- - setSearch(e.target.value)} - className="flex-1 bg-transparent text-xs text-gray-700 placeholder:text-gray-400 outline-none" - /> - {search && ( - - )} -
-
- - {loading ? ( -
- {[60, 45, 75, 50, 65, 40, 55].map((w, i) => ( -
-
-
-
- ))} -
- ) : filteredWorkflows.length === 0 ? ( -

- {search ? "No matches found" : "No assistant workflows found"} -

- ) : ( -
- {filteredWorkflows.map((wf) => ( - - ))} -
- )} -
- - {/* Right panel — prompt preview */} - {selected && ( -
-
-

- Workflow Prompt -

- -
-
- ( -

- {children} -

- ), - h2: ({ children }) => ( -

- {children} -

- ), - h3: ({ children }) => ( -

- {children} -

- ), - p: ({ children }) => ( -

- {children} -

- ), - ul: ({ children }) => ( -
    - {children} -
- ), - ol: ({ children }) => ( -
    - {children} -
- ), - li: ({ children }) => ( -
  • {children}
  • - ), - strong: ({ children }) => ( - - {children} - - ), - em: ({ children }) => ( - - {children} - - ), - }} - > - {selected.prompt_md ?? - "_No prompt defined._"} -
    -
    -
    - )} -
    - - + primaryLabel="Use" + initialWorkflowId={initialWorkflowId} + /> ); } diff --git a/frontend/src/app/components/projects/ProjectAssistantTab.tsx b/frontend/src/app/components/projects/ProjectAssistantTab.tsx index b2690b7..fbddf25 100644 --- a/frontend/src/app/components/projects/ProjectAssistantTab.tsx +++ b/frontend/src/app/components/projects/ProjectAssistantTab.tsx @@ -41,7 +41,7 @@ export function ProjectAssistantTab({ setRenamingChatId: Dispatch>; setRenameChatValue: Dispatch>; }) { - const stickyCellBg = "bg-[#fcfcfd]"; + const stickyCellBg = "bg-[#fafbfc]"; return ( <> diff --git a/frontend/src/app/components/projects/ProjectDetailsModal.tsx b/frontend/src/app/components/projects/ProjectDetailsModal.tsx new file mode 100644 index 0000000..1fbb2ea --- /dev/null +++ b/frontend/src/app/components/projects/ProjectDetailsModal.tsx @@ -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; + onClose: () => void; + onSave: (values: { name: string; cmNumber: string }) => Promise; + 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(null); + const [peopleLoading, setPeopleLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(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 ( + , + onClick: onShareProject, + }} + footerStatus={ + error ? ( + {error} + ) : saved ? ( + Updated + ) : null + } + primaryAction={ + canEdit + ? { + label: saving ? "Updating..." : "Update", + onClick: () => void handleSave(), + disabled: saving || !hasChanges || !trimmedName, + } + : undefined + } + cancelAction={canEdit ? undefined : false} + > +
    +
    + + { + 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" + /> +
    + +
    + + { + 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" + /> +
    + +
    + + + + Loading + + ) : ( + ownerLabel + ) + } + /> +
    +
    +
    + ); +} + +function DetailRow({ label, value }: { label: string; value: ReactNode }) { + return ( +
    + {label} + + {value} + +
    + ); +} diff --git a/frontend/src/app/components/projects/ProjectPage.tsx b/frontend/src/app/components/projects/ProjectPage.tsx index f58e751..808c8d2 100644 --- a/frontend/src/app/components/projects/ProjectPage.tsx +++ b/frontend/src/app/components/projects/ProjectPage.tsx @@ -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(null); const { user } = useAuth(); - const stickyCellBg = "bg-[#fcfcfd]"; + const { profile } = useUserProfile(); + const stickyCellBg = "bg-[#fafbfc]"; const [viewingDoc, setViewingDoc] = useState(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)} /> + setProjectDetailsOpen(false)} + onSave={handleProjectDetailsSave} + onShareProject={() => { + setProjectDetailsOpen(false); + setPeopleModalOpen(true); + }} + /> + (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) => { - 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" ? ( - setDraft(e.target.value)} - onKeyDown={handleEditKeyDown} - onBlur={commitEdit} - className={`${editInputClassName} text-gray-900`} - aria-label="Rename project" - /> - ) : ( + const titleLabel = !project ? undefined : ( startEdit("name")} + onClick={requestRename} className="inline-block cursor-text" title="Rename" > @@ -441,31 +402,6 @@ export function ProjectPageHeader({ ); - const cmSuffix = !project ? null : editingField === "cm" ? ( - - (# - setDraft(e.target.value)} - onKeyDown={handleEditKeyDown} - onBlur={commitEdit} - className={`${editInputClassName} text-gray-400`} - aria-label="Rename CM number" - /> - ) - - ) : project.cm_number ? ( - startEdit("cm")} - className="ml-1 inline-block cursor-text text-gray-400" - title="Rename CM" - > - (#{project.cm_number}) - - ) : null; - return ( 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 ? ( ) : ( @@ -549,6 +485,7 @@ export function ProjectPageHeader({ { onClick: onNewReview, disabled: docsCount === 0 || creatingReview, + compact: true, icon: creatingReview ? ( ) : ( diff --git a/frontend/src/app/components/projects/ProjectReviewsTab.tsx b/frontend/src/app/components/projects/ProjectReviewsTab.tsx index 14cf49c..9d9eb8f 100644 --- a/frontend/src/app/components/projects/ProjectReviewsTab.tsx +++ b/frontend/src/app/components/projects/ProjectReviewsTab.tsx @@ -45,7 +45,7 @@ export function ProjectReviewsTab({ setRenamingReviewId: Dispatch>; setRenameReviewValue: Dispatch>; }) { - const stickyCellBg = "bg-[#fcfcfd]"; + const stickyCellBg = "bg-[#fafbfc]"; return ( <> diff --git a/frontend/src/app/components/projects/ProjectsOverview.tsx b/frontend/src/app/components/projects/ProjectsOverview.tsx index 80d0223..f6d8a2f 100644 --- a/frontend/src/app/components/projects/ProjectsOverview.tsx +++ b/frontend/src/app/components/projects/ProjectsOverview.tsx @@ -41,7 +41,7 @@ export function ProjectsOverview() { const actionsRef = useRef(null); const router = useRouter(); const { user, isAuthenticated, authLoading } = useAuth(); - const stickyCellBg = "bg-[#fcfcfd]"; + const stickyCellBg = "bg-[#fafbfc]"; useEffect(() => { if (authLoading) { diff --git a/frontend/src/app/components/shared/PageHeader.tsx b/frontend/src/app/components/shared/PageHeader.tsx index f32c02b..4e82158 100644 --- a/frontend/src/app/components/shared/PageHeader.tsx +++ b/frontend/src/app/components/shared/PageHeader.tsx @@ -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 ? ( ) : ( @@ -124,6 +127,7 @@ export function PageHeader({ ? [{ actions: actionItems, gap: actionGap }] : []) ); + const hasActions = groupedActionItems.length > 0; return (
    {headerContent} - {groupedActionItems.length > 0 && ( -
    - {groupedActionItems.map((group, groupIndex) => ( -
    - {group.actions.map((action, index) => ( - - - - ))} -
    - ))} + {hasActions && ( +
    +
    )} + {hasActions && + mobileActionsContainer && + createPortal( +
    + +
    , + mobileActionsContainer, + )}
    ); } +function PageHeaderActionGroups({ + groupedActionItems, + actionsDisabled, +}: { + groupedActionItems: { + actions: PageHeaderAction[]; + gap: PageHeaderActionGap; + }[]; + actionsDisabled: boolean; +}) { + return ( + <> + {groupedActionItems.map((group, groupIndex) => ( +
    + {group.actions.map((action, index) => ( + + + + ))} +
    + ))} + + ); +} + 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[] }) { )} -
    +
    {items.map((item, index) => ( ))}
    -
    - {current ? ( - - ) : null} -
    ); } @@ -484,11 +518,9 @@ function PageHeaderBreadcrumbs({ items }: { items: PageHeaderBreadcrumb[] }) { function BreadcrumbItem({ item, current, - showSuffix, }: { item: PageHeaderBreadcrumb; current: boolean; - showSuffix: boolean; }) { const content = item.loading ? (
    {item.label} - {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 ( - <> + {current ? ( {content} ) : item.onClick ? ( @@ -533,6 +568,6 @@ function BreadcrumbItem({ {content} )} {!current && } - + ); } diff --git a/frontend/src/app/components/shared/PeopleModal.tsx b/frontend/src/app/components/shared/PeopleModal.tsx index bc3c0a8..fba63ef 100644 --- a/frontend/src/app/components/shared/PeopleModal.tsx +++ b/frontend/src/app/components/shared/PeopleModal.tsx @@ -207,9 +207,10 @@ export function PeopleModal({ } with access.` } > +
    {/* Add-member row */} {onSharedWithChange && ( -
    +
    @@ -269,90 +270,92 @@ export function PeopleModal({ {error}

    )} -
    +
    )} - {/* Section heading */} -
    -

    - People with Access -

    - {peopleLoading && ( - - )} -
    - - {/* Member list */} - {roster.length === 0 ? ( -
    - No one has access yet. +
    +
    +

    + People with Access +

    + {peopleLoading && ( + + )}
    - ) : ( -
      - {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 ( -
    • -
      - -
      -
      -

      - {primary} - {isYou && ( - - (You) - - )} - {entry.role === "owner" && ( - - Owner - - )} -

      - {showSecondary && ( -

      - {entry.email} -

      - )} -
      - {entry.role === "member" && - onSharedWithChange && ( - - )} -
    • - ); - })} -
    - )} + {entry.role === "owner" && ( + + Owner + + )} +

    + {showSecondary && ( +

    + {entry.email} +

    + )} +
    + {entry.role === "member" && + onSharedWithChange && ( + + )} + + ); + })} + + )} + +
    ); diff --git a/frontend/src/app/components/shared/RenameableTitle.tsx b/frontend/src/app/components/shared/RenameableTitle.tsx index e436062..db1560c 100644 --- a/frontend/src/app/components/shared/RenameableTitle.tsx +++ b/frontend/src/app/components/shared/RenameableTitle.tsx @@ -21,6 +21,7 @@ export function RenameableTitle({ value, onCommit, suffix }: Props) { const [draft, setDraft] = useState(""); const caretPos = useRef(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); } }} diff --git a/frontend/src/app/components/shared/RowActions.tsx b/frontend/src/app/components/shared/RowActions.tsx index 801cf56..6fe1603 100644 --- a/frontend/src/app/components/shared/RowActions.tsx +++ b/frontend/src/app/components/shared/RowActions.tsx @@ -141,9 +141,12 @@ export function RowActionMenuItems({ )} {onDelete && ( - ); - }) - )} -
    -
    - - ); -} diff --git a/frontend/src/app/components/tabular/TRTable.tsx b/frontend/src/app/components/tabular/TRTable.tsx index 9317183..0fc77df 100644 --- a/frontend/src/app/components/tabular/TRTable.tsx +++ b/frontend/src/app/components/tabular/TRTable.tsx @@ -67,7 +67,7 @@ export const TRTable = forwardRef(function TRTable( }, ref, ) { - const stickyCellBg = "bg-[#fcfcfd]"; + const stickyCellBg = "bg-[#fafbfc]"; const scrollContainerRef = useRef(null); const sortedColumns = [...columns].sort((a, b) => a.index - b.index); const totalContentWidth = diff --git a/frontend/src/app/components/tabular/TRWorkflowModal.tsx b/frontend/src/app/components/tabular/TRWorkflowModal.tsx new file mode 100644 index 0000000..8290678 --- /dev/null +++ b/frontend/src/app/components/tabular/TRWorkflowModal.tsx @@ -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; + breadcrumbs: ReactNode[]; + applying?: boolean; +} + +export function TRWorkflowModal({ + open, + onClose, + onApply, + breadcrumbs, + applying = false, +}: TRWorkflowModalProps) { + return ( + !workflow.columns_config?.length} + /> + ); +} diff --git a/frontend/src/app/components/tabular/TabularReviewView.tsx b/frontend/src/app/components/tabular/TabularReviewView.tsx index 0e4d1a6..802a07c 100644 --- a/frontend/src/app/components/tabular/TabularReviewView.tsx +++ b/frontend/src/app/components/tabular/TabularReviewView.tsx @@ -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 ? ( - - (#{project.cm_number}) - - ) : 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) { } /> - { - 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} /> ( -

    - {children} -

    - ), - h2: ({ children }) => ( -

    - {children} -

    - ), - h3: ({ children }) => ( -

    - {children} -

    - ), - p: ({ children }) => ( -

    {children}

    - ), - ul: ({ children }) => ( -
      - {children} -
    - ), - ol: ({ children }) => ( -
      - {children} -
    - ), - li: ({ children }) =>
  • {children}
  • , - strong: ({ children }) => ( - - {children} - - ), - em: ({ children }) => {children}, - }} - > - {content} - - ); -} - -// --------------------------------------------------------------------------- -// Right panel for assistant workflows (select screen) -// --------------------------------------------------------------------------- -function AssistantPanel({ workflow }: { workflow: Workflow }) { - return ( -
    -
    -

    - Workflow Prompt -

    -
    -
    - -
    -
    - ); -} - -// --------------------------------------------------------------------------- -// Right panel for tabular workflows — accordion column list (select screen) -// --------------------------------------------------------------------------- -function TabularPanel({ workflow }: { workflow: Workflow }) { - const [expandedIndex, setExpandedIndex] = useState(null); - const columns = (workflow.columns_config ?? []).sort( - (a, b) => a.index - b.index, - ); - - return ( -
    -
    -

    Columns

    -
    -
    - {columns.length === 0 ? ( -

    - No columns defined -

    - ) : ( - columns.map((col) => { - const isExpanded = expandedIndex === col.index; - const FormatIcon = formatIcon(col.format ?? "text"); - return ( -
    - - {isExpanded && ( -
    - {col.tags && col.tags.length > 0 && ( -
    -

    - Tags -

    -
    - {col.tags.map((tag) => ( - - {tag} - - ))} -
    -
    - )} -
    -

    - Prompt -

    - -
    -
    - )} -
    - ); - }) - )} -
    -
    - ); -} - // --------------------------------------------------------------------------- // DisplayWorkflowModal // --------------------------------------------------------------------------- @@ -288,7 +118,6 @@ export function DisplayWorkflowModal({ workflows, workflow, onClose }: Props) { const [screen, setScreen] = useState<"select" | "configure">("select"); const [selected, setSelected] = useState(workflow); const [listSearch, setListSearch] = useState(""); - const selectedRowRef = useRef(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" && ( -
    - {/* Left: workflow list */} -
    - {/* Search */} -
    -
    - - setListSearch(e.target.value)} - className="flex-1 bg-transparent text-xs text-gray-700 placeholder:text-gray-400 outline-none" - /> - {listSearch && ( - - )} -
    -
    - {/* List */} -
    - {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 ( - - ); - })} -
    -
    - - {/* Right: workflow detail */} - {wf.type === "assistant" ? ( - - ) : ( - - )} -
    + { + if (next) setSelected(next); + }} + search={listSearch} + onSearchChange={setListSearch} + workflowType="all" + previewMode="auto" + showTypeIcon + allowClearPreview={false} + /> )} {/* ── ASSISTANT CONFIGURE SCREEN ── */} diff --git a/frontend/src/app/components/workflows/ShareWorkflowModal.tsx b/frontend/src/app/components/workflows/ShareWorkflowModal.tsx index 69f99de..b2e1459 100644 --- a/frontend/src/app/components/workflows/ShareWorkflowModal.tsx +++ b/frontend/src/app/components/workflows/ShareWorkflowModal.tsx @@ -84,6 +84,8 @@ export function ShareWorkflowModal({ disabled: saving || pendingEmails.length === 0, }} > +
    +
    ) : null} +
    {/* Permission toggle */} -
    +
    Allow editing by share recipients -
    + {/* Existing access */} -
    +

    People with access

    {loading ? (
    @@ -146,7 +149,8 @@ export function ShareWorkflowModal({ ))}
    )} -
    + +
    ); } diff --git a/frontend/src/app/(pages)/workflows/[id]/page.tsx b/frontend/src/app/components/workflows/WorkflowDetailPage.tsx similarity index 64% rename from frontend/src/app/(pages)/workflows/[id]/page.tsx rename to frontend/src/app/components/workflows/WorkflowDetailPage.tsx index 4f5f9f1..db62ecb 100644 --- a/frontend/src/app/(pages)/workflows/[id]/page.tsx +++ b/frontend/src/app/components/workflows/WorkflowDetailPage.tsx @@ -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(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("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 ( -
    - {/* Header skeleton */} +
    router.push("/workflows"), + title: "Back to Workflows", + }, { loading: true, skeletonClassName: "w-40" }, ]} /> - - {/* Toolbar skeleton */} -
    -
    -
    - - {/* Table header skeleton */} -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - - {/* Row skeletons */} -
    - {[1, 2, 3, 4, 5].map((i) => ( -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - ))} +
    + {workflowType === "tabular" ? ( + + ) : ( + + )}
    ); @@ -263,31 +274,29 @@ export default function WorkflowDetailPage({ params }: Props) { title: "Back to Workflows", }, { - label: readOnly ? ( + label: ( {workflow.title} - ) : ( - ), }, ]} actions={[ - { - type: "custom", - render: ( - - {saveStatus === "saving" - ? "Saving…" - : saveStatus === "saved" - ? "Saved" - : ""} - - ), - }, + saveStatus !== "idle" + ? { + type: "custom", + render: ( + + {saveStatus === "saved" ? ( + + ) : null} + {saveStatus === "saving" + ? "Saving…" + : "Saved"} + + ), + } + : null, canShare ? { onClick: () => setShareOpen(true), @@ -296,8 +305,57 @@ export default function WorkflowDetailPage({ params }: Props) { icon: , } : null, + !readOnly + ? { + type: "custom", + render: ( + + 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, ]} /> + setDetailsOpen(false)} + onSave={handleWorkflowDetailsSave} + onShareWorkflow={() => { + setDetailsOpen(false); + setShareOpen(true); + }} + /> {shareOpen && ( setShareOpen(false)} /> )} - - {/* Read-only badge for built-in workflows */} - {readOnly && ( -
    - Read-only -
    - )} + void handleDeleteWorkflow()} + onCancel={() => { + if (deleteStatus === "loading") return; + setDeleteOpen(false); + setDeleteStatus("idle"); + }} + /> {/* Body */}
    {workflow.type === "assistant" ? ( /* ── Assistant: WYSIWYG editor ── */ -
    +
    {/* Toolbar */} {!readOnly && ( -
    +
    )} + {readOnly && ( +
    + + Read-only + +
    + )}
    {/* Table header */} -
    +
    {columns.length > 0 && ( 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" >
    @@ -505,3 +576,84 @@ export default function WorkflowDetailPage({ params }: Props) {
    ); } + +function AssistantWorkflowEditorSkeleton() { + return ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ); +} + +function TabularWorkflowEditorSkeleton() { + return ( + <> +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + {[1, 2, 3, 4, 5].map((i) => ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ))} +
    + + ); +} diff --git a/frontend/src/app/components/workflows/WorkflowDetailsModal.tsx b/frontend/src/app/components/workflows/WorkflowDetailsModal.tsx new file mode 100644 index 0000000..957afe3 --- /dev/null +++ b/frontend/src/app/components/workflows/WorkflowDetailsModal.tsx @@ -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; + onShareWorkflow: () => void; +} + +export function WorkflowDetailsModal({ + open, + workflow, + canEdit, + canShare, + currentUserDisplayName, + currentUserEmail, + onClose, + onSave, + onShareWorkflow, +}: WorkflowDetailsModalProps) { + const [titleDraft, setTitleDraft] = useState(""); + const [shareCount, setShareCount] = useState(null); + const [sharesLoading, setSharesLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(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 ( + , + onClick: onShareWorkflow, + } + : undefined + } + footerStatus={ + error ? ( + {error} + ) : saved ? ( + Updated + ) : null + } + primaryAction={ + canEdit + ? { + label: saving ? "Updating..." : "Update", + onClick: () => void handleSave(), + disabled: saving || !hasChanges || !trimmedTitle, + } + : undefined + } + cancelAction={canEdit ? undefined : false} + > +
    +
    + + { + 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" + /> +
    + +
    + + + ) : ( + ownershipLabel + ) + } + /> + +
    +
    +
    + ); +} + +function DetailRow({ label, value }: { label: string; value: ReactNode }) { + return ( +
    + {label} + + {value} + +
    + ); +} diff --git a/frontend/src/app/components/workflows/WorkflowList.tsx b/frontend/src/app/components/workflows/WorkflowList.tsx index c8691fc..2888cd2 100644 --- a/frontend/src/app/components/workflows/WorkflowList.tsx +++ b/frontend/src/app/components/workflows/WorkflowList.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [selected, setSelected] = useState(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)); }} />
    diff --git a/frontend/src/app/components/workflows/WorkflowPickerContent.tsx b/frontend/src/app/components/workflows/WorkflowPickerContent.tsx new file mode 100644 index 0000000..cf4bb9b --- /dev/null +++ b/frontend/src/app/components/workflows/WorkflowPickerContent.tsx @@ -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(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 ( +
    +
    +
    +
    + + + onSearchChange(event.target.value) + } + className="flex-1 bg-transparent text-sm text-gray-700 outline-none placeholder:text-gray-400" + /> + {search && ( + + )} +
    +
    + + {loading ? ( +
    + {[60, 45, 75, 50, 65, 40, 55].map((width, index) => ( +
    +
    +
    +
    + ))} +
    + ) : filteredWorkflows.length === 0 ? ( +

    + {resolvedEmptyMessage} +

    + ) : ( +
    + {filteredWorkflows.map((workflow) => { + const disabled = disabledWorkflow?.(workflow) ?? false; + const isSelected = selected?.id === workflow.id; + const TypeIcon = + workflow.type === "tabular" + ? Table2 + : MessageSquare; + return ( + + ); + })} +
    + )} +
    + + {selected && ( + onSelect(null)} + allowClear={allowClearPreview} + /> + )} +
    + ); +} + +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 ( +
    +
    +

    + Workflow Details +

    + {allowClear ? ( + + ) : null} +
    + {resolvedMode === "columns" ? ( + + ) : ( + + )} +
    + ); +} + +function WorkflowPromptPreview({ content }: { content: string }) { + return ( +
    + +
    + ); +} + +function WorkflowPromptMarkdown({ content }: { content: string }) { + return ( + ( +

    + {children} +

    + ), + h2: ({ children }) => ( +

    + {children} +

    + ), + h3: ({ children }) => ( +

    + {children} +

    + ), + p: ({ children }) => ( +

    {children}

    + ), + ul: ({ children }) => ( +
      + {children} +
    + ), + ol: ({ children }) => ( +
      + {children} +
    + ), + li: ({ children }) =>
  • {children}
  • , + strong: ({ children }) => ( + + {children} + + ), + em: ({ children }) => {children}, + }} + > + {content} +
    + ); +} + +function WorkflowColumnPreview({ columns }: { columns: ColumnConfig[] }) { + const [expandedIndex, setExpandedIndex] = useState(null); + const sortedColumns = [...columns].sort((a, b) => a.index - b.index); + return ( +
    + {sortedColumns.length === 0 ? ( +

    + No columns defined +

    + ) : ( + sortedColumns.map((column) => { + const isExpanded = expandedIndex === column.index; + const FormatIcon = formatIcon(column.format ?? "text"); + return ( +
    + + {isExpanded ? ( +
    + {column.tags && column.tags.length > 0 ? ( +
    +

    + Tags +

    +
    + {column.tags.map((tag, tagIdx) => ( + + {tag} + + ))} +
    +
    + ) : null} +
    +

    + Prompt +

    + +
    +
    + ) : null} +
    + ); + }) + )} +
    + ); +} diff --git a/frontend/src/app/components/workflows/WorkflowPickerModal.tsx b/frontend/src/app/components/workflows/WorkflowPickerModal.tsx new file mode 100644 index 0000000..327dd42 --- /dev/null +++ b/frontend/src/app/components/workflows/WorkflowPickerModal.tsx @@ -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; + 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([]); + const [loading, setLoading] = useState(false); + const [selected, setSelected] = useState(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 ( + void handleSelect(), + disabled: selectionDisabled, + }} + > + + + ); +} diff --git a/frontend/src/app/components/workflows/WorkflowPromptEditor.tsx b/frontend/src/app/components/workflows/WorkflowPromptEditor.tsx index fc4ddf7..4ea6f09 100644 --- a/frontend/src/app/components/workflows/WorkflowPromptEditor.tsx +++ b/frontend/src/app/components/workflows/WorkflowPromptEditor.tsx @@ -99,7 +99,13 @@ export function WorkflowPromptEditor({ }, [value, editor]); return ( -
    +
    {!readOnly && editor && (
    )} -
    + {readOnly && ( +
    + + Read-only + +
    + )} +
    diff --git a/frontend/src/app/components/workflows/workflowRoutes.ts b/frontend/src/app/components/workflows/workflowRoutes.ts new file mode 100644 index 0000000..55337a0 --- /dev/null +++ b/frontend/src/app/components/workflows/workflowRoutes.ts @@ -0,0 +1,7 @@ +import type { Workflow } from "../shared/types"; + +export function workflowDetailPath(workflow: Pick) { + return workflow.type === "assistant" + ? `/workflows/assistant/${workflow.id}` + : `/workflows/tabular-review/${workflow.id}`; +} diff --git a/frontend/src/app/contexts/PageChromeContext.tsx b/frontend/src/app/contexts/PageChromeContext.tsx new file mode 100644 index 0000000..9792eaf --- /dev/null +++ b/frontend/src/app/contexts/PageChromeContext.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { createContext, useContext } from "react"; + +interface PageChromeContextValue { + mobileActionsContainer: HTMLElement | null; +} + +export const PageChromeContext = createContext({ + mobileActionsContainer: null, +}); + +export function usePageChrome() { + return useContext(PageChromeContext); +}