Sync CourtListener verification and document safety updates

- Refine CourtListener citation verification, bulk lookup logging, and API fallback behavior
- Persist cancelled chat stream output and render cancellation as the final assistant message
- Add document/version deletion safety fixes and shared warning/modal UI updates
- Sync document panel, case law panel, and response UI styling refinements
- Harden OSS sync script to preserve local env, dependency, and generated files
This commit is contained in:
willchen96 2026-06-09 01:46:58 +08:00
parent 44e868eb42
commit f32a194b33
24 changed files with 2494 additions and 1222 deletions

View file

@ -40,7 +40,6 @@ export type CaseTab = {
url: string | null;
dateFiled: string | null;
pdfUrl: string | null;
judges: string | null;
quotes?: CaseCitationQuote[];
opinions?: CaseLawOpinion[];
};
@ -281,7 +280,6 @@ export function CaseLawPanel({
const citation = tab.citation;
const courtlistenerUrl = tab.url;
const filedDate = formatCaseDate(tab.dateFiled);
const judges = tab.judges?.trim() || null;
const orderedOpinions = orderOpinions(opinions);
const activeOpinion = opinions.find(
(opinion) => opinion.opinionId === activeOpinionId,
@ -377,13 +375,9 @@ export function CaseLawPanel({
<span className="text-gray-500">, {citation}</span>
)}
</h2>
{filedDate || judges ? (
{filedDate ? (
<p className="mt-1 font-serif text-sm text-gray-600">
{filedDate && <>Date: {filedDate}</>}
{filedDate && judges && (
<span className="mx-1.5 text-gray-300">|</span>
)}
{judges && <>Judges: {judges}</>}
Date: {filedDate}
</p>
) : null}
</div>

View file

@ -214,7 +214,6 @@ export function ChatView({
url: citation.url ?? null,
dateFiled: citation.dateFiled ?? null,
pdfUrl: citation.pdfUrl ?? null,
judges: citation.judges ?? null,
quotes: showQuotes ? citation.quotes : undefined,
opinions: undefined,
});
@ -259,7 +258,6 @@ export function ChatView({
url: citation.url,
dateFiled: citation.dateFiled ?? null,
pdfUrl: citation.pdfUrl ?? null,
judges: citation.judges ?? null,
quotes: undefined,
opinions: citation.case?.opinions,
});

View file

@ -14,7 +14,6 @@ import {
} from "lucide-react";
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
import { DocView } from "@/app/components/shared/DocView";
import { DocFileIcon } from "@/app/components/shared/FileDirectory";
import { WarningPopup } from "@/app/components/shared/WarningPopup";
import type { Document } from "@/app/components/shared/types";
import type { DocumentVersion } from "@/app/lib/mikeApi";
@ -27,6 +26,10 @@ const MIN_DATA_COLUMN_WIDTH = 280;
const DEFAULT_DATA_COLUMN_WIDTH = 340;
const RESIZER_WIDTH = 6;
const MAX_PANEL_WIDTH = 1180;
const primaryGlassButtonClass =
"inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-gray-700/40 bg-gray-950/88 px-3 text-xs font-medium text-white shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl transition-all hover:bg-gray-900/90 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100";
const dangerGlassButtonClass =
"inline-flex h-8 items-center justify-center gap-1.5 rounded-full border border-red-700/35 bg-red-600/90 px-3 text-xs font-medium text-white shadow-[0_3px_9px_rgba(127,29,29,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(127,29,29,0.18)] backdrop-blur-xl transition-all hover:bg-red-600 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100";
interface DocumentSidePanelProps {
doc: Document | null;
@ -48,10 +51,7 @@ interface DocumentSidePanelProps {
versionId: string,
filename: string,
) => Promise<void> | void;
onDeleteVersion: (
docId: string,
versionId: string,
) => Promise<void> | void;
onDeleteVersion: (docId: string, versionId: string) => Promise<void> | void;
onUploadNewVersion: (
doc: Document,
file: File,
@ -69,7 +69,6 @@ export function DocumentSidePanel({
onClose,
onLoadVersions,
onSelectVersion,
onDownloadDocument,
onDownloadVersion,
onRenameVersion,
onDeleteVersion,
@ -84,7 +83,9 @@ export function DocumentSidePanel({
const [savingName, setSavingName] = useState(false);
const [nameError, setNameError] = useState<string | null>(null);
const [extensionWarningOpen, setExtensionWarningOpen] = useState(false);
const [deletingVersion, setDeletingVersion] = useState(false);
const [deletingVersionId, setDeletingVersionId] = useState<string | null>(
null,
);
const [deletingDocument, setDeletingDocument] = useState(false);
const [confirmDeleteDocumentOpen, setConfirmDeleteDocumentOpen] =
useState(false);
@ -142,8 +143,7 @@ export function DocumentSidePanel({
orderedVersions[0] ??
null;
const selectedVersionId = selectedVersion?.id ?? versionId ?? null;
const selectedFilename =
selectedVersion?.filename?.trim() || doc.filename;
const selectedFilename = selectedVersion?.filename?.trim() || doc.filename;
const selectedFileType =
selectedVersion != null
? fileTypeForVersion(selectedVersion, doc.file_type)
@ -207,15 +207,14 @@ export function DocumentSidePanel({
}
}
async function handleDeleteSelectedVersion() {
if (!selectedVersionId) return;
setDeletingVersion(true);
async function handleDeleteVersion(versionIdToDelete: string) {
setDeletingVersionId(versionIdToDelete);
try {
await onDeleteVersion(documentId, selectedVersionId);
await onDeleteVersion(documentId, versionIdToDelete);
} catch (err) {
console.error("delete version failed", err);
} finally {
setDeletingVersion(false);
setDeletingVersionId(null);
}
}
@ -261,7 +260,8 @@ export function DocumentSidePanel({
panelWidth - MIN_DOC_COLUMN_WIDTH - RESIZER_WIDTH,
);
const nextWidth =
dragStartDataWidth.current + (dragStartX.current - event.clientX);
dragStartDataWidth.current +
(dragStartX.current - event.clientX);
setDataColumnWidth(
Math.min(
maxDataWidth,
@ -290,7 +290,8 @@ export function DocumentSidePanel({
const handleMouseMove = (event: MouseEvent) => {
const nextWidth =
dragStartPanelWidth.current + (dragStartX.current - event.clientX);
dragStartPanelWidth.current +
(dragStartX.current - event.clientX);
setPanelWidth(clampPanelWidth(nextWidth, dataColumnWidth));
};
@ -383,13 +384,13 @@ export function DocumentSidePanel({
<aside
className={cn(
"flex min-h-0 flex-col",
"bg-white/25",
"mb-3 ml-2 mr-3 flex min-h-0 flex-col overflow-hidden rounded-xl",
"border border-white/70 bg-white/55 shadow-[0_3px_9px_rgba(15,23,42,0.06),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-4px_9px_rgba(255,255,255,0.08)] backdrop-blur-2xl",
)}
>
<div
className={cn(
"shrink-0 px-4 pb-3 pt-0",
"shrink-0 px-4 py-3",
"border-b border-white/60",
)}
>
@ -400,28 +401,30 @@ export function DocumentSidePanel({
{editingName ? (
<div className="space-y-1.5">
<div className="flex min-h-6 items-center gap-2">
<input
value={nameDraft}
onChange={(e) => {
setNameDraft(e.target.value);
setNameError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleSaveName();
}
if (e.key === "Escape") {
setEditingName(false);
<input
value={nameDraft}
onChange={(e) => {
setNameDraft(e.target.value);
setNameError(null);
}
}}
className="h-6 min-w-0 flex-1 border-0 border-b border-gray-300 bg-transparent px-0 text-xs leading-6 text-gray-900 outline-none transition-colors focus:border-gray-500"
autoFocus
/>
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleSaveName();
}
if (e.key === "Escape") {
setEditingName(false);
setNameError(null);
}
}}
className="h-6 min-w-0 flex-1 border-0 border-b border-gray-300 bg-transparent px-0 text-xs leading-6 text-gray-900 outline-none transition-colors focus:border-gray-500"
autoFocus
/>
<button
type="button"
onClick={() => void handleSaveName()}
onClick={() =>
void handleSaveName()
}
disabled={savingName}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-white/65 hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-40"
title="Save name"
@ -465,96 +468,57 @@ export function DocumentSidePanel({
<div className="mb-3 text-xs font-medium text-gray-900">
Document Data
</div>
<div className="space-y-1.5">
<DataRow label="Type" value={selectedFileType ?? "—"} />
<DataRow
label="Size"
value={
selectedSizeBytes != null
? formatBytes(selectedSizeBytes)
: "—"
}
/>
<DataRow
label="Version"
value={
selectedVersionNumber != null
? String(selectedVersionNumber)
: "—"
}
/>
<DataRow
label="Uploaded"
value={
selectedUploadedAt
? formatDate(selectedUploadedAt)
: "—"
}
/>
{selectedPageCount != null && (
<div className="rounded-xl bg-gray-100/70 px-3 py-3">
<div className="space-y-1.5">
<DataRow
label="Pages"
value={String(selectedPageCount)}
label="Type"
value={selectedFileType ?? "—"}
/>
)}
</div>
<div className="mt-4 flex items-center justify-between gap-2">
<button
type="button"
onClick={() =>
void handleDeleteSelectedVersion()
}
disabled={
!selectedVersionId ||
versions.length <= 1 ||
deletingVersion
}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/65 px-3 py-2 text-xs font-medium text-red-600 transition-colors hover:border-red-200 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40",
<DataRow
label="Size"
value={
selectedSizeBytes != null
? formatBytes(selectedSizeBytes)
: "—"
}
/>
<DataRow
label="Version"
value={
selectedVersionNumber != null
? String(selectedVersionNumber)
: "—"
}
/>
<DataRow
label="Uploaded"
value={
selectedUploadedAt
? formatDate(selectedUploadedAt)
: "—"
}
/>
{selectedPageCount != null && (
<DataRow
label="Pages"
value={String(selectedPageCount)}
/>
)}
>
{deletingVersion ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
Delete version
</button>
<button
type="button"
onClick={() =>
selectedVersionId
? void onDownloadVersion(
doc.id,
selectedVersionId,
selectedFilename,
)
: void onDownloadDocument(doc.id)
}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/65 px-3 py-2 text-xs font-medium text-gray-700 transition-colors hover:border-gray-400 hover:bg-white hover:text-gray-900"
>
<Download className="h-3.5 w-3.5" />
Download
</button>
</div>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col px-4 pb-3 pt-0">
<div className="mb-2 text-xs font-medium text-gray-900">
Versions
</div>
<div
className={cn(
"flex min-h-0 flex-1 flex-col overflow-visible rounded-xl",
"border border-white/60 bg-white/35 shadow-[inset_0_1px_0_rgba(255,255,255,0.8)]",
"bg-gray-100 px-2",
)}
>
<div
className={cn(
"shrink-0 py-2 text-xs font-medium text-gray-900",
"border-b border-white/60",
)}
>
Versions
</div>
<div className="-mx-2 min-h-0 flex-1 overflow-y-auto px-2 py-2">
<div className="min-h-0 flex-1 overflow-y-auto py-2">
{versionsLoading && versions.length === 0 ? (
<div className="flex items-center gap-2 py-2 text-xs text-gray-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
@ -565,59 +529,136 @@ export function DocumentSidePanel({
No version history.
</div>
) : (
<div className="space-y-1">
<div className="space-y-1.5">
{orderedVersions.map((version) => {
const title =
versionTitleFor(version);
const filename =
versionFilenameFor(version);
const selected =
selectedVersionId === version.id;
const fileType =
fileTypeForVersion(
version,
doc.file_type,
);
selectedVersionId ===
version.id;
const versionDeleting =
deletingVersionId ===
version.id;
const fileType = fileTypeForVersion(
version,
doc.file_type,
);
const typeLabel =
fileType === "pdf"
? "PDF"
: "DOCX";
return (
<button
<div
key={version.id}
type="button"
role="button"
tabIndex={0}
onClick={() =>
onSelectVersion(
version.id,
filename,
)
}
className={cn(
"group -mx-2 flex w-[calc(100%+1rem)] items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors",
selected
? "bg-gray-100"
: "hover:bg-white/55",
)}
onKeyDown={(event) => {
if (
event.key !==
"Enter" &&
event.key !== " "
) return;
event.preventDefault();
onSelectVersion(
version.id,
filename,
);
}}
className="group relative flex w-full cursor-pointer flex-col overflow-hidden rounded-lg border border-white/70 bg-white px-3 py-2 shadow-[0_1px_4px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.72)] backdrop-blur-xl transition-all hover:bg-white"
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<DocFileIcon
fileType={
fileType
}
/>
<div className="min-w-0 flex-1 truncate text-xs font-medium text-gray-800">
{title}
</div>
{selected && (
<span className="absolute inset-y-0 left-0 w-[3px] bg-blue-500" />
)}
<div className="flex min-w-0 items-center gap-2">
<div
className={cn(
"min-w-0 flex-1 truncate text-xs font-medium text-gray-800",
)}
>
{title}
</div>
<div className="truncate pl-[22px] text-[11px] text-gray-400">
{filename}
</div>
<div className="truncate pl-[22px] text-[11px] text-gray-400">
<span
className={cn(
"shrink-0 text-[10px] font-semibold tracking-normal",
typeLabel ===
"PDF"
? "text-red-600"
: "text-blue-600",
)}
>
{typeLabel}
</span>
</div>
<div className="truncate text-[11px] text-gray-400">
{filename}
</div>
<div className="flex min-w-0 items-center gap-2">
<div className="min-w-0 flex-1 truncate text-[11px] text-gray-400">
{version.created_at
? new Date(
version.created_at,
).toLocaleString()
: "—"}
</div>
<div
className={cn(
"flex h-5 shrink-0 items-center gap-0.5 transition-opacity",
selected
? "opacity-100"
: "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100",
)}
>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void onDownloadVersion(
doc.id,
version.id,
filename,
);
}}
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900"
aria-label={`Download ${title}`}
title="Download version"
>
<Download className="h-3 w-3" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void handleDeleteVersion(
version.id,
);
}}
disabled={
versions.length <=
1 ||
deletingVersionId !=
null
}
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={`Delete ${title}`}
title="Delete version"
>
{versionDeleting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Trash2 className="h-3 w-3" />
)}
</button>
</div>
</div>
</button>
</div>
);
})}
</div>
@ -650,12 +691,12 @@ export function DocumentSidePanel({
type="button"
onClick={requestDeleteDocument}
disabled={deletingDocument}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/35 px-3 text-xs font-medium text-red-600 transition-colors hover:border-red-200 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40"
className={dangerGlassButtonClass}
>
{deletingDocument ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
<Trash2 className="h-3.5 w-3.5 shrink-0" />
)}
Delete
</button>
@ -663,12 +704,12 @@ export function DocumentSidePanel({
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-gray-300/80 bg-white/35 px-3 text-xs font-medium text-gray-800 transition-colors hover:border-gray-400 hover:bg-white/60 disabled:cursor-not-allowed disabled:opacity-40"
className={primaryGlassButtonClass}
>
{uploading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
<Upload className="h-3.5 w-3.5 shrink-0" />
)}
Upload new version
</button>
@ -742,10 +783,7 @@ function versionFilenameFor(version: DocumentVersion) {
return version.source === "upload" ? "Original" : "—";
}
function fileTypeForVersion(
version: DocumentVersion,
fallback: string | null,
) {
function fileTypeForVersion(version: DocumentVersion, fallback: string | null) {
const name = version.filename?.trim().toLowerCase() ?? "";
if (name.endsWith(".pdf")) return "pdf";
if (name.endsWith(".doc") || name.endsWith(".docx")) return "docx";

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
import { createPortal } from "react-dom";
import type { ReactNode } from "react";
import { Loader2 } from "lucide-react";
import { Loader2, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
type ConfirmStatus = "idle" | "loading" | "complete";
@ -37,14 +37,20 @@ export function ConfirmPopup({
const resolvedConfirmDisabled = confirmDisabled || confirmStatus !== "idle";
const normalizedConfirmLabel =
typeof confirmLabel === "string" ? confirmLabel : "Confirm";
const isDeleteAction = normalizedConfirmLabel.toLowerCase() === "delete";
const resolvedConfirmLabel =
confirmStatus === "loading" ? (
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="inline-flex h-full items-center gap-1.5">
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
{progressiveLabel(normalizedConfirmLabel)}
</span>
) : confirmStatus === "complete" ? (
completedLabel(normalizedConfirmLabel)
) : isDeleteAction ? (
<span className="inline-flex h-full items-center gap-1.5">
<Trash2 className="h-3 w-3 shrink-0" />
{confirmLabel}
</span>
) : (
confirmLabel
);
@ -53,17 +59,19 @@ export function ConfirmPopup({
<div className="pointer-events-none fixed inset-x-0 bottom-5 z-[230] flex justify-center px-4">
<div
className={cn(
"pointer-events-auto w-[min(92vw,520px)] rounded-2xl border border-white/70 bg-white/58 px-4 py-3 text-sm shadow-[0_8px_24px_rgba(15,23,42,0.13),inset_0_1px_0_rgba(255,255,255,0.92),inset_0_-10px_24px_rgba(255,255,255,0.2)] backdrop-blur-2xl",
"pointer-events-auto w-[min(92vw,520px)] rounded-2xl border border-white/70 bg-white px-4 py-3 text-sm shadow-[0_4px_14px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.92)] backdrop-blur-2xl",
className,
)}
>
{title && (
<div className="text-sm font-medium text-gray-950">
<div className="text-sm font-medium text-gray-950 mb-3">
{title}
</div>
)}
{message && (
<div className={cn("text-xs text-gray-700", title && "mt-1")}>
<div
className={cn("text-xs text-gray-700", title && "mt-1")}
>
{message}
</div>
)}
@ -71,7 +79,7 @@ export function ConfirmPopup({
<button
type="button"
onClick={onCancel}
className="rounded-full px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-100"
className="px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:text-gray-950"
>
{cancelLabel}
</button>
@ -79,7 +87,12 @@ export function ConfirmPopup({
type="button"
onClick={onConfirm}
disabled={resolvedConfirmDisabled}
className="rounded-full bg-gray-950 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-40"
className={cn(
"inline-flex h-7 items-center justify-center rounded-full px-3.5 text-xs font-medium leading-none text-white backdrop-blur-xl transition-all active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100",
isDeleteAction
? "border border-red-700/35 bg-red-600/90 shadow-[0_3px_9px_rgba(127,29,29,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(127,29,29,0.18)] hover:bg-red-600"
: "border border-gray-700/40 bg-gray-950/88 shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] hover:bg-gray-900/90",
)}
aria-busy={confirmBusy}
>
{resolvedConfirmLabel}

View file

@ -77,9 +77,9 @@ export function Modal({
>
<div
className={cn(
"w-full rounded-2xl shadow-2xl flex h-[600px] flex-col",
"w-full rounded-2xl flex h-[600px] flex-col",
sizeClassName[size],
"border border-white/70 bg-white/80 shadow-[0_24px_80px_rgba(15,23,42,0.18)] backdrop-blur-2xl",
"border border-white/70 bg-white/94 shadow-[0_12px_36px_rgba(15,23,42,0.1)] backdrop-blur-2xl",
className,
)}
onClick={(e) => e.stopPropagation()}
@ -123,7 +123,7 @@ export function Modal({
{hasFooter && (
<div
className={cn(
"flex items-center gap-3 px-4 py-3",
"flex items-center gap-3 p-4",
secondaryAction || footerInfo
? "justify-between"
: "justify-end",
@ -181,14 +181,14 @@ function ModalActionButton({
return (
<button
className={cn(
"inline-flex items-center justify-center gap-1.5 rounded-lg px-4 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40",
"inline-flex items-center justify-center gap-1.5 px-4 py-1.5 text-sm font-medium transition-all disabled:cursor-not-allowed disabled:opacity-40",
variant === "primary" &&
"bg-gray-900 text-white hover:bg-gray-700",
variant === "secondary" && "text-gray-600 hover:bg-gray-100",
"rounded-full border border-gray-700/40 bg-gray-950/88 text-white shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl hover:bg-gray-900/90 active:scale-[0.98] disabled:active:scale-100",
variant === "secondary" && "text-gray-600 hover:text-gray-950",
fallbackVariant === "secondary" &&
"border border-gray-200 hover:bg-gray-50",
"rounded-full border border-gray-200/80 bg-gray-100/70 shadow-[0_1px_4px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.78),inset_0_-3px_8px_rgba(148,163,184,0.14)] backdrop-blur-xl hover:bg-gray-100",
variant === "danger" &&
"bg-red-600 text-white hover:bg-red-700",
"rounded-full border border-red-700/35 bg-red-600/90 text-white shadow-[0_3px_9px_rgba(127,29,29,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(127,29,29,0.18)] backdrop-blur-xl hover:bg-red-600 active:scale-[0.98] disabled:active:scale-100",
)}
{...props}
>

View file

@ -44,8 +44,7 @@ export function OwnerOnlyModal({
onClose={onClose}
title={title}
message={body}
icon={<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600" />}
primaryAction={{ label: "OK", onClick: onClose }}
icon={<Lock className="h-3.5 w-3.5 shrink-0 text-red-600" />}
>
{ownerEmail && (
<p className="mt-1 text-xs text-gray-600">

View file

@ -36,6 +36,10 @@ export function WarningPopup({
}: WarningPopupProps) {
if (!open) return null;
const warningIcon = icon ?? (
<AlertCircle className="h-3 w-3 shrink-0 text-red-600" />
);
return createPortal(
<div className="pointer-events-none fixed left-1/2 top-5 z-[220] w-[min(92vw,520px)] -translate-x-1/2 px-4">
<div
@ -44,16 +48,21 @@ export function WarningPopup({
className,
)}
>
{icon ?? (
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-600" />
)}
<div className="min-w-0 flex-1 self-center text-gray-900">
<div className="min-w-0 flex-1 self-center text-red-600">
{title && (
<div className="font-medium text-gray-950">
<div className="flex items-center gap-1.5 font-medium mb-1">
{warningIcon}
{title}
</div>
)}
{message && <div>{message}</div>}
{message && (
<div
className={cn(!title && "flex items-start gap-1.5")}
>
{!title && warningIcon}
<span className="min-w-0">{message}</span>
</div>
)}
{children}
{(primaryAction || secondaryAction) && (
<div className="mt-2 flex items-center gap-2">
@ -72,7 +81,7 @@ export function WarningPopup({
<button
type="button"
onClick={onClose}
className="shrink-0 text-gray-700 transition-colors hover:text-gray-950"
className="shrink-0 text-red-700 transition-colors hover:text-red-500"
aria-label="Dismiss warning"
>
<X className="h-3.5 w-3.5" />

View file

@ -207,7 +207,6 @@ export type AssistantEvent =
url: string;
pdfUrl?: string | null;
dateFiled?: string | null;
judges?: string | null;
case?: Extract<AssistantEvent, { type: "case_opinions" }>["case"];
}
| {
@ -288,7 +287,6 @@ export type CaseCitationAnnotation = {
url?: string | null;
pdfUrl?: string | null;
dateFiled?: string | null;
judges?: string | null;
quotes: CaseCitationQuote[];
};

View file

@ -34,6 +34,7 @@ export function ShareWorkflowModal({
const [existingShares, setExistingShares] = useState<Share[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const { user } = useAuth();
const ownEmail = user?.email?.trim().toLowerCase() ?? null;
@ -55,13 +56,18 @@ export function ShareWorkflowModal({
: pendingEmails;
if (emails.length === 0) return;
setSaving(true);
setError(null);
try {
await shareWorkflow(workflowId, { emails, allow_edit: allowEdit });
const updated = await listWorkflowShares(workflowId);
setExistingShares(updated);
setPendingEmails([]);
} catch {
// ignore
} catch (err) {
setError(
err instanceof Error && err.message
? err.message
: "Unable to share this workflow. Please try again.",
);
} finally {
setSaving(false);
}
@ -90,6 +96,12 @@ export function ShareWorkflowModal({
autoFocus
/>
{error ? (
<div className="rounded-lg bg-red-50 px-3 py-2 text-xs font-medium text-red-700">
{error}
</div>
) : null}
{/* Permission toggle */}
<div className="flex flex-col gap-2">
<span className="text-xs font-medium text-gray-700">Allow editing by share recipients</span>

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
streamChat,
@ -20,13 +20,6 @@ interface UseAssistantChatOptions {
projectId?: string;
}
function findLastContentIndex(events: AssistantEvent[]): number {
for (let i = events.length - 1; i >= 0; i--) {
if (events[i].type === "content") return i;
}
return -1;
}
function readableStreamError(value: unknown): string {
if (typeof value === "string" && value.trim()) return value.trim();
return "Sorry, something went wrong.";
@ -161,21 +154,51 @@ export function useAssistantChat({
});
};
const cancel = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
setIsResponseLoading(false);
setIsLoadingCitations(false);
}
};
// Transient placeholder events (tool_call_start, thinking) fill the
// latency gap between real SSE events so the wrapper doesn't look stuck.
// Anytime a real event arrives, drop any streaming placeholder first.
const isStreamingPlaceholder = (e: AssistantEvent) =>
(e.type === "tool_call_start" || e.type === "thinking") && !!e.isStreaming;
const cancelStreamingEvents = (events: AssistantEvent[]) =>
events
.filter((event) => !isStreamingPlaceholder(event))
.map((event) => {
if (!("isStreaming" in event) || !event.isStreaming) return event;
const rest = { ...event };
delete (rest as { isStreaming?: boolean }).isStreaming;
return rest as AssistantEvent;
});
const appendCancellationEvent = (events: AssistantEvent[]) => {
const cancelledEvents = cancelStreamingEvents(events);
return [
...cancelledEvents,
{ type: "content" as const, text: "Cancelled by user." },
];
};
const cancel = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
const snapshot = cancelStreamingEvents(eventsRef.current);
eventsRef.current = snapshot;
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
if (last?.role === "assistant") {
updated[updated.length - 1] = {
...last,
events: cancelStreamingEvents(last.events ?? snapshot),
};
}
return updated;
});
setIsResponseLoading(false);
setIsLoadingCitations(false);
}
};
const clearStreamingPlaceholders = () => {
const before = eventsRef.current;
const after = before.filter((e) => !isStreamingPlaceholder(e));
@ -284,10 +307,10 @@ export function useAssistantChat({
eventsRef.current = [];
try {
const controller = new AbortController();
abortControllerRef.current = controller;
const controller = new AbortController();
abortControllerRef.current = controller;
try {
const apiMessages = newMessages.map((currentMessage) => ({
role: currentMessage.role,
content: currentMessage.content,
@ -1114,43 +1137,29 @@ export function useAssistantChat({
} catch (error: unknown) {
if (error instanceof Error && error.name === "AbortError") {
finalizeStreamingContent();
finalizeStreamingReasoning();
eventsRef.current = appendCancellationEvent(eventsRef.current);
setMessages((prev) => {
const last = prev[prev.length - 1];
if (last?.role === "assistant") {
const updated = [...prev];
const events = last.events ?? [];
const idx = findLastContentIndex(events);
const cancelText = "Cancelled by user";
if (idx >= 0) {
const newEvents = [...events];
const existing = newEvents[idx] as {
type: "content";
text: string;
};
newEvents[idx] = {
type: "content",
text: existing.text
? `${existing.text}\n\nCancelled by user`
: cancelText,
};
updated[updated.length - 1] = {
...last,
events: newEvents,
};
} else {
updated[updated.length - 1] = {
...last,
events: [...events, { type: "content", text: cancelText }],
};
}
const events = appendCancellationEvent(
last.events ?? eventsRef.current,
);
eventsRef.current = events;
updated[updated.length - 1] = {
...last,
events,
};
return updated;
}
eventsRef.current = [{ type: "content", text: "Cancelled by user." }];
return [
...prev,
{
role: "assistant",
content: "",
events: [{ type: "content", text: "Cancelled by user" }],
events: [{ type: "content", text: "Cancelled by user." }],
},
];
});
@ -1185,7 +1194,9 @@ export function useAssistantChat({
setIsLoadingCitations(false);
return null;
} finally {
abortControllerRef.current = null;
if (abortControllerRef.current === controller) {
abortControllerRef.current = null;
}
}
};