mirror of
https://github.com/willchen96/mike.git
synced 2026-07-26 23:51:08 +02:00
Takes frontend/npm run lint from 23 errors / 40 warnings to 0 errors / 40 warnings, then removes continue-on-error from the CI lint step so it gates merges. Errors fixed outright: - @typescript-eslint/no-explicit-any (5, ChatView.tsx): dropped redundant (msg as any) casts — Message already declares files, workflow, and error with the exact shapes the props expect. - react/no-unescaped-entities (1, support/page.tsx): "We'll" -> "We'll". Targeted disables (17, all react-hooks/set-state-in-effect): every site is an intentional effect-driven state pattern — SSR/hydration mount gates (Modal, useSelectedModel), reset-on-prop/identity-change (CaseLawPanel x3, ChatView chat switch, CitationQuotesHeader, ChatHistoryContext logout, useFetchDocxBytes), sync fast paths of async fetch/check effects (CaseLawPanel, MfaLoginGate x2), DOM-measured state (ChatView scroll button, message-visibility restore), and timed UI latches (PreResponseWrapper, TRChatPanel, AskInputPopup auto-submit). Rewriting any of them would change runtime behavior, so each carries a // eslint-disable-next-line with a one-line reason instead of a fix or a repo-wide rule change. CI: lint step is now blocking; comments updated to say the backlog is at zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
703 lines
31 KiB
TypeScript
703 lines
31 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { Check, X } from "lucide-react";
|
|
import { PillButton } from "@/app/components/ui/pill-button";
|
|
import { TabPillButton } from "@/app/components/ui/tab-pill-button";
|
|
import type { AssistantEvent, Document } from "../shared/types";
|
|
import { FileTypeIcon } from "../shared/FileTypeIcon";
|
|
import { AddDocumentsModal } from "../modals/AddDocumentsModal";
|
|
|
|
type AskInputsEvent = Extract<AssistantEvent, { type: "ask_inputs" }>;
|
|
type AskInputItem = AskInputsEvent["items"][number];
|
|
type AskInputsResponse = Extract<
|
|
AssistantEvent,
|
|
{ type: "ask_inputs_response" }
|
|
>;
|
|
|
|
export function AskInputPopup({
|
|
event,
|
|
onSubmit,
|
|
onDismiss,
|
|
}: {
|
|
event: AskInputsEvent;
|
|
onSubmit?: (
|
|
response: AskInputsResponse,
|
|
content: string,
|
|
files: { filename: string; document_id: string }[],
|
|
) => void;
|
|
onDismiss?: () => void;
|
|
}) {
|
|
const [choiceAnswers, setChoiceAnswers] = useState<Record<string, string>>(
|
|
{},
|
|
);
|
|
const [otherOpen, setOtherOpen] = useState<Record<string, boolean>>({});
|
|
const [otherValues, setOtherValues] = useState<Record<string, string>>({});
|
|
const [docsByInput, setDocsByInput] = useState<
|
|
Record<string, Record<number, Document[]>>
|
|
>({});
|
|
const [skipped, setSkipped] = useState<Set<string>>(() => new Set());
|
|
const [confirmed, setConfirmed] = useState<Set<string>>(() => new Set());
|
|
const [submitted, setSubmitted] = useState(false);
|
|
const [dismissed, setDismissed] = useState(false);
|
|
const [docSelectorTarget, setDocSelectorTarget] = useState<{
|
|
inputId: string;
|
|
typeIndex: number;
|
|
} | null>(null);
|
|
const [activeInputId, setActiveInputId] = useState(
|
|
() => event.items[0]?.id ?? "",
|
|
);
|
|
|
|
const docsForItem = useCallback(
|
|
(inputId: string) => {
|
|
const seen = new Set<string>();
|
|
return Object.values(docsByInput[inputId] ?? {})
|
|
.flat()
|
|
.filter((doc) => {
|
|
if (seen.has(doc.id)) return false;
|
|
seen.add(doc.id);
|
|
return true;
|
|
});
|
|
},
|
|
[docsByInput],
|
|
);
|
|
|
|
const itemAnswered = useCallback(
|
|
(item: AskInputItem) => {
|
|
if (item.kind === "choice") return !!choiceAnswers[item.id]?.trim();
|
|
return docsForItem(item.id).length > 0;
|
|
},
|
|
[choiceAnswers, docsForItem],
|
|
);
|
|
|
|
const itemResolved = useCallback(
|
|
(item: AskInputItem) =>
|
|
skipped.has(item.id) || confirmed.has(item.id),
|
|
[confirmed, skipped],
|
|
);
|
|
|
|
const firstUnresolvedId = useCallback(
|
|
(resolvedId?: string) =>
|
|
event.items.find((item) => {
|
|
if (item.id === resolvedId) return false;
|
|
return !itemResolved(item);
|
|
})?.id ?? null,
|
|
[event.items, itemResolved],
|
|
);
|
|
|
|
const goToNextUnresolved = useCallback(
|
|
(resolvedId: string) => {
|
|
const nextId = firstUnresolvedId(resolvedId);
|
|
if (nextId) setActiveInputId(nextId);
|
|
},
|
|
[firstUnresolvedId],
|
|
);
|
|
|
|
const setSkippedFor = (id: string, shouldSkip = true) => {
|
|
setSkipped((prev) => {
|
|
const next = new Set(prev);
|
|
if (shouldSkip) next.add(id);
|
|
else next.delete(id);
|
|
return next;
|
|
});
|
|
if (shouldSkip) goToNextUnresolved(id);
|
|
};
|
|
|
|
const confirmItem = (id: string) => {
|
|
setConfirmed((prev) => {
|
|
const next = new Set(prev);
|
|
next.add(id);
|
|
return next;
|
|
});
|
|
goToNextUnresolved(id);
|
|
};
|
|
|
|
const addDocs = (
|
|
inputId: string,
|
|
typeIndex: number,
|
|
selected: Document[],
|
|
) => {
|
|
if (selected.length === 0) return;
|
|
setSkippedFor(inputId, false);
|
|
setDocsByInput((prev) => {
|
|
const byType = prev[inputId] ?? {};
|
|
const current = byType[typeIndex] ?? [];
|
|
const existing = new Set(current.map((doc) => doc.id));
|
|
return {
|
|
...prev,
|
|
[inputId]: {
|
|
...byType,
|
|
[typeIndex]: [
|
|
...current,
|
|
...selected.filter((doc) => !existing.has(doc.id)),
|
|
],
|
|
},
|
|
};
|
|
});
|
|
};
|
|
|
|
const removeDoc = (inputId: string, typeIndex: number, docId: string) => {
|
|
setDocsByInput((prev) => {
|
|
const byType = prev[inputId] ?? {};
|
|
return {
|
|
...prev,
|
|
[inputId]: {
|
|
...byType,
|
|
[typeIndex]: (byType[typeIndex] ?? []).filter(
|
|
(doc) => doc.id !== docId,
|
|
),
|
|
},
|
|
};
|
|
});
|
|
};
|
|
|
|
const chooseAnswer = (
|
|
item: Extract<AskInputItem, { kind: "choice" }>,
|
|
answer: string,
|
|
) => {
|
|
const trimmed = answer.trim();
|
|
if (!trimmed || submitted) return;
|
|
setSkippedFor(item.id, false);
|
|
setChoiceAnswers((prev) => ({ ...prev, [item.id]: trimmed }));
|
|
setOtherOpen((prev) => ({ ...prev, [item.id]: false }));
|
|
};
|
|
|
|
const allResolved =
|
|
event.items.length > 0 && event.items.every(itemResolved);
|
|
const canSubmit = !submitted && allResolved && !!onSubmit;
|
|
|
|
const buildResponse = (): AskInputsResponse => {
|
|
const responses = event.items.map((item) => {
|
|
if (skipped.has(item.id)) {
|
|
return item.kind === "choice"
|
|
? {
|
|
id: item.id,
|
|
kind: "choice" as const,
|
|
question: item.question,
|
|
skipped: true,
|
|
}
|
|
: {
|
|
id: item.id,
|
|
kind: "documents" as const,
|
|
filenames: [],
|
|
skipped: true,
|
|
};
|
|
}
|
|
if (item.kind === "choice") {
|
|
return {
|
|
id: item.id,
|
|
kind: "choice" as const,
|
|
question: item.question,
|
|
answer: choiceAnswers[item.id]?.trim() ?? "",
|
|
};
|
|
}
|
|
return {
|
|
id: item.id,
|
|
kind: "documents" as const,
|
|
filenames: docsForItem(item.id).map((doc) => doc.filename),
|
|
};
|
|
});
|
|
return { type: "ask_inputs_response", responses };
|
|
};
|
|
|
|
const responseFiles = (response: AskInputsResponse) => {
|
|
const responseById = new Map(response.responses.map((item) => [item.id, item]));
|
|
const docs = event.items.flatMap((item) => {
|
|
const responseItem = responseById.get(item.id);
|
|
if (
|
|
item.kind !== "documents" ||
|
|
responseItem?.kind !== "documents" ||
|
|
responseItem.skipped
|
|
) {
|
|
return [];
|
|
}
|
|
return docsForItem(item.id);
|
|
});
|
|
const seen = new Set<string>();
|
|
return docs.flatMap((doc) => {
|
|
if (seen.has(doc.id)) return [];
|
|
seen.add(doc.id);
|
|
return [{ filename: doc.filename, document_id: doc.id }];
|
|
});
|
|
};
|
|
|
|
const buildContent = (response: AskInputsResponse) => {
|
|
const lines = response.responses.map((item, index) => {
|
|
if (item.kind === "choice") {
|
|
if (item.skipped)
|
|
return `${index + 1}. Skipped: ${item.question}`;
|
|
return `${index + 1}. ${item.question}\n${item.answer ?? ""}`;
|
|
}
|
|
if (item.skipped) return `${index + 1}. Skipped document request.`;
|
|
return `${index + 1}. Documents attached: ${item.filenames.join(", ")}`;
|
|
});
|
|
return `Responses to Mike's questions:\n${lines.join("\n\n")}`;
|
|
};
|
|
|
|
const submit = () => {
|
|
if (!canSubmit) return;
|
|
const response = buildResponse();
|
|
setSubmitted(true);
|
|
onSubmit?.(response, buildContent(response), responseFiles(response));
|
|
};
|
|
|
|
useEffect(() => {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- auto-submit when every question is answered; submit() sets state as part of the side effect
|
|
if (canSubmit) submit();
|
|
});
|
|
|
|
const dismiss = useCallback(() => {
|
|
if (submitted || dismissed) return;
|
|
setDismissed(true);
|
|
onDismiss?.();
|
|
}, [dismissed, onDismiss, submitted]);
|
|
|
|
useEffect(() => {
|
|
if (submitted || dismissed) return;
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") dismiss();
|
|
};
|
|
window.addEventListener("keydown", handler);
|
|
return () => window.removeEventListener("keydown", handler);
|
|
}, [submitted, dismissed, dismiss]);
|
|
|
|
if (dismissed) return null;
|
|
|
|
const activeItem =
|
|
event.items.find((item) => item.id === activeInputId) ?? event.items[0];
|
|
|
|
return (
|
|
<>
|
|
<div className="w-full overflow-hidden rounded-[18px] border border-white/65 bg-white/60 pb-3 font-serif shadow-[0_4px_10px_rgba(15,23,42,0.084),inset_0_1px_0_rgba(255,255,255,0.595),inset_0_-6px_14px_rgba(255,255,255,0.126)] backdrop-blur-2xl md:rounded-[22px]">
|
|
<div className="flex min-w-0 items-center justify-between gap-2 px-3 py-2">
|
|
<div className="flex min-w-0 items-center">
|
|
<div className="text-sm text-gray-500">
|
|
{submitted ? (
|
|
"Inputs sent"
|
|
) : (
|
|
<div className="flex flex-wrap gap-x-1.5 gap-y-1">
|
|
{event.items.map((item) => {
|
|
const isActive =
|
|
item.id === activeItem?.id;
|
|
const isResolved = itemResolved(item);
|
|
const label =
|
|
item.kind === "choice"
|
|
? "Question"
|
|
: "Documents";
|
|
return (
|
|
<TabPillButton
|
|
key={item.id}
|
|
active={isActive}
|
|
disabled={submitted}
|
|
onClick={() =>
|
|
setActiveInputId(item.id)
|
|
}
|
|
className="h-6 px-2 font-sans text-[10px]"
|
|
>
|
|
{isResolved ? (
|
|
<Check className="h-3 w-3" />
|
|
) : (
|
|
<span className="h-2.5 w-2.5 rounded-full border border-current opacity-70" />
|
|
)}
|
|
{label}
|
|
</TabPillButton>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{!submitted && (
|
|
<TabPillButton
|
|
type="button"
|
|
onClick={dismiss}
|
|
aria-label="Dismiss"
|
|
className="h-6 w-6 shrink-0 px-0"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</TabPillButton>
|
|
)}
|
|
</div>
|
|
|
|
<div className="px-3">
|
|
{activeItem && (
|
|
<div className="mt-3 flex min-h-54 flex-col">
|
|
<div>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
{activeItem.kind === "choice" ? (
|
|
<p className="text-sm text-gray-800">
|
|
{activeItem.question}
|
|
</p>
|
|
) : (
|
|
<DocumentPrompt />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-3">
|
|
{activeItem.kind === "choice" ? (
|
|
<OptionInput
|
|
item={activeItem}
|
|
disabled={
|
|
submitted ||
|
|
skipped.has(activeItem.id)
|
|
}
|
|
selectedAnswer={
|
|
choiceAnswers[activeItem.id] ??
|
|
null
|
|
}
|
|
otherOpen={
|
|
!!otherOpen[activeItem.id]
|
|
}
|
|
otherValue={
|
|
otherValues[activeItem.id] ?? ""
|
|
}
|
|
onAnswer={(answer) =>
|
|
chooseAnswer(activeItem, answer)
|
|
}
|
|
onOtherOpen={() => {
|
|
setOtherOpen((prev) => ({
|
|
...prev,
|
|
[activeItem.id]: true,
|
|
}));
|
|
setChoiceAnswers((prev) => ({
|
|
...prev,
|
|
[activeItem.id]: (
|
|
otherValues[
|
|
activeItem.id
|
|
] ?? ""
|
|
).trim(),
|
|
}));
|
|
}}
|
|
onOtherValue={(value) => {
|
|
setOtherValues((prev) => ({
|
|
...prev,
|
|
[activeItem.id]: value,
|
|
}));
|
|
setChoiceAnswers((prev) => ({
|
|
...prev,
|
|
[activeItem.id]:
|
|
value.trim(),
|
|
}));
|
|
if (value.trim())
|
|
setSkippedFor(
|
|
activeItem.id,
|
|
false,
|
|
);
|
|
}}
|
|
/>
|
|
) : (
|
|
<DocumentInput
|
|
item={activeItem}
|
|
disabled={
|
|
submitted ||
|
|
skipped.has(activeItem.id)
|
|
}
|
|
docsByType={
|
|
docsByInput[activeItem.id] ?? {}
|
|
}
|
|
onOpenSelector={(typeIndex) =>
|
|
setDocSelectorTarget({
|
|
inputId: activeItem.id,
|
|
typeIndex,
|
|
})
|
|
}
|
|
onRemoveDoc={(typeIndex, docId) =>
|
|
removeDoc(
|
|
activeItem.id,
|
|
typeIndex,
|
|
docId,
|
|
)
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{!submitted && (
|
|
<div className="mt-auto flex items-center justify-end gap-2 pt-3">
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setSkippedFor(
|
|
activeItem.id,
|
|
!skipped.has(activeItem.id),
|
|
)
|
|
}
|
|
className="px-1 font-sans text-[10px] text-gray-500 transition-colors hover:text-gray-800"
|
|
>
|
|
{skipped.has(activeItem.id)
|
|
? "Unskip"
|
|
: "Skip"}
|
|
</button>
|
|
<PillButton
|
|
tone="black"
|
|
type="button"
|
|
disabled={
|
|
skipped.has(activeItem.id) ||
|
|
confirmed.has(activeItem.id) ||
|
|
!itemAnswered(activeItem)
|
|
}
|
|
onClick={() =>
|
|
confirmItem(activeItem.id)
|
|
}
|
|
className="h-6 px-3 font-sans text-[10px]"
|
|
>
|
|
{confirmed.has(activeItem.id) ? (
|
|
<>
|
|
Confirmed
|
|
<Check className="h-3 w-3" />
|
|
</>
|
|
) : (
|
|
"Confirm"
|
|
)}
|
|
</PillButton>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<AddDocumentsModal
|
|
open={!!docSelectorTarget}
|
|
keepMounted
|
|
onClose={() => setDocSelectorTarget(null)}
|
|
onSelect={(selected) => {
|
|
if (!docSelectorTarget) return;
|
|
// A document can only be added once per input, so docs
|
|
// already living in another type row are left where they
|
|
// are; only genuinely new picks join the targeted row.
|
|
const existing = new Set(
|
|
docsForItem(docSelectorTarget.inputId).map(
|
|
(doc) => doc.id,
|
|
),
|
|
);
|
|
addDocs(
|
|
docSelectorTarget.inputId,
|
|
docSelectorTarget.typeIndex,
|
|
selected.filter((doc) => !existing.has(doc.id)),
|
|
);
|
|
}}
|
|
breadcrumb={["Assistant", "Add Documents"]}
|
|
initialSelectedDocuments={
|
|
docSelectorTarget
|
|
? docsForItem(docSelectorTarget.inputId)
|
|
: []
|
|
}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function OptionInput({
|
|
item,
|
|
disabled,
|
|
selectedAnswer,
|
|
otherOpen,
|
|
otherValue,
|
|
onAnswer,
|
|
onOtherOpen,
|
|
onOtherValue,
|
|
}: {
|
|
item: Extract<AskInputItem, { kind: "choice" }>;
|
|
disabled?: boolean;
|
|
selectedAnswer: string | null;
|
|
otherOpen: boolean;
|
|
otherValue: string;
|
|
onAnswer: (answer: string) => void;
|
|
onOtherOpen: () => void;
|
|
onOtherValue: (value: string) => void;
|
|
}) {
|
|
return (
|
|
<div className="mt-2 grid gap-1.5">
|
|
{item.options.map((option, idx) => {
|
|
const answer = option.value.trim();
|
|
const isSelected = !otherOpen && selectedAnswer === answer;
|
|
return (
|
|
<button
|
|
key={`${item.id}-${option.value}-${idx}`}
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => onAnswer(answer)}
|
|
className={`w-full rounded-lg px-3 py-2 text-left transition-colors ${
|
|
isSelected
|
|
? "bg-gray-200/80 text-gray-900"
|
|
: "bg-gray-100/70 text-gray-700 hover:bg-gray-200/70 disabled:hover:bg-gray-100/70"
|
|
} disabled:cursor-default disabled:opacity-60`}
|
|
>
|
|
<span className="flex items-start gap-1">
|
|
<span className="mt-0.5 w-4 shrink-0 text-xs text-gray-500">
|
|
{idx + 1}.
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-sm">{answer}</span>
|
|
</span>
|
|
{isSelected && (
|
|
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-gray-700" />
|
|
)}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
{item.allow_other && (
|
|
<div
|
|
className={`w-full rounded-lg px-3 py-2 transition-colors ${
|
|
otherOpen
|
|
? "bg-gray-200/80"
|
|
: "cursor-pointer bg-gray-100/70 hover:bg-gray-200/70"
|
|
} ${disabled ? "cursor-default opacity-60" : ""}`}
|
|
onClick={() => !otherOpen && !disabled && onOtherOpen()}
|
|
>
|
|
<span className="flex items-start gap-1">
|
|
<span className="mt-0.5 w-4 shrink-0 text-xs text-gray-500">
|
|
{item.options.length + 1}.
|
|
</span>
|
|
{otherOpen ? (
|
|
<span className="min-w-0 flex-1 flex items-start gap-2">
|
|
<textarea
|
|
name={`other-${item.id}`}
|
|
rows={1}
|
|
autoFocus
|
|
value={otherValue}
|
|
disabled={disabled}
|
|
onFocus={(e) => {
|
|
const end = e.target.value.length;
|
|
e.target.setSelectionRange(end, end);
|
|
e.target.style.height = "auto";
|
|
e.target.style.height = `${e.target.scrollHeight}px`;
|
|
}}
|
|
onChange={(e) => {
|
|
onOtherValue(e.target.value);
|
|
e.target.style.height = "auto";
|
|
e.target.style.height = `${e.target.scrollHeight}px`;
|
|
}}
|
|
placeholder="Type your answer..."
|
|
className="flex-1 resize-none overflow-hidden bg-transparent text-sm leading-5 text-gray-600 outline-none placeholder:text-gray-400"
|
|
/>
|
|
</span>
|
|
) : (
|
|
<span className="min-w-0 flex-1 text-sm text-gray-700">
|
|
{item.other_label || "Other"}
|
|
</span>
|
|
)}
|
|
{otherOpen && (
|
|
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-gray-700" />
|
|
)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DocumentPrompt() {
|
|
return (
|
|
<p className="mt-0.5 text-sm text-gray-800">
|
|
Add the following documents if available:
|
|
</p>
|
|
);
|
|
}
|
|
|
|
function DocumentInput({
|
|
item,
|
|
disabled,
|
|
docsByType,
|
|
onOpenSelector,
|
|
onRemoveDoc,
|
|
}: {
|
|
item: Extract<AskInputItem, { kind: "documents" }>;
|
|
disabled?: boolean;
|
|
docsByType: Record<number, Document[]>;
|
|
onOpenSelector: (typeIndex: number) => void;
|
|
onRemoveDoc: (typeIndex: number, docId: string) => void;
|
|
}) {
|
|
const documentTypes = item.document_types ?? [];
|
|
const rows = documentTypes.length > 0 ? documentTypes : ["Documents"];
|
|
return (
|
|
<div className="mt-2 grid gap-1.5">
|
|
{rows.map((documentType, idx) => {
|
|
const docs = docsByType[idx] ?? [];
|
|
return (
|
|
<div
|
|
key={`${item.id}-${documentType}-${idx}`}
|
|
role="button"
|
|
tabIndex={disabled ? -1 : 0}
|
|
aria-disabled={disabled}
|
|
onClick={() => !disabled && onOpenSelector(idx)}
|
|
onKeyDown={(e) => {
|
|
if (
|
|
!disabled &&
|
|
(e.key === "Enter" || e.key === " ")
|
|
) {
|
|
e.preventDefault();
|
|
onOpenSelector(idx);
|
|
}
|
|
}}
|
|
className={`w-full rounded-lg px-3 py-2 text-left transition-colors ${
|
|
docs.length > 0
|
|
? "bg-gray-200/80 text-gray-900"
|
|
: "bg-gray-100/70 text-gray-700"
|
|
} ${
|
|
disabled
|
|
? "cursor-default opacity-60"
|
|
: "cursor-pointer hover:bg-gray-200/70"
|
|
}`}
|
|
>
|
|
<span className="flex items-start gap-1">
|
|
<span className="mt-0.5 w-4 shrink-0 text-xs text-gray-500">
|
|
{idx + 1}.
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block break-words text-sm">
|
|
{documentType}
|
|
</span>
|
|
{docs.length > 0 && (
|
|
<span className="mt-1.5 flex flex-wrap gap-1.5">
|
|
{docs.map((doc) => (
|
|
<span
|
|
key={`${item.id}-${doc.id}`}
|
|
onClick={(e) =>
|
|
e.stopPropagation()
|
|
}
|
|
className="inline-flex items-center gap-1 rounded-[10px] border border-white/70 bg-white py-0.5 pl-2 pr-1 text-xs text-gray-800 shadow-[0_2px_6px_rgba(15,23,42,0.08),inset_0_1px_0_rgba(255,255,255,0.9)] backdrop-blur-xl"
|
|
>
|
|
<FileTypeIcon
|
|
fileType={doc.file_type}
|
|
className="h-2.5 w-2.5"
|
|
/>
|
|
<span className="max-w-[160px] truncate">
|
|
{doc.filename}
|
|
</span>
|
|
{!disabled && (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRemoveDoc(
|
|
idx,
|
|
doc.id,
|
|
);
|
|
}}
|
|
className="ml-0.5 rounded-full p-0.5 text-gray-400 transition-colors hover:bg-gray-900/5 hover:text-gray-700"
|
|
>
|
|
<X className="h-2.5 w-2.5" />
|
|
</button>
|
|
)}
|
|
</span>
|
|
))}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="mt-0.5 shrink-0 whitespace-nowrap font-sans text-[10px] text-gray-500">
|
|
{docs.length > 0
|
|
? `${docs.length} file${docs.length === 1 ? "" : "s"} added`
|
|
: "+ Add"}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|