Merge pull request #216 from Open-Legal-Products/more-ui-updates

feat: refine liquid surfaces and document review panels
This commit is contained in:
cosimoastrada 2026-07-17 01:25:02 +08:00 committed by GitHub
commit 81590ea137
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1070 additions and 586 deletions

View file

@ -23,7 +23,6 @@ import { useAuth } from "@/app/contexts/AuthContext";
import { PageHeader } from "@/app/components/shared/PageHeader";
import {
TABLE_CHECKBOX_CLASS,
TABLE_STICKY_CELL_BG,
SkeletonDot,
SkeletonLine,
TableBody,
@ -519,12 +518,10 @@ export default function TabularReviewsPage() {
const projectName = review.project_id
? projectNameById.get(review.project_id)
: null;
const rowBg = selectedIds.includes(review.id)
? "bg-gray-50"
: TABLE_STICKY_CELL_BG;
return (
<TableRow
key={review.id}
selected={selectedIds.includes(review.id)}
rightClickDropdown={(close, menuProps) => (
<RowActionMenuItems
onClose={close}
@ -563,7 +560,6 @@ export default function TabularReviewsPage() {
}}
>
<TablePrimaryCell
bgClassName={rowBg}
selected={selectedIds.includes(
review.id,
)}

View file

@ -95,17 +95,17 @@ export function CitationQuotesHeader({
citationText ??
""
}
className="rounded-sm bg-white px-2 h-6 text-gray-600 shadow-[0_1px_3px_rgba(0,0,0,0.22)] hover:bg-gray-50"
className="rounded-full bg-white px-2 h-6 text-gray-600 shadow-[0_1px_3px_rgba(0,0,0,0.22)] hover:bg-gray-50"
showText
/>
)}
<div
className={`relative flex h-6 items-center justify-start gap-1 rounded-sm bg-gray-200 p-1 ${
className={`relative flex h-6 items-center justify-start gap-1 rounded-full bg-gray-200 p-1 ${
hasMultipleQuotes ? "w-16" : "w-11"
}`}
>
<div
className={`absolute top-1 h-4 w-4 rounded bg-white shadow-sm transition-all ${
className={`absolute top-1 h-4 w-4 rounded-full bg-white shadow-sm transition-all ${
!isExpanded
? "left-1"
: hasMultipleQuotes &&
@ -117,7 +117,7 @@ export function CitationQuotesHeader({
<button
type="button"
onClick={() => setIsExpanded(false)}
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded-full ${
!isExpanded
? "text-gray-800"
: "text-gray-500 hover:text-gray-700"
@ -132,7 +132,7 @@ export function CitationQuotesHeader({
setIsExpanded(true);
setViewMode("single");
}}
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded-full ${
isExpanded && viewMode === "single"
? "text-gray-800"
: "text-gray-500 hover:text-gray-700"
@ -148,7 +148,7 @@ export function CitationQuotesHeader({
setIsExpanded(true);
setViewMode("list");
}}
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded ${
className={`relative z-10 flex h-4 w-4 items-center justify-center rounded-full ${
isExpanded && viewMode === "list"
? "text-gray-800"
: "text-gray-500 hover:text-gray-700"

View file

@ -43,7 +43,7 @@ export function EventBlock({
? "bg-green-400 shadow-[0_1px_3px_rgba(15,23,42,0.15),inset_0_1px_0_rgba(255,255,255,0.5)]"
: dotColor === "red"
? "bg-red-400 shadow-[0_1px_3px_rgba(15,23,42,0.15),inset_0_1px_0_rgba(255,255,255,0.5)]"
: "bg-gray-300 shadow-[0_1px_3px_rgba(15,23,42,0.15),inset_0_1px_0_rgba(255,255,255,0.35)]";
: "bg-gray-500 shadow-[0_1px_3px_rgba(15,23,42,0.15)]";
return (
<div className="flex items-start text-sm font-serif text-gray-500 relative">
{showConnector && <EventConnector />}

View file

@ -64,6 +64,11 @@ import {
} from "@/app/components/projects/ProjectPageParts";
import { DocumentSidePanel } from "@/app/components/shared/DocumentSidePanel";
import { LibrarySkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons";
import {
APP_SURFACE_ACTIVE_CLASS,
APP_SURFACE_GROUP_HOVER_CLASS,
APP_SURFACE_HOVER_CLASS,
} from "@/app/components/ui/liquid-surface";
import {
TABLE_CHECKBOX_CLASS,
TableFilters,
@ -271,9 +276,6 @@ export function DocTable({
const [addDocsOpen, setAddDocsOpen] = useState(false);
const { user } = useAuth();
const stickyCellBg = "bg-app-surface";
const activeRowBg = "bg-app-surface-active";
const surfaceHoverBg = "hover:bg-app-surface-hover";
const surfaceGroupHoverBg = "group-hover:bg-app-surface-hover";
const [viewingDoc, setViewingDoc] = useState<Document | null>(null);
const [viewingDocVersion, setViewingDocVersion] = useState<{
id: string;
@ -1434,6 +1436,7 @@ export function DocTable({
const isUploadingVersion = uploadingVersionDocIds.has(
doc.id,
);
const isSelected = selectedDocIds.includes(doc.id);
const isDeletingDoc = deletingDocIds.has(doc.id);
if (isDeletingDoc) {
return renderDocumentActivityRow({
@ -1447,6 +1450,7 @@ export function DocTable({
return (
<div key={`doc-${doc.id}`}>
<div
data-document-row
draggable={renamingDocumentId !== doc.id}
onDragStart={(e) => {
if (renamingDocumentId === doc.id) {
@ -1487,18 +1491,18 @@ export function DocTable({
showFolderActions: false,
});
}}
className={`group flex h-10 min-w-max items-center pr-8 ${surfaceHoverBg} cursor-pointer transition-colors ${isVersionDragOver ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : ""}`}
className={`group flex h-10 min-w-max items-center pr-8 cursor-pointer transition-colors ${isVersionDragOver ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : isSelected ? APP_SURFACE_ACTIVE_CLASS : APP_SURFACE_HOVER_CLASS}`}
>
{(() => {
const rowBg = isVersionDragOver
? "bg-blue-50"
: selectedDocIds.includes(doc.id)
? activeRowBg
: isSelected
? APP_SURFACE_ACTIVE_CLASS
: stickyCellBg;
return (
<>
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${rowBg} py-2 pl-4 pr-2 transition-colors ${isVersionDragOver ? "" : surfaceGroupHoverBg}`}
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${rowBg} py-2 pl-4 pr-2 transition-colors ${isVersionDragOver || isSelected ? "" : APP_SURFACE_GROUP_HOVER_CLASS}`}
style={treeNameCellStyle(depth)}
>
<div className="flex items-center">
@ -1632,7 +1636,7 @@ export function DocTable({
doc.id,
)
}
className="flex items-center gap-1 rounded px-1 py-0.5 hover:bg-app-surface-hover transition-colors"
className={`flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${APP_SURFACE_HOVER_CLASS}`}
>
<span>
{versionNumber}
@ -1815,10 +1819,10 @@ export function DocTable({
showFolderActions: true,
});
}}
className={`group flex h-10 min-w-max items-center pr-8 ${surfaceHoverBg} cursor-pointer transition-colors ${isRenaming ? "" : "select-none"} ${dragOverFolderId === folder.id ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : ""}`}
className={`group flex h-10 min-w-max items-center pr-8 ${APP_SURFACE_HOVER_CLASS} cursor-pointer transition-colors ${isRenaming ? "" : "select-none"} ${dragOverFolderId === folder.id ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : ""}`}
>
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} py-2 pl-4 pr-2 ${dragOverFolderId === folder.id ? "bg-blue-50" : stickyCellBg} transition-colors ${dragOverFolderId === folder.id ? "" : surfaceGroupHoverBg}`}
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} py-2 pl-4 pr-2 ${dragOverFolderId === folder.id ? "bg-blue-50" : stickyCellBg} transition-colors ${dragOverFolderId === folder.id ? "" : APP_SURFACE_GROUP_HOVER_CLASS}`}
style={treeNameCellStyle(depth)}
>
<div className="flex items-center">
@ -2557,6 +2561,10 @@ export function DocTable({
uploadingVersionDocIds.has(
doc.id,
);
const isSelected =
selectedDocIds.includes(
doc.id,
);
const isDeletingDoc =
deletingDocIds.has(
doc.id,
@ -2578,6 +2586,7 @@ export function DocTable({
return (
<div key={doc.id}>
<div
data-document-row
draggable={
renamingDocumentId !==
doc.id
@ -2652,10 +2661,10 @@ export function DocTable({
},
);
}}
className={`group flex h-10 min-w-max items-center pr-8 ${surfaceHoverBg} cursor-pointer transition-colors ${isVersionDragOver ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : ""}`}
className={`group flex h-10 min-w-max items-center pr-8 cursor-pointer transition-colors ${isVersionDragOver ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : isSelected ? APP_SURFACE_ACTIVE_CLASS : APP_SURFACE_HOVER_CLASS}`}
>
<div
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${isVersionDragOver ? "bg-blue-50" : selectedDocIds.includes(doc.id) ? activeRowBg : stickyCellBg} py-2 pl-4 pr-2 transition-colors ${isVersionDragOver ? "" : surfaceGroupHoverBg}`}
className={`sticky left-0 z-[60] ${DOC_NAME_COL_W} ${isVersionDragOver ? "bg-blue-50" : isSelected ? APP_SURFACE_ACTIVE_CLASS : stickyCellBg} py-2 pl-4 pr-2 transition-colors ${isVersionDragOver || isSelected ? "" : APP_SURFACE_GROUP_HOVER_CLASS}`}
>
<div className="flex items-center">
{isProcessing ||
@ -2806,7 +2815,7 @@ export function DocTable({
doc.id,
)
}
className="flex items-center gap-1 rounded px-1 py-0.5 hover:bg-app-surface-hover transition-colors"
className={`flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${APP_SURFACE_HOVER_CLASS}`}
>
<span>
{

View file

@ -5,6 +5,10 @@ import { SearchBar } from "@/app/components/ui/search-bar";
import { ClosedProjectSvgIcon } from "@/app/components/shared/FolderSvgIcon";
import type { Project } from "../shared/types";
import { Modal } from "./Modal";
import {
APP_SURFACE_ACTIVE_CLASS,
APP_SURFACE_HOVER_CLASS,
} from "@/app/components/ui/liquid-surface";
type PrimaryAction = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
@ -101,7 +105,7 @@ export function ProjectPickerModal({
isSelected ? null : project.id,
)
}
className={`w-full flex rounded-md items-center gap-2 px-2 py-2 text-xs transition-all text-left ${isSelected ? "bg-gray-100" : "hover:bg-gray-100/70"}`}
className={`w-full flex rounded-md items-center gap-2 px-2 py-2 text-xs transition-all text-left ${isSelected ? APP_SURFACE_ACTIVE_CLASS : APP_SURFACE_HOVER_CLASS}`}
>
<span
className={`shrink-0 h-3.5 w-3.5 rounded border flex items-center justify-center ${isSelected ? "bg-gray-900 border-gray-900" : "border-gray-300"}`}

View file

@ -8,7 +8,6 @@ import {
} from "@/app/components/shared/RowActions";
import {
TABLE_CHECKBOX_CLASS,
TABLE_STICKY_CELL_BG,
SkeletonDot,
SkeletonLine,
TableBody,
@ -249,6 +248,7 @@ export function ProjectAssistantTable({
{visibleChats.map((chat) => (
<TableRow
key={chat.id}
selected={selectedChatIds.includes(chat.id)}
rightClickDropdown={(close, menuProps) => (
<RowActionMenuItems
onClose={close}
@ -276,11 +276,6 @@ export function ProjectAssistantTable({
className="pr-8 md:pr-8"
>
<TablePrimaryCell
bgClassName={
selectedChatIds.includes(chat.id)
? "bg-gray-50"
: TABLE_STICKY_CELL_BG
}
selected={selectedChatIds.includes(chat.id)}
onSelectionChange={() =>
setSelectedChatIds((prev) =>

View file

@ -29,6 +29,7 @@ import {
} from "@/app/components/documents/DocTable";
import { TabPillButton } from "@/app/components/ui/tab-pill-button";
import { ProjectSectionToolbar, useProjectWorkspace } from "./ProjectWorkspace";
import { APP_SURFACE_HOVER_CLASS } from "@/app/components/ui/liquid-surface";
interface Props {
projectId: string;
@ -139,7 +140,7 @@ export function ProjectDocumentsView({ projectId }: Props) {
setActionsOpen(false);
void selectionActions.onDownload();
}}
className="w-full px-3 py-1.5 text-left text-xs text-gray-600 transition-colors hover:bg-app-surface-hover"
className={`w-full px-3 py-1.5 text-left text-xs text-gray-600 transition-colors ${APP_SURFACE_HOVER_CLASS}`}
>
Download
</button>
@ -149,7 +150,7 @@ export function ProjectDocumentsView({ projectId }: Props) {
setActionsOpen(false);
void selectionActions.onRemoveFromFolder();
}}
className="w-full px-3 py-1.5 text-left text-xs text-gray-600 transition-colors hover:bg-app-surface-hover"
className={`w-full px-3 py-1.5 text-left text-xs text-gray-600 transition-colors ${APP_SURFACE_HOVER_CLASS}`}
>
Remove from subfolder
</button>

View file

@ -8,7 +8,6 @@ import {
} from "@/app/components/shared/RowActions";
import {
TABLE_CHECKBOX_CLASS,
TABLE_STICKY_CELL_BG,
SkeletonDot,
SkeletonLine,
TableBody,
@ -255,6 +254,7 @@ export function ProjectReviewsTable({
{visibleReviews.map((review) => (
<TableRow
key={review.id}
selected={selectedReviewIds.includes(review.id)}
rightClickDropdown={(close, menuProps) => (
<RowActionMenuItems
onClose={close}
@ -278,11 +278,6 @@ export function ProjectReviewsTable({
className="pr-8 md:pr-8"
>
<TablePrimaryCell
bgClassName={
selectedReviewIds.includes(review.id)
? "bg-gray-50"
: TABLE_STICKY_CELL_BG
}
selected={selectedReviewIds.includes(review.id)}
onSelectionChange={() =>
setSelectedReviewIds((prev) =>

View file

@ -25,7 +25,6 @@ import {
} from "@/app/components/shared/FolderSvgIcon";
import {
TABLE_CHECKBOX_CLASS,
TABLE_STICKY_CELL_BG,
SkeletonDot,
SkeletonLine,
TableBody,
@ -660,12 +659,10 @@ export function ProjectsOverview() {
) : (
<TableBody>
{filtered.map((project) => {
const rowBg = selectedIds.includes(project.id)
? "bg-gray-50"
: TABLE_STICKY_CELL_BG;
return (
<TableRow
key={project.id}
selected={selectedIds.includes(project.id)}
rightClickDropdown={
(project.is_owner ??
project.user_id === user?.id)
@ -698,7 +695,6 @@ export function ProjectsOverview() {
>
{/* Project Name */}
<TablePrimaryCell
bgClassName={rowBg}
selected={selectedIds.includes(project.id)}
onSelectionChange={() =>
toggleOne(project.id)

View file

@ -27,6 +27,10 @@ import {
import { listProjects } from "@/app/lib/mikeApi";
import type { Project } from "@/app/components/shared/types";
import { cn } from "@/app/lib/utils";
import {
APP_SURFACE_ACTIVE_CLASS,
APP_SURFACE_HOVER_CLASS,
} from "@/app/components/ui/liquid-surface";
const NAV_ITEMS = [
{ href: "/assistant", label: "Assistant", icon: ChatSkeuoIcon },
@ -176,7 +180,8 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
onClick={handleToggle}
className={cn(
"h-9 w-9 p-2.5 items-center flex transition-colors",
"rounded-md hover:bg-gray-100",
"rounded-md",
APP_SURFACE_HOVER_CLASS,
)}
title={isOpen ? "Close sidebar" : "Open sidebar"}
>
@ -201,8 +206,8 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
className={cn(
"w-full h-9 flex items-center gap-3 px-2.5 py-2 rounded-md transition-colors text-left",
isActive
? "bg-app-surface-active text-gray-900"
: "text-gray-700 hover:bg-gray-100",
? `${APP_SURFACE_ACTIVE_CLASS} text-gray-900`
: `text-gray-700 ${APP_SURFACE_HOVER_CLASS}`,
!isOpen ? "hidden md:flex" : "flex",
)}
>
@ -301,8 +306,8 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
className={cn(
"flex h-8 w-full items-center gap-2 rounded-md px-2.5 py-1 text-left text-xs transition-colors",
isActive
? "bg-app-surface-active text-gray-900"
: "text-gray-700 hover:bg-gray-100",
? `${APP_SURFACE_ACTIVE_CLASS} text-gray-900`
: `text-gray-700 ${APP_SURFACE_HOVER_CLASS}`,
)}
>
<ProjectSvgIcon
@ -409,7 +414,7 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
onClick={loadMoreChats}
className={cn(
"flex h-8 w-full items-center justify-start rounded-md px-3 text-left text-xs font-medium text-gray-500 transition-colors hover:text-gray-700",
"hover:bg-gray-100",
APP_SURFACE_HOVER_CLASS,
)}
>
Load more
@ -436,8 +441,8 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
"rounded-xl border-white/60",
!isOpen ? "hidden md:flex" : "",
pathname === "/account" || isDropdownOpen
? "bg-app-surface-active"
: "hover:bg-gray-100",
? APP_SURFACE_ACTIVE_CLASS
: APP_SURFACE_HOVER_CLASS,
)}
title={!isOpen ? user.email : undefined}
>

View file

@ -6,6 +6,7 @@ import {
AlertCircle,
Check,
Download,
Eye,
Loader2,
Pencil,
Trash2,
@ -164,6 +165,56 @@ export function DocumentSidePanel({
setMobilePane("document");
}, [doc?.id, versionId, currentVersionId]);
useEffect(() => {
if (!mounted || !doc) return;
const handleOutsidePointerDown = (event: PointerEvent) => {
const target = event.target;
if (
!(target instanceof Node) ||
panelRef.current?.contains(target)
) {
return;
}
if (
event
.composedPath()
.some(
(node) =>
node instanceof HTMLElement &&
node.hasAttribute("data-document-row"),
)
) {
return;
}
if (
extensionWarningOpen ||
replaceConfirmOpen ||
confirmDeleteDocumentOpen
) {
return;
}
onClose();
};
document.addEventListener("pointerdown", handleOutsidePointerDown);
return () =>
document.removeEventListener(
"pointerdown",
handleOutsidePointerDown,
);
}, [
confirmDeleteDocumentOpen,
doc,
extensionWarningOpen,
mounted,
onClose,
replaceConfirmOpen,
]);
if (!mounted || !doc) return null;
const activeDoc = doc;
@ -517,357 +568,337 @@ export function DocumentSidePanel({
<aside
className={cn(
"mx-3 mb-3 min-h-0 flex-col overflow-hidden rounded-xl",
"mx-5 mt-2 min-h-0 flex-col",
mobilePane === "details" ? "flex" : "hidden md:flex",
"border border-white/70 bg-white shadow-[0_3px_9px_rgba(15,23,42,0.045),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 p-4")}>
<div className="mb-4">
<div className="mb-3 text-xs font-medium text-gray-900">
Name
</div>
{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);
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()
}
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"
>
{savingName ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Check className="h-3.5 w-3.5" />
)}
</button>
</div>
{nameError && (
<div className="text-xs text-red-600">
{nameError}
</div>
)}
</div>
) : (
<div className="flex min-h-6 items-center gap-2">
<div className="min-w-0 flex-1 truncate text-xs leading-6 text-gray-800">
{selectedFilename}
</div>
{selectedVersionId && (
<button
type="button"
onClick={() => {
setNameDraft(selectedFilename);
setEditingName(true);
setNameError(null);
}}
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"
title="Edit name"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
</div>
<div className="mb-4 shrink-0">
<div className="mb-3 text-xs font-medium text-gray-900">
Document Data
Name
</div>
<div className="rounded-xl bg-gray-100/70 px-3 py-3">
{editingName ? (
<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="Owner" value={ownerLabel} />
<DataRow
label="Uploaded"
value={
selectedUploadedAt
? formatDateTime(selectedUploadedAt)
: "—"
}
/>
<DataRow
label="Pages"
value={
selectedPageCount != null
? String(selectedPageCount)
: "—"
}
/>
</div>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col px-4 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",
"bg-gray-100 px-2",
)}
>
<div className="min-h-0 flex-1 overflow-y-auto py-2">
{versionsLoading && versions.length === 0 ? (
<div className="space-y-1.5">
{Array.from({
length: versionSkeletonCount(
doc.active_version_number,
),
}).map((_, index) => (
<VersionUploadSkeleton
key={`version-skeleton-${index}`}
/>
))}
</div>
) : orderedVersions.length === 0 ? (
<div className="py-2 text-xs text-gray-400">
No version history.
</div>
) : (
<div className="space-y-1.5">
{uploading && <VersionUploadSkeleton />}
{orderedVersions.map((version) => {
const title =
versionTitleFor(version);
const filename =
versionFilenameFor(version);
const selected =
selectedVersionId ===
version.id;
const deleted =
version.deleted_at != null;
const versionDeleting =
deletingVersionId ===
version.id;
const versionReplacing =
replacingVersionId ===
version.id;
const fileType = fileTypeForVersion(
version,
doc.file_type,
);
const typeLabel =
fileType === "pdf"
? "PDF"
: "DOCX";
return (
<div
key={version.id}
role="button"
tabIndex={0}
onClick={() => {
if (deleted) return;
onSelectVersion(
version.id,
filename,
);
}}
onKeyDown={(event) => {
if (deleted) return;
if (
event.key !==
"Enter" &&
event.key !== " "
)
return;
event.preventDefault();
onSelectVersion(
version.id,
filename,
);
}}
aria-disabled={deleted}
className={cn(
"group relative flex w-full 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",
deleted
? "cursor-not-allowed opacity-55"
: "cursor-pointer",
)}
>
{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>
<span
className={cn(
"shrink-0 text-[10px] font-semibold tracking-normal",
deleted
? "text-gray-300"
: 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",
deleted ||
selected
? "opacity-100"
: "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100",
)}
>
{deleted ? (
<span className="text-[11px] font-medium text-gray-800">
Deleted
</span>
) : (
<>
<button
type="button"
onClick={(
event,
) => {
event.stopPropagation();
requestReplaceVersion(
version,
);
}}
disabled={
replacingVersionId !=
null ||
deletingVersionId !=
null
}
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-blue-500 transition-colors hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={`Replace ${title}`}
title="Replace version file"
>
{versionReplacing ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Upload className="h-3 w-3" />
)}
</button>
<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={
(canDelete &&
activeVersionCount <=
1) ||
deletingVersionId !=
null
}
className={cn(
"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",
!canDelete &&
"cursor-not-allowed opacity-40 hover:bg-transparent hover:text-red-500",
)}
aria-label={`Delete ${title}`}
title={
canDelete
? "Delete version"
: "Only the document owner can delete versions"
}
>
{versionDeleting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Trash2 className="h-3 w-3" />
)}
</button>
</>
)}
</div>
</div>
</div>
);
})}
<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);
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()}
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"
>
{savingName ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Check className="h-3.5 w-3.5" />
)}
</button>
</div>
{nameError && (
<div className="text-xs text-red-600">
{nameError}
</div>
)}
</div>
) : (
<div className="flex min-h-6 items-center gap-2">
<div className="min-w-0 flex-1 truncate text-xs leading-6 text-gray-800">
{selectedFilename}
</div>
{selectedVersionId && (
<button
type="button"
onClick={() => {
setNameDraft(selectedFilename);
setEditingName(true);
setNameError(null);
}}
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"
title="Edit name"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
</div>
<div className="mb-3 shrink-0 text-xs font-medium text-gray-900">
Document Data
</div>
<div className="shrink-0 rounded-xl py-2">
<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="Owner" value={ownerLabel} />
<DataRow
label="Uploaded"
value={
selectedUploadedAt
? formatDateTime(selectedUploadedAt)
: "—"
}
/>
<DataRow
label="Pages"
value={
selectedPageCount != null
? String(selectedPageCount)
: "—"
}
/>
</div>
</div>
<div className="mb-2 shrink-0 text-xs font-medium text-gray-900">
Versions
</div>
<div
className={cn(
"flex min-h-0 flex-1 flex-col overflow-visible rounded-xl",
"bg-[#eceef1] px-2",
)}
>
<div className="min-h-0 flex-1 overflow-y-auto py-2">
{versionsLoading && versions.length === 0 ? (
<div className="space-y-1.5">
{Array.from({
length: versionSkeletonCount(
doc.active_version_number,
),
}).map((_, index) => (
<VersionUploadSkeleton
key={`version-skeleton-${index}`}
/>
))}
</div>
) : orderedVersions.length === 0 ? (
<div className="py-2 text-xs text-gray-400">
No version history.
</div>
) : (
<div className="space-y-1.5">
{uploading && <VersionUploadSkeleton />}
{orderedVersions.map((version) => {
const title = versionTitleFor(version);
const filename =
versionFilenameFor(version);
const selected =
selectedVersionId === version.id;
const deleted =
version.deleted_at != null;
const versionDeleting =
deletingVersionId === version.id;
const versionReplacing =
replacingVersionId === version.id;
return (
<div
key={version.id}
role="button"
tabIndex={0}
onClick={() => {
if (deleted) return;
onSelectVersion(
version.id,
filename,
);
}}
onKeyDown={(event) => {
if (deleted) return;
if (
event.key !== "Enter" &&
event.key !== " "
)
return;
event.preventDefault();
onSelectVersion(
version.id,
filename,
);
}}
aria-disabled={deleted}
className={cn(
"group relative flex w-full 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",
deleted
? "cursor-not-allowed opacity-55"
: "cursor-pointer",
)}
>
<div className="flex min-w-0 items-center gap-1.5">
<FileTypeIcon
fileType={filename}
className="h-3 w-3 shrink-0"
/>
<div
className={cn(
"min-w-0 flex-1 truncate text-xs font-medium text-gray-950",
)}
>
{filename}
</div>
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 text-[11px] font-normal",
deleted
? "text-gray-300"
: selected
? "text-blue-500"
: "text-gray-400",
)}
>
{selected &&
!deleted && (
<Eye className="h-3 w-3" />
)}
{title}
</span>
</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",
deleted || selected
? "opacity-100"
: "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100",
)}
>
{deleted ? (
<span className="text-[11px] font-medium text-gray-800">
Deleted
</span>
) : (
<>
<button
type="button"
onClick={(
event,
) => {
event.stopPropagation();
requestReplaceVersion(
version,
);
}}
disabled={
replacingVersionId !=
null ||
deletingVersionId !=
null
}
className="inline-flex h-5 w-5 items-center justify-center rounded-full text-blue-500 transition-colors hover:bg-blue-50 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={`Replace ${title}`}
title="Replace version file"
>
{versionReplacing ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Upload className="h-3 w-3" />
)}
</button>
<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={
(canDelete &&
activeVersionCount <=
1) ||
deletingVersionId !=
null
}
className={cn(
"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",
!canDelete &&
"cursor-not-allowed opacity-40 hover:bg-transparent hover:text-red-500",
)}
aria-label={`Delete ${title}`}
title={
canDelete
? "Delete version"
: "Only the document owner can delete versions"
}
>
{versionDeleting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Trash2 className="h-3 w-3" />
)}
</button>
</>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
@ -880,7 +911,7 @@ export function DocumentSidePanel({
<div
className={cn(
"flex shrink-0 items-center justify-between px-4 py-3",
"flex shrink-0 items-center justify-between py-3",
"bg-white/25",
)}
>
@ -990,7 +1021,7 @@ export function DocumentSidePanel({
function DataRow({ label, value }: { label: string; value: string }) {
return (
<div className="grid grid-cols-[112px_minmax(0,1fr)] gap-2 text-xs">
<span className="text-gray-400">{label}</span>
<span className="text-gray-600">{label}</span>
<span className="truncate text-gray-800">{value}</span>
</div>
);
@ -1001,10 +1032,15 @@ function VersionUploadSkeleton() {
<div className="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)]">
<div className="animate-pulse space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="h-3 w-20 rounded-full bg-gray-200" />
<div className="h-3 w-9 rounded-full bg-blue-100" />
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<div className="h-3 w-3 shrink-0 rounded bg-gray-200" />
<div className="h-3 w-3/5 rounded-full bg-gray-200" />
</div>
<div className="flex shrink-0 items-center gap-1">
<div className="h-3 w-3 rounded bg-gray-200" />
<div className="h-[11px] w-12 rounded-full bg-gray-200" />
</div>
</div>
<div className="h-2.5 w-4/5 rounded-full bg-gray-200" />
<div className="h-2.5 w-2/5 rounded-full bg-gray-200" />
</div>
</div>

View file

@ -12,6 +12,10 @@ import { SearchBar } from "@/app/components/ui/search-bar";
import { TabPillButton } from "@/app/components/ui/tab-pill-button";
import { SkeletonLine } from "./TablePrimitive";
import { useDirectoryData, type DirectoryTab } from "./useDirectoryData";
import {
APP_SURFACE_ACTIVE_CLASS,
APP_SURFACE_HOVER_CLASS,
} from "@/app/components/ui/liquid-surface";
const DIRECTORY_GRID_CLASS =
"grid grid-cols-[14px_14px_minmax(0,1fr)_48px_84px_64px] items-center gap-2";
@ -294,7 +298,9 @@ export function FileDirectory({
onClick={() => toggle(doc)}
style={{ paddingLeft: indentedRowPadding(depth) }}
className={`w-full rounded-md ${DIRECTORY_GRID_CLASS} py-2 pr-2 text-xs transition-all text-left ${
selected ? "bg-gray-100" : "hover:bg-gray-100/70"
selected
? APP_SURFACE_ACTIVE_CLASS
: APP_SURFACE_HOVER_CLASS
}`}
>
<span
@ -344,7 +350,7 @@ export function FileDirectory({
type="button"
onClick={() => toggleLibraryFolder(folder.id)}
style={{ paddingLeft: indentedRowPadding(depth) }}
className={`w-full rounded-md ${DIRECTORY_GRID_CLASS} py-2 pr-2 text-xs transition-all text-left hover:bg-gray-100/70`}
className={`w-full rounded-md ${DIRECTORY_GRID_CLASS} py-2 pr-2 text-xs transition-all text-left ${APP_SURFACE_HOVER_CLASS}`}
>
<span
role="checkbox"
@ -597,7 +603,7 @@ export function FileDirectory({
onClick={() =>
toggleFolder(project.id)
}
className={`w-full rounded-md ${DIRECTORY_GRID_CLASS} px-2 py-2 text-xs transition-all text-left hover:bg-gray-100/70`}
className={`w-full rounded-md ${DIRECTORY_GRID_CLASS} px-2 py-2 text-xs transition-all text-left ${APP_SURFACE_HOVER_CLASS}`}
>
<span
role="checkbox"
@ -672,8 +678,8 @@ export function FileDirectory({
}
className={`w-full rounded-md ${DIRECTORY_GRID_CLASS} py-2 pl-7 pr-2 text-xs transition-all text-left ${
selected
? "bg-gray-100"
: "hover:bg-gray-100/70"
? APP_SURFACE_ACTIVE_CLASS
: APP_SURFACE_HOVER_CLASS
}`}
>
<span

View file

@ -10,6 +10,7 @@ import {
LiquidDropdownItem,
} from "@/app/components/ui/liquid-dropdown";
import { cn } from "@/app/lib/utils";
import { APP_SURFACE_HOVER_CLASS } from "@/app/components/ui/liquid-surface";
export type HeaderActionsMenuItem = {
label: string;
@ -33,7 +34,8 @@ export function HeaderActionsMenu({
type="button"
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-full text-gray-600 transition-all",
"hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-300",
APP_SURFACE_HOVER_CLASS,
"hover:text-gray-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-300",
)}
aria-label={title}
title={title}

View file

@ -11,6 +11,11 @@ import { createPortal } from "react-dom";
import { ChevronLeft, Loader2, Plus, Search } from "lucide-react";
import { usePageChrome } from "@/app/contexts/PageChromeContext";
import { cn } from "@/app/lib/utils";
import {
APP_SURFACE_ACTIVE_CLASS,
APP_SURFACE_HOVER_CLASS,
APP_SURFACE_PRESSED_CLASS,
} from "@/app/components/ui/liquid-surface";
export interface PageHeaderBreadcrumb {
label?: ReactNode;
@ -308,7 +313,7 @@ function PageHeaderSearchActionControl({
className:
"cursor-text justify-start gap-2 px-3 text-gray-700 hover:text-gray-700",
}),
"w-56 bg-app-surface-active sm:w-80",
`w-56 sm:w-80 ${APP_SURFACE_ACTIVE_CLASS}`,
)}
>
<Search className="h-3.5 w-3.5 text-gray-400 shrink-0" />
@ -356,7 +361,9 @@ function pageHeaderActionControlClassName({
className,
}: PageHeaderActionControlClassNameOptions = {}) {
return cn(
"flex h-7 items-center justify-center rounded-full text-sm transition-colors hover:bg-app-surface-hover active:bg-app-surface-active disabled:cursor-default disabled:text-gray-300 disabled:hover:bg-transparent disabled:hover:text-gray-300",
"flex h-7 items-center justify-center rounded-full text-sm transition-colors disabled:cursor-default disabled:text-gray-300 disabled:hover:bg-transparent disabled:hover:text-gray-300",
APP_SURFACE_HOVER_CLASS,
APP_SURFACE_PRESSED_CLASS,
iconOnly
? "w-7"
: "w-7 gap-1.5 px-0 sm:w-auto sm:px-3",

View file

@ -29,6 +29,7 @@ import {
LiquidDropdownSurface,
} from "@/app/components/ui/liquid-dropdown";
import { cn } from "@/app/lib/utils";
import { APP_SURFACE_HOVER_CLASS } from "@/app/components/ui/liquid-surface";
export { CLOSE_ROW_ACTIONS_EVENT, closeRowActionMenus };
@ -254,7 +255,7 @@ export function RowActions(props: Props) {
<button
ref={btnRef}
onClick={handleToggle}
className="flex items-center justify-center w-6 h-6 rounded text-gray-700 hover:text-gray-900 hover:bg-app-surface-hover transition-colors leading-none"
className={`flex items-center justify-center w-6 h-6 rounded text-gray-700 hover:text-gray-900 transition-colors leading-none ${APP_SURFACE_HOVER_CLASS}`}
>
<span className="tracking-widest text-xs">···</span>
</button>

View file

@ -16,6 +16,10 @@ import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup";
import type { Chat } from "@/app/components/shared/types";
import { ChatSkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons";
import { cn } from "@/app/lib/utils";
import {
APP_SURFACE_ACTIVE_CLASS,
APP_SURFACE_HOVER_CLASS,
} from "@/app/components/ui/liquid-surface";
interface Props {
chat: Chat;
@ -55,8 +59,8 @@ export function SidebarChatItem({ chat, isActive, onSelect, projectName }: Props
className={cn(
"group relative flex h-8 w-full items-center rounded-md transition-colors",
isActive
? "bg-gray-200/60 pr-1"
: "pr-3 hover:bg-gray-100 hover:pr-1",
? `${APP_SURFACE_ACTIVE_CLASS} pr-1`
: `pr-3 ${APP_SURFACE_HOVER_CLASS} hover:pr-1`,
)}
>
{isRenaming ? (

View file

@ -22,7 +22,12 @@ import {
LiquidDropdownContent,
LiquidDropdownItem,
} from "@/app/components/ui/liquid-dropdown";
import { LIQUID_TABLE_SURFACE_CLASS } from "@/app/components/ui/liquid-surface";
import {
APP_SURFACE_ACTIVE_CLASS,
APP_SURFACE_GROUP_HOVER_CLASS,
APP_SURFACE_HOVER_CLASS,
LIQUID_TABLE_SURFACE_CLASS,
} from "@/app/components/ui/liquid-surface";
export const CLOSE_ROW_ACTIONS_EVENT = "mike:close-row-actions";
@ -84,8 +89,8 @@ export function TableFilters<T extends string>({
title={selected?.label ?? label}
className={`flex h-[18px] w-[22px] items-center justify-center rounded-sm transition-colors ${
value
? "text-gray-700 hover:bg-app-surface-hover hover:text-gray-900"
: "text-gray-400 hover:bg-app-surface-hover hover:text-gray-700"
? `text-gray-700 ${APP_SURFACE_HOVER_CLASS} hover:text-gray-900`
: `text-gray-400 ${APP_SURFACE_HOVER_CLASS} hover:text-gray-700`
}`}
>
<ChevronDown
@ -219,11 +224,13 @@ export function TableRow({
children,
className,
interactive = true,
selected = false,
onContextMenu,
rightClickDropdown,
...props
}: DivProps & {
interactive?: boolean;
selected?: boolean;
rightClickDropdown?:
| ReactNode
| ((close: () => void, menuProps: DivProps) => ReactNode);
@ -274,7 +281,11 @@ export function TableRow({
<div
className={cn(
"group flex h-10 min-w-max items-center pr-3 transition-colors",
interactive && "cursor-pointer hover:bg-app-surface-hover",
interactive && "cursor-pointer",
interactive &&
!selected &&
APP_SURFACE_HOVER_CLASS,
selected && APP_SURFACE_ACTIVE_CLASS,
className,
)}
onContextMenu={handleContextMenu}
@ -326,7 +337,7 @@ export function TableStickyCell({
header
? "z-[80] items-center self-stretch"
: "py-2 transition-colors",
!header && hover && "group-hover:bg-app-surface-hover",
!header && hover && APP_SURFACE_GROUP_HOVER_CLASS,
className,
)}
>
@ -389,8 +400,11 @@ export function TablePrimaryCell({
return (
<TableStickyCell
widthClassName={widthClassName}
bgClassName={bgClassName}
bgClassName={
selected ? APP_SURFACE_ACTIVE_CLASS : bgClassName
}
className={className}
hover={!selected}
>
<div className="flex min-w-0 items-center">
<input

View file

@ -42,7 +42,11 @@ import {
type ModelProvider,
} from "@/app/lib/modelAvailability";
import type { ApiKeyState } from "@/app/lib/mikeApi";
import { LIQUID_PANEL_SURFACE_CLASS } from "@/app/components/ui/liquid-surface";
import {
APP_SURFACE_ACTIVE_CLASS,
APP_SURFACE_HOVER_CLASS,
LIQUID_PANEL_SURFACE_CLASS,
} from "@/app/components/ui/liquid-surface";
import {
LiquidDropdownButton,
LiquidDropdownSurface,
@ -637,7 +641,7 @@ function HistoryDropdown({
setRenamingChatId(null);
}}
onBlur={() => commitRename(chat.id)}
className="w-full rounded-lg bg-app-surface-active px-2 py-1.5 text-xs text-gray-700 outline-none"
className={`w-full rounded-lg px-2 py-1.5 text-xs text-gray-700 outline-none ${APP_SURFACE_ACTIVE_CLASS}`}
/>
);
}
@ -669,7 +673,7 @@ function HistoryDropdown({
}}
title="Chat options"
className={cn(
"absolute right-1.5 flex h-5 w-5 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-app-surface-hover hover:text-gray-700",
`absolute right-1.5 flex h-5 w-5 items-center justify-center rounded-full text-gray-400 transition-colors hover:text-gray-700 ${APP_SURFACE_HOVER_CLASS}`,
menu?.chatId === chat.id
? "opacity-100"
: "opacity-0 group-hover:opacity-100",
@ -741,8 +745,7 @@ function findLastContentIndex(events: AssistantEvent[]): number {
const HEADER_PILL_CLASS =
"flex shrink-0 items-center gap-1 rounded-full border border-white/70 bg-app-surface px-1 py-0.5 shadow-[0_8px_24px_rgba(15,23,42,0.06)] backdrop-blur-2xl";
const HEADER_PILL_BUTTON_CLASS =
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-app-surface-hover hover:text-gray-900";
const HEADER_PILL_BUTTON_CLASS = `flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-gray-500 transition-colors hover:text-gray-900 ${APP_SURFACE_HOVER_CLASS}`;
// ---------------------------------------------------------------------------
// Main component
@ -1795,7 +1798,7 @@ export function TRChatPanel({
<button
onClick={() => setHistoryOpen((v) => !v)}
title="Chat history"
className="flex h-5 min-w-0 items-center gap-1 rounded-full px-1.5 text-gray-700 transition-colors hover:bg-app-surface-hover"
className={`flex h-5 min-w-0 items-center gap-1 rounded-full px-1.5 text-gray-700 transition-colors ${APP_SURFACE_HOVER_CLASS}`}
>
<span className="min-w-0 truncate text-xs font-medium">
{currentChatTitle ?? "New chat"}

View file

@ -1,6 +1,12 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
useEffect,
useRef,
useState,
} from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
@ -8,23 +14,27 @@ import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Loader2,
PanelLeft,
RefreshCw,
X,
} from "lucide-react";
import type {
ColumnConfig,
Document,
TabularCell,
} from "../shared/types";
import type { ColumnConfig, Document, TabularCell } from "../shared/types";
import { isSpreadsheetFilename } from "../shared/types";
import { preprocessCitations, type ParsedCitation } from "./citation-utils";
import { getPillClass } from "./pillUtils";
import { PdfView } from "../shared/views/PdfView";
import { SpreadsheetView } from "../shared/views/SpreadsheetView";
import { DocxView } from "../shared/views/DocxView";
import { FileTypeIcon } from "../shared/FileTypeIcon";
import { CitationQuotesHeader } from "../assistant/CitationQuotesHeader";
import { cn } from "@/app/lib/utils";
import { LIQUID_PANEL_SURFACE_CLASS } from "@/app/components/ui/liquid-surface";
import {
APP_SURFACE_HOVER_CLASS,
APP_SURFACE_PRESSED_CLASS,
LIQUID_PANEL_SURFACE_CLASS,
} from "@/app/components/ui/liquid-surface";
function isDocxDocument(d: {
file_type?: string | null;
@ -39,10 +49,11 @@ function isDocxDocument(d: {
interface Props {
cell: TabularCell;
document: Document;
documents: Document[];
column: ColumnConfig;
columns: ColumnConfig[];
onClose: () => void;
onNavigate: (columnIndex: number) => void;
onNavigate: (documentId: string, columnIndex: number) => void;
onRegenerate?: () => Promise<void>;
/** If true, open the document panel immediately */
displayDocument?: boolean;
@ -50,8 +61,22 @@ interface Props {
citationQuote?: string;
/** Page to scroll to when opening document panel */
citationPage?: number;
/** Spreadsheet worksheet containing the cited cell */
citationSheet?: string;
/** Spreadsheet A1 cell address or range */
citationCell?: string;
/** One-based citation number shown in the cell content */
citationRef?: number;
}
type TRPanelCitation = {
quote: string;
page?: number;
sheet?: string;
cell?: string;
citationRef?: number;
};
const FLAG_BADGE: Record<string, string> = {
green: "bg-emerald-600 backdrop-blur-md border border-emerald-300/20 text-white shadow-md",
grey: "bg-slate-500 backdrop-blur-md border border-slate-300/20 text-white shadow-md",
@ -59,6 +84,11 @@ const FLAG_BADGE: Record<string, string> = {
red: "bg-red-600 backdrop-blur-md border border-red-300/20 text-white shadow-md",
};
const MIN_DOCUMENT_PANE_WIDTH = 420;
const DEFAULT_DOCUMENT_PANE_WIDTH = 600;
const MAX_DOCUMENT_PANE_WIDTH = 1000;
const INFO_PANE_WIDTH = 300;
// ---------------------------------------------------------------------------
// TRSidePanel
// ---------------------------------------------------------------------------
@ -66,6 +96,7 @@ const FLAG_BADGE: Record<string, string> = {
export function TRSidePanel({
cell,
document: doc,
documents,
column,
columns,
onClose,
@ -74,25 +105,47 @@ export function TRSidePanel({
displayDocument = false,
citationQuote,
citationPage,
citationSheet,
citationCell,
citationRef,
}: Props) {
const sortedColumns = [...columns].sort((a, b) => a.index - b.index);
const currentPos = sortedColumns.findIndex((c) => c.index === column.index);
const prevColumn = currentPos > 0 ? sortedColumns[currentPos - 1] : null;
const previousColumn =
currentPos > 0 ? sortedColumns[currentPos - 1] : null;
const nextColumn =
currentPos < sortedColumns.length - 1
currentPos >= 0 && currentPos < sortedColumns.length - 1
? sortedColumns[currentPos + 1]
: null;
const currentDocumentPos = documents.findIndex(
(candidate) => candidate.id === doc.id,
);
const previousDocument =
currentDocumentPos > 0 ? documents[currentDocumentPos - 1] : null;
const nextDocument =
currentDocumentPos >= 0 && currentDocumentPos < documents.length - 1
? documents[currentDocumentPos + 1]
: null;
const [regenerating, setRegenerating] = useState(false);
const [quoteExpanded, setQuoteExpanded] = useState(false);
const [isTruncated, setIsTruncated] = useState(false);
const quoteParagraphRef = useRef<HTMLParagraphElement>(null);
const [documentPaneOpen, setDocumentPaneOpen] = useState(displayDocument);
const [documentPaneWidth, setDocumentPaneWidth] = useState(
DEFAULT_DOCUMENT_PANE_WIDTH,
);
const panelRef = useRef<HTMLDivElement>(null);
const resizePointerId = useRef<number | null>(null);
const resizeStartX = useRef(0);
const resizeStartWidth = useRef(DEFAULT_DOCUMENT_PANE_WIDTH);
// Internal state — initialised from props, also toggled by badge clicks inside the panel
const [docCitation, setDocCitation] = useState<
{ quote: string; page: number } | undefined
>(
const [docCitation, setDocCitation] = useState<TRPanelCitation | undefined>(
displayDocument && citationQuote
? { quote: citationQuote, page: citationPage ?? 1 }
? {
quote: citationQuote,
page: citationPage,
sheet: citationSheet,
cell: citationCell,
citationRef,
}
: undefined,
);
@ -100,17 +153,100 @@ export function TRSidePanel({
useEffect(() => {
setDocCitation(
displayDocument && citationQuote
? { quote: citationQuote, page: citationPage ?? 1 }
? {
quote: citationQuote,
page: citationPage,
sheet: citationSheet,
cell: citationCell,
citationRef,
}
: undefined,
);
setQuoteExpanded(false);
}, [cell.id, displayDocument, citationQuote, citationPage]);
setDocumentPaneOpen(displayDocument);
}, [
cell.id,
displayDocument,
citationCell,
citationPage,
citationQuote,
citationRef,
citationSheet,
]);
useEffect(
() => () => {
document.body.style.cursor = "";
document.body.style.userSelect = "";
},
[],
);
useEffect(() => {
const el = quoteParagraphRef.current;
if (!el || quoteExpanded) return;
setIsTruncated(el.scrollWidth > el.clientWidth);
}, [docCitation?.quote, quoteExpanded]);
const handleOutsidePointerDown = (event: PointerEvent) => {
const target = event.target;
if (
!(target instanceof Node) ||
panelRef.current?.contains(target)
) {
return;
}
onClose();
};
document.addEventListener("pointerdown", handleOutsidePointerDown);
return () =>
document.removeEventListener(
"pointerdown",
handleOutsidePointerDown,
);
}, [onClose]);
function handleDocumentResizePointerDown(
event: ReactPointerEvent<HTMLDivElement>,
) {
event.preventDefault();
resizePointerId.current = event.pointerId;
resizeStartX.current = event.clientX;
resizeStartWidth.current = documentPaneWidth;
event.currentTarget.setPointerCapture(event.pointerId);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}
function handleDocumentResizePointerMove(
event: ReactPointerEvent<HTMLDivElement>,
) {
if (resizePointerId.current !== event.pointerId) return;
const viewportMax = window.innerWidth - INFO_PANE_WIDTH - 2 * 12 - 24;
const maxWidth = Math.max(
MIN_DOCUMENT_PANE_WIDTH,
Math.min(MAX_DOCUMENT_PANE_WIDTH, viewportMax),
);
const nextWidth =
resizeStartWidth.current + (resizeStartX.current - event.clientX);
setDocumentPaneWidth(
Math.min(maxWidth, Math.max(MIN_DOCUMENT_PANE_WIDTH, nextWidth)),
);
}
function handleDocumentResizePointerEnd(
event: ReactPointerEvent<HTMLDivElement>,
) {
if (resizePointerId.current !== event.pointerId) return;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
resizePointerId.current = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
function handleCitationOpen(citation: TRPanelCitation) {
setDocCitation(citation);
setDocumentPaneOpen(true);
}
const { processed: summaryText, citations: summaryCitations } =
preprocessCitations(cell.content?.summary ?? "");
@ -119,74 +255,97 @@ export function TRSidePanel({
return (
<div
ref={panelRef}
className={cn(
"fixed z-100 flex flex-row",
LIQUID_PANEL_SURFACE_CLASS,
"right-3 top-3 bottom-3 overflow-hidden",
)}
>
{/* Document panel — left, 600px */}
{docCitation !== undefined && (
<div className="relative flex w-[600px] shrink-0 flex-col border-r border-white/30 px-3 pb-3">
{/* Resizable document panel — left */}
{documentPaneOpen && (
<div
className="relative flex shrink-0 flex-col border-r border-white/30 px-3 pb-3"
style={{ width: documentPaneWidth }}
>
<div
onPointerDown={handleDocumentResizePointerDown}
onPointerMove={handleDocumentResizePointerMove}
onPointerUp={handleDocumentResizePointerEnd}
onPointerCancel={handleDocumentResizePointerEnd}
className="absolute inset-y-0 left-0 z-20 w-1.5 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-blue-400/60"
title="Resize document pane"
/>
{/* Doc header */}
<div className="flex items-center gap-2 pt-3 shrink-0 border-b border-white/30">
<p
className="flex-1 truncate text-sm font-semibold font-sans text-slate-700 font-serif"
title={doc.filename}
>
{doc.filename}
</p>
<button
onClick={() => setDocCitation(undefined)}
className="shrink-0 rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-white/40 hover:text-slate-600"
>
<X className="h-4 w-4" />
</button>
<div className="flex min-h-11 shrink-0 items-center gap-3">
<div className="flex min-w-0 items-center gap-2">
<FileTypeIcon
fileType={doc.file_type ?? doc.filename}
className="h-4 w-4"
/>
<div
className="min-w-0 truncate text-sm font-medium text-gray-700"
title={doc.filename}
>
{doc.filename}
</div>
</div>
</div>
{/* Quote row */}
{docCitation.quote && (
<div className="py-2 shrink-0">
<div className="w-full rounded-md bg-gray-50 border border-gray-200 px-2 py-2">
<button
onClick={() =>
isTruncated || quoteExpanded
? setQuoteExpanded((v) => !v)
: undefined
}
className={`flex w-full items-start gap-1 text-left ${!(isTruncated || quoteExpanded) ? "cursor-default" : ""}`}
>
<p
ref={quoteParagraphRef}
className={`flex-1 text-sm text-gray-600 ${quoteExpanded ? "" : "truncate"}`}
>
"{docCitation.quote}"
</p>
{(isTruncated || quoteExpanded) && (
<ChevronDown
className={`mt-0.5 h-3 w-3 shrink-0 text-gray-500 transition-transform ${quoteExpanded ? "rotate-180" : ""}`}
/>
)}
</button>
</div>
{docCitation?.quote && (
<div className="-mx-3 shrink-0 py-2">
<CitationQuotesHeader
quotes={[
{
id: citationKey(cell.id, docCitation),
quote: docCitation.quote,
inlineDetail:
formatCitationLocation(docCitation),
citationText: `${doc.filename}, ${formatCitationLocation(docCitation)}`,
},
]}
activeQuoteId={citationKey(
cell.id,
docCitation,
)}
citationRef={docCitation.citationRef}
citationText={`${doc.filename}, ${formatCitationLocation(docCitation)}`}
/>
</div>
)}
{isDocxDocument(doc) && !doc.pdf_storage_path ? (
<DocxView
documentId={doc.id}
quotes={[
{
page: docCitation.page,
quote: docCitation.quote,
},
]}
quotes={
docCitation
? [
{
page: docCitation.page,
quote: docCitation.quote,
},
]
: undefined
}
/>
) : isSpreadsheetFilename(doc.filename ?? "") ? (
<SpreadsheetView documentId={doc.id} />
<SpreadsheetView
documentId={doc.id}
highlightCells={
docCitation?.sheet || docCitation?.cell
? [
{
sheet: docCitation.sheet,
cell: docCitation.cell,
},
]
: undefined
}
/>
) : (
<PdfView
doc={{ document_id: doc.id }}
quote={docCitation.quote}
fallbackPage={docCitation.page}
quote={docCitation?.quote}
fallbackPage={docCitation?.page}
/>
)}
</div>
@ -195,32 +354,28 @@ export function TRSidePanel({
{/* Info column — right, 300px fixed */}
<div className="flex w-[300px] shrink-0 flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-end gap-3 px-5 pt-3 pb-1 shrink-0 border-b border-white/30">
<div className="flex items-center gap-1 mr-auto">
<button
onClick={() =>
prevColumn && onNavigate(prevColumn.index)
}
disabled={!prevColumn}
title={prevColumn ? prevColumn.name : undefined}
className="rounded-lg p-0.5 text-slate-600 transition-colors hover:bg-slate-200 hover:text-slate-900 disabled:opacity-30 disabled:cursor-default"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-xs text-slate-600 font-sans tabular-nums">
{currentPos + 1} / {sortedColumns.length}
</span>
<button
onClick={() =>
nextColumn && onNavigate(nextColumn.index)
}
disabled={!nextColumn}
title={nextColumn ? nextColumn.name : undefined}
className="rounded-lg p-0.5 text-slate-600 transition-colors hover:bg-slate-200 hover:text-slate-900 disabled:opacity-30 disabled:cursor-default"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
<div className="mb-2 flex min-h-11 shrink-0 items-center justify-end gap-1.5 border-b border-white/30 px-3">
<button
type="button"
onClick={() => setDocumentPaneOpen((open) => !open)}
className={cn(
"mr-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-white/75 hover:text-gray-700",
documentPaneOpen && "bg-white/55 text-gray-700",
)}
aria-label={
documentPaneOpen
? "Collapse document pane"
: "Expand document pane"
}
title={
documentPaneOpen
? "Collapse document pane"
: "Expand document pane"
}
aria-pressed={documentPaneOpen}
>
<PanelLeft className="h-4 w-4" />
</button>
{onRegenerate && (
<button
onClick={async () => {
@ -243,31 +398,51 @@ export function TRSidePanel({
</button>
)}
<button
type="button"
onClick={onClose}
className="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/70 bg-white/55 text-gray-500 shadow-[inset_0_1px_0_rgba(255,255,255,0.75),inset_0_-1px_0_rgba(255,255,255,0.55),0_6px_18px_rgba(15,23,42,0.08)] backdrop-blur-xl transition-colors hover:bg-white/75 hover:text-gray-700"
aria-label="Close"
>
<X className="h-4 w-4" />
<X className="h-3.5 w-3.5" />
</button>
</div>
{/* Analysis panel */}
<div className="flex-1 overflow-y-auto">
<div className="pb-2 px-5">
{/* Column name */}
<div className="mb-1">
<span className="text-lg font-semibold text-slate-900">
{column.name}
</span>
{/* Document field */}
<div className="mb-4">
<div className="mb-3 text-xs font-medium text-gray-900">
Document
</div>
<div className="flex min-h-6 items-center gap-1.5">
<FileTypeIcon
fileType={doc.file_type ?? doc.filename}
className="h-3 w-3"
/>
<div
className="min-w-0 flex-1 truncate text-xs leading-6 text-gray-800"
title={doc.filename}
>
{doc.filename}
</div>
</div>
</div>
{/* Column field */}
<div className="mb-4">
<div className="mb-3 text-xs font-medium text-gray-900">
Column
</div>
<div className="min-h-6 truncate text-xs leading-6 text-gray-800">
{column.name}
</div>
</div>
{/* Document name */}
<p className="text-xs mb-4">
{doc.filename}
</p>
{/* Flag section */}
{cell.content?.flag && (
<div className="mb-5">
<h4 className="mb-2 text-sm font-semibold tracking-wider font-sans">
<h4 className="mb-2 text-xs font-medium text-gray-900">
Flag
</h4>
<span
@ -281,13 +456,13 @@ export function TRSidePanel({
{/* Results */}
<div className="mb-6">
<h4 className="mb-2 text-sm font-semibold tracking-wider font-sans">
<h4 className="mb-2 text-xs font-medium text-gray-900">
Results
</h4>
<div className="text-xs leading-relaxed text-slate-600">
<MarkdownContent
citations={summaryCitations}
onCitationClick={setDocCitation}
onCitationClick={handleCitationOpen}
column={column}
>
{summaryText || "—"}
@ -298,13 +473,13 @@ export function TRSidePanel({
{/* Reasoning */}
{cell.content?.reasoning && (
<div>
<h4 className="mb-2 text-sm font-semibold tracking-wider font-sans">
<h4 className="mb-2 text-xs font-medium text-gray-900">
Reasoning
</h4>
<div className="text-xs leading-relaxed text-slate-600">
<MarkdownContent
citations={reasoningCitations}
onCitationClick={setDocCitation}
onCitationClick={handleCitationOpen}
citationOffset={summaryCitations.length}
column={column}
inline
@ -316,15 +491,116 @@ export function TRSidePanel({
)}
</div>
</div>
<div className="flex shrink-0 justify-center bg-white/25 pb-7 pt-1">
<div className="grid grid-cols-3 grid-rows-3 gap-0.5">
<CellNavigatorButton
className="col-start-2 row-start-1"
label="Previous document"
title={previousDocument?.filename}
disabled={!previousDocument}
onClick={() =>
previousDocument &&
onNavigate(previousDocument.id, column.index)
}
>
<ChevronUp className="h-4 w-4" />
</CellNavigatorButton>
<CellNavigatorButton
className="col-start-1 row-start-2"
label="Previous column"
title={previousColumn?.name}
disabled={!previousColumn}
onClick={() =>
previousColumn &&
onNavigate(doc.id, previousColumn.index)
}
>
<ChevronLeft className="h-4 w-4" />
</CellNavigatorButton>
<div className="col-start-2 row-start-2 h-7 w-7 rounded-md bg-white/35" />
<CellNavigatorButton
className="col-start-3 row-start-2"
label="Next column"
title={nextColumn?.name}
disabled={!nextColumn}
onClick={() =>
nextColumn &&
onNavigate(doc.id, nextColumn.index)
}
>
<ChevronRight className="h-4 w-4" />
</CellNavigatorButton>
<CellNavigatorButton
className="col-start-2 row-start-3"
label="Next document"
title={nextDocument?.filename}
disabled={!nextDocument}
onClick={() =>
nextDocument &&
onNavigate(nextDocument.id, column.index)
}
>
<ChevronDown className="h-4 w-4" />
</CellNavigatorButton>
</div>
</div>
</div>
</div>
);
}
function CellNavigatorButton({
label,
title,
disabled,
onClick,
className,
children,
}: {
label: string;
title?: string;
disabled: boolean;
onClick: () => void;
className?: string;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={title ? `${label}: ${title}` : label}
className={cn(
"flex h-7 w-7 items-center justify-center rounded-md text-gray-600 transition-colors disabled:cursor-default disabled:opacity-25",
APP_SURFACE_HOVER_CLASS,
APP_SURFACE_PRESSED_CLASS,
className,
)}
>
{children}
</button>
);
}
// ---------------------------------------------------------------------------
// Markdown renderer
// ---------------------------------------------------------------------------
function formatCitationLocation(citation: ParsedCitation): string {
if (citation.sheet && citation.cell) {
return `${citation.sheet}, cell ${citation.cell}`;
}
return `Page ${citation.page ?? 1}`;
}
function citationKey(cellId: string, citation: ParsedCitation): string {
const location = citation.sheet
? `${citation.sheet}:${citation.cell ?? ""}`
: `page:${citation.page ?? 1}`;
return `tr-cell:${cellId}:${location}`;
}
function CitationBadge({
index,
citation,
@ -332,16 +608,24 @@ function CitationBadge({
}: {
index: number;
citation: ParsedCitation;
onClick: (c: { quote: string; page: number }) => void;
onClick: (citation: TRPanelCitation) => void;
}) {
return (
<button
type="button"
data-page={citation.page}
data-sheet={citation.sheet}
data-cell={citation.cell}
data-quote={citation.quote}
title={`Page ${citation.page}: "${citation.quote}"`}
title={`${formatCitationLocation(citation)}: "${citation.quote}"`}
onClick={() =>
onClick({ quote: citation.quote, page: citation.page })
onClick({
quote: citation.quote,
page: citation.page,
sheet: citation.sheet,
cell: citation.cell,
citationRef: index + 1,
})
}
className="inline-flex items-center justify-center rounded-full bg-gray-200 w-3.5 h-3.5 text-[9px] font-medium text-gray-700 align-super cursor-pointer hover:bg-gray-300 transition-colors"
>
@ -360,7 +644,7 @@ function MarkdownContent({
}: {
children: string;
citations: ParsedCitation[];
onCitationClick: (c: { quote: string; page: number }) => void;
onCitationClick: (citation: TRPanelCitation) => void;
inline?: boolean;
citationOffset?: number;
column?: ColumnConfig;

View file

@ -22,6 +22,11 @@ import {
} from "../shared/TablePrimitive";
import { PillButton } from "@/app/components/ui/pill-button";
import { TabularReviewSkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons";
import {
APP_SURFACE_ACTIVE_CLASS,
APP_SURFACE_GROUP_HOVER_CLASS,
APP_SURFACE_HOVER_CLASS,
} from "@/app/components/ui/liquid-surface";
const SKELETON_COLS = 4;
const SKELETON_ROWS = 5;
@ -30,7 +35,6 @@ const COL_W = "w-[300px] shrink-0";
const DOC_COL_W = "w-[332px] shrink-0";
const TR_STICKY_CELL_BG = "bg-app-surface";
const TR_HEADER_BG = "bg-app-surface";
const TR_ACTIVE_ROW_BG = "bg-app-surface-active";
// Pixel widths matching the CSS constants above
const DOC_COL_W_PX = 332;
@ -54,7 +58,14 @@ interface Props {
highlightedCell?: { colIdx: number; rowIdx: number } | null;
onSelectionChange: (ids: string[]) => void;
onExpand: (cell: TabularCell) => void;
onCitationClick: (cell: TabularCell, page: number, quote: string) => void;
onCitationClick: (
cell: TabularCell,
page: number | undefined,
quote: string,
citationRef: number,
sheet?: string,
citationCell?: string,
) => void;
onUpdateColumn: (col: ColumnConfig) => void;
onDeleteColumn: (colIndex: number) => void;
onAddColumn: () => void;
@ -186,7 +197,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
{Array.from({ length: SKELETON_ROWS }).map((_, row) => (
<div
key={row}
className="flex h-10"
className="flex h-8"
style={{ minWidth: skeletonContentWidth }}
>
<div className={`sticky left-0 z-[60] ${DOC_COL_W} ${TR_STICKY_CELL_BG} flex items-center border-b border-r border-gray-200 py-2 pl-4 pr-2`}>
@ -324,7 +335,7 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
{uploadingFilenames.map((filename) => (
<div
key={`uploading-${filename}`}
className="flex h-10"
className="flex h-8"
style={{ minWidth: totalContentWidth }}
>
<div
@ -352,20 +363,21 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
</div>
))}
{documents.map((doc, docIdx) => {
const rowBg = selectedDocIds.includes(doc.id)
? TR_ACTIVE_ROW_BG
: "bg-transparent";
const stickyRowBg = selectedDocIds.includes(doc.id)
? TR_ACTIVE_ROW_BG
const isSelected = selectedDocIds.includes(doc.id);
const rowBg = isSelected
? APP_SURFACE_ACTIVE_CLASS
: APP_SURFACE_HOVER_CLASS;
const stickyRowBg = isSelected
? APP_SURFACE_ACTIVE_CLASS
: TR_STICKY_CELL_BG;
return (
<div
key={doc.id}
className={`flex ${rowBg}`}
className={`group flex transition-colors ${rowBg}`}
style={{ minWidth: totalContentWidth }}
>
<div
className={`sticky left-0 z-[60] ${DOC_COL_W} border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs text-gray-800 flex items-center ${stickyRowBg}`}
className={`sticky left-0 z-[60] ${DOC_COL_W} border-b border-r border-gray-200 py-2 pl-4 pr-2 text-xs text-gray-800 flex items-center transition-colors ${stickyRowBg} ${isSelected ? "" : APP_SURFACE_GROUP_HOVER_CLASS}`}
>
<input
type="checkbox"
@ -402,11 +414,17 @@ export const TRTable = forwardRef<TRTableHandle, Props>(function TRTable(
onCitationClick={(
page,
quote,
citationRef,
sheet,
citationCell,
) =>
onCitationClick(
cell,
page,
quote,
citationRef,
sheet,
citationCell,
)
}
/>

View file

@ -14,7 +14,13 @@ interface Props {
column?: ColumnConfig;
closeSignal?: number;
onExpand: () => void;
onCitationClick?: (page: number, quote: string) => void;
onCitationClick?: (
page: number | undefined,
quote: string,
citationRef: number,
sheet?: string,
cell?: string,
) => void;
}
const FLAG_STYLES = {
@ -63,7 +69,13 @@ function CellMarkdown({
citations: ParsedCitation[];
pills: string[];
column?: ColumnConfig;
onCitationClick?: (page: number, quote: string) => void;
onCitationClick?: (
page: number | undefined,
quote: string,
citationRef: number,
sheet?: string,
cell?: string,
) => void;
onExpand: () => void;
inline?: boolean;
}) {
@ -108,13 +120,16 @@ function CellMarkdown({
if (citation) {
return (
<span
title={`Page ${citation.page}: "${citation.quote}"`}
title={`${formatCitationLocation(citation)}: "${citation.quote}"`}
onClick={(e) => {
e.stopPropagation();
if (onCitationClick) {
onCitationClick(
citation.page,
citation.quote,
idx + 1,
citation.sheet,
citation.cell,
);
} else {
onExpand();
@ -156,6 +171,13 @@ function CellMarkdown({
);
}
function formatCitationLocation(citation: ParsedCitation): string {
if (citation.sheet && citation.cell) {
return `${citation.sheet}!${citation.cell}`;
}
return `Page ${citation.page ?? 1}`;
}
export function TabularCell({
cell,
column,
@ -210,9 +232,15 @@ export function TabularCell({
const firstLine = processed.split("\n").find((l) => l.trim()) ?? processed;
const collapsedDisplay = firstLine.replace(/^[-*•]\s+/, "");
function handleCitationClickInOverlay(page: number, quote: string) {
function handleCitationClickInOverlay(
page: number | undefined,
quote: string,
citationRef: number,
sheet?: string,
citationCell?: string,
) {
setInlineExpanded(false);
onCitationClick?.(page, quote);
onCitationClick?.(page, quote, citationRef, sheet, citationCell);
}
function handleSeeDetails() {

View file

@ -97,7 +97,13 @@ export function TRView({ reviewId, projectId }: Props) {
const { user } = useAuth();
const [expandedCell, setExpandedCell] = useState<TabularCell | null>(null);
const [expandedCellCitation, setExpandedCellCitation] = useState<
{ quote: string; page: number } | undefined
{
quote: string;
page?: number;
sheet?: string;
cell?: string;
citationRef: number;
} | undefined
>(undefined);
const [selectedDocIds, setSelectedDocIds] = useState<string[]>([]);
const [actionsOpen, setActionsOpen] = useState(false);
@ -981,9 +987,22 @@ export function TRView({ reviewId, projectId }: Props) {
setExpandedCell(cell);
setExpandedCellCitation(undefined);
}}
onCitationClick={(cell, page, quote) => {
onCitationClick={(
cell,
page,
quote,
citationRef,
sheet,
citationCell,
) => {
setExpandedCell(cell);
setExpandedCellCitation({ quote, page });
setExpandedCellCitation({
quote,
page,
sheet,
cell: citationCell,
citationRef,
});
}}
onUpdateColumn={handleUpdateColumn}
onDeleteColumn={handleDeleteColumn}
@ -1025,18 +1044,19 @@ export function TRView({ reviewId, projectId }: Props) {
<TRSidePanel
cell={expandedCell}
document={expandedDoc}
documents={filteredDocuments}
column={expandedCol}
columns={columns}
onClose={() => {
setExpandedCell(null);
setExpandedCellCitation(undefined);
}}
onNavigate={(columnIndex) => {
onNavigate={(documentId, columnIndex) => {
const nextCell = cells.find(
(c) =>
c.document_id ===
expandedCell.document_id &&
c.column_index === columnIndex,
(candidate) =>
candidate.document_id ===
documentId &&
candidate.column_index === columnIndex,
);
if (nextCell) {
setExpandedCell(nextCell);
@ -1052,6 +1072,9 @@ export function TRView({ reviewId, projectId }: Props) {
displayDocument={expandedCellCitation !== undefined}
citationQuote={expandedCellCitation?.quote}
citationPage={expandedCellCitation?.page}
citationSheet={expandedCellCitation?.sheet}
citationCell={expandedCellCitation?.cell}
citationRef={expandedCellCitation?.citationRef}
/>
);
})()}

View file

@ -1,9 +1,11 @@
"use client";
const PAGE_CITATION_RE = /\[\[page:(\d+)\|\|(?:quote:)?((?:[^\[\]]|\[[^\]]*\])+)\]\]/gi;
const INLINE_METADATA_RE = /\[\[((?:[^\[\]]|\[[^\]]*\])+)\]\]/g;
export interface ParsedCitation {
page: number;
page?: number;
sheet?: string;
cell?: string;
quote: string;
}
@ -16,11 +18,59 @@ export function preprocessCitations(text: string): {
citations: ParsedCitation[];
} {
const citations: ParsedCitation[] = [];
PAGE_CITATION_RE.lastIndex = 0;
const processed = text.replace(PAGE_CITATION_RE, (_, page, quote) => {
const idx = citations.length;
citations.push({ page: parseInt(page, 10), quote: quote.trim() });
return `§${idx}§`;
});
INLINE_METADATA_RE.lastIndex = 0;
const processed = text.replace(
INLINE_METADATA_RE,
(fullMarker, rawMetadata: string) => {
const pageCitation = parsePageCitation(rawMetadata);
const spreadsheetCitation = parseSpreadsheetCitation(rawMetadata);
const citation = pageCitation ?? spreadsheetCitation;
if (!citation) return fullMarker;
const idx = citations.length;
citations.push(citation);
return `§${idx}§`;
},
);
return { processed, citations };
}
function parsePageCitation(metadata: string): ParsedCitation | null {
const match = metadata.match(/^page:(\d+)\|\|(?:quote:)?([\s\S]+)$/i);
if (!match) return null;
return {
page: parseInt(match[1], 10),
quote: match[2].trim(),
};
}
function parseSpreadsheetCitation(metadata: string): ParsedCitation | null {
if (!metadata.toLowerCase().startsWith("sheet:")) return null;
const quoteSeparator = metadata.search(/\|\|quote:/i);
if (quoteSeparator < 0) return null;
const locatorMetadata = metadata.slice(0, quoteSeparator);
const quote = metadata
.slice(quoteSeparator)
.replace(/^\|\|quote:/i, "")
.trim();
if (!quote) return null;
const fields = new Map<string, string>();
for (const part of locatorMetadata.split("||")) {
const separator = part.indexOf(":");
if (separator < 0) continue;
const key = part.slice(0, separator).trim().toLowerCase();
const value = part.slice(separator + 1).trim();
if (value) fields.set(key, value);
}
const sheet = fields.get("sheet");
const row = fields.get("row");
const column = fields.get("col") ?? fields.get("column");
const cell = fields.get("cell") ?? (column && row ? `${column}${row}` : undefined);
if (!sheet || !cell) return null;
return { sheet, cell, quote };
}

View file

@ -7,12 +7,13 @@ import {
DropdownMenuRadioItem,
} from "@/app/components/ui/dropdown-menu";
import { cn } from "@/app/lib/utils";
import { APP_SURFACE_HOVER_CLASS } from "@/app/components/ui/liquid-surface";
const LIQUID_DROPDOWN_CLASS =
"rounded-2xl border border-white/70 bg-app-surface shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18)] backdrop-blur-2xl";
const LIQUID_DROPDOWN_ITEM_CLASS =
"cursor-pointer text-xs text-gray-600 transition-colors hover:bg-app-surface-hover focus:bg-app-surface-hover focus:text-gray-800";
`cursor-pointer text-xs text-gray-600 transition-colors ${APP_SURFACE_HOVER_CLASS} focus:bg-app-surface-hover focus:text-gray-800`;
export function LiquidDropdownContent({
className,

View file

@ -1,5 +1,11 @@
export const LIQUID_TABLE_SURFACE_CLASS =
"rounded-2xl border-y border-white/70 bg-app-surface shadow-[0_10px_30px_rgba(15,23,42,0.11),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.16)] backdrop-blur-2xl";
export const APP_SURFACE_HOVER_CLASS = "hover:bg-app-surface-hover";
export const APP_SURFACE_ACTIVE_CLASS = "bg-app-surface-active";
export const APP_SURFACE_PRESSED_CLASS = "active:bg-app-surface-active";
export const APP_SURFACE_GROUP_HOVER_CLASS =
"group-hover:bg-app-surface-hover";
export const LIQUID_PANEL_SURFACE_CLASS =
"rounded-2xl border border-white/70 bg-white/20 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl";
"rounded-2xl border border-white/70 bg-white/50 shadow-[0_8px_24px_rgba(15,23,42,0.12),inset_0_1px_0_rgba(255,255,255,0.9),inset_0_-10px_24px_rgba(255,255,255,0.18),inset_1px_0_0_rgba(255,255,255,0.5)] backdrop-blur-2xl";

View file

@ -35,7 +35,6 @@ import {
import { workflowDetailPath } from "./workflowRoutes";
import {
TABLE_CHECKBOX_CLASS,
TABLE_STICKY_CELL_BG,
SkeletonDot,
SkeletonLine,
TableBody,
@ -626,12 +625,10 @@ export function WorkflowList() {
<TableBody>
{filtered.map((wf) => {
const isHiddenSystem = hiddenSystemIds.includes(wf.id);
const rowBg = selectedIds.includes(wf.id)
? "bg-gray-50"
: TABLE_STICKY_CELL_BG;
return (
<TableRow
key={wf.id}
selected={selectedIds.includes(wf.id)}
className={isHiddenSystem ? "opacity-45" : undefined}
rightClickDropdown={
wf.is_system
@ -685,7 +682,6 @@ export function WorkflowList() {
onClick={() => setSelected(wf)}
>
<TablePrimaryCell
bgClassName={rowBg}
selected={selectedIds.includes(wf.id)}
onSelectionChange={() => toggleOne(wf.id)}
label={wf.metadata.title}

View file

@ -12,6 +12,10 @@ import {
formatLabel,
} from "../tabular/columnFormat";
import { TAG_COLORS } from "../tabular/pillUtils";
import {
APP_SURFACE_ACTIVE_CLASS,
APP_SURFACE_HOVER_CLASS,
} from "@/app/components/ui/liquid-surface";
type WorkflowPreviewMode = "auto" | "prompt" | "columns";
type MobilePickerPane = "list" | "details";
@ -157,8 +161,8 @@ export function WorkflowPickerContent({
}
className={`flex min-w-0 w-full items-center gap-3 rounded-md px-3 py-2 text-left text-xs transition-all ${
isSelected
? "bg-gray-100 text-gray-900"
: "hover:bg-gray-100/70"
? `${APP_SURFACE_ACTIVE_CLASS} text-gray-900`
: APP_SURFACE_HOVER_CLASS
} ${disabled ? "cursor-not-allowed opacity-45" : ""}`}
>
<span
@ -234,7 +238,7 @@ function WorkflowPreview({
<button
type="button"
onClick={onClear}
className="rounded-md p-1 text-gray-400 transition-colors hover:bg-gray-100/70 hover:text-gray-600"
className={`rounded-md p-1 text-gray-400 transition-colors hover:text-gray-600 ${APP_SURFACE_HOVER_CLASS}`}
>
<X className="h-3.5 w-3.5" />
</button>
@ -372,8 +376,8 @@ function WorkflowColumnPreview({ columns }: { columns: ColumnConfig[] }) {
}
className={`flex min-w-0 w-full items-center gap-2.5 rounded-md px-3 py-2.5 text-left text-xs transition-all ${
isExpanded
? "bg-gray-100"
: "hover:bg-gray-100/70"
? APP_SURFACE_ACTIVE_CLASS
: APP_SURFACE_HOVER_CLASS
}`}
>
<FormatIcon