mirror of
https://github.com/willchen96/mike.git
synced 2026-06-16 21:05:12 +02:00
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:
parent
44e868eb42
commit
f32a194b33
24 changed files with 2494 additions and 1222 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue