mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
Merge upstream/dev
This commit is contained in:
commit
8bdfd00a15
191 changed files with 3301 additions and 4079 deletions
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
import { ListOrdered, Settings2, Tag, Target } from "lucide-react";
|
||||
import { Dot } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import type { AutomationDefinition } from "@/contracts/types/automation.types";
|
||||
import { ExecutionSummary } from "./execution-summary";
|
||||
import { InputsSchemaPreview } from "./inputs-schema-preview";
|
||||
|
|
@ -11,34 +13,30 @@ interface AutomationDefinitionSectionProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* The Definition card. Read view; editing happens on the sibling /edit
|
||||
* route (Edit button in the header). Layout is top-down:
|
||||
* goal → tags → execution defaults → inputs schema (if any) → plan
|
||||
*
|
||||
* The schema_version is rendered as a small badge next to the section
|
||||
* title so it's discoverable but doesn't fight for attention.
|
||||
* User-facing read view of the saved automation definition. Editing happens on
|
||||
* the sibling /edit route; this card should summarize behavior, not expose the
|
||||
* raw persisted schema.
|
||||
*/
|
||||
export function AutomationDefinitionSection({ definition }: AutomationDefinitionSectionProps) {
|
||||
const hasTags = definition.metadata.tags.length > 0;
|
||||
const hasInputs = !!definition.inputs;
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const stepCount = `${definition.plan.length} step${definition.plan.length === 1 ? "" : "s"}`;
|
||||
|
||||
return (
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<CardTitle className="text-base font-semibold">Definition</CardTitle>
|
||||
<span className="text-xs font-mono text-muted-foreground border border-border/60 rounded px-1.5 py-0.5">
|
||||
v{definition.schema_version}
|
||||
</span>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-semibold">Automation details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{definition.goal && (
|
||||
<Field icon={Target} label="Goal">
|
||||
<Field label="Goal">
|
||||
<p className="text-sm text-foreground">{definition.goal}</p>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{hasTags && (
|
||||
<Field icon={Tag} label="Tags">
|
||||
<Field label="Tags">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{definition.metadata.tags.map((tag) => (
|
||||
<span
|
||||
|
|
@ -52,25 +50,39 @@ export function AutomationDefinitionSection({ definition }: AutomationDefinition
|
|||
</Field>
|
||||
)}
|
||||
|
||||
<Field icon={Settings2} label="Execution defaults">
|
||||
<ExecutionSummary execution={definition.execution} />
|
||||
</Field>
|
||||
|
||||
{hasInputs && (
|
||||
<Field icon={Settings2} label="Inputs schema">
|
||||
<Field label="Inputs">
|
||||
{definition.inputs && <InputsSchemaPreview inputs={definition.inputs} />}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field
|
||||
icon={ListOrdered}
|
||||
label={`Plan · ${definition.plan.length} step${definition.plan.length === 1 ? "" : "s"}`}
|
||||
label={
|
||||
<span className="inline-flex items-center">
|
||||
Plan
|
||||
<Dot className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
{stepCount}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{definition.plan.map((step, idx) => (
|
||||
<PlanStepCard key={step.step_id} step={step} index={idx} />
|
||||
))}
|
||||
</div>
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen} className="mt-3">
|
||||
<CollapsibleTrigger className="text-xs font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline">
|
||||
{advancedOpen ? "Hide advanced options" : "Advanced options"}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="mt-3 rounded-md border border-border/60 bg-background/30 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-muted-foreground">
|
||||
Execution defaults
|
||||
</div>
|
||||
<ExecutionSummary execution={definition.execution} />
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -78,20 +90,15 @@ export function AutomationDefinitionSection({ definition }: AutomationDefinition
|
|||
}
|
||||
|
||||
function Field({
|
||||
icon: Icon,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
icon: typeof Target;
|
||||
label: string;
|
||||
label: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden />
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-muted-foreground">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { updateAutomationMutationAtom } from "@/atoms/automations/automations-mu
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { Automation } from "@/contracts/types/automation.types";
|
||||
import { AutomationStatusBadge } from "../../components/automation-status-badge";
|
||||
import { DeleteAutomationDialog } from "../../components/delete-automation-dialog";
|
||||
|
||||
interface AutomationDetailHeaderProps {
|
||||
|
|
@ -70,12 +69,9 @@ export function AutomationDetailHeader({
|
|||
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-2 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground break-words">
|
||||
{automation.name}
|
||||
</h1>
|
||||
<AutomationStatusBadge status={automation.status} />
|
||||
</div>
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground break-words">
|
||||
{automation.name}
|
||||
</h1>
|
||||
{automation.description && (
|
||||
<p className="text-sm text-muted-foreground max-w-3xl">{automation.description}</p>
|
||||
)}
|
||||
|
|
@ -83,9 +79,15 @@ export function AutomationDetailHeader({
|
|||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{canUpdate && (
|
||||
<Button asChild type="button" variant="outline" size="sm">
|
||||
<Button
|
||||
asChild
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start rounded-md bg-muted px-3 hover:bg-accent"
|
||||
>
|
||||
<Link href={`/dashboard/${searchSpaceId}/automations/${automation.id}/edit`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<Pencil className="mr-1 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
|
|
@ -93,28 +95,30 @@ export function AutomationDetailHeader({
|
|||
{canToggle && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleTogglePause}
|
||||
disabled={updating}
|
||||
className="relative justify-start rounded-md bg-muted px-3 hover:bg-accent"
|
||||
>
|
||||
{updating ? (
|
||||
<Spinner size="xs" className="mr-2" />
|
||||
) : (
|
||||
<PauseIcon className="mr-2 h-4 w-4" />
|
||||
<span className={updating ? "inline-flex items-center whitespace-nowrap opacity-0" : "inline-flex items-center whitespace-nowrap"}>
|
||||
<PauseIcon className="mr-1 h-4 w-4" />
|
||||
{pauseLabel}
|
||||
</span>
|
||||
{updating && (
|
||||
<Spinner size="xs" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
)}
|
||||
{pauseLabel}
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
className="justify-start rounded-md bg-muted px-3 hover:bg-accent"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trash2 className="mr-1 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ export function AutomationRunsSection({ automationId }: AutomationRunsSectionPro
|
|||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-base font-semibold inline-flex items-center gap-2">
|
||||
<History className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
Recent runs
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export function AutomationTriggersSection({
|
|||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-semibold">Triggers</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When this automation fires. v1 supports scheduled triggers only.
|
||||
When this automation runs
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ export function ExecutionSummary({ execution }: ExecutionSummaryProps) {
|
|||
<dl className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-2 text-xs">
|
||||
<Item label="Timeout" value={`${execution.timeout_seconds}s`} />
|
||||
<Item label="Max retries" value={String(execution.max_retries)} />
|
||||
<Item label="Retry backoff" value={execution.retry_backoff} />
|
||||
<Item label="Concurrency" value={execution.concurrency} />
|
||||
<Item label="Retry backoff" value={formatEnumValue(execution.retry_backoff)} />
|
||||
<Item label="Concurrency" value={formatEnumValue(execution.concurrency)} />
|
||||
{execution.on_failure.length > 0 && (
|
||||
<Item
|
||||
label="On failure"
|
||||
|
|
@ -27,6 +27,11 @@ export function ExecutionSummary({ execution }: ExecutionSummaryProps) {
|
|||
);
|
||||
}
|
||||
|
||||
function formatEnumValue(value: string): string {
|
||||
const text = value.replace(/_/g, " ");
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
function Item({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
"use client";
|
||||
import { JsonView } from "@/components/json-view";
|
||||
import type { Inputs } from "@/contracts/types/automation.types";
|
||||
|
||||
interface InputsSchemaPreviewProps {
|
||||
|
|
@ -13,9 +12,63 @@ interface InputsSchemaPreviewProps {
|
|||
* is null.
|
||||
*/
|
||||
export function InputsSchemaPreview({ inputs }: InputsSchemaPreviewProps) {
|
||||
const fields = getInputFields(inputs.schema);
|
||||
|
||||
if (fields.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No extra inputs are required.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md bg-muted/40 px-3 py-2 max-h-72 overflow-auto">
|
||||
<JsonView src={inputs.schema} collapsed={2} />
|
||||
<div className="rounded-md border border-border/60 bg-background/30">
|
||||
{fields.map((field) => (
|
||||
<div
|
||||
key={field.name}
|
||||
className="flex items-start justify-between gap-4 border-border/60 px-3 py-2 text-sm not-last:border-b"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-foreground">{field.name}</div>
|
||||
{field.description ? (
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">{field.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="shrink-0 text-xs text-muted-foreground">
|
||||
{field.type}
|
||||
{field.required ? " · required" : ""}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getInputFields(schema: Record<string, unknown>): {
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
required: boolean;
|
||||
}[] {
|
||||
const properties = schema.properties;
|
||||
if (!properties || typeof properties !== "object" || Array.isArray(properties)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
||||
return Object.entries(properties as Record<string, unknown>).map(([name, value]) => {
|
||||
const field = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
||||
return {
|
||||
name,
|
||||
type: formatType((field as Record<string, unknown>).type),
|
||||
description:
|
||||
typeof (field as Record<string, unknown>).description === "string"
|
||||
? ((field as Record<string, unknown>).description as string)
|
||||
: undefined,
|
||||
required: required.has(name),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function formatType(value: unknown): string {
|
||||
if (Array.isArray(value)) return value.join(" or ");
|
||||
if (typeof value === "string") return value;
|
||||
return "value";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
"use client";
|
||||
import { ArrowRightCircle, GitCommitHorizontal } from "lucide-react";
|
||||
import { JsonView } from "@/components/json-view";
|
||||
import type { PlanStep } from "@/contracts/types/automation.types";
|
||||
|
||||
interface PlanStepCardProps {
|
||||
|
|
@ -9,62 +7,35 @@ interface PlanStepCardProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* Read-only view of one plan step. Renders the step_id + action prominently,
|
||||
* then a definition list of the per-step knobs, and finally the params as
|
||||
* formatted JSON. Editable mode is out of scope here — definition edits live
|
||||
* on the (future) raw-JSON path.
|
||||
* Read-only view of one plan step. Keep this user-facing: summarize what the
|
||||
* step does and only show advanced step controls when they are explicitly set.
|
||||
*/
|
||||
export function PlanStepCard({ step, index }: PlanStepCardProps) {
|
||||
const title = getStepTitle(step);
|
||||
const details = getStepDetails(step);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border/60 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/60 bg-muted/30">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
|
||||
<div className="rounded-md border border-border/60 bg-background/30 px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground">{step.step_id}</span>
|
||||
<ArrowRightCircle className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
<span className="text-xs font-mono text-muted-foreground">{step.action}</span>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
{(step.when ||
|
||||
step.output_as ||
|
||||
step.max_retries != null ||
|
||||
step.timeout_seconds != null) && (
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
|
||||
{step.when && (
|
||||
<DefRow label="When" value={<code className="font-mono">{step.when}</code>} />
|
||||
)}
|
||||
{step.output_as && (
|
||||
<DefRow
|
||||
label="Output as"
|
||||
value={<code className="font-mono">{step.output_as}</code>}
|
||||
/>
|
||||
)}
|
||||
{step.max_retries != null && (
|
||||
<DefRow label="Max retries" value={String(step.max_retries)} />
|
||||
)}
|
||||
{step.timeout_seconds != null && (
|
||||
<DefRow label="Timeout" value={`${step.timeout_seconds}s`} />
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground mb-1.5">
|
||||
<GitCommitHorizontal className="h-3.5 w-3.5" aria-hidden />
|
||||
Params
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 px-3 py-2 overflow-auto">
|
||||
<JsonView src={step.params} collapsed={1} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-medium text-foreground">{title}</h3>
|
||||
{details.length > 0 ? (
|
||||
<dl className="mt-3 grid grid-cols-1 gap-x-6 gap-y-1.5 text-xs sm:grid-cols-2">
|
||||
{details.map((detail) => (
|
||||
<DefRow key={detail.label} label={detail.label} value={detail.value} />
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
function DefRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-2 min-w-0">
|
||||
<dt className="text-muted-foreground shrink-0">{label}:</dt>
|
||||
|
|
@ -72,3 +43,104 @@ function DefRow({ label, value }: { label: string; value: React.ReactNode }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStepTitle(step: PlanStep): string {
|
||||
if (step.action === "agent_task") {
|
||||
return readStringParam(step.params, "query") ?? "Run an agent task";
|
||||
}
|
||||
return sentenceCase(formatAction(step.action));
|
||||
}
|
||||
|
||||
function getStepDetails(step: PlanStep): { label: string; value: string }[] {
|
||||
const details: { label: string; value: string }[] = [];
|
||||
|
||||
if (step.action === "agent_task") {
|
||||
if (typeof step.params.auto_approve_all === "boolean") {
|
||||
details.push({
|
||||
label: "Approval",
|
||||
value: step.params.auto_approve_all ? "Auto-approve agent actions" : "Ask before actions",
|
||||
});
|
||||
}
|
||||
|
||||
const mentionSummary = summarizeMentions(step.params);
|
||||
if (mentionSummary) {
|
||||
details.push({ label: "Scope", value: mentionSummary });
|
||||
}
|
||||
} else {
|
||||
const readableParams = Object.entries(step.params)
|
||||
.filter(([, value]) => value !== null && value !== undefined && value !== "")
|
||||
.map(([key, value]) => `${sentenceCase(formatKey(key))}: ${formatValue(value)}`);
|
||||
if (readableParams.length > 0) {
|
||||
details.push({ label: "Details", value: readableParams.join(" · ") });
|
||||
}
|
||||
}
|
||||
|
||||
if (step.when) details.push({ label: "Runs when", value: step.when });
|
||||
if (step.output_as) details.push({ label: "Saves output as", value: step.output_as });
|
||||
if (step.max_retries != null) details.push({ label: "Max retries", value: String(step.max_retries) });
|
||||
if (step.timeout_seconds != null) details.push({ label: "Timeout", value: `${step.timeout_seconds}s` });
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
function readStringParam(params: Record<string, unknown>, key: string): string | null {
|
||||
const value = params[key];
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function summarizeMentions(params: Record<string, unknown>): string | null {
|
||||
const parts: string[] = [];
|
||||
addMentionTitles(parts, params.mentioned_documents, "Documents and folders");
|
||||
addMentionTitles(parts, params.mentioned_connectors, "Connectors");
|
||||
if (parts.length === 0) {
|
||||
addCount(parts, params.mentioned_document_ids, "document");
|
||||
addCount(parts, params.mentioned_folder_ids, "folder");
|
||||
addCount(parts, params.mentioned_connector_ids, "connector");
|
||||
}
|
||||
return parts.length > 0 ? parts.join(", ") : null;
|
||||
}
|
||||
|
||||
function addMentionTitles(parts: string[], value: unknown, label: string): void {
|
||||
if (!Array.isArray(value) || value.length === 0) return;
|
||||
const titles = value
|
||||
.map((entry) => {
|
||||
const record = asRecord(entry);
|
||||
const title = typeof record.title === "string" ? record.title : null;
|
||||
const accountName = typeof record.account_name === "string" ? record.account_name : null;
|
||||
return title ?? accountName;
|
||||
})
|
||||
.filter((title): title is string => !!title);
|
||||
if (titles.length === 0) return;
|
||||
parts.push(`${label}: ${titles.join(", ")}`);
|
||||
}
|
||||
|
||||
function addCount(parts: string[], value: unknown, singular: string): void {
|
||||
if (!Array.isArray(value) || value.length === 0) return;
|
||||
parts.push(`${value.length} ${singular}${value.length === 1 ? "" : "s"}`);
|
||||
}
|
||||
|
||||
function formatAction(action: string): string {
|
||||
return formatKey(action);
|
||||
}
|
||||
|
||||
function formatKey(key: string): string {
|
||||
return key.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function sentenceCase(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||
if (typeof value === "string" || typeof value === "number") return String(value);
|
||||
if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? "" : "s"}`;
|
||||
if (value && typeof value === "object") return "Configured";
|
||||
return String(value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,11 @@ export function RunDetailsPanel({
|
|||
const isTerminal = liveStatus !== "pending" && liveStatus !== "running";
|
||||
// Defer the REST round-trip until the run can actually carry heavy
|
||||
// fields — output/artifacts/error are only written at terminal mark.
|
||||
const { data: run, isLoading, error } = useAutomationRun(automationId, runId, {
|
||||
const {
|
||||
data: run,
|
||||
isLoading,
|
||||
error,
|
||||
} = useAutomationRun(automationId, runId, {
|
||||
enabled: isTerminal,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,36 @@
|
|||
"use client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, CalendarClock, Clock, Pencil, Save, Trash2 } from "lucide-react";
|
||||
import { AlertCircle, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { updateTriggerMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
|
||||
import { JsonView } from "@/components/json-view";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { type Trigger, triggerUpdateRequest } from "@/contracts/types/automation.types";
|
||||
import { describeCron } from "@/lib/automations/describe-cron";
|
||||
import { formatRelativeDate, formatRelativeFutureDate } from "@/lib/format-date";
|
||||
import { formatRelativeFutureDate } from "@/lib/format-date";
|
||||
import {
|
||||
DEFAULT_SCHEDULE,
|
||||
fromCron,
|
||||
type ScheduleFrequency,
|
||||
toCron,
|
||||
} from "@/lib/automations/schedule-builder";
|
||||
import { TimezoneCombobox } from "../../components/builder/timezone-combobox";
|
||||
import { DeleteTriggerDialog } from "./delete-trigger-dialog";
|
||||
|
||||
interface TriggerCardProps {
|
||||
|
|
@ -19,27 +40,58 @@ interface TriggerCardProps {
|
|||
canDelete: boolean;
|
||||
}
|
||||
|
||||
type SimpleFrequency = Extract<ScheduleFrequency, "hourly" | "daily" | "weekdays"> | "custom";
|
||||
|
||||
interface TriggerDraft {
|
||||
params: Record<string, unknown>;
|
||||
static_inputs: Record<string, unknown>;
|
||||
frequency: SimpleFrequency;
|
||||
hour: number;
|
||||
minute: number;
|
||||
timezone: string;
|
||||
cron: string;
|
||||
}
|
||||
|
||||
const SIMPLE_FREQUENCIES = new Set<ScheduleFrequency>(["hourly", "daily", "weekdays"]);
|
||||
|
||||
function draftFromTrigger(trigger: Trigger): TriggerDraft {
|
||||
const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : "";
|
||||
const timezone = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "UTC";
|
||||
const model = fromCron(cron);
|
||||
if (model && SIMPLE_FREQUENCIES.has(model.frequency)) {
|
||||
return {
|
||||
frequency: model.frequency as SimpleFrequency,
|
||||
hour: model.hour,
|
||||
minute: model.minute,
|
||||
timezone,
|
||||
cron,
|
||||
};
|
||||
}
|
||||
return {
|
||||
params: trigger.params,
|
||||
static_inputs: trigger.static_inputs ?? {},
|
||||
frequency: "custom",
|
||||
hour: DEFAULT_SCHEDULE.hour,
|
||||
minute: DEFAULT_SCHEDULE.minute,
|
||||
timezone,
|
||||
cron,
|
||||
};
|
||||
}
|
||||
|
||||
function pad(value: number): string {
|
||||
return value.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
function clampInt(raw: string, min: number, max: number): number {
|
||||
const value = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(value)) return min;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* One trigger row in the Triggers section of the detail page. Renders:
|
||||
* - type icon + human-readable schedule + timezone
|
||||
* - last_fired_at / next_fire_at hints
|
||||
* - static_inputs as formatted JSON (when present)
|
||||
* - enable toggle + remove button + inline edit (each gated independently)
|
||||
* - human-readable schedule
|
||||
* - compact enable toggle
|
||||
* - dropdown actions for edit/remove
|
||||
*
|
||||
* Inline edit covers ``params`` and ``static_inputs`` — the two fields the
|
||||
* backend ``PATCH /triggers/[id]`` endpoint accepts beyond ``enabled``.
|
||||
* Inline edit keeps schedule editing intentionally small: common frequencies,
|
||||
* time, timezone, and raw cron only for schedules outside the simple model.
|
||||
* ``enabled`` stays on the Switch so the two surfaces don't fight.
|
||||
*/
|
||||
export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: TriggerCardProps) {
|
||||
|
|
@ -51,10 +103,9 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
|
|||
const [issues, setIssues] = useState<string[]>([]);
|
||||
|
||||
const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : undefined;
|
||||
const tz = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "UTC";
|
||||
const human = cron ? describeCron(cron) : trigger.type;
|
||||
const triggerLabel = cron ? `${human} · ${tz}` : trigger.type;
|
||||
const hasStaticInputs = Object.keys(trigger.static_inputs ?? {}).length > 0;
|
||||
const triggerLabel = human;
|
||||
const showActions = (canUpdate && !isEditing) || canDelete;
|
||||
|
||||
async function handleToggle(checked: boolean) {
|
||||
await updateTrigger({
|
||||
|
|
@ -77,7 +128,22 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
|
|||
|
||||
async function saveEdit() {
|
||||
setIssues([]);
|
||||
const result = triggerUpdateRequest.safeParse(draft);
|
||||
const params =
|
||||
draft.frequency === "custom"
|
||||
? { cron: draft.cron.trim(), timezone: draft.timezone }
|
||||
: {
|
||||
cron: toCron({
|
||||
...DEFAULT_SCHEDULE,
|
||||
frequency: draft.frequency,
|
||||
hour: draft.hour,
|
||||
minute: draft.minute,
|
||||
}),
|
||||
timezone: draft.timezone,
|
||||
};
|
||||
const result = triggerUpdateRequest.safeParse({
|
||||
params,
|
||||
static_inputs: trigger.static_inputs ?? {},
|
||||
});
|
||||
if (!result.success) {
|
||||
setIssues(
|
||||
result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
||||
|
|
@ -98,134 +164,206 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md border border-border/60 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3 border-b border-border/60">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<CalendarClock className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-foreground">{human}</span>
|
||||
<span className="text-muted-foreground">· {tz}</span>
|
||||
</div>
|
||||
{cron && <code className="text-xs font-mono text-muted-foreground">{cron}</code>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/60 bg-background/30">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="min-w-0 truncate text-sm font-medium text-foreground">{human}</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{canUpdate && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{trigger.enabled ? "Enabled" : "Off"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={trigger.enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={updating || isEditing}
|
||||
aria-label={trigger.enabled ? "Disable trigger" : "Enable trigger"}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={trigger.enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={updating || isEditing}
|
||||
aria-label={trigger.enabled ? "Disable trigger" : "Enable trigger"}
|
||||
className="h-5 w-9 [&>span]:h-4 [&>span]:w-4 [&>span[data-state=checked]]:translate-x-4"
|
||||
/>
|
||||
)}
|
||||
{canUpdate && !isEditing && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
onClick={startEdit}
|
||||
aria-label="Edit trigger"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
disabled={isEditing}
|
||||
aria-label="Remove trigger"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{showActions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 hover:bg-transparent"
|
||||
disabled={isEditing}
|
||||
aria-label="Trigger actions"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-32 z-80">
|
||||
{canUpdate && !isEditing && (
|
||||
<DropdownMenuItem onSelect={startEdit}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem onSelect={() => setDeleteOpen(true)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 space-y-3 text-xs">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<div className="rounded-md border border-input bg-background px-3 py-2 max-h-[24rem] overflow-auto">
|
||||
<JsonView
|
||||
src={draft}
|
||||
editable
|
||||
onChange={(next) => setDraft(next as TriggerDraft)}
|
||||
collapsed={false}
|
||||
/>
|
||||
{!isEditing && trigger.next_fire_at ? (
|
||||
<div className="flex items-center gap-3 border-t border-border/60 px-4 py-3 text-sm">
|
||||
<div className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<span>Next fire:</span>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
trigger.enabled
|
||||
? "min-w-0 truncate font-medium text-foreground"
|
||||
: "min-w-0 truncate text-muted-foreground"
|
||||
}
|
||||
title={new Date(trigger.next_fire_at).toLocaleString()}
|
||||
>
|
||||
{formatRelativeFutureDate(trigger.next_fire_at)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-3 border-t border-border/60 px-4 py-3 text-xs">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="trigger-runs">
|
||||
Runs
|
||||
</label>
|
||||
<Select
|
||||
value={draft.frequency}
|
||||
onValueChange={(value) =>
|
||||
setDraft((prev) => ({ ...prev, frequency: value as SimpleFrequency }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="trigger-runs" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hourly">Every hour</SelectItem>
|
||||
<SelectItem value="daily">Daily</SelectItem>
|
||||
<SelectItem value="weekdays">Weekdays</SelectItem>
|
||||
<SelectItem value="custom">Custom cron</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{issues.length > 0 && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 font-medium text-destructive mb-1">
|
||||
<AlertCircle className="h-3 w-3" aria-hidden />
|
||||
{issues.length === 1 ? "1 issue" : `${issues.length} issues`}
|
||||
</div>
|
||||
<ul className="space-y-0.5 text-destructive list-disc list-inside">
|
||||
{draft.frequency === "hourly" ? (
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
htmlFor="trigger-minute"
|
||||
>
|
||||
At minute
|
||||
</label>
|
||||
<Input
|
||||
id="trigger-minute"
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
value={draft.minute}
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
minute: clampInt(event.target.value, 0, 59),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : draft.frequency !== "custom" ? (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="trigger-time">
|
||||
Time
|
||||
</label>
|
||||
<Input
|
||||
id="trigger-time"
|
||||
type="time"
|
||||
value={`${pad(draft.hour)}:${pad(draft.minute)}`}
|
||||
onChange={(event) => {
|
||||
const [hour, minute] = event.target.value.split(":");
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
hour: clampInt(hour, 0, 23),
|
||||
minute: clampInt(minute, 0, 59),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="trigger-cron">
|
||||
Schedule expression
|
||||
</label>
|
||||
<Input
|
||||
id="trigger-cron"
|
||||
value={draft.cron}
|
||||
placeholder="0 9 * * 1-5"
|
||||
className="font-mono"
|
||||
onChange={(event) =>
|
||||
setDraft((prev) => ({ ...prev, cron: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Timezone</div>
|
||||
<TimezoneCombobox
|
||||
value={draft.timezone}
|
||||
onChange={(timezone) => setDraft((prev) => ({ ...prev, timezone }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{issues.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle aria-hidden />
|
||||
<AlertTitle>
|
||||
{issues.length === 1 ? "1 issue" : `${issues.length} issues`}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-inside list-disc">
|
||||
{issues.map((issue) => (
|
||||
<li key={issue}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={cancelEdit}
|
||||
disabled={updating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={saveEdit} disabled={updating}>
|
||||
{updating ? (
|
||||
<Spinner size="xs" className="mr-1.5" />
|
||||
) : (
|
||||
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{(trigger.last_fired_at || trigger.next_fire_at) && (
|
||||
<dl className="grid grid-cols-[auto_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1">
|
||||
{trigger.next_fire_at && (
|
||||
<TimeRow
|
||||
label="Next fire"
|
||||
iso={trigger.next_fire_at}
|
||||
tense="future"
|
||||
highlight={trigger.enabled}
|
||||
/>
|
||||
)}
|
||||
{trigger.last_fired_at && (
|
||||
<TimeRow label="Last fired" iso={trigger.last_fired_at} tense="past" />
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{hasStaticInputs && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">Static inputs</div>
|
||||
<div className="rounded-md bg-muted/40 px-3 py-2 overflow-auto">
|
||||
<JsonView src={trigger.static_inputs} collapsed={1} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={cancelEdit}
|
||||
disabled={updating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={saveEdit}
|
||||
disabled={updating}
|
||||
className="relative"
|
||||
>
|
||||
<span className={updating ? "opacity-0" : undefined}>Save</span>
|
||||
{updating ? (
|
||||
<Spinner
|
||||
size="xs"
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||
/>
|
||||
) : null}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canDelete && (
|
||||
|
|
@ -240,35 +378,3 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeRow({
|
||||
label,
|
||||
iso,
|
||||
tense,
|
||||
highlight = false,
|
||||
}: {
|
||||
label: string;
|
||||
iso: string;
|
||||
tense: "past" | "future";
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
const formatted = tense === "future" ? formatRelativeFutureDate(iso) : formatRelativeDate(iso);
|
||||
return (
|
||||
<>
|
||||
<dt className="text-muted-foreground inline-flex items-center gap-1.5 whitespace-nowrap">
|
||||
<Clock className="h-3 w-3" aria-hidden />
|
||||
{label}
|
||||
</dt>
|
||||
<dd
|
||||
className={
|
||||
highlight
|
||||
? "text-foreground font-medium min-w-0 truncate"
|
||||
: "text-muted-foreground min-w-0 truncate"
|
||||
}
|
||||
title={new Date(iso).toLocaleString()}
|
||||
>
|
||||
{formatted}
|
||||
</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,9 +51,17 @@ export function AutomationEditContent({ searchSpaceId, automationId }: Automatio
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AutomationEditHeader automation={automation} searchSpaceId={searchSpaceId} />
|
||||
<AutomationBuilderForm mode="edit" searchSpaceId={searchSpaceId} automation={automation} />
|
||||
</>
|
||||
<AutomationBuilderForm
|
||||
mode="edit"
|
||||
searchSpaceId={searchSpaceId}
|
||||
automation={automation}
|
||||
renderModeSwitcher={(modeSwitcher) => (
|
||||
<AutomationEditHeader
|
||||
automation={automation}
|
||||
searchSpaceId={searchSpaceId}
|
||||
modeSwitcher={modeSwitcher}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,21 @@
|
|||
"use client";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Automation } from "@/contracts/types/automation.types";
|
||||
|
||||
interface AutomationEditHeaderProps {
|
||||
automation: Automation;
|
||||
searchSpaceId: number;
|
||||
modeSwitcher?: ReactNode;
|
||||
}
|
||||
|
||||
export function AutomationEditHeader({ automation, searchSpaceId }: AutomationEditHeaderProps) {
|
||||
export function AutomationEditHeader({
|
||||
automation,
|
||||
searchSpaceId,
|
||||
modeSwitcher,
|
||||
}: AutomationEditHeaderProps) {
|
||||
const detailHref = `/dashboard/${searchSpaceId}/automations/${automation.id}`;
|
||||
|
||||
return (
|
||||
|
|
@ -20,11 +26,11 @@ export function AutomationEditHeader({ automation, searchSpaceId }: AutomationEd
|
|||
Back to automation
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground wrap-break-word">
|
||||
Edit automation
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">{automation.name}</p>
|
||||
{modeSwitcher ? <div className="ml-auto">{modeSwitcher}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { AlertCircle, ShieldAlert } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { useAutomations } from "@/hooks/use-automations";
|
||||
import { AutomationsEmptyState } from "./components/automations-empty-state";
|
||||
import { AutomationsHeader } from "./components/automations-header";
|
||||
|
|
@ -60,9 +61,10 @@ export function AutomationsContent({ searchSpaceId }: AutomationsContentProps) {
|
|||
loading={false}
|
||||
canCreate={perms.canCreate}
|
||||
/>
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 px-6 py-8 text-center">
|
||||
<p className="text-sm text-destructive">Couldn't load automations. {error.message}</p>
|
||||
</div>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle aria-hidden />
|
||||
<AlertDescription>Couldn't load automations {error.message}</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { AutomationSummary } from "@/contracts/types/automation.types";
|
||||
|
|
@ -58,25 +57,21 @@ export function AutomationRowActions({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
className="h-6 w-6 hover:bg-transparent"
|
||||
aria-label={`Actions for ${automation.name}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuContent align="end" className="w-32 z-80">
|
||||
{canToggle && (
|
||||
<DropdownMenuItem onSelect={handleTogglePause} disabled={updating}>
|
||||
<PauseIcon className="mr-2 h-4 w-4" />
|
||||
{pauseLabel}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canToggle && canDelete && <DropdownMenuSeparator />}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setDeleteOpen(true)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<DropdownMenuItem onSelect={() => setDeleteOpen(true)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
|
|
@ -26,35 +26,30 @@ export function AutomationRow({
|
|||
canDelete,
|
||||
}: AutomationRowProps) {
|
||||
return (
|
||||
<TableRow className="border-b border-border/60 hover:bg-muted/40">
|
||||
<TableCell className="px-4 md:px-6 py-3 border-r border-border/60">
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/automations/${automation.id}`}
|
||||
className="text-sm font-medium text-foreground hover:underline truncate"
|
||||
>
|
||||
{automation.name}
|
||||
</Link>
|
||||
{automation.description && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">
|
||||
{automation.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<TableRow className="h-12 border-b border-border/60 hover:bg-muted/40">
|
||||
<TableCell className="px-4 md:px-6 py-2.5 border-r border-border/60 align-middle">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/automations/${automation.id}`}
|
||||
className="block truncate text-sm font-medium text-foreground hover:underline"
|
||||
>
|
||||
{automation.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 border-r border-border/60 w-32">
|
||||
<TableCell className="px-4 py-2.5 border-r border-border/60 w-32 align-middle">
|
||||
<AutomationStatusBadge status={automation.status} />
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell px-4 py-3 border-r border-border/60 w-40 text-xs text-muted-foreground">
|
||||
<TableCell className="hidden md:table-cell px-4 py-2.5 border-r border-border/60 w-40 align-middle text-xs text-muted-foreground">
|
||||
{formatRelativeDate(automation.updated_at)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 md:px-6 py-3 w-16 text-right">
|
||||
<AutomationRowActions
|
||||
automation={automation}
|
||||
searchSpaceId={searchSpaceId}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
<TableCell className="px-4 md:px-6 py-2.5 w-16 align-middle">
|
||||
<div className="flex justify-end">
|
||||
<AutomationRowActions
|
||||
automation={automation}
|
||||
searchSpaceId={searchSpaceId}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
"use client";
|
||||
import { Archive, CircleDot, Pause } from "lucide-react";
|
||||
import type { AutomationStatus } from "@/contracts/types/automation.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -8,41 +7,37 @@ interface AutomationStatusBadgeProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
// Color + icon per status. Active = green, paused = amber, archived = muted.
|
||||
// Small borderless status pills, matching model-selector badges.
|
||||
const STATUS_STYLES: Record<
|
||||
AutomationStatus,
|
||||
{ label: string; icon: typeof CircleDot; classes: string }
|
||||
{ label: string; classes: string }
|
||||
> = {
|
||||
active: {
|
||||
label: "Active",
|
||||
icon: CircleDot,
|
||||
classes:
|
||||
"bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-900/50",
|
||||
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300",
|
||||
},
|
||||
paused: {
|
||||
label: "Paused",
|
||||
icon: Pause,
|
||||
classes:
|
||||
"bg-amber-50 text-amber-700 border border-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-900/50",
|
||||
"bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
|
||||
},
|
||||
archived: {
|
||||
label: "Archived",
|
||||
icon: Archive,
|
||||
classes: "bg-muted text-muted-foreground border border-border/60",
|
||||
classes: "bg-muted text-muted-foreground",
|
||||
},
|
||||
};
|
||||
|
||||
export function AutomationStatusBadge({ status, className }: AutomationStatusBadgeProps) {
|
||||
const { label, icon: Icon, classes } = STATUS_STYLES[status];
|
||||
const { label, classes } = STATUS_STYLES[status];
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium",
|
||||
"inline-flex items-center rounded-md border-0 px-1.5 py-0 text-sm font-medium leading-5",
|
||||
classes,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" aria-hidden />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"use client";
|
||||
import { MessageSquarePlus, SquarePen, Workflow } from "lucide-react";
|
||||
import { Workflow } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
|
|
@ -28,16 +28,14 @@ export function AutomationsEmptyState({ searchSpaceId, canCreate }: AutomationsE
|
|||
{canCreate ? (
|
||||
<div className="mt-6 flex items-center justify-center gap-2 flex-wrap">
|
||||
<Button asChild>
|
||||
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>
|
||||
<MessageSquarePlus className="mr-2 h-4 w-4" />
|
||||
Create via chat
|
||||
</Link>
|
||||
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>Create via chat</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>
|
||||
<SquarePen className="mr-2 h-4 w-4" />
|
||||
Create manually
|
||||
</Link>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
className="h-10 justify-start rounded-md bg-muted px-3 text-sm hover:bg-accent"
|
||||
>
|
||||
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>Create manually</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
"use client";
|
||||
import { MessageSquarePlus, SquarePen } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
|
|
@ -41,17 +40,16 @@ export function AutomationsHeader({
|
|||
</div>
|
||||
{canCreate && showCreateCta && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>
|
||||
<SquarePen className="mr-2 h-4 w-4" />
|
||||
Create manually
|
||||
</Link>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="justify-start rounded-md bg-muted px-3 hover:bg-accent"
|
||||
>
|
||||
<Link href={`/dashboard/${searchSpaceId}/automations/new`}>Create manually</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>
|
||||
<MessageSquarePlus className="mr-2 h-4 w-4" />
|
||||
Create via chat
|
||||
</Link>
|
||||
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>Create via chat</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"use client";
|
||||
import { Activity, CalendarDays, Workflow } from "lucide-react";
|
||||
import { CalendarDays, Info, Workflow } from "lucide-react";
|
||||
import { Table, TableBody, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import type { AutomationSummary } from "@/contracts/types/automation.types";
|
||||
import { AutomationRow } from "./automation-row";
|
||||
|
|
@ -37,7 +37,7 @@ export function AutomationsTable({
|
|||
</TableHead>
|
||||
<TableHead className="border-r border-border/60 w-32">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
|
||||
<Activity size={14} className="opacity-60 text-muted-foreground" />
|
||||
<Info size={14} className="opacity-60 text-muted-foreground" />
|
||||
Status
|
||||
</span>
|
||||
</TableHead>
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export function AdvancedSection({
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label="Timeout (seconds)" hint="Wall-clock cap for the whole run.">
|
||||
<Field label="Timeout (seconds)" hint="Wall-clock cap for the whole run">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
|
|
@ -68,7 +68,7 @@ export function AdvancedSection({
|
|||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Max retries" hint="Per-step retry budget.">
|
||||
<Field label="Max retries" hint="Per-step retry budget">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
|
|
@ -86,7 +86,7 @@ export function AdvancedSection({
|
|||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent matchTriggerWidth={false} className="w-auto min-w-48">
|
||||
{BACKOFF_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
|
|
@ -105,7 +105,7 @@ export function AdvancedSection({
|
|||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent matchTriggerWidth={false} className="w-auto min-w-64">
|
||||
{CONCURRENCY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Code2, LayoutList, Save } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { AlertCircle, Code2, LayoutList } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
|
|
@ -12,9 +12,12 @@ import {
|
|||
updateAutomationMutationAtom,
|
||||
updateTriggerMutationAtom,
|
||||
} from "@/atoms/automations/automations-mutation.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
type Automation,
|
||||
|
|
@ -35,7 +38,6 @@ import {
|
|||
hasResolvedModels,
|
||||
hydrateForm,
|
||||
} from "@/lib/automations/builder-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AdvancedSection } from "./advanced-section";
|
||||
import { AutomationModelFields } from "./automation-model-fields";
|
||||
import { BasicsSection } from "./basics-section";
|
||||
|
|
@ -56,6 +58,7 @@ interface AutomationBuilderFormProps {
|
|||
* eligibility itself is now owned by the in-form pickers.
|
||||
*/
|
||||
submitDisabledReason?: string;
|
||||
renderModeSwitcher?: (modeSwitcher: ReactNode) => ReactNode;
|
||||
}
|
||||
|
||||
type Mode = "form" | "json";
|
||||
|
|
@ -78,6 +81,7 @@ export function AutomationBuilderForm({
|
|||
searchSpaceId,
|
||||
automation,
|
||||
submitDisabledReason,
|
||||
renderModeSwitcher,
|
||||
}: AutomationBuilderFormProps) {
|
||||
const router = useRouter();
|
||||
const { mutateAsync: createAutomation } = useAtomValue(createAutomationMutationAtom);
|
||||
|
|
@ -97,7 +101,7 @@ export function AutomationBuilderForm({
|
|||
return {
|
||||
mode: "json" as Mode,
|
||||
form: createEmptyForm(),
|
||||
notice: `This automation ${result.reason}, which the form can't show. Edit it as JSON below.`,
|
||||
notice: `This automation ${result.reason}, which the form can't show. Edit it as JSON below`,
|
||||
};
|
||||
}
|
||||
return { mode: "form" as Mode, form: createEmptyForm(), notice: undefined };
|
||||
|
|
@ -116,11 +120,6 @@ export function AutomationBuilderForm({
|
|||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const cancelHref =
|
||||
mode === "edit" && automation
|
||||
? `/dashboard/${searchSpaceId}/automations/${automation.id}`
|
||||
: `/dashboard/${searchSpaceId}/automations`;
|
||||
|
||||
// Eligible models + the search-space-seeded defaults. Models are chosen per
|
||||
// automation on create; in edit mode the backend preserves the captured
|
||||
// snapshot, so the picker is create-only.
|
||||
|
|
@ -192,7 +191,7 @@ export function AutomationBuilderForm({
|
|||
// form's own validation enforces completeness on submit.
|
||||
const definition = jsonValue.definition;
|
||||
if (!definition || typeof definition !== "object") {
|
||||
return { ok: false, issues: [], notice: "Add a definition before switching to the form." };
|
||||
return { ok: false, issues: [], notice: "Add a definition before switching to the form" };
|
||||
}
|
||||
|
||||
const name =
|
||||
|
|
@ -210,7 +209,7 @@ export function AutomationBuilderForm({
|
|||
const h = hydrateForm(name, description, definition, triggers);
|
||||
return h.formable
|
||||
? { ok: true, form: h.form }
|
||||
: { ok: false, issues: [], notice: `Can't show in the form: it ${h.reason}.` };
|
||||
: { ok: false, issues: [], notice: `Can't show in the form: it ${h.reason}` };
|
||||
}
|
||||
|
||||
function validateForm(): Record<string, string> | null {
|
||||
|
|
@ -328,119 +327,133 @@ export function AutomationBuilderForm({
|
|||
: undefined);
|
||||
// Only gate creation; editing an existing automation isn't blocked here.
|
||||
const submitBlocked = mode === "create" && !!effectiveDisabledReason;
|
||||
const modeSwitcher = (
|
||||
<Tabs
|
||||
value={activeMode}
|
||||
onValueChange={(value) => {
|
||||
if (value === activeMode) return;
|
||||
if (value === "form") switchToForm();
|
||||
else if (value === "json") switchToJson();
|
||||
}}
|
||||
>
|
||||
<TabsList className="h-6 gap-0 rounded-md bg-muted/60 p-0.5 select-none">
|
||||
<TabsTrigger
|
||||
value="form"
|
||||
className="h-5 gap-1 px-1.5 text-[11px] select-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=active]:bg-muted-foreground/25 data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
>
|
||||
<LayoutList className="size-3 shrink-0" />
|
||||
<span className="leading-none">Form</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="json"
|
||||
className="h-5 gap-1 px-1.5 text-[11px] select-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=active]:bg-muted-foreground/25 data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
>
|
||||
<Code2 className="size-3 shrink-0" />
|
||||
<span className="leading-none">Edit as JSON</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="inline-flex rounded-md border border-border/60 p-0.5">
|
||||
<ModeButton
|
||||
active={activeMode === "form"}
|
||||
icon={LayoutList}
|
||||
label="Form"
|
||||
onClick={() => (activeMode === "form" ? undefined : switchToForm())}
|
||||
/>
|
||||
<ModeButton
|
||||
active={activeMode === "json"}
|
||||
icon={Code2}
|
||||
label="Edit as JSON"
|
||||
onClick={() => (activeMode === "json" ? undefined : switchToJson())}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{renderModeSwitcher ? (
|
||||
renderModeSwitcher(modeSwitcher)
|
||||
) : (
|
||||
<div className="flex items-center justify-end">{modeSwitcher}</div>
|
||||
)}
|
||||
|
||||
{activeMode === "json" ? (
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardContent className="pt-6">
|
||||
<JsonModePanel
|
||||
value={jsonValue}
|
||||
issues={jsonIssues}
|
||||
notice={jsonNotice}
|
||||
onChange={setJsonValue}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<JsonModePanel
|
||||
value={jsonValue}
|
||||
issues={jsonIssues}
|
||||
notice={jsonNotice}
|
||||
onChange={setJsonValue}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Basics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BasicsSection
|
||||
name={form.name}
|
||||
description={form.description}
|
||||
errors={errors}
|
||||
onChange={patchForm}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Tasks</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<TaskList
|
||||
tasks={form.tasks}
|
||||
errors={errors}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onChange={(tasks) => patchForm({ tasks })}
|
||||
/>
|
||||
<UnattendedToggle
|
||||
checked={form.unattended}
|
||||
onChange={(unattended) => patchForm({ unattended })}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Schedule</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScheduleSection
|
||||
schedule={form.schedule}
|
||||
timezone={form.timezone}
|
||||
errors={errors}
|
||||
onScheduleChange={(schedule) => patchForm({ schedule })}
|
||||
onTimezoneChange={(timezone) => patchForm({ timezone })}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Models</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AutomationModelFields
|
||||
searchSpaceId={searchSpaceId}
|
||||
value={resolvedModels}
|
||||
onChange={(patch) => patchForm({ models: { ...form.models, ...patch } })}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/60 bg-accent">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AdvancedSection
|
||||
execution={form.execution}
|
||||
tags={form.tags}
|
||||
onExecutionChange={(patch) =>
|
||||
patchForm({ execution: { ...form.execution, ...patch } })
|
||||
}
|
||||
onTagsChange={(tags) => patchForm({ tags })}
|
||||
/>
|
||||
</CardContent>
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="rounded-md border-accent bg-accent/20">
|
||||
<section>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Basics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BasicsSection
|
||||
name={form.name}
|
||||
description={form.description}
|
||||
errors={errors}
|
||||
onChange={patchForm}
|
||||
/>
|
||||
</CardContent>
|
||||
</section>
|
||||
<Separator className="mx-auto data-[orientation=horizontal]:w-[calc(100%-6rem)]" />
|
||||
<section>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Tasks</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<TaskList
|
||||
tasks={form.tasks}
|
||||
errors={errors}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onChange={(tasks) => patchForm({ tasks })}
|
||||
/>
|
||||
<UnattendedToggle
|
||||
checked={form.unattended}
|
||||
onChange={(unattended) => patchForm({ unattended })}
|
||||
/>
|
||||
</CardContent>
|
||||
</section>
|
||||
<Separator className="mx-auto data-[orientation=horizontal]:w-[calc(100%-6rem)]" />
|
||||
<section>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Schedule</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScheduleSection
|
||||
schedule={form.schedule}
|
||||
timezone={form.timezone}
|
||||
errors={errors}
|
||||
onScheduleChange={(schedule) => patchForm({ schedule })}
|
||||
onTimezoneChange={(timezone) => patchForm({ timezone })}
|
||||
/>
|
||||
</CardContent>
|
||||
</section>
|
||||
<Separator className="mx-auto data-[orientation=horizontal]:w-[calc(100%-6rem)]" />
|
||||
<section>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Models</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AutomationModelFields
|
||||
searchSpaceId={searchSpaceId}
|
||||
value={resolvedModels}
|
||||
onChange={(patch) => patchForm({ models: { ...form.models, ...patch } })}
|
||||
/>
|
||||
</CardContent>
|
||||
</section>
|
||||
<Separator className="mx-auto data-[orientation=horizontal]:w-[calc(100%-6rem)]" />
|
||||
<section>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AdvancedSection
|
||||
execution={form.execution}
|
||||
tags={form.tags}
|
||||
onExecutionChange={(patch) =>
|
||||
patchForm({ execution: { ...form.execution, ...patch } })
|
||||
}
|
||||
onTagsChange={(tags) => patchForm({ tags })}
|
||||
/>
|
||||
</CardContent>
|
||||
</section>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="border-border/60 bg-accent lg:sticky lg:top-4">
|
||||
<Card className="rounded-md border-accent bg-accent/20 lg:sticky lg:top-4">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-semibold">Summary</CardTitle>
|
||||
</CardHeader>
|
||||
|
|
@ -452,12 +465,14 @@ export function AutomationBuilderForm({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{rootError && <p className="text-right text-xs text-destructive">{rootError}</p>}
|
||||
{rootError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle aria-hidden />
|
||||
<AlertDescription>{rootError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button asChild type="button" variant="ghost" size="sm">
|
||||
<Link href={cancelHref}>Cancel</Link>
|
||||
</Button>
|
||||
{submitBlocked ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -470,7 +485,6 @@ export function AutomationBuilderForm({
|
|||
className="cursor-not-allowed opacity-50"
|
||||
onClick={(event) => event.preventDefault()}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
|
@ -481,14 +495,11 @@ export function AutomationBuilderForm({
|
|||
type="button"
|
||||
size="sm"
|
||||
disabled={submitting}
|
||||
className="relative"
|
||||
onClick={() => (activeMode === "json" ? submitJson() : submitForm())}
|
||||
>
|
||||
{submitting ? (
|
||||
<Spinner size="xs" className="mr-2" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{submitLabel}
|
||||
<span className={submitting ? "opacity-0" : ""}>{submitLabel}</span>
|
||||
{submitting && <Spinner size="xs" className="absolute" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -496,34 +507,6 @@ export function AutomationBuilderForm({
|
|||
);
|
||||
}
|
||||
|
||||
function ModeButton({
|
||||
active,
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
icon: typeof Code2;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
active
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function extractTriggers(raw: unknown): HydratableTrigger[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((entry) => {
|
||||
|
|
|
|||
|
|
@ -118,15 +118,14 @@ const ModelSelectField = memo(function ModelSelectField({
|
|||
if (kind.options.length === 0) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<Alert>
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert aria-hidden />
|
||||
<AlertTitle>No eligible models</AlertTitle>
|
||||
<AlertDescription>
|
||||
Automations need a premium or your own (BYOK) model. Set one up in{" "}
|
||||
<AlertDescription className="block leading-5">
|
||||
Use a premium model or your own (BYOK) model in{" "}
|
||||
<Link href={rolesHref} className="font-medium underline underline-offset-2">
|
||||
role settings
|
||||
</Link>
|
||||
.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</Field>
|
||||
|
|
@ -155,7 +154,7 @@ const ModelSelectField = memo(function ModelSelectField({
|
|||
<SelectValue placeholder="Select a model" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent matchTriggerWidth={false} className="w-auto min-w-80 max-w-[90vw]">
|
||||
{premium.length > 0 ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Premium</SelectLabel>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"use client";
|
||||
import { CalendarClock, CheckCircle2, ListOrdered, type LucideIcon, XCircle } from "lucide-react";
|
||||
import { Dot } from "lucide-react";
|
||||
import { type BuilderForm, scheduleToCron } from "@/lib/automations/builder-schema";
|
||||
import { describeCron } from "@/lib/automations/describe-cron";
|
||||
|
||||
|
|
@ -12,85 +12,70 @@ interface BuilderSummaryProps {
|
|||
* chat ``AutomationDraftPreview`` so the two creation paths feel consistent.
|
||||
*/
|
||||
export function BuilderSummary({ form }: BuilderSummaryProps) {
|
||||
const scheduleLabel = form.schedule
|
||||
? `${describeCron(scheduleToCron(form.schedule))} · ${form.timezone}`
|
||||
: "No schedule — won't run automatically";
|
||||
const automationName = form.name.trim() || "Untitled automation";
|
||||
const scheduleDescription = form.schedule ? describeCron(scheduleToCron(form.schedule)) : null;
|
||||
const taskCountLabel = `${form.tasks.length} task${form.tasks.length === 1 ? "" : "s"}`;
|
||||
const visibleTasks = form.tasks.slice(0, 2);
|
||||
const hiddenTaskCount = form.tasks.length - visibleTasks.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-foreground">{form.name.trim() || "Untitled automation"}</p>
|
||||
{form.description?.trim() && (
|
||||
<p className="text-xs text-muted-foreground">{form.description.trim()}</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-4 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="truncate text-sm font-semibold text-muted-foreground" title={automationName}>
|
||||
{automationName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Section icon={CalendarClock} label="Schedule">
|
||||
<p className="text-xs text-foreground">{scheduleLabel}</p>
|
||||
</Section>
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
<Section
|
||||
icon={ListOrdered}
|
||||
label={`Tasks · ${form.tasks.length} step${form.tasks.length === 1 ? "" : "s"}`}
|
||||
>
|
||||
<ol className="space-y-1.5 text-xs">
|
||||
{form.tasks.map((task, index) => (
|
||||
<li key={task.id} className="flex items-start gap-2">
|
||||
<span className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground shrink-0 mt-0.5">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 space-y-1">
|
||||
<span className="block text-foreground line-clamp-2">
|
||||
{task.query.trim() || (
|
||||
<span className="text-muted-foreground">No instructions yet</span>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
<SummaryRow label="Schedule">
|
||||
{scheduleDescription ? (
|
||||
<span className="flex flex-wrap items-center gap-x-1 gap-y-0.5">
|
||||
<span>{scheduleDescription}</span>
|
||||
<Dot className="size-4 text-muted-foreground" aria-hidden />
|
||||
<span>{form.timezone}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>No schedule — won't run automatically</span>
|
||||
)}
|
||||
</SummaryRow>
|
||||
|
||||
<SummaryRow label={taskCountLabel}>
|
||||
<ol className="ml-4 space-y-1">
|
||||
{visibleTasks.map((task, index) => (
|
||||
<li key={task.id} className="flex gap-2">
|
||||
<span className="shrink-0 text-muted-foreground">{index + 1}.</span>
|
||||
<span className="line-clamp-1 min-w-0">
|
||||
{task.query.trim() || "No instructions yet"}
|
||||
</span>
|
||||
{task.mentions.length > 0 && (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{task.mentions.map((mention) => (
|
||||
<span
|
||||
key={`${mention.kind}:${mention.id}`}
|
||||
className="inline-flex max-w-[140px] items-center truncate rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary/70"
|
||||
>
|
||||
@{mention.title}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Section>
|
||||
</li>
|
||||
))}
|
||||
{hiddenTaskCount > 0 && (
|
||||
<li className="text-muted-foreground">+{hiddenTaskCount} more tasks</li>
|
||||
)}
|
||||
</ol>
|
||||
</SummaryRow>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{form.unattended ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" aria-hidden />
|
||||
) : (
|
||||
<XCircle className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
{form.unattended ? "Runs without approval prompts" : "Will reject approval prompts"}
|
||||
<SummaryRow label="Approvals">
|
||||
{form.unattended ? "Runs without approval prompts" : "Approval prompts are rejected"}
|
||||
</SummaryRow>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
icon: Icon,
|
||||
function SummaryRow({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<Icon className="h-3 w-3" aria-hidden />
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<div className="font-medium text-muted-foreground">{label}</div>
|
||||
<div className="text-foreground">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { AlertCircle, TriangleAlert } from "lucide-react";
|
||||
import { JsonView } from "@/components/json-view";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
||||
interface JsonModePanelProps {
|
||||
value: Record<string, unknown>;
|
||||
|
|
@ -19,32 +20,32 @@ export function JsonModePanel({ value, issues, notice, onChange }: JsonModePanel
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
{notice && (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
|
||||
{notice}
|
||||
</div>
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert aria-hidden />
|
||||
<AlertDescription>{notice}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border border-input bg-background px-3 py-2 max-h-144 overflow-auto">
|
||||
<JsonView
|
||||
src={value}
|
||||
editable
|
||||
onChange={(next) => onChange(next as Record<string, unknown>)}
|
||||
collapsed={false}
|
||||
/>
|
||||
</div>
|
||||
<JsonView
|
||||
src={value}
|
||||
editable
|
||||
onChange={(next) => onChange(next as Record<string, unknown>)}
|
||||
collapsed={false}
|
||||
className="max-h-144 overflow-auto rounded-md border border-accent bg-accent/20"
|
||||
/>
|
||||
|
||||
{issues.length > 0 && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-destructive mb-1.5">
|
||||
<AlertCircle className="h-3.5 w-3.5" aria-hidden />
|
||||
{issues.length === 1 ? "1 issue" : `${issues.length} issues`}
|
||||
</div>
|
||||
<ul className="space-y-0.5 text-xs text-destructive list-disc list-inside">
|
||||
{issues.map((issue) => (
|
||||
<li key={issue}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle aria-hidden />
|
||||
<AlertTitle>{issues.length === 1 ? "1 issue" : `${issues.length} issues`}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-inside list-disc">
|
||||
{issues.map((issue) => (
|
||||
<li key={issue}>{issue}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"use client";
|
||||
import { CalendarClock, CalendarOff, Plus, X } from "lucide-react";
|
||||
import { CalendarClock, CalendarOff, Dot, Plus, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
|
|
@ -70,17 +70,18 @@ export function ScheduleSection({
|
|||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-3 rounded-md border border-border/60 bg-background px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-border/60 bg-accent px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-sm min-w-0">
|
||||
<CalendarClock className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<span className="font-medium text-foreground truncate">{label}</span>
|
||||
<span className="text-muted-foreground shrink-0">· {timezone}</span>
|
||||
<Dot className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<span className="text-muted-foreground shrink-0">{timezone}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Remove schedule"
|
||||
onClick={() => onScheduleChange(null)}
|
||||
>
|
||||
|
|
@ -135,7 +136,7 @@ function PresetEditor({ model, onChange, onSwitchToCron }: PresetEditorProps) {
|
|||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent matchTriggerWidth={false} className="w-auto min-w-64">
|
||||
{FREQUENCY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"use client";
|
||||
import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -43,7 +43,7 @@ export function TaskItem({
|
|||
onRemove,
|
||||
}: TaskItemProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-background p-3 space-y-3">
|
||||
<div className="rounded-md border border-border/60 bg-transparent p-3 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-semibold text-foreground">
|
||||
|
|
@ -103,27 +103,30 @@ export function TaskItem({
|
|||
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="advanced" className="border-b-0">
|
||||
<AccordionTrigger className="py-1.5 text-xs text-muted-foreground hover:no-underline">
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger className="group flex flex-1 items-center justify-between rounded-md py-1.5 text-left text-xs font-medium text-muted-foreground outline-none transition-all focus-visible:ring-[3px] focus-visible:ring-ring/50">
|
||||
Advanced
|
||||
</AccordionTrigger>
|
||||
<ChevronRight className="pointer-events-none size-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
<AccordionContent className="pb-1">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Max retries" hint="Leave blank to use the default.">
|
||||
<Field label="Max retries">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={task.maxRetries ?? ""}
|
||||
placeholder="default"
|
||||
placeholder="2 retries"
|
||||
onChange={(e) => onChange({ maxRetries: parseOptionalInt(e.target.value) })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Timeout (seconds)" hint="Leave blank to use the default.">
|
||||
<Field label="Timeout (seconds)">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={task.timeoutSeconds ?? ""}
|
||||
placeholder="default"
|
||||
placeholder="600 seconds"
|
||||
onChange={(e) => onChange({ timeoutSeconds: parseOptionalInt(e.target.value) })}
|
||||
/>
|
||||
</Field>
|
||||
|
|
|
|||
|
|
@ -53,11 +53,10 @@ export function TaskList({ tasks, errors, searchSpaceId, onChange }: TaskListPro
|
|||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange([...tasks, emptyTask()])}
|
||||
>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
<Plus className="h-4 w-4" />
|
||||
Add task
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,22 +35,26 @@ export function TimezoneCombobox({ value, onChange }: TimezoneComboboxProps) {
|
|||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between font-normal"
|
||||
className="w-full justify-between border-popover-border bg-transparent font-normal hover:bg-transparent"
|
||||
>
|
||||
<span className="truncate">{value || "Select timezone"}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||
<Command>
|
||||
<PopoverContent
|
||||
className="w-[calc(var(--radix-popover-trigger-width)/3)] min-w-72 max-w-[90vw] overflow-hidden border border-popover-border p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command className="bg-popover">
|
||||
<CommandInput placeholder="Search timezone..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No timezone found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandGroup className="p-0">
|
||||
{timezones.map((tz) => (
|
||||
<CommandItem
|
||||
key={tz}
|
||||
value={tz}
|
||||
className="rounded-none px-3"
|
||||
onSelect={() => {
|
||||
onChange(tz);
|
||||
setOpen(false);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
"use client";
|
||||
import { Info } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface UnattendedToggleProps {
|
||||
checked: boolean;
|
||||
|
|
@ -15,26 +13,15 @@ interface UnattendedToggleProps {
|
|||
*/
|
||||
export function UnattendedToggle({ checked, onChange }: UnattendedToggleProps) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 rounded-lg border border-border/60 bg-background px-3 py-3">
|
||||
<div className="flex items-start justify-between gap-3 rounded-md bg-transparent">
|
||||
<div className="space-y-0.5 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
Run without asking for approvals
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" aria-label="More info" className="text-muted-foreground">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
Automations run unattended. With this off, any approval the agent asks for is
|
||||
rejected, which can stall a step.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Auto-approve actions the agent would normally pause to confirm.
|
||||
Tasks run automatically without asking for confirmation
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
|
|
|
|||
|
|
@ -38,9 +38,12 @@ export function AutomationNewContent({ searchSpaceId }: AutomationNewContentProp
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AutomationNewHeader searchSpaceId={searchSpaceId} />
|
||||
<AutomationBuilderForm mode="create" searchSpaceId={searchSpaceId} />
|
||||
</>
|
||||
<AutomationBuilderForm
|
||||
mode="create"
|
||||
searchSpaceId={searchSpaceId}
|
||||
renderModeSwitcher={(modeSwitcher) => (
|
||||
<AutomationNewHeader searchSpaceId={searchSpaceId} modeSwitcher={modeSwitcher} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,38 @@
|
|||
"use client";
|
||||
import { ArrowLeft, MessageSquarePlus } from "lucide-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AutomationNewHeaderProps {
|
||||
searchSpaceId: number;
|
||||
modeSwitcher?: ReactNode;
|
||||
}
|
||||
|
||||
export function AutomationNewHeader({ searchSpaceId }: AutomationNewHeaderProps) {
|
||||
export function AutomationNewHeader({ searchSpaceId, modeSwitcher }: AutomationNewHeaderProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Button asChild variant="ghost" size="sm" className="-ml-2 h-auto px-2 py-1">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/automations`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||
Back to automations
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Button asChild variant="ghost" size="sm" className="-ml-2 h-auto px-2 py-1">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/automations`}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
|
||||
Back to automations
|
||||
</Link>
|
||||
</Button>
|
||||
{modeSwitcher ? <div className="shrink-0 md:hidden">{modeSwitcher}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground">New automation</h1>
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
Set up a task and a schedule. Prefer natural language? Use chat instead.
|
||||
Configure the task, schedule, and execution settings for this automation.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/dashboard/${searchSpaceId}/new-chat`}>
|
||||
<MessageSquarePlus className="mr-2 h-4 w-4" />
|
||||
Switch to chat
|
||||
</Link>
|
||||
</Button>
|
||||
{modeSwitcher ? <div className="ml-auto hidden shrink-0 md:block">{modeSwitcher}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export default async function AutomationsPage({
|
|||
const { search_space_id } = await params;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
<div className="mx-auto w-full max-w-5xl space-y-6">
|
||||
<AutomationsContent searchSpaceId={Number(search_space_id)} />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { BuyPagesContent } from "@/components/settings/buy-pages-content";
|
||||
import { BuyTokensContent } from "@/components/settings/buy-tokens-content";
|
||||
|
|
@ -17,12 +16,7 @@ export default function BuyMorePage() {
|
|||
const [activeTab, setActiveTab] = useState<TabId>("pages");
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="w-full select-none"
|
||||
>
|
||||
<div className="w-full select-none">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => {
|
||||
|
|
@ -49,6 +43,6 @@ export default function BuyMorePage() {
|
|||
<BuyTokensContent />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,14 +47,9 @@ export function DashboardClientLayout({
|
|||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
const isOnboardingComplete = useCallback(() => {
|
||||
// Check that both LLM IDs are set (including 0 for Auto mode)
|
||||
return (
|
||||
preferences.agent_llm_id !== null &&
|
||||
preferences.agent_llm_id !== undefined &&
|
||||
preferences.document_summary_llm_id !== null &&
|
||||
preferences.document_summary_llm_id !== undefined
|
||||
);
|
||||
}, [preferences]);
|
||||
// Check that the Agent LLM ID is set, including 0 for Auto mode.
|
||||
return preferences.agent_llm_id !== null && preferences.agent_llm_id !== undefined;
|
||||
}, [preferences.agent_llm_id]);
|
||||
|
||||
const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom);
|
||||
const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false);
|
||||
|
|
@ -100,7 +95,6 @@ export function DashboardClientLayout({
|
|||
search_space_id: Number(searchSpaceId),
|
||||
data: {
|
||||
agent_llm_id: firstGlobalConfig.id,
|
||||
document_summary_llm_id: firstGlobalConfig.id,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-4">
|
||||
<Skeleton className="h-4 w-64" />
|
||||
<Skeleton className="h-32 w-full max-w-2xl rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import { disabledToolsAtom } from "@/atoms/agent-tools/agent-tools.atoms";
|
|||
import {
|
||||
clearTargetCommentIdAtom,
|
||||
currentThreadAtom,
|
||||
setCurrentThreadMetadataAtom,
|
||||
setTargetCommentIdAtom,
|
||||
} from "@/atoms/chat/current-thread.atom";
|
||||
import {
|
||||
|
|
@ -36,7 +37,7 @@ import { closeReportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
|||
import { type AgentCreatedDocument, agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { closeEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { removeChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import { removeChatTabAtom, syncChatTabAtom, updateChatTabTitleAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import {
|
||||
EditMessageDialog,
|
||||
|
|
@ -50,6 +51,7 @@ import {
|
|||
TokenUsageProvider,
|
||||
} from "@/components/assistant-ui/token-usage-context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
type HitlDecision,
|
||||
PendingInterruptProvider,
|
||||
|
|
@ -64,6 +66,7 @@ import {
|
|||
} from "@/hooks/use-agent-actions-query";
|
||||
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
|
||||
import { useMessagesSync } from "@/hooks/use-messages-sync";
|
||||
import { useThreadDetail, useThreadMessages } from "@/hooks/use-thread-queries";
|
||||
import { getAgentFilesystemSelection } from "@/lib/agent-filesystem";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
|
|
@ -100,8 +103,6 @@ import {
|
|||
appendMessage,
|
||||
createThread,
|
||||
getRegenerateUrl,
|
||||
getThreadFull,
|
||||
getThreadMessages,
|
||||
type ThreadListItem,
|
||||
type ThreadListResponse,
|
||||
type ThreadRecord,
|
||||
|
|
@ -119,7 +120,7 @@ import {
|
|||
trackChatMessageSent,
|
||||
trackChatResponseReceived,
|
||||
} from "@/lib/posthog/events";
|
||||
import Loading from "../loading";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
const MobileEditorPanel = dynamic(
|
||||
() =>
|
||||
|
|
@ -287,11 +288,78 @@ function computeFallbackTurnCancellingRetryDelay(attempt: number): number {
|
|||
return Math.min(raw, TURN_CANCELLING_MAX_DELAY_MS);
|
||||
}
|
||||
|
||||
function parseUrlChatId(id: string | string[] | undefined): number {
|
||||
let parsed = 0;
|
||||
if (Array.isArray(id) && id.length > 0) {
|
||||
parsed = Number.parseInt(id[0], 10);
|
||||
} else if (typeof id === "string") {
|
||||
parsed = Number.parseInt(id, 10);
|
||||
}
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function ThreadMessagesSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-panel"
|
||||
style={{
|
||||
["--thread-max-width" as string]: "42rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 scroll-smooth"
|
||||
style={{ scrollbarGutter: "stable" }}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="aui-chat-viewport-top-fade pointer-events-none sticky top-0 z-10 -mx-4 h-2 shrink-0 bg-gradient-to-b from-panel from-20% to-transparent"
|
||||
/>
|
||||
<div className="mx-auto w-full max-w-(--thread-max-width) flex flex-1 flex-col gap-6 py-8">
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="h-12 w-[65%] max-w-56 rounded-2xl" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-[85%]" />
|
||||
<Skeleton className="h-18 w-[40%]" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Skeleton className="h-12 w-[78%] max-w-72 rounded-2xl" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-10 w-[30%]" />
|
||||
<Skeleton className="h-4 w-[90%]" />
|
||||
<Skeleton className="h-6 w-[60%]" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Skeleton className="h-12 w-[85%] max-w-96 rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="aui-chat-composer-footer sticky bottom-0 z-20 -mx-4 mt-auto flex flex-col items-stretch bg-gradient-to-t from-panel from-60% to-transparent px-4 pt-6"
|
||||
style={{ paddingBottom: "max(0.5rem, env(safe-area-inset-bottom))" }}
|
||||
>
|
||||
<div className="aui-chat-composer-area relative mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-3 overflow-visible">
|
||||
<Skeleton className="h-28 w-full rounded-3xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NewChatPage() {
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
const [threadId, setThreadId] = useState<number | null>(null);
|
||||
const urlChatId = useMemo(() => parseUrlChatId(params.chat_id), [params.chat_id]);
|
||||
const [threadId, setThreadId] = useState<number | null>(() => (urlChatId > 0 ? urlChatId : null));
|
||||
const activeThreadId = urlChatId > 0 ? urlChatId : threadId;
|
||||
const handledLoadErrorThreadRef = useRef<number | null>(null);
|
||||
const [currentThread, setCurrentThread] = useState<ThreadRecord | null>(null);
|
||||
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
|
@ -375,12 +443,14 @@ export default function NewChatPage() {
|
|||
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
|
||||
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
|
||||
const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
|
||||
const setCurrentThreadState = useSetAtom(currentThreadAtom);
|
||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||
const setCurrentThreadMetadata = useSetAtom(setCurrentThreadMetadataAtom);
|
||||
const setPremiumAlertForThread = useSetAtom(setPremiumAlertForThreadAtom);
|
||||
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
|
||||
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
|
||||
const closeReportPanel = useSetAtom(closeReportPanelAtom);
|
||||
const closeEditorPanel = useSetAtom(closeEditorPanelAtom);
|
||||
const syncChatTab = useSetAtom(syncChatTabAtom);
|
||||
const updateChatTabTitle = useSetAtom(updateChatTabTitleAtom);
|
||||
const removeChatTab = useSetAtom(removeChatTabAtom);
|
||||
const setAgentCreatedDocuments = useSetAtom(agentCreatedDocumentsAtom);
|
||||
|
|
@ -402,9 +472,11 @@ export default function NewChatPage() {
|
|||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
const { data: agentFlags } = useAtomValue(agentFlagsAtom);
|
||||
const localFilesystemEnabled = agentFlags?.enable_desktop_local_filesystem === true;
|
||||
const threadDetailQuery = useThreadDetail(activeThreadId);
|
||||
const threadMessagesQuery = useThreadMessages(activeThreadId);
|
||||
|
||||
// Live collaboration: sync session state and messages via Zero
|
||||
useChatSessionStateSync(threadId);
|
||||
useChatSessionStateSync(activeThreadId);
|
||||
const { data: membersData } = useAtomValue(membersAtom);
|
||||
|
||||
const handleSyncedMessagesUpdate = useCallback(
|
||||
|
|
@ -465,7 +537,7 @@ export default function NewChatPage() {
|
|||
[isRunning, membersData]
|
||||
);
|
||||
|
||||
useMessagesSync(threadId, handleSyncedMessagesUpdate);
|
||||
useMessagesSync(activeThreadId, handleSyncedMessagesUpdate);
|
||||
|
||||
// Extract search_space_id from URL params
|
||||
const searchSpaceId = useMemo(() => {
|
||||
|
|
@ -479,19 +551,7 @@ export default function NewChatPage() {
|
|||
// per-turn Revert button all read). Hydrates from
|
||||
// ``GET /threads/{id}/actions`` and is updated incrementally by the
|
||||
// SSE handlers + revert-batch results below — no atom side-channel.
|
||||
const { items: agentActionItems } = useAgentActionsQuery(threadId);
|
||||
|
||||
// Extract chat_id from URL params
|
||||
const urlChatId = useMemo(() => {
|
||||
const id = params.chat_id;
|
||||
let parsed = 0;
|
||||
if (Array.isArray(id) && id.length > 0) {
|
||||
parsed = Number.parseInt(id[0], 10);
|
||||
} else if (typeof id === "string") {
|
||||
parsed = Number.parseInt(id, 10);
|
||||
}
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}, [params.chat_id]);
|
||||
const { items: agentActionItems } = useAgentActionsQuery(activeThreadId);
|
||||
|
||||
const handleChatFailure = useCallback(
|
||||
async ({
|
||||
|
|
@ -630,14 +690,19 @@ export default function NewChatPage() {
|
|||
});
|
||||
}, []);
|
||||
|
||||
// Initialize thread and load messages
|
||||
// For new chats (no urlChatId), we use lazy creation - thread is created on first message
|
||||
const initializeThread = useCallback(async () => {
|
||||
setIsInitializing(true);
|
||||
const hydratedMessagesRef = useRef<{
|
||||
threadId: number | null;
|
||||
data: typeof threadMessagesQuery.data;
|
||||
}>({ threadId: null, data: undefined });
|
||||
|
||||
// Reset all state when switching between chats/search spaces to prevent stale data
|
||||
// Reset thread-local runtime state on route/search-space changes. Data fetching
|
||||
// is handled by React Query below so the chat shell can render immediately.
|
||||
useEffect(() => {
|
||||
const nextThreadId = urlChatId > 0 ? urlChatId : null;
|
||||
handledLoadErrorThreadRef.current = null;
|
||||
hydratedMessagesRef.current = { threadId: null, data: undefined };
|
||||
setThreadId(nextThreadId);
|
||||
setMessages([]);
|
||||
setThreadId(null);
|
||||
setCurrentThread(null);
|
||||
setMentionedDocuments([]);
|
||||
tokenUsageStore.clear();
|
||||
|
|
@ -647,82 +712,105 @@ export default function NewChatPage() {
|
|||
closeEditorPanel();
|
||||
// Note: agent-action data is keyed by threadId in react-query so
|
||||
// switching threads naturally swaps caches; no explicit reset.
|
||||
|
||||
try {
|
||||
if (urlChatId > 0) {
|
||||
// Thread exists - load thread data and messages
|
||||
setThreadId(urlChatId);
|
||||
|
||||
// Load thread data (for visibility info) and messages in parallel
|
||||
const [threadData, messagesResponse] = await Promise.all([
|
||||
getThreadFull(urlChatId),
|
||||
getThreadMessages(urlChatId),
|
||||
]);
|
||||
|
||||
setCurrentThread(threadData);
|
||||
|
||||
if (messagesResponse.messages && messagesResponse.messages.length > 0) {
|
||||
const loadedMessages = reconcileInterruptedAssistantMessages(
|
||||
messagesResponse.messages
|
||||
).map(convertToThreadMessage);
|
||||
setMessages(loadedMessages);
|
||||
|
||||
for (const msg of messagesResponse.messages) {
|
||||
if (msg.token_usage) {
|
||||
tokenUsageStore.set(`msg-${msg.id}`, msg.token_usage as TokenUsageData);
|
||||
}
|
||||
}
|
||||
|
||||
const restoredDocsMap: Record<string, MentionedDocumentInfo[]> = {};
|
||||
for (const msg of messagesResponse.messages) {
|
||||
if (msg.role === "user") {
|
||||
const docs = extractMentionedDocuments(msg.content);
|
||||
if (docs.length > 0) {
|
||||
restoredDocsMap[`msg-${msg.id}`] = docs;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(restoredDocsMap).length > 0) {
|
||||
setMessageDocumentsMap(restoredDocsMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
// For new chats (urlChatId === 0), don't create thread yet
|
||||
// Thread will be created lazily when user sends first message
|
||||
// This improves UX (instant load) and avoids orphan threads
|
||||
} catch (error) {
|
||||
console.error("[NewChatPage] Failed to initialize thread:", error);
|
||||
if (urlChatId > 0 && error instanceof NotFoundError) {
|
||||
removeChatTab(urlChatId);
|
||||
if (typeof window !== "undefined") {
|
||||
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
|
||||
}
|
||||
toast.error("This chat was deleted.");
|
||||
return;
|
||||
}
|
||||
// Keep threadId as null - don't use Date.now() as it creates an invalid ID
|
||||
// that will cause 404 errors on subsequent API calls
|
||||
setThreadId(null);
|
||||
setCurrentThread(null);
|
||||
toast.error("Failed to load chat. Please try again.");
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
}, [
|
||||
urlChatId,
|
||||
setMessageDocumentsMap,
|
||||
setMentionedDocuments,
|
||||
setMessageDocumentsMap,
|
||||
tokenUsageStore,
|
||||
closeReportPanel,
|
||||
closeEditorPanel,
|
||||
removeChatTab,
|
||||
searchSpaceId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeThreadId) {
|
||||
setCurrentThread(null);
|
||||
return;
|
||||
}
|
||||
if (threadDetailQuery.data?.id === activeThreadId) {
|
||||
const thread = threadDetailQuery.data;
|
||||
setCurrentThread(thread);
|
||||
syncChatTab({
|
||||
chatId: thread.id,
|
||||
title: thread.title,
|
||||
chatUrl: `/dashboard/${thread.search_space_id ?? searchSpaceId}/new-chat/${thread.id}`,
|
||||
searchSpaceId: thread.search_space_id ?? searchSpaceId,
|
||||
visibility: thread.visibility,
|
||||
hasComments: thread.has_comments ?? false,
|
||||
});
|
||||
}
|
||||
}, [activeThreadId, searchSpaceId, syncChatTab, threadDetailQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
const messagesResponse = threadMessagesQuery.data;
|
||||
if (!activeThreadId || !messagesResponse) return;
|
||||
|
||||
if (
|
||||
hydratedMessagesRef.current.threadId === activeThreadId &&
|
||||
hydratedMessagesRef.current.data === messagesResponse
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadedMessages = reconcileInterruptedAssistantMessages(messagesResponse.messages).map(
|
||||
convertToThreadMessage
|
||||
);
|
||||
setMessages(loadedMessages);
|
||||
|
||||
tokenUsageStore.clear();
|
||||
const restoredDocsMap: Record<string, MentionedDocumentInfo[]> = {};
|
||||
for (const msg of messagesResponse.messages) {
|
||||
if (msg.token_usage) {
|
||||
tokenUsageStore.set(`msg-${msg.id}`, msg.token_usage as TokenUsageData);
|
||||
}
|
||||
if (msg.role === "user") {
|
||||
const docs = extractMentionedDocuments(msg.content);
|
||||
if (docs.length > 0) {
|
||||
restoredDocsMap[`msg-${msg.id}`] = docs;
|
||||
}
|
||||
}
|
||||
}
|
||||
setMessageDocumentsMap(restoredDocsMap);
|
||||
hydratedMessagesRef.current = { threadId: activeThreadId, data: messagesResponse };
|
||||
}, [
|
||||
activeThreadId,
|
||||
isRunning,
|
||||
setMessageDocumentsMap,
|
||||
threadMessagesQuery.data,
|
||||
tokenUsageStore,
|
||||
]);
|
||||
|
||||
// Initialize on mount, and re-init when switching search spaces (even if urlChatId is the same)
|
||||
useEffect(() => {
|
||||
initializeThread();
|
||||
}, [initializeThread]);
|
||||
const loadError = threadDetailQuery.error ?? threadMessagesQuery.error;
|
||||
if (!activeThreadId || !loadError) return;
|
||||
if (handledLoadErrorThreadRef.current === activeThreadId) return;
|
||||
|
||||
handledLoadErrorThreadRef.current = activeThreadId;
|
||||
console.error("[NewChatPage] Failed to load thread:", loadError);
|
||||
|
||||
if (loadError instanceof NotFoundError) {
|
||||
removeChatTab(activeThreadId);
|
||||
if (typeof window !== "undefined") {
|
||||
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
|
||||
}
|
||||
setThreadId(null);
|
||||
setCurrentThread(null);
|
||||
setMessages([]);
|
||||
toast.error("This chat was deleted.");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("Failed to load chat. Please try again.");
|
||||
}, [
|
||||
activeThreadId,
|
||||
removeChatTab,
|
||||
searchSpaceId,
|
||||
threadDetailQuery.error,
|
||||
threadMessagesQuery.error,
|
||||
]);
|
||||
|
||||
// Prefetch document titles for @ mention picker
|
||||
// Runs when user lands on page so data is ready when they type @
|
||||
|
|
@ -750,7 +838,7 @@ export default function NewChatPage() {
|
|||
const readAndApplyCommentId = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get("commentId");
|
||||
if (raw && !isInitializing) {
|
||||
if (raw && activeThreadId) {
|
||||
const commentId = Number.parseInt(raw, 10);
|
||||
if (!Number.isNaN(commentId)) {
|
||||
setTargetCommentId(commentId);
|
||||
|
|
@ -768,17 +856,42 @@ export default function NewChatPage() {
|
|||
window.removeEventListener("popstate", readAndApplyCommentId);
|
||||
clearTargetCommentId();
|
||||
};
|
||||
}, [isInitializing, setTargetCommentId, clearTargetCommentId]);
|
||||
}, [activeThreadId, setTargetCommentId, clearTargetCommentId]);
|
||||
|
||||
// Sync current thread state to atom
|
||||
useEffect(() => {
|
||||
setCurrentThreadState((prev) => ({
|
||||
...prev,
|
||||
id: currentThread?.id ?? null,
|
||||
visibility: currentThread?.visibility ?? null,
|
||||
hasComments: currentThread?.has_comments ?? false,
|
||||
}));
|
||||
}, [currentThread, setCurrentThreadState]);
|
||||
if (!currentThread) {
|
||||
if (activeThreadId) {
|
||||
return;
|
||||
}
|
||||
setCurrentThreadMetadata({
|
||||
id: null,
|
||||
searchSpaceId: null,
|
||||
visibility: null,
|
||||
hasComments: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const visibility =
|
||||
currentThreadState.id === currentThread.id && currentThreadState.visibility !== null
|
||||
? currentThreadState.visibility
|
||||
: currentThread.visibility;
|
||||
|
||||
setCurrentThreadMetadata({
|
||||
id: currentThread.id,
|
||||
searchSpaceId: currentThread.search_space_id ?? searchSpaceId,
|
||||
visibility,
|
||||
hasComments: currentThread.has_comments ?? false,
|
||||
});
|
||||
}, [
|
||||
activeThreadId,
|
||||
currentThread,
|
||||
currentThreadState.id,
|
||||
currentThreadState.visibility,
|
||||
searchSpaceId,
|
||||
setCurrentThreadMetadata,
|
||||
]);
|
||||
|
||||
// Cleanup on unmount - abort any in-flight requests
|
||||
useEffect(() => {
|
||||
|
|
@ -862,6 +975,8 @@ export default function NewChatPage() {
|
|||
setThreadId(currentThreadId);
|
||||
// Set currentThread so share button in header appears immediately
|
||||
setCurrentThread(newThread);
|
||||
queryClient.setQueryData(cacheKeys.threads.detail(newThread.id), newThread);
|
||||
queryClient.setQueryData(cacheKeys.threads.messages(newThread.id), { messages: [] });
|
||||
|
||||
// Track chat creation
|
||||
trackChatCreated(searchSpaceId, currentThreadId);
|
||||
|
|
@ -1369,6 +1484,14 @@ export default function NewChatPage() {
|
|||
} finally {
|
||||
setIsRunning(false);
|
||||
abortControllerRef.current = null;
|
||||
if (currentThreadId) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.threads.messages(currentThreadId),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.threads.detail(currentThreadId),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
|
|
@ -1717,6 +1840,12 @@ export default function NewChatPage() {
|
|||
} finally {
|
||||
setIsRunning(false);
|
||||
abortControllerRef.current = null;
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.threads.messages(resumeThreadId),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.threads.detail(resumeThreadId),
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
|
|
@ -2210,6 +2339,12 @@ export default function NewChatPage() {
|
|||
} finally {
|
||||
setIsRunning(false);
|
||||
abortControllerRef.current = null;
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.threads.messages(threadId),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.threads.detail(threadId),
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
|
|
@ -2396,22 +2531,25 @@ export default function NewChatPage() {
|
|||
onCancel: cancelRun,
|
||||
});
|
||||
|
||||
// Show loading state only when loading an existing thread
|
||||
if (isInitializing) {
|
||||
return <Loading />;
|
||||
}
|
||||
const threadLoadError = activeThreadId
|
||||
? (threadDetailQuery.error ?? threadMessagesQuery.error)
|
||||
: null;
|
||||
const shouldShowThreadLoadError =
|
||||
!!threadLoadError && !!activeThreadId && !currentThread && messages.length === 0;
|
||||
const isThreadMessagesLoading =
|
||||
!!activeThreadId &&
|
||||
threadMessagesQuery.isPending &&
|
||||
messages.length === 0 &&
|
||||
!threadMessagesQuery.error;
|
||||
|
||||
// Show error state only if we tried to load an existing thread but failed
|
||||
// For new chats (urlChatId === 0), threadId being null is expected (lazy creation)
|
||||
if (!threadId && urlChatId > 0) {
|
||||
if (shouldShowThreadLoadError) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4">
|
||||
<div className="text-destructive">Failed to load chat</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsInitializing(true);
|
||||
initializeThread();
|
||||
void Promise.all([threadDetailQuery.refetch(), threadMessagesQuery.refetch()]);
|
||||
}}
|
||||
>
|
||||
Try Again
|
||||
|
|
@ -2430,8 +2568,13 @@ export default function NewChatPage() {
|
|||
onSubmit={handleApprovalSubmit}
|
||||
>
|
||||
<div key={searchSpaceId} className="flex h-full overflow-hidden">
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<div className="relative flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<Thread />
|
||||
{isThreadMessagesLoading ? (
|
||||
<div className="absolute inset-0 z-10 bg-panel">
|
||||
<ThreadMessagesSkeleton />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<MobileReportPanel />
|
||||
<MobileEditorPanel />
|
||||
|
|
|
|||
|
|
@ -54,10 +54,7 @@ export default function OnboardPage() {
|
|||
|
||||
// Check if onboarding is already complete (including 0 for Auto mode)
|
||||
const isOnboardingComplete =
|
||||
preferences.agent_llm_id !== null &&
|
||||
preferences.agent_llm_id !== undefined &&
|
||||
preferences.document_summary_llm_id !== null &&
|
||||
preferences.document_summary_llm_id !== undefined;
|
||||
preferences.agent_llm_id !== null && preferences.agent_llm_id !== undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!preferencesLoading && isOnboardingComplete) {
|
||||
|
|
@ -83,7 +80,6 @@ export default function OnboardPage() {
|
|||
search_space_id: searchSpaceId,
|
||||
data: {
|
||||
agent_llm_id: firstGlobalConfig.id,
|
||||
document_summary_llm_id: firstGlobalConfig.id,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -120,7 +116,6 @@ export default function OnboardPage() {
|
|||
search_space_id: searchSpaceId,
|
||||
data: {
|
||||
agent_llm_id: newConfig.id,
|
||||
document_summary_llm_id: newConfig.id,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -236,35 +236,36 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
|||
if (accessLoading || membersLoading) {
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm bg-black text-white dark:bg-white dark:text-black"
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
Invite members
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 md:h-4 md:w-4 rotate-315" />
|
||||
Active invites
|
||||
<span className="inline-flex items-center justify-center h-4 md:h-5 min-w-4 md:min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200">
|
||||
<Skeleton className="h-2.5 w-2.5 rounded-sm bg-neutral-500/60" />
|
||||
</span>
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 text-xs md:text-sm text-muted-foreground whitespace-nowrap">
|
||||
<Skeleton className="h-3 w-2 rounded-sm" />
|
||||
members
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground">Members</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm bg-black text-white dark:bg-white dark:text-black"
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
Invite members
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none gap-1.5 md:gap-2 rounded-md bg-muted px-3 text-xs md:text-sm hover:bg-accent"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 md:h-4 md:w-4 rotate-315" />
|
||||
Active invites
|
||||
<span className="inline-flex items-center justify-center h-4 md:h-5 min-w-4 md:min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200">
|
||||
<Skeleton className="h-2.5 w-2.5 rounded-sm bg-neutral-500/60" />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-accent overflow-hidden">
|
||||
|
|
@ -319,51 +320,54 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) {
|
|||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{canInvite &&
|
||||
(rolesLoading ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm bg-black text-white dark:bg-white dark:text-black"
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
Invite members
|
||||
</Button>
|
||||
) : (
|
||||
<CreateInviteDialog
|
||||
roles={roles}
|
||||
onCreateInvite={handleCreateInvite}
|
||||
searchSpaceId={searchSpaceId}
|
||||
/>
|
||||
))}
|
||||
{canInvite &&
|
||||
(invitesLoading ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 md:h-4 md:w-4 rotate-315" />
|
||||
Active invites
|
||||
<span className="inline-flex items-center justify-center h-4 md:h-5 min-w-4 md:min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200">
|
||||
<Skeleton className="h-2.5 w-2.5 rounded-sm bg-neutral-500/60" />
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
activeInvites.length > 0 && (
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground">Members</h1>
|
||||
<p className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{members.length} {members.length === 1 ? "member" : "members"}
|
||||
</p>
|
||||
</div>
|
||||
{canInvite && (
|
||||
<div className="flex items-center gap-2">
|
||||
{rolesLoading ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none gap-1.5 md:gap-2 text-xs md:text-sm bg-black text-white dark:bg-white dark:text-black"
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
Invite members
|
||||
</Button>
|
||||
) : (
|
||||
<CreateInviteDialog
|
||||
roles={roles}
|
||||
onCreateInvite={handleCreateInvite}
|
||||
searchSpaceId={searchSpaceId}
|
||||
/>
|
||||
)}
|
||||
{invitesLoading ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none gap-1.5 md:gap-2 rounded-md bg-muted px-3 text-xs md:text-sm hover:bg-accent"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 md:h-4 md:w-4 rotate-315" />
|
||||
Active invites
|
||||
<span className="inline-flex items-center justify-center h-4 md:h-5 min-w-4 md:min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200">
|
||||
<Skeleton className="h-2.5 w-2.5 rounded-sm bg-neutral-500/60" />
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<AllInvitesDialog invites={activeInvites} onRevokeInvite={handleRevokeInvite} />
|
||||
)
|
||||
))}
|
||||
<p className="text-xs md:text-sm text-muted-foreground whitespace-nowrap">
|
||||
{members.length} {members.length === 1 ? "member" : "members"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/60 bg-accent overflow-hidden">
|
||||
|
|
@ -859,7 +863,11 @@ function AllInvitesDialog({
|
|||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary" size="sm" className="gap-1.5 md:gap-2 text-xs md:text-sm">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 md:gap-2 rounded-md bg-muted px-3 text-xs md:text-sm hover:bg-accent"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 md:h-4 md:w-4 rotate-315" />
|
||||
Active invites
|
||||
<span className="inline-flex items-center justify-center h-4 md:h-5 min-w-4 md:min-w-5 px-1 rounded-full bg-neutral-700 text-neutral-200 text-[10px] md:text-xs font-medium">
|
||||
|
|
|
|||
|
|
@ -4,12 +4,27 @@ import { reportPanelAtom } from "./report-panel.atom";
|
|||
|
||||
interface CurrentThreadState {
|
||||
id: number | null;
|
||||
searchSpaceId: number | null;
|
||||
visibility: ChatVisibility | null;
|
||||
hasComments: boolean;
|
||||
}
|
||||
|
||||
interface CurrentThreadMetadataPatch {
|
||||
id: number | null;
|
||||
searchSpaceId?: number | null;
|
||||
visibility?: ChatVisibility | null;
|
||||
hasComments?: boolean;
|
||||
}
|
||||
|
||||
interface CurrentThreadMetadataUpdate {
|
||||
id: number;
|
||||
visibility?: ChatVisibility | null;
|
||||
hasComments?: boolean;
|
||||
}
|
||||
|
||||
const initialState: CurrentThreadState = {
|
||||
id: null,
|
||||
searchSpaceId: null,
|
||||
visibility: null,
|
||||
hasComments: false,
|
||||
};
|
||||
|
|
@ -20,9 +35,52 @@ export const commentsEnabledAtom = atom(
|
|||
(get) => get(currentThreadAtom).visibility === "SEARCH_SPACE"
|
||||
);
|
||||
|
||||
export const setThreadVisibilityAtom = atom(null, (get, set, newVisibility: ChatVisibility) => {
|
||||
set(currentThreadAtom, { ...get(currentThreadAtom), visibility: newVisibility });
|
||||
});
|
||||
export const setCurrentThreadMetadataAtom = atom(
|
||||
null,
|
||||
(get, set, metadata: CurrentThreadMetadataPatch) => {
|
||||
const current = get(currentThreadAtom);
|
||||
const isSameThread = current.id === metadata.id;
|
||||
|
||||
set(currentThreadAtom, {
|
||||
...current,
|
||||
id: metadata.id,
|
||||
searchSpaceId:
|
||||
"searchSpaceId" in metadata
|
||||
? (metadata.searchSpaceId ?? null)
|
||||
: isSameThread
|
||||
? current.searchSpaceId
|
||||
: null,
|
||||
visibility:
|
||||
"visibility" in metadata
|
||||
? (metadata.visibility ?? null)
|
||||
: isSameThread
|
||||
? current.visibility
|
||||
: null,
|
||||
hasComments:
|
||||
"hasComments" in metadata
|
||||
? (metadata.hasComments ?? false)
|
||||
: isSameThread
|
||||
? current.hasComments
|
||||
: false,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const patchCurrentThreadMetadataAtom = atom(
|
||||
null,
|
||||
(get, set, patch: CurrentThreadMetadataUpdate) => {
|
||||
const current = get(currentThreadAtom);
|
||||
if (current.id !== patch.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
set(currentThreadAtom, {
|
||||
...current,
|
||||
visibility: "visibility" in patch ? (patch.visibility ?? null) : current.visibility,
|
||||
hasComments: "hasComments" in patch ? (patch.hasComments ?? false) : current.hasComments,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const resetCurrentThreadAtom = atom(null, (_, set) => {
|
||||
set(currentThreadAtom, initialState);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { atom } from "jotai";
|
||||
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
|
||||
|
||||
export type TabType = "chat" | "document";
|
||||
|
||||
|
|
@ -10,6 +11,8 @@ export interface Tab {
|
|||
/** For chat tabs */
|
||||
chatId?: number | null;
|
||||
chatUrl?: string;
|
||||
visibility?: ChatVisibility;
|
||||
hasComments?: boolean;
|
||||
/** For document tabs */
|
||||
documentId?: number;
|
||||
searchSpaceId?: number;
|
||||
|
|
@ -79,11 +82,15 @@ export const syncChatTabAtom = atom(
|
|||
title,
|
||||
chatUrl,
|
||||
searchSpaceId,
|
||||
visibility,
|
||||
hasComments,
|
||||
}: {
|
||||
chatId: number | null;
|
||||
title?: string;
|
||||
chatUrl?: string;
|
||||
searchSpaceId: number;
|
||||
visibility?: ChatVisibility;
|
||||
hasComments?: boolean;
|
||||
}
|
||||
) => {
|
||||
if (chatId && get(deletedChatIdsAtom).has(chatId)) {
|
||||
|
|
@ -105,6 +112,8 @@ export const syncChatTabAtom = atom(
|
|||
title: title || t.title,
|
||||
chatUrl: chatUrl || t.chatUrl,
|
||||
searchSpaceId: searchSpaceId ?? t.searchSpaceId,
|
||||
...(visibility !== undefined ? { visibility } : {}),
|
||||
...(hasComments !== undefined ? { hasComments } : {}),
|
||||
}
|
||||
: t
|
||||
),
|
||||
|
|
@ -140,6 +149,8 @@ export const syncChatTabAtom = atom(
|
|||
chatId,
|
||||
chatUrl,
|
||||
searchSpaceId,
|
||||
...(visibility !== undefined ? { visibility } : {}),
|
||||
...(hasComments !== undefined ? { hasComments } : {}),
|
||||
};
|
||||
|
||||
let updatedTabs: Tab[];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ThreadPrimitive } from "@assistant-ui/react";
|
||||
import { AuiIf, ThreadPrimitive } from "@assistant-ui/react";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -40,15 +40,17 @@ export const ChatViewport: FC<ChatViewportProps> = ({ children, footer }) => (
|
|||
/>
|
||||
{children}
|
||||
{footer ? (
|
||||
<ThreadPrimitive.ViewportFooter
|
||||
className="aui-chat-composer-footer sticky bottom-0 z-20 -mx-4 mt-auto flex flex-col items-stretch bg-gradient-to-t from-main-panel from-60% to-transparent px-4 pt-6"
|
||||
style={{ paddingBottom: "max(0.5rem, env(safe-area-inset-bottom))" }}
|
||||
>
|
||||
<div className="aui-chat-composer-area relative mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-3 overflow-visible">
|
||||
<ChatScrollToBottom />
|
||||
{footer}
|
||||
</div>
|
||||
</ThreadPrimitive.ViewportFooter>
|
||||
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<ThreadPrimitive.ViewportFooter
|
||||
className="aui-chat-composer-footer sticky bottom-0 z-20 -mx-4 mt-auto flex flex-col items-stretch bg-gradient-to-t from-main-panel from-60% to-transparent px-4 pt-6"
|
||||
style={{ paddingBottom: "max(0.5rem, env(safe-area-inset-bottom))" }}
|
||||
>
|
||||
<div className="aui-chat-composer-area relative mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-3 overflow-visible">
|
||||
<ChatScrollToBottom />
|
||||
{footer}
|
||||
</div>
|
||||
</ThreadPrimitive.ViewportFooter>
|
||||
</AuiIf>
|
||||
) : null}
|
||||
</ThreadPrimitive.Viewport>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
llmPreferencesAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
|
@ -44,28 +36,7 @@ interface ConnectorIndicatorProps {
|
|||
|
||||
export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
|
||||
(_props, ref) => {
|
||||
const router = useRouter();
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const { data: preferences = {}, isFetching: preferencesLoading } =
|
||||
useAtomValue(llmPreferencesAtom);
|
||||
const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
|
||||
useAtomValue(globalNewLLMConfigsAtom);
|
||||
|
||||
// Check if document summary LLM is properly configured
|
||||
// - If ID is 0 (Auto mode), we need global configs to be available
|
||||
// - If ID is positive (user config) or negative (specific global config), it's configured
|
||||
// - If ID is null/undefined, it's not configured
|
||||
const docSummaryLlmId = preferences.document_summary_llm_id;
|
||||
const isAutoMode = docSummaryLlmId === 0;
|
||||
const hasGlobalConfigs = globalConfigs.length > 0;
|
||||
|
||||
const hasDocumentSummaryLLM =
|
||||
docSummaryLlmId !== null &&
|
||||
docSummaryLlmId !== undefined &&
|
||||
// If it's Auto mode, we need global configs to actually be available
|
||||
(!isAutoMode || hasGlobalConfigs);
|
||||
|
||||
const llmConfigLoading = preferencesLoading || globalConfigsLoading;
|
||||
|
||||
// Real-time document type counts via Zero (updates instantly as docs are indexed)
|
||||
const documentTypeCounts = useZeroDocumentTypeCounts(searchSpaceId);
|
||||
|
|
@ -97,7 +68,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
isDisconnecting,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
enableSummary,
|
||||
enableVisionLlm,
|
||||
allConnectors,
|
||||
viewingAccountsType,
|
||||
|
|
@ -109,7 +79,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
setEndDate,
|
||||
setPeriodicEnabled,
|
||||
setFrequencyMinutes,
|
||||
setEnableSummary,
|
||||
setEnableVisionLlm,
|
||||
handleOpenChange,
|
||||
handleTabChange,
|
||||
|
|
@ -280,7 +249,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
endDate={endDate}
|
||||
periodicEnabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
enableSummary={enableSummary}
|
||||
enableVisionLlm={enableVisionLlm}
|
||||
isSaving={isSaving}
|
||||
isDisconnecting={isDisconnecting}
|
||||
|
|
@ -290,7 +258,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
onEndDateChange={setEndDate}
|
||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||
onFrequencyChange={setFrequencyMinutes}
|
||||
onEnableSummaryChange={setEnableSummary}
|
||||
onEnableVisionLlmChange={setEnableVisionLlm}
|
||||
onSave={() => {
|
||||
startIndexing(editingConnector.id);
|
||||
|
|
@ -339,7 +306,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
endDate={endDate}
|
||||
periodicEnabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
enableSummary={enableSummary}
|
||||
enableVisionLlm={enableVisionLlm}
|
||||
isStartingIndexing={isStartingIndexing}
|
||||
isFromOAuth={isFromOAuth}
|
||||
|
|
@ -347,7 +313,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
onEndDateChange={setEndDate}
|
||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||
onFrequencyChange={setFrequencyMinutes}
|
||||
onEnableSummaryChange={setEnableSummary}
|
||||
onEnableVisionLlmChange={setEnableVisionLlm}
|
||||
onConfigChange={setIndexingConnectorConfig}
|
||||
onStartIndexing={() => {
|
||||
|
|
@ -378,35 +343,6 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||
<div className="h-full overflow-y-auto" onScroll={handleScroll}>
|
||||
<div className="px-4 sm:px-12 py-4 sm:py-8 pb-12 sm:pb-16">
|
||||
{/* LLM Configuration Warning */}
|
||||
{!llmConfigLoading && !hasDocumentSummaryLLM && (
|
||||
<div className="mb-6">
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle />
|
||||
<AlertTitle>LLM Configuration Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
{isAutoMode && !hasGlobalConfigs
|
||||
? "Auto mode requires a global LLM configuration. Please add one in Settings"
|
||||
: "A Document Summary LLM is required to process uploads, configure one in Settings"}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
handleOpenChange(false);
|
||||
router.push(
|
||||
`/dashboard/${searchSpaceId}/search-space-settings/models`
|
||||
);
|
||||
}}
|
||||
>
|
||||
Go to Settings
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TabsContent value="all" className="m-0">
|
||||
<AllConnectorsTab
|
||||
searchQuery={searchQuery}
|
||||
|
|
@ -416,14 +352,10 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
allConnectors={connectors}
|
||||
documentTypeCounts={documentTypeCounts}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onConnectOAuth={hasDocumentSummaryLLM ? handleConnectOAuth : () => {}}
|
||||
onConnectNonOAuth={hasDocumentSummaryLLM ? handleConnectNonOAuth : () => {}}
|
||||
onCreateWebcrawler={
|
||||
hasDocumentSummaryLLM ? handleCreateWebcrawler : () => {}
|
||||
}
|
||||
onCreateYouTubeCrawler={
|
||||
hasDocumentSummaryLLM ? handleCreateYouTubeCrawler : () => {}
|
||||
}
|
||||
onConnectOAuth={handleConnectOAuth}
|
||||
onConnectNonOAuth={handleConnectNonOAuth}
|
||||
onCreateWebcrawler={handleCreateWebcrawler}
|
||||
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
|
||||
onManage={handleStartEdit}
|
||||
onViewAccountsList={handleViewAccountsList}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface SummaryConfigProps {
|
||||
enabled: boolean;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export const SummaryConfig: FC<SummaryConfigProps> = ({ enabled, onEnabledChange }) => {
|
||||
return (
|
||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm sm:text-base">Enable AI Summary</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Improves search quality but adds latency during indexing
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={onEnabledChange} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -17,7 +17,6 @@ import { BACKEND_URL } from "@/lib/env-config";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
||||
import { SummaryConfig } from "../../components/summary-config";
|
||||
import { VisionLLMConfig } from "../../components/vision-llm-config";
|
||||
import { LIVE_CONNECTOR_TYPES } from "../../constants/connector-constants";
|
||||
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
||||
|
|
@ -38,7 +37,6 @@ interface ConnectorEditViewProps {
|
|||
endDate: Date | undefined;
|
||||
periodicEnabled: boolean;
|
||||
frequencyMinutes: string;
|
||||
enableSummary: boolean;
|
||||
enableVisionLlm: boolean;
|
||||
isSaving: boolean;
|
||||
isDisconnecting: boolean;
|
||||
|
|
@ -48,7 +46,6 @@ interface ConnectorEditViewProps {
|
|||
onEndDateChange: (date: Date | undefined) => void;
|
||||
onPeriodicEnabledChange: (enabled: boolean) => void;
|
||||
onFrequencyChange: (frequency: string) => void;
|
||||
onEnableSummaryChange: (enabled: boolean) => void;
|
||||
onEnableVisionLlmChange: (enabled: boolean) => void;
|
||||
onSave: () => void;
|
||||
onDisconnect: () => void;
|
||||
|
|
@ -64,7 +61,6 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
endDate,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
enableSummary,
|
||||
enableVisionLlm,
|
||||
isSaving,
|
||||
isDisconnecting,
|
||||
|
|
@ -74,7 +70,6 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
onEndDateChange,
|
||||
onPeriodicEnabledChange,
|
||||
onFrequencyChange,
|
||||
onEnableSummaryChange,
|
||||
onEnableVisionLlmChange,
|
||||
onSave,
|
||||
onDisconnect,
|
||||
|
|
@ -87,9 +82,13 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
const isAuthExpired = connector.config?.auth_expired === true;
|
||||
const reauthEndpoint = getReauthEndpoint(connector);
|
||||
const [reauthing, setReauthing] = useState(false);
|
||||
const isMCPBacked = Boolean(connector.config?.server_config);
|
||||
const isLive = isMCPBacked || LIVE_CONNECTOR_TYPES.has(connector.connector_type);
|
||||
const supportsVisionLlm = VISION_LLM_CONNECTOR_TYPES.has(connector.connector_type);
|
||||
const showsAiToggles =
|
||||
connector.is_indexable || connector.connector_type === EnumConnectorName.OBSIDIAN_CONNECTOR;
|
||||
const showsVisionToggle =
|
||||
!isLive &&
|
||||
supportsVisionLlm &&
|
||||
(connector.is_indexable || connector.connector_type === EnumConnectorName.OBSIDIAN_CONNECTOR);
|
||||
|
||||
const handleReauth = useCallback(async () => {
|
||||
const spaceId = searchSpaceId ?? searchSpaceIdAtom;
|
||||
|
|
@ -121,9 +120,6 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
}
|
||||
}, [searchSpaceId, searchSpaceIdAtom, reauthEndpoint, connector.id]);
|
||||
|
||||
const isMCPBacked = Boolean(connector.config?.server_config);
|
||||
const isLive = isMCPBacked || LIVE_CONNECTOR_TYPES.has(connector.connector_type);
|
||||
|
||||
// Get connector-specific config component (MCP-backed connectors use a generic view)
|
||||
const ConnectorConfigComponent = useMemo(() => {
|
||||
if (isMCPBacked) return MCPServiceConfig;
|
||||
|
|
@ -280,77 +276,64 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Summary + vision toggles (Obsidian is plugin-push, non-indexable by design) */}
|
||||
{showsAiToggles && !isLive && (
|
||||
<>
|
||||
{/* AI Summary toggle */}
|
||||
<SummaryConfig enabled={enableSummary} onEnabledChange={onEnableSummaryChange} />
|
||||
|
||||
{/* Vision LLM toggle for file/attachment connectors */}
|
||||
{supportsVisionLlm && (
|
||||
<VisionLLMConfig
|
||||
enabled={enableVisionLlm}
|
||||
onEnabledChange={onEnableVisionLlmChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Date-range and periodic sync stay indexable-only */}
|
||||
{connector.is_indexable &&
|
||||
connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
connector.connector_type !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
connector.connector_type !== "DROPBOX_CONNECTOR" &&
|
||||
connector.connector_type !== "ONEDRIVE_CONNECTOR" &&
|
||||
connector.connector_type !== "WEBCRAWLER_CONNECTOR" &&
|
||||
connector.connector_type !== "GITHUB_CONNECTOR" && (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={onStartDateChange}
|
||||
onEndDateChange={onEndDateChange}
|
||||
allowFutureDates={
|
||||
connector.connector_type === "GOOGLE_CALENDAR_CONNECTOR" ||
|
||||
connector.connector_type === "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR" ||
|
||||
connector.connector_type === "LUMA_CONNECTOR"
|
||||
}
|
||||
lastIndexedAt={connector.last_indexed_at}
|
||||
/>
|
||||
)}
|
||||
|
||||
{connector.is_indexable &&
|
||||
(() => {
|
||||
const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
|
||||
const isComposioGoogleDrive =
|
||||
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
|
||||
const requiresFolderSelection = isGoogleDrive || isComposioGoogleDrive;
|
||||
const selectedFolders =
|
||||
(connector.config?.selected_folders as
|
||||
| Array<{ id: string; name: string }>
|
||||
| undefined) || [];
|
||||
const selectedFiles =
|
||||
(connector.config?.selected_files as
|
||||
| Array<{ id: string; name: string }>
|
||||
| undefined) || [];
|
||||
const hasItemsSelected = selectedFolders.length > 0 || selectedFiles.length > 0;
|
||||
const isDisabled = requiresFolderSelection && !hasItemsSelected;
|
||||
|
||||
return (
|
||||
<PeriodicSyncConfig
|
||||
enabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
onEnabledChange={onPeriodicEnabledChange}
|
||||
onFrequencyChange={onFrequencyChange}
|
||||
disabled={isDisabled}
|
||||
disabledMessage={
|
||||
isDisabled
|
||||
? "Select at least one folder or file above to enable periodic sync"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
{/* Vision toggle (Obsidian is plugin-push, non-indexable by design) */}
|
||||
{showsVisionToggle && (
|
||||
<VisionLLMConfig enabled={enableVisionLlm} onEnabledChange={onEnableVisionLlmChange} />
|
||||
)}
|
||||
|
||||
{/* Date-range and periodic sync stay indexable-only */}
|
||||
{connector.is_indexable &&
|
||||
connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
connector.connector_type !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
connector.connector_type !== "DROPBOX_CONNECTOR" &&
|
||||
connector.connector_type !== "ONEDRIVE_CONNECTOR" &&
|
||||
connector.connector_type !== "WEBCRAWLER_CONNECTOR" &&
|
||||
connector.connector_type !== "GITHUB_CONNECTOR" && (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={onStartDateChange}
|
||||
onEndDateChange={onEndDateChange}
|
||||
allowFutureDates={
|
||||
connector.connector_type === "GOOGLE_CALENDAR_CONNECTOR" ||
|
||||
connector.connector_type === "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR" ||
|
||||
connector.connector_type === "LUMA_CONNECTOR"
|
||||
}
|
||||
lastIndexedAt={connector.last_indexed_at}
|
||||
/>
|
||||
)}
|
||||
|
||||
{connector.is_indexable &&
|
||||
(() => {
|
||||
const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
|
||||
const isComposioGoogleDrive =
|
||||
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
|
||||
const requiresFolderSelection = isGoogleDrive || isComposioGoogleDrive;
|
||||
const selectedFolders =
|
||||
(connector.config?.selected_folders as Array<{ id: string; name: string }> | undefined) ||
|
||||
[];
|
||||
const selectedFiles =
|
||||
(connector.config?.selected_files as Array<{ id: string; name: string }> | undefined) ||
|
||||
[];
|
||||
const hasItemsSelected = selectedFolders.length > 0 || selectedFiles.length > 0;
|
||||
const isDisabled = requiresFolderSelection && !hasItemsSelected;
|
||||
|
||||
return (
|
||||
<PeriodicSyncConfig
|
||||
enabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
onEnabledChange={onPeriodicEnabledChange}
|
||||
onFrequencyChange={onFrequencyChange}
|
||||
disabled={isDisabled}
|
||||
disabledMessage={
|
||||
isDisabled
|
||||
? "Select at least one folder or file above to enable periodic sync"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Info box - hidden for live connectors */}
|
||||
{connector.is_indexable && !isLive && (
|
||||
<Alert>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
||||
import { SummaryConfig } from "../../components/summary-config";
|
||||
import { VisionLLMConfig } from "../../components/vision-llm-config";
|
||||
import {
|
||||
type IndexingConfigState,
|
||||
|
|
@ -35,7 +34,6 @@ interface IndexingConfigurationViewProps {
|
|||
endDate: Date | undefined;
|
||||
periodicEnabled: boolean;
|
||||
frequencyMinutes: string;
|
||||
enableSummary: boolean;
|
||||
enableVisionLlm: boolean;
|
||||
isStartingIndexing: boolean;
|
||||
isFromOAuth?: boolean;
|
||||
|
|
@ -43,7 +41,6 @@ interface IndexingConfigurationViewProps {
|
|||
onEndDateChange: (date: Date | undefined) => void;
|
||||
onPeriodicEnabledChange: (enabled: boolean) => void;
|
||||
onFrequencyChange: (frequency: string) => void;
|
||||
onEnableSummaryChange: (enabled: boolean) => void;
|
||||
onEnableVisionLlmChange: (enabled: boolean) => void;
|
||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||
onStartIndexing: () => void;
|
||||
|
|
@ -57,7 +54,6 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
endDate,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
enableSummary,
|
||||
enableVisionLlm,
|
||||
isStartingIndexing,
|
||||
isFromOAuth = false,
|
||||
|
|
@ -65,7 +61,6 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
onEndDateChange,
|
||||
onPeriodicEnabledChange,
|
||||
onFrequencyChange,
|
||||
onEnableSummaryChange,
|
||||
onEnableVisionLlmChange,
|
||||
onConfigChange,
|
||||
onStartIndexing,
|
||||
|
|
@ -78,9 +73,11 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
() => (connector ? getConnectorConfigComponent(connector.connector_type) : null),
|
||||
[connector]
|
||||
);
|
||||
const showsAiToggles =
|
||||
(connector?.is_indexable ?? false) ||
|
||||
connector?.connector_type === EnumConnectorName.OBSIDIAN_CONNECTOR;
|
||||
const showsVisionToggle =
|
||||
!isLive &&
|
||||
((connector?.is_indexable ?? false) ||
|
||||
connector?.connector_type === EnumConnectorName.OBSIDIAN_CONNECTOR) &&
|
||||
VISION_LLM_CONNECTOR_TYPES.has(config.connectorType);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [hasMoreContent, setHasMoreContent] = useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -178,57 +175,46 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
<ConnectorConfigComponent connector={connector} onConfigChange={onConfigChange} />
|
||||
)}
|
||||
|
||||
{/* Summary + vision toggles (Obsidian is plugin-push, non-indexable by design) */}
|
||||
{showsAiToggles && !isLive && (
|
||||
<>
|
||||
{/* AI Summary toggle */}
|
||||
<SummaryConfig enabled={enableSummary} onEnabledChange={onEnableSummaryChange} />
|
||||
|
||||
{/* Vision LLM toggle for file/attachment connectors */}
|
||||
{VISION_LLM_CONNECTOR_TYPES.has(config.connectorType) && (
|
||||
<VisionLLMConfig
|
||||
enabled={enableVisionLlm}
|
||||
onEnabledChange={onEnableVisionLlmChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Date-range and periodic sync stay indexable-only */}
|
||||
{connector?.is_indexable &&
|
||||
config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "DROPBOX_CONNECTOR" &&
|
||||
config.connectorType !== "ONEDRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "WEBCRAWLER_CONNECTOR" &&
|
||||
config.connectorType !== "GITHUB_CONNECTOR" && (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={onStartDateChange}
|
||||
onEndDateChange={onEndDateChange}
|
||||
allowFutureDates={
|
||||
config.connectorType === "GOOGLE_CALENDAR_CONNECTOR" ||
|
||||
config.connectorType === "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR" ||
|
||||
config.connectorType === "LUMA_CONNECTOR"
|
||||
}
|
||||
lastIndexedAt={connector?.last_indexed_at}
|
||||
/>
|
||||
)}
|
||||
|
||||
{connector?.is_indexable &&
|
||||
config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "DROPBOX_CONNECTOR" &&
|
||||
config.connectorType !== "ONEDRIVE_CONNECTOR" && (
|
||||
<PeriodicSyncConfig
|
||||
enabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
onEnabledChange={onPeriodicEnabledChange}
|
||||
onFrequencyChange={onFrequencyChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
{/* Vision toggle (Obsidian is plugin-push, non-indexable by design) */}
|
||||
{showsVisionToggle && (
|
||||
<VisionLLMConfig enabled={enableVisionLlm} onEnabledChange={onEnableVisionLlmChange} />
|
||||
)}
|
||||
|
||||
{/* Date-range and periodic sync stay indexable-only */}
|
||||
{connector?.is_indexable &&
|
||||
config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "DROPBOX_CONNECTOR" &&
|
||||
config.connectorType !== "ONEDRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "WEBCRAWLER_CONNECTOR" &&
|
||||
config.connectorType !== "GITHUB_CONNECTOR" && (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={onStartDateChange}
|
||||
onEndDateChange={onEndDateChange}
|
||||
allowFutureDates={
|
||||
config.connectorType === "GOOGLE_CALENDAR_CONNECTOR" ||
|
||||
config.connectorType === "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR" ||
|
||||
config.connectorType === "LUMA_CONNECTOR"
|
||||
}
|
||||
lastIndexedAt={connector?.last_indexed_at}
|
||||
/>
|
||||
)}
|
||||
|
||||
{connector?.is_indexable &&
|
||||
config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "DROPBOX_CONNECTOR" &&
|
||||
config.connectorType !== "ONEDRIVE_CONNECTOR" && (
|
||||
<PeriodicSyncConfig
|
||||
enabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
onEnabledChange={onPeriodicEnabledChange}
|
||||
onFrequencyChange={onFrequencyChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info box - hidden for live connectors */}
|
||||
{connector?.is_indexable && !isLive && (
|
||||
<Alert>
|
||||
|
|
|
|||
|
|
@ -82,7 +82,6 @@ export const useConnectorDialog = () => {
|
|||
const [isStartingIndexing, setIsStartingIndexing] = useState(false);
|
||||
const [periodicEnabled, setPeriodicEnabled] = useState(false);
|
||||
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
|
||||
const [enableSummary, setEnableSummary] = useState(false);
|
||||
const [enableVisionLlm, setEnableVisionLlm] = useState(false);
|
||||
|
||||
// Edit mode state
|
||||
|
|
@ -418,7 +417,6 @@ export const useConnectorDialog = () => {
|
|||
periodic_indexing_enabled: false,
|
||||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
enable_summary: false,
|
||||
enable_vision_llm: false,
|
||||
},
|
||||
queryParams: {
|
||||
|
|
@ -520,7 +518,6 @@ export const useConnectorDialog = () => {
|
|||
connector_type: connectorData.connector_type as EnumConnectorName,
|
||||
is_active: true,
|
||||
next_scheduled_at: connectorData.next_scheduled_at as string | null,
|
||||
enable_summary: false,
|
||||
enable_vision_llm: false,
|
||||
},
|
||||
queryParams: {
|
||||
|
|
@ -657,8 +654,7 @@ export const useConnectorDialog = () => {
|
|||
setConnectorConfig(connector.config || {});
|
||||
setPeriodicEnabled(false);
|
||||
setFrequencyMinutes("1440");
|
||||
setEnableSummary(connector.enable_summary ?? false);
|
||||
setEnableVisionLlm(connector.enable_vision_llm ?? false);
|
||||
setEnableVisionLlm(connector.enable_vision_llm ?? false);
|
||||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
|
||||
|
|
@ -806,14 +802,13 @@ export const useConnectorDialog = () => {
|
|||
const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined;
|
||||
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
|
||||
|
||||
// Update connector with summary, periodic sync settings, and config changes
|
||||
if (enableSummary || enableVisionLlm || periodicEnabled || indexingConnectorConfig) {
|
||||
// Update connector with vision, periodic sync settings, and config changes
|
||||
if (enableVisionLlm || periodicEnabled || indexingConnectorConfig) {
|
||||
const frequency = periodicEnabled ? parseInt(frequencyMinutes, 10) : undefined;
|
||||
await updateConnector({
|
||||
id: indexingConfig.connectorId,
|
||||
data: {
|
||||
enable_summary: enableSummary,
|
||||
enable_vision_llm: enableVisionLlm,
|
||||
enable_vision_llm: enableVisionLlm,
|
||||
...(periodicEnabled && {
|
||||
periodic_indexing_enabled: true,
|
||||
indexing_frequency_minutes: frequency,
|
||||
|
|
@ -940,7 +935,6 @@ export const useConnectorDialog = () => {
|
|||
updateConnector,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
enableSummary,
|
||||
enableVisionLlm,
|
||||
indexingConnectorConfig,
|
||||
setIsOpen,
|
||||
|
|
@ -1005,7 +999,6 @@ export const useConnectorDialog = () => {
|
|||
setConnectorName(connector.name);
|
||||
setPeriodicEnabled(!connector.is_indexable ? false : connector.periodic_indexing_enabled);
|
||||
setFrequencyMinutes(connector.indexing_frequency_minutes?.toString() || "1440");
|
||||
setEnableSummary(connector.enable_summary ?? false);
|
||||
setEnableVisionLlm(connector.enable_vision_llm ?? false);
|
||||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
|
|
@ -1084,7 +1077,6 @@ export const useConnectorDialog = () => {
|
|||
id: editingConnector.id,
|
||||
data: {
|
||||
name: connectorName || editingConnector.name,
|
||||
enable_summary: enableSummary,
|
||||
enable_vision_llm: enableVisionLlm,
|
||||
periodic_indexing_enabled: !editingConnector.is_indexable ? false : periodicEnabled,
|
||||
indexing_frequency_minutes: !editingConnector.is_indexable ? null : frequency,
|
||||
|
|
@ -1219,7 +1211,6 @@ export const useConnectorDialog = () => {
|
|||
updateConnector,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
enableSummary,
|
||||
enableVisionLlm,
|
||||
getFrequencyLabel,
|
||||
connectorConfig,
|
||||
|
|
@ -1380,7 +1371,6 @@ export const useConnectorDialog = () => {
|
|||
setEndDate(undefined);
|
||||
setPeriodicEnabled(false);
|
||||
setFrequencyMinutes("1440");
|
||||
setEnableSummary(false);
|
||||
setEnableVisionLlm(false);
|
||||
}
|
||||
}
|
||||
|
|
@ -1417,7 +1407,6 @@ export const useConnectorDialog = () => {
|
|||
isDisconnecting,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
enableSummary,
|
||||
enableVisionLlm,
|
||||
searchSpaceId,
|
||||
allConnectors,
|
||||
|
|
@ -1432,7 +1421,6 @@ export const useConnectorDialog = () => {
|
|||
setEndDate,
|
||||
setPeriodicEnabled,
|
||||
setFrequencyMinutes,
|
||||
setEnableSummary,
|
||||
setEnableVisionLlm,
|
||||
setConnectorName,
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
createContext,
|
||||
type FC,
|
||||
|
|
@ -12,14 +10,8 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
llmPreferencesAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { DocumentUploadTab } from "@/components/sources/DocumentUploadTab";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -98,12 +90,7 @@ const DocumentUploadPopupContent: FC<{
|
|||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}> = ({ isOpen, onOpenChange }) => {
|
||||
const router = useRouter();
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const { data: preferences = {}, isFetching: preferencesLoading } =
|
||||
useAtomValue(llmPreferencesAtom);
|
||||
const { data: globalConfigs = [], isFetching: globalConfigsLoading } =
|
||||
useAtomValue(globalNewLLMConfigsAtom);
|
||||
|
||||
if (!searchSpaceId) return null;
|
||||
|
||||
|
|
@ -111,22 +98,6 @@ const DocumentUploadPopupContent: FC<{
|
|||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// Check if document summary LLM is properly configured
|
||||
// - If ID is 0 (Auto mode), we need global configs to be available
|
||||
// - If ID is positive (user config) or negative (specific global config), it's configured
|
||||
// - If ID is null/undefined, it's not configured
|
||||
const docSummaryLlmId = preferences.document_summary_llm_id;
|
||||
const isAutoMode = docSummaryLlmId === 0;
|
||||
const hasGlobalConfigs = globalConfigs.length > 0;
|
||||
|
||||
const hasDocumentSummaryLLM =
|
||||
docSummaryLlmId !== null &&
|
||||
docSummaryLlmId !== undefined &&
|
||||
// If it's Auto mode, we need global configs to actually be available
|
||||
(!isAutoMode || hasGlobalConfigs);
|
||||
|
||||
const isLoading = preferencesLoading || globalConfigsLoading;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
|
|
@ -146,33 +117,7 @@ const DocumentUploadPopupContent: FC<{
|
|||
</DialogHeader>
|
||||
|
||||
<div className="px-4 sm:px-6 pb-4 sm:pb-6">
|
||||
{!isLoading && !hasDocumentSummaryLLM ? (
|
||||
<div className="mb-4">
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle />
|
||||
<AlertTitle>LLM Configuration Required</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
{isAutoMode && !hasGlobalConfigs
|
||||
? "Auto mode requires a global LLM configuration. Please add one in Settings"
|
||||
: "A Document Summary LLM is required to process uploads, configure one in Settings"}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
router.push(`/dashboard/${searchSpaceId}/search-space-settings/models`);
|
||||
}}
|
||||
>
|
||||
Go to Settings
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
) : (
|
||||
<DocumentUploadTab searchSpaceId={searchSpaceId} onSuccess={handleSuccess} />
|
||||
)}
|
||||
<DocumentUploadTab searchSpaceId={searchSpaceId} onSuccess={handleSuccess} />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useSetAtom } from "jotai";
|
||||
import { FileText } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { openCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
|
||||
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
|
||||
import { CitationPanelContent } from "@/components/citation-panel/citation-panel";
|
||||
|
|
@ -120,12 +120,14 @@ interface UrlCitationProps {
|
|||
* page title and snippet (extracted deterministically from web_search tool results).
|
||||
*/
|
||||
export const UrlCitation: FC<UrlCitationProps> = ({ url }) => {
|
||||
const reactId = useId();
|
||||
const citationInstanceId = `url-cite-${reactId.replace(/:/g, "")}`;
|
||||
const domain = tryGetHostname(url) ?? url;
|
||||
const meta = useCitationMetadata(url);
|
||||
|
||||
return (
|
||||
<Citation
|
||||
id={`url-cite-${url}`}
|
||||
id={citationInstanceId}
|
||||
href={url}
|
||||
title={meta?.title || domain}
|
||||
snippet={meta?.snippet}
|
||||
|
|
|
|||
|
|
@ -445,6 +445,7 @@ const Composer: FC = () => {
|
|||
const [actionQuery, setActionQuery] = useState("");
|
||||
const [suggestionAnchorPoint, setSuggestionAnchorPoint] =
|
||||
useState<ComposerSuggestionAnchorPoint | null>(null);
|
||||
const [isComposerInputEmpty, setIsComposerInputEmpty] = useState(true);
|
||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||
const prevMentionedDocsRef = useRef<Map<string, MentionedDocumentInfo>>(new Map());
|
||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||
|
|
@ -536,6 +537,7 @@ const Composer: FC = () => {
|
|||
// short-circuit keeps pure-text keystrokes from churning the atom.
|
||||
const handleEditorChange = useCallback(
|
||||
(text: string, docs: MentionedDocument[]) => {
|
||||
setIsComposerInputEmpty(text.trim().length === 0 && docs.length === 0);
|
||||
aui.composer().setText(text);
|
||||
setMentionedDocuments((prev) => {
|
||||
if (prev.length === docs.length) {
|
||||
|
|
@ -651,6 +653,7 @@ const Composer: FC = () => {
|
|||
: action.prompt;
|
||||
editorRef.current?.setText(finalPrompt);
|
||||
aui.composer().setText(finalPrompt);
|
||||
setIsComposerInputEmpty(false);
|
||||
setShowPromptPicker(false);
|
||||
setActionQuery("");
|
||||
setSuggestionAnchorPoint(null);
|
||||
|
|
@ -662,6 +665,7 @@ const Composer: FC = () => {
|
|||
(prompt: string) => {
|
||||
editorRef.current?.setText(prompt);
|
||||
aui.composer().setText(prompt);
|
||||
setIsComposerInputEmpty(false);
|
||||
editorRef.current?.focus();
|
||||
},
|
||||
[aui]
|
||||
|
|
@ -676,6 +680,7 @@ const Composer: FC = () => {
|
|||
: `${action.prompt}\n\n${clipboardInitialText}`;
|
||||
editorRef.current?.setText(finalPrompt);
|
||||
aui.composer().setText(finalPrompt);
|
||||
setIsComposerInputEmpty(false);
|
||||
setShowPromptPicker(false);
|
||||
setActionQuery("");
|
||||
setSuggestionAnchorPoint(null);
|
||||
|
|
@ -755,6 +760,7 @@ const Composer: FC = () => {
|
|||
|
||||
aui.composer().send();
|
||||
editorRef.current?.clear();
|
||||
setIsComposerInputEmpty(true);
|
||||
setMentionedDocuments([]);
|
||||
}, [
|
||||
showDocumentPopover,
|
||||
|
|
@ -893,7 +899,7 @@ const Composer: FC = () => {
|
|||
<div className="flex w-full flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"aui-composer-attachment-dropzone relative z-10 flex w-full flex-col overflow-hidden rounded-3xl border border-input bg-muted pt-2 shadow-sm shadow-black/5 outline-none transition-shadow dark:shadow-black/10",
|
||||
"aui-composer-attachment-dropzone relative z-10 flex w-full flex-col overflow-hidden rounded-3xl border border-input/20 bg-muted pt-2 shadow-sm shadow-black/5 outline-none transition-[border-color,box-shadow] hover:border-input/60 focus-within:border-input/60 dark:shadow-black/10",
|
||||
connectToolsTrayVisible && "rounded-b-3xl shadow-none dark:shadow-none"
|
||||
)}
|
||||
>
|
||||
|
|
@ -904,7 +910,7 @@ const Composer: FC = () => {
|
|||
onDismiss={() => setClipboardInitialText(undefined)}
|
||||
/>
|
||||
)}
|
||||
<div className="aui-composer-input-wrapper px-4 pt-3 pb-6">
|
||||
<div className="aui-composer-input-wrapper px-4 pt-3 pb-2 sm:pb-6">
|
||||
<InlineMentionEditor
|
||||
ref={editorRef}
|
||||
placeholder={currentPlaceholder}
|
||||
|
|
@ -916,7 +922,7 @@ const Composer: FC = () => {
|
|||
onDocumentRemove={handleDocumentRemove}
|
||||
onSubmit={handleSubmit}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="min-h-[24px] **:data-slate-placeholder:font-normal"
|
||||
className="min-h-[48px] sm:min-h-[24px] **:data-slate-placeholder:font-normal"
|
||||
/>
|
||||
</div>
|
||||
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
|
||||
|
|
@ -926,7 +932,9 @@ const Composer: FC = () => {
|
|||
isThreadEmpty={isThreadEmpty}
|
||||
onVisibleChange={setConnectToolsTrayVisible}
|
||||
/>
|
||||
{isThreadEmpty && <ChatExamplePrompts onSelect={handleExampleSelect} />}
|
||||
{isThreadEmpty && isComposerInputEmpty ? (
|
||||
<ChatExamplePrompts onSelect={handleExampleSelect} />
|
||||
) : null}
|
||||
</div>
|
||||
</ComposerPrimitive.Root>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -351,9 +351,24 @@ function GetStartedButton() {
|
|||
}
|
||||
|
||||
function DownloadButton() {
|
||||
const { os, primary, alternatives } = usePrimaryDownload();
|
||||
const { os, primary, alternatives, isMobileOS } = usePrimaryDownload();
|
||||
|
||||
const fallbackUrl = GITHUB_RELEASES_URL;
|
||||
const mobileDisabledLabel = "Desktop app unavailable on mobile";
|
||||
|
||||
if (isMobileOS) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled
|
||||
className="h-14 w-full gap-2 rounded-lg border border-neutral-200 bg-white text-center text-base font-medium text-neutral-700 shadow-sm transition duration-150 sm:w-auto sm:px-6 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
{mobileDisabledLabel}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (!primary) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { Inbox, LibraryBig } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAnonymousMode } from "@/contexts/anonymous-mode";
|
||||
import { useLoginGate } from "@/contexts/login-gate";
|
||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
|
|
@ -110,15 +110,13 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
|
|||
navItems={navItems}
|
||||
onNavItemClick={handleNavItemClick}
|
||||
chats={[]}
|
||||
sharedChats={[]}
|
||||
activeChatId={null}
|
||||
onNewChat={resetChat}
|
||||
onChatSelect={handleChatSelect}
|
||||
onChatRename={gatedAction("rename chats")}
|
||||
onChatDelete={gatedAction("delete chats")}
|
||||
onChatArchive={gatedAction("archive chats")}
|
||||
onViewAllSharedChats={gatedAction("view shared chats")}
|
||||
onViewAllPrivateChats={gatedAction("view chat history")}
|
||||
onViewAllChats={gatedAction("view chat history")}
|
||||
user={{
|
||||
email: "Guest",
|
||||
name: "Guest",
|
||||
|
|
@ -137,7 +135,7 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
|
|||
onOpenChange: setIsDocsSidebarOpen,
|
||||
}}
|
||||
>
|
||||
<Fragment>{children}</Fragment>
|
||||
{children}
|
||||
</LayoutShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlertTriangle, Inbox, LibraryBig, Workflow } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
|
|
@ -41,13 +41,15 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useActivateChatThread } from "@/hooks/use-activate-chat-thread";
|
||||
import { useAnnouncements } from "@/hooks/use-announcements";
|
||||
import { useInbox } from "@/hooks/use-inbox";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations";
|
||||
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { getLoginPath, logout } from "@/lib/auth-utils";
|
||||
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
|
||||
import { fetchThreads } from "@/lib/chat/thread-persistence";
|
||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
|
||||
|
|
@ -77,7 +79,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
|
|
@ -96,6 +97,10 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
|
||||
const syncChatTab = useSetAtom(syncChatTabAtom);
|
||||
const removeChatTab = useSetAtom(removeChatTabAtom);
|
||||
const { activateChatThread, prefetchChatThread } = useActivateChatThread();
|
||||
const { mutateAsync: archiveThread } = useArchiveThread(searchSpaceId);
|
||||
const { mutateAsync: deleteThread } = useDeleteThread(searchSpaceId);
|
||||
const { mutateAsync: renameThread } = useRenameThread(searchSpaceId);
|
||||
|
||||
// Key used to force-remount the page component (e.g. after deleting the active chat
|
||||
// when the router is out of sync due to replaceState)
|
||||
|
|
@ -121,7 +126,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
});
|
||||
|
||||
// Unified slide-out panel state (only one can be open at a time)
|
||||
type SlideoutPanel = "inbox" | "shared" | "private" | null;
|
||||
type SlideoutPanel = "inbox" | "chats" | null;
|
||||
const [activeSlideoutPanel, setActiveSlideoutPanel] = useState<SlideoutPanel>(null);
|
||||
|
||||
const isInboxSidebarOpen = activeSlideoutPanel === "inbox";
|
||||
|
|
@ -301,37 +306,21 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
title: chatId ? (thread?.title ?? undefined) : "New Chat",
|
||||
chatUrl,
|
||||
searchSpaceId: Number(searchSpaceId),
|
||||
...(thread?.visibility !== undefined ? { visibility: thread.visibility } : {}),
|
||||
});
|
||||
}, [currentChatId, searchSpaceId, threadsData?.threads, syncChatTab]);
|
||||
|
||||
// Transform and split chats into private and shared based on visibility
|
||||
const { myChats, sharedChats } = useMemo(() => {
|
||||
if (!threadsData?.threads) return { myChats: [], sharedChats: [] };
|
||||
const chats = useMemo(() => {
|
||||
if (!threadsData?.threads) return [];
|
||||
|
||||
const privateChats: ChatItem[] = [];
|
||||
const sharedChatsList: ChatItem[] = [];
|
||||
|
||||
for (const thread of threadsData.threads) {
|
||||
const chatItem: ChatItem = {
|
||||
id: thread.id,
|
||||
name: thread.title || `Chat ${thread.id}`,
|
||||
url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
|
||||
visibility: thread.visibility,
|
||||
isOwnThread: thread.is_own_thread,
|
||||
archived: thread.archived,
|
||||
};
|
||||
|
||||
// Split based on visibility, not ownership:
|
||||
// - PRIVATE chats go to "Private Chats" section
|
||||
// - SEARCH_SPACE chats go to "Shared Chats" section
|
||||
if (thread.visibility === "SEARCH_SPACE") {
|
||||
sharedChatsList.push(chatItem);
|
||||
} else {
|
||||
privateChats.push(chatItem);
|
||||
}
|
||||
}
|
||||
|
||||
return { myChats: privateChats, sharedChats: sharedChatsList };
|
||||
return threadsData.threads.map<ChatItem>((thread) => ({
|
||||
id: thread.id,
|
||||
name: thread.title || `Chat ${thread.id}`,
|
||||
url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
|
||||
visibility: thread.visibility,
|
||||
isOwnThread: thread.is_own_thread,
|
||||
archived: thread.archived,
|
||||
}));
|
||||
}, [threadsData, searchSpaceId]);
|
||||
|
||||
// Navigation items
|
||||
|
|
@ -478,12 +467,34 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
const handleTabSwitch = useCallback(
|
||||
(tab: Tab) => {
|
||||
if (tab.type === "chat") {
|
||||
const url = tab.chatUrl || `/dashboard/${searchSpaceId}/new-chat`;
|
||||
router.push(url);
|
||||
activateChatThread({
|
||||
id: tab.chatId ?? null,
|
||||
title: tab.title,
|
||||
url: tab.chatUrl,
|
||||
searchSpaceId: tab.searchSpaceId ?? searchSpaceId,
|
||||
...(tab.visibility !== undefined ? { visibility: tab.visibility } : {}),
|
||||
...(tab.hasComments !== undefined ? { hasComments: tab.hasComments } : {}),
|
||||
});
|
||||
}
|
||||
// Document tabs are handled in-place by LayoutShell — no navigation needed
|
||||
},
|
||||
[router, searchSpaceId]
|
||||
[activateChatThread, searchSpaceId]
|
||||
);
|
||||
|
||||
const handleTabPrefetch = useCallback(
|
||||
(tab: Tab) => {
|
||||
if (tab.type === "chat") {
|
||||
prefetchChatThread(tab.chatId);
|
||||
}
|
||||
},
|
||||
[prefetchChatThread]
|
||||
);
|
||||
|
||||
const handleChatPrefetch = useCallback(
|
||||
(chat: ChatItem) => {
|
||||
prefetchChatThread(chat.id);
|
||||
},
|
||||
[prefetchChatThread]
|
||||
);
|
||||
|
||||
const handleNavItemClick = useCallback(
|
||||
|
|
@ -535,9 +546,15 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
|
||||
const handleChatSelect = useCallback(
|
||||
(chat: ChatItem) => {
|
||||
router.push(chat.url);
|
||||
activateChatThread({
|
||||
id: chat.id,
|
||||
title: chat.name,
|
||||
url: chat.url,
|
||||
searchSpaceId,
|
||||
...(chat.visibility !== undefined ? { visibility: chat.visibility } : {}),
|
||||
});
|
||||
},
|
||||
[router]
|
||||
[activateChatThread, searchSpaceId]
|
||||
);
|
||||
|
||||
const handleChatDelete = useCallback((chat: ChatItem) => {
|
||||
|
|
@ -559,18 +576,14 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
: tSidebar("chat_unarchived") || "Chat restored";
|
||||
|
||||
try {
|
||||
await updateThread(chat.id, { archived: newArchivedState });
|
||||
await archiveThread({ threadId: chat.id, archived: newArchivedState });
|
||||
toast.success(successMessage);
|
||||
// Invalidate queries to refresh UI (React Query will only refetch active queries)
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||
} catch (error) {
|
||||
console.error("Error archiving thread:", error);
|
||||
toast.error(tSidebar("error_archiving_chat") || "Failed to archive chat");
|
||||
}
|
||||
},
|
||||
[queryClient, searchSpaceId, tSidebar]
|
||||
[archiveThread, tSidebar]
|
||||
);
|
||||
|
||||
const handleSettings = useCallback(() => {
|
||||
|
|
@ -599,12 +612,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
}
|
||||
}, [router]);
|
||||
|
||||
const handleViewAllSharedChats = useCallback(() => {
|
||||
setActiveSlideoutPanel((prev) => (prev === "shared" ? null : "shared"));
|
||||
}, []);
|
||||
|
||||
const handleViewAllPrivateChats = useCallback(() => {
|
||||
setActiveSlideoutPanel((prev) => (prev === "private" ? null : "private"));
|
||||
const handleViewAllChats = useCallback(() => {
|
||||
setActiveSlideoutPanel((prev) => (prev === "chats" ? null : "chats"));
|
||||
}, []);
|
||||
|
||||
// Delete handlers
|
||||
|
|
@ -612,13 +621,21 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
if (!chatToDelete) return;
|
||||
setIsDeletingChat(true);
|
||||
try {
|
||||
await deleteThread(chatToDelete.id);
|
||||
await deleteThread({ threadId: chatToDelete.id });
|
||||
const fallbackTab = removeChatTab(chatToDelete.id);
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||
if (currentChatId === chatToDelete.id) {
|
||||
resetCurrentThread();
|
||||
if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) {
|
||||
router.push(fallbackTab.chatUrl);
|
||||
activateChatThread({
|
||||
id: fallbackTab.chatId ?? null,
|
||||
title: fallbackTab.title,
|
||||
url: fallbackTab.chatUrl,
|
||||
searchSpaceId: fallbackTab.searchSpaceId ?? searchSpaceId,
|
||||
...(fallbackTab.visibility !== undefined ? { visibility: fallbackTab.visibility } : {}),
|
||||
...(fallbackTab.hasComments !== undefined
|
||||
? { hasComments: fallbackTab.hasComments }
|
||||
: {}),
|
||||
});
|
||||
} else {
|
||||
const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
|
||||
if (isOutOfSync) {
|
||||
|
|
@ -638,7 +655,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
}
|
||||
}, [
|
||||
chatToDelete,
|
||||
queryClient,
|
||||
deleteThread,
|
||||
searchSpaceId,
|
||||
resetCurrentThread,
|
||||
currentChatId,
|
||||
|
|
@ -646,6 +663,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
params?.chat_id,
|
||||
router,
|
||||
removeChatTab,
|
||||
activateChatThread,
|
||||
]);
|
||||
|
||||
// Rename handler
|
||||
|
|
@ -653,11 +671,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
if (!chatToRename || !newChatTitle.trim()) return;
|
||||
setIsRenamingChat(true);
|
||||
try {
|
||||
await updateThread(chatToRename.id, { title: newChatTitle.trim() });
|
||||
await renameThread({
|
||||
threadId: chatToRename.id,
|
||||
title: newChatTitle.trim(),
|
||||
previousTitle: chatToRename.name,
|
||||
});
|
||||
toast.success(tSidebar("chat_renamed") || "Chat renamed");
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||
} catch (error) {
|
||||
console.error("Error renaming thread:", error);
|
||||
toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat");
|
||||
|
|
@ -667,7 +686,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
setChatToRename(null);
|
||||
setNewChatTitle("");
|
||||
}
|
||||
}, [chatToRename, newChatTitle, queryClient, searchSpaceId, tSidebar]);
|
||||
}, [chatToRename, newChatTitle, renameThread, tSidebar]);
|
||||
|
||||
// Detect if we're on the chat page (needs overflow-hidden for chat's own scroll)
|
||||
const isChatPage = pathname?.includes("/new-chat") ?? false;
|
||||
|
|
@ -695,16 +714,15 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
searchSpace={activeSearchSpace}
|
||||
navItems={navItems}
|
||||
onNavItemClick={handleNavItemClick}
|
||||
chats={myChats}
|
||||
sharedChats={sharedChats}
|
||||
chats={chats}
|
||||
activeChatId={currentChatId}
|
||||
onNewChat={handleNewChat}
|
||||
onChatSelect={handleChatSelect}
|
||||
onChatPrefetch={handleChatPrefetch}
|
||||
onChatRename={handleChatRename}
|
||||
onChatDelete={handleChatDelete}
|
||||
onChatArchive={handleChatArchive}
|
||||
onViewAllSharedChats={handleViewAllSharedChats}
|
||||
onViewAllPrivateChats={handleViewAllPrivateChats}
|
||||
onViewAllChats={handleViewAllChats}
|
||||
user={{
|
||||
email: user?.email || "",
|
||||
name: user?.display_name || user?.email?.split("@")[0],
|
||||
|
|
@ -727,7 +745,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
}
|
||||
workspacePanelContentClassName={
|
||||
isAutomationsPage
|
||||
? "max-w-none"
|
||||
? "max-w-none select-none"
|
||||
: isUserSettingsPage || isSearchSpaceSettingsPage || isTeamPage
|
||||
? "max-w-5xl"
|
||||
: undefined
|
||||
|
|
@ -759,10 +777,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
markAllAsRead: statusInbox.markAllAsRead,
|
||||
},
|
||||
}}
|
||||
allSharedChatsPanel={{
|
||||
searchSpaceId,
|
||||
}}
|
||||
allPrivateChatsPanel={{
|
||||
allChatsPanel={{
|
||||
searchSpaceId,
|
||||
}}
|
||||
documentsPanel={{
|
||||
|
|
@ -770,6 +785,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
onOpenChange: setIsDocumentsSidebarOpen,
|
||||
}}
|
||||
onTabSwitch={handleTabSwitch}
|
||||
onTabPrefetch={handleTabPrefetch}
|
||||
>
|
||||
<Fragment key={chatResetKey}>{children}</Fragment>
|
||||
</LayoutShell>
|
||||
|
|
@ -841,7 +857,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
<span className={isRenamingChat ? "opacity-0" : ""}>
|
||||
{tSidebar("rename") || "Rename"}
|
||||
</span>
|
||||
{isRenamingChat && <Spinner size="sm" className="absolute" />}
|
||||
{isRenamingChat && (
|
||||
<Spinner
|
||||
size="sm"
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -70,8 +70,7 @@ export interface ChatsSectionProps {
|
|||
activeChatId?: number | null;
|
||||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onViewAllSharedChats?: () => void;
|
||||
onViewAllPrivateChats?: () => void;
|
||||
onViewAllChats?: () => void;
|
||||
searchSpaceId?: string;
|
||||
}
|
||||
|
||||
|
|
@ -96,13 +95,11 @@ export interface SidebarProps {
|
|||
searchSpaceId?: string;
|
||||
navItems: NavItem[];
|
||||
chats: ChatItem[];
|
||||
sharedChats?: ChatItem[];
|
||||
activeChatId?: number | null;
|
||||
onNewChat: () => void;
|
||||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onViewAllSharedChats?: () => void;
|
||||
onViewAllPrivateChats?: () => void;
|
||||
onViewAllChats?: () => void;
|
||||
user: User;
|
||||
theme?: string;
|
||||
onSettings?: () => void;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { activeTabAtom } from "@/atoms/tabs/tabs.atom";
|
|||
import { ActionLogButton } from "@/components/agent-action-log/action-log-button";
|
||||
import { ChatHeader } from "@/components/new-chat/chat-header";
|
||||
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
||||
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
||||
import type { ThreadRecord } from "@/lib/chat/thread-persistence";
|
||||
|
||||
interface HeaderProps {
|
||||
mobileMenuTrigger?: React.ReactNode;
|
||||
|
|
@ -26,6 +26,14 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
|||
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||
|
||||
const hasThread = isChatPage && !isDocumentTab && currentThreadState.id !== null;
|
||||
const activeSearchSpaceId = searchSpaceId ? Number(searchSpaceId) : null;
|
||||
const canRenderShareButton =
|
||||
hasThread &&
|
||||
currentThreadState.id !== null &&
|
||||
currentThreadState.visibility !== null &&
|
||||
currentThreadState.searchSpaceId !== null &&
|
||||
activeSearchSpaceId !== null &&
|
||||
currentThreadState.searchSpaceId === activeSearchSpaceId;
|
||||
|
||||
// Free chat pages have their own header with model selector; only render mobile trigger
|
||||
if (isFreePage) {
|
||||
|
|
@ -37,21 +45,24 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
|||
);
|
||||
}
|
||||
|
||||
const threadForButton: ThreadRecord | null =
|
||||
hasThread && currentThreadState.id !== null
|
||||
? {
|
||||
id: currentThreadState.id,
|
||||
visibility: currentThreadState.visibility ?? "PRIVATE",
|
||||
created_by_id: null,
|
||||
search_space_id: 0,
|
||||
title: "",
|
||||
archived: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
}
|
||||
: null;
|
||||
|
||||
const handleVisibilityChange = (_visibility: ChatVisibility) => {};
|
||||
let threadForButton: ThreadRecord | null = null;
|
||||
if (
|
||||
canRenderShareButton &&
|
||||
currentThreadState.id !== null &&
|
||||
currentThreadState.visibility !== null &&
|
||||
currentThreadState.searchSpaceId !== null
|
||||
) {
|
||||
threadForButton = {
|
||||
id: currentThreadState.id,
|
||||
visibility: currentThreadState.visibility,
|
||||
created_by_id: null,
|
||||
search_space_id: currentThreadState.searchSpaceId,
|
||||
title: "",
|
||||
archived: false,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 bg-main-panel/95 backdrop-blur supports-backdrop-filter:bg-main-panel/60 px-4">
|
||||
|
|
@ -66,9 +77,7 @@ export function Header({ mobileMenuTrigger }: HeaderProps) {
|
|||
{/* Right side - Actions */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{hasThread && <ActionLogButton threadId={currentThreadState.id} />}
|
||||
{hasThread && (
|
||||
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
||||
)}
|
||||
{threadForButton && <ChatShareButton thread={threadForButton} />}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -27,8 +27,7 @@ import {
|
|||
RightPanelToggleButton,
|
||||
} from "../right-panel/RightPanel";
|
||||
import {
|
||||
AllPrivateChatsSidebarContent,
|
||||
AllSharedChatsSidebarContent,
|
||||
AllChatsSidebarContent,
|
||||
DocumentsSidebar,
|
||||
InboxSidebarContent,
|
||||
MobileSidebar,
|
||||
|
|
@ -94,7 +93,7 @@ interface TabDataSource {
|
|||
markAllAsRead: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export type ActiveSlideoutPanel = "inbox" | "shared" | "private" | null;
|
||||
export type ActiveSlideoutPanel = "inbox" | "chats" | null;
|
||||
|
||||
// Inbox-related props — per-tab data sources with independent loading/pagination
|
||||
interface InboxProps {
|
||||
|
|
@ -115,15 +114,14 @@ interface LayoutShellProps {
|
|||
navItems: NavItem[];
|
||||
onNavItemClick?: (item: NavItem) => void;
|
||||
chats: ChatItem[];
|
||||
sharedChats?: ChatItem[];
|
||||
activeChatId?: number | null;
|
||||
onNewChat: () => void;
|
||||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatPrefetch?: (chat: ChatItem) => void;
|
||||
onChatRename?: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onChatArchive?: (chat: ChatItem) => void;
|
||||
onViewAllSharedChats?: () => void;
|
||||
onViewAllPrivateChats?: () => void;
|
||||
onViewAllChats?: () => void;
|
||||
user: User;
|
||||
onSettings?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
|
|
@ -148,10 +146,7 @@ interface LayoutShellProps {
|
|||
inbox?: InboxProps;
|
||||
isLoadingChats?: boolean;
|
||||
// All chats panel props
|
||||
allSharedChatsPanel?: {
|
||||
searchSpaceId: string;
|
||||
};
|
||||
allPrivateChatsPanel?: {
|
||||
allChatsPanel?: {
|
||||
searchSpaceId: string;
|
||||
};
|
||||
documentsPanel?: {
|
||||
|
|
@ -159,11 +154,13 @@ interface LayoutShellProps {
|
|||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
onTabSwitch?: (tab: Tab) => void;
|
||||
onTabPrefetch?: (tab: Tab) => void;
|
||||
}
|
||||
|
||||
function MainContentPanel({
|
||||
isChatPage,
|
||||
onTabSwitch,
|
||||
onTabPrefetch,
|
||||
onNewChat,
|
||||
showRightPanelExpandButton = true,
|
||||
showTopBorder = false,
|
||||
|
|
@ -171,6 +168,7 @@ function MainContentPanel({
|
|||
}: {
|
||||
isChatPage: boolean;
|
||||
onTabSwitch?: (tab: Tab) => void;
|
||||
onTabPrefetch?: (tab: Tab) => void;
|
||||
onNewChat?: () => void;
|
||||
showRightPanelExpandButton?: boolean;
|
||||
showTopBorder?: boolean;
|
||||
|
|
@ -185,6 +183,7 @@ function MainContentPanel({
|
|||
>
|
||||
<TabBar
|
||||
onTabSwitch={onTabSwitch}
|
||||
onTabPrefetch={onTabPrefetch}
|
||||
onNewChat={onNewChat}
|
||||
rightActions={showRightPanelExpandButton ? <RightPanelExpandButton /> : null}
|
||||
className="min-w-0"
|
||||
|
|
@ -226,15 +225,14 @@ export function LayoutShell({
|
|||
navItems,
|
||||
onNavItemClick,
|
||||
chats,
|
||||
sharedChats,
|
||||
activeChatId,
|
||||
onNewChat,
|
||||
onChatSelect,
|
||||
onChatPrefetch,
|
||||
onChatRename,
|
||||
onChatDelete,
|
||||
onChatArchive,
|
||||
onViewAllSharedChats,
|
||||
onViewAllPrivateChats,
|
||||
onViewAllChats,
|
||||
user,
|
||||
onSettings,
|
||||
onManageMembers,
|
||||
|
|
@ -256,10 +254,10 @@ export function LayoutShell({
|
|||
onSlideoutPanelChange,
|
||||
inbox,
|
||||
isLoadingChats = false,
|
||||
allSharedChatsPanel,
|
||||
allPrivateChatsPanel,
|
||||
allChatsPanel,
|
||||
documentsPanel,
|
||||
onTabSwitch,
|
||||
onTabPrefetch,
|
||||
}: LayoutShellProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const electronAPI = useElectronAPI();
|
||||
|
|
@ -288,13 +286,7 @@ export function LayoutShell({
|
|||
const anySlideOutOpen = activeSlideoutPanel !== null;
|
||||
|
||||
const panelAriaLabel =
|
||||
activeSlideoutPanel === "inbox"
|
||||
? "Inbox"
|
||||
: activeSlideoutPanel === "shared"
|
||||
? "Shared Chats"
|
||||
: activeSlideoutPanel === "private"
|
||||
? "Private Chats"
|
||||
: "Panel";
|
||||
activeSlideoutPanel === "inbox" ? "Inbox" : activeSlideoutPanel === "chats" ? "Chats" : "Panel";
|
||||
|
||||
// Mobile layout
|
||||
if (isMobile) {
|
||||
|
|
@ -317,17 +309,15 @@ export function LayoutShell({
|
|||
navItems={navItems}
|
||||
onNavItemClick={onNavItemClick}
|
||||
chats={chats}
|
||||
sharedChats={sharedChats}
|
||||
activeChatId={activeChatId}
|
||||
onNewChat={onNewChat}
|
||||
onChatSelect={onChatSelect}
|
||||
onChatPrefetch={onChatPrefetch}
|
||||
onChatRename={onChatRename}
|
||||
onChatDelete={onChatDelete}
|
||||
onChatArchive={onChatArchive}
|
||||
onViewAllSharedChats={onViewAllSharedChats}
|
||||
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||
isSharedChatsPanelOpen={activeSlideoutPanel === "shared"}
|
||||
isPrivateChatsPanelOpen={activeSlideoutPanel === "private"}
|
||||
onViewAllChats={onViewAllChats}
|
||||
isChatsPanelOpen={activeSlideoutPanel === "chats"}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
onManageMembers={onManageMembers}
|
||||
|
|
@ -379,34 +369,18 @@ export function LayoutShell({
|
|||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeSlideoutPanel === "shared" && allSharedChatsPanel && (
|
||||
{activeSlideoutPanel === "chats" && allChatsPanel && (
|
||||
<motion.div
|
||||
key="shared"
|
||||
key="chats"
|
||||
className="h-full flex flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<AllSharedChatsSidebarContent
|
||||
<AllChatsSidebarContent
|
||||
onOpenChange={(open) => closeSlideout(open)}
|
||||
searchSpaceId={allSharedChatsPanel.searchSpaceId}
|
||||
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeSlideoutPanel === "private" && allPrivateChatsPanel && (
|
||||
<motion.div
|
||||
key="private"
|
||||
className="h-full flex flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<AllPrivateChatsSidebarContent
|
||||
onOpenChange={(open) => closeSlideout(open)}
|
||||
searchSpaceId={allPrivateChatsPanel.searchSpaceId}
|
||||
searchSpaceId={allChatsPanel.searchSpaceId}
|
||||
onCloseMobileSidebar={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
</motion.div>
|
||||
|
|
@ -478,17 +452,15 @@ export function LayoutShell({
|
|||
navItems={navItems}
|
||||
onNavItemClick={onNavItemClick}
|
||||
chats={chats}
|
||||
sharedChats={sharedChats}
|
||||
activeChatId={activeChatId}
|
||||
onNewChat={onNewChat}
|
||||
onChatSelect={onChatSelect}
|
||||
onChatPrefetch={onChatPrefetch}
|
||||
onChatRename={onChatRename}
|
||||
onChatDelete={onChatDelete}
|
||||
onChatArchive={onChatArchive}
|
||||
onViewAllSharedChats={onViewAllSharedChats}
|
||||
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||
isSharedChatsPanelOpen={activeSlideoutPanel === "shared"}
|
||||
isPrivateChatsPanelOpen={activeSlideoutPanel === "private"}
|
||||
onViewAllChats={onViewAllChats}
|
||||
isChatsPanelOpen={activeSlideoutPanel === "chats"}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
onManageMembers={onManageMembers}
|
||||
|
|
@ -554,33 +526,18 @@ export function LayoutShell({
|
|||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeSlideoutPanel === "shared" && allSharedChatsPanel && (
|
||||
{activeSlideoutPanel === "chats" && allChatsPanel && (
|
||||
<motion.div
|
||||
key="shared"
|
||||
key="chats"
|
||||
className="h-full flex flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<AllSharedChatsSidebarContent
|
||||
<AllChatsSidebarContent
|
||||
onOpenChange={(open) => closeSlideout(open)}
|
||||
searchSpaceId={allSharedChatsPanel.searchSpaceId}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{activeSlideoutPanel === "private" && allPrivateChatsPanel && (
|
||||
<motion.div
|
||||
key="private"
|
||||
className="h-full flex flex-col"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<AllPrivateChatsSidebarContent
|
||||
onOpenChange={(open) => closeSlideout(open)}
|
||||
searchSpaceId={allPrivateChatsPanel.searchSpaceId}
|
||||
searchSpaceId={allChatsPanel.searchSpaceId}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
|
@ -603,6 +560,7 @@ export function LayoutShell({
|
|||
<MainContentPanel
|
||||
isChatPage={isChatPage}
|
||||
onTabSwitch={onTabSwitch}
|
||||
onTabPrefetch={onTabPrefetch}
|
||||
onNewChat={onNewChat}
|
||||
showRightPanelExpandButton={!isMacDesktop}
|
||||
showTopBorder={isMacDesktop}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Search,
|
||||
Trash2,
|
||||
User,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
|
@ -39,40 +40,41 @@ import { Input } from "@/components/ui/input";
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useActivateChatThread } from "@/hooks/use-activate-chat-thread";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import {
|
||||
deleteThread,
|
||||
fetchThreads,
|
||||
searchThreads,
|
||||
updateThread,
|
||||
} from "@/lib/chat/thread-persistence";
|
||||
import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations";
|
||||
import { fetchThreads, searchThreads, type ThreadListItem } from "@/lib/chat/thread-persistence";
|
||||
import { formatThreadTimestamp } from "@/lib/format-date";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||
|
||||
export interface AllPrivateChatsSidebarContentProps {
|
||||
export interface AllChatsSidebarContentProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
searchSpaceId: string;
|
||||
onCloseMobileSidebar?: () => void;
|
||||
}
|
||||
|
||||
interface AllPrivateChatsSidebarProps extends AllPrivateChatsSidebarContentProps {
|
||||
interface AllChatsSidebarProps extends AllChatsSidebarContentProps {
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export function AllPrivateChatsSidebarContent({
|
||||
export function AllChatsSidebarContent({
|
||||
onOpenChange,
|
||||
searchSpaceId,
|
||||
onCloseMobileSidebar,
|
||||
}: AllPrivateChatsSidebarContentProps) {
|
||||
}: AllChatsSidebarContentProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const isMobile = useIsMobile();
|
||||
const removeChatTab = useSetAtom(removeChatTabAtom);
|
||||
const { activateChatThread, prefetchChatThread } = useActivateChatThread();
|
||||
const { mutateAsync: deleteThread } = useDeleteThread(searchSpaceId);
|
||||
const { mutateAsync: archiveThread } = useArchiveThread(searchSpaceId);
|
||||
const { mutateAsync: renameThread } = useRenameThread(searchSpaceId);
|
||||
|
||||
const currentChatId = Array.isArray(params.chat_id)
|
||||
? Number(params.chat_id[0])
|
||||
|
|
@ -122,57 +124,66 @@ export function AllPrivateChatsSidebarContent({
|
|||
enabled: !!searchSpaceId && isSearchMode,
|
||||
});
|
||||
|
||||
// Filter to only private chats (PRIVATE visibility or no visibility set)
|
||||
const { activeChats, archivedChats } = useMemo(() => {
|
||||
if (isSearchMode) {
|
||||
const privateSearchResults = (searchData ?? []).filter(
|
||||
(thread) => thread.visibility !== "SEARCH_SPACE"
|
||||
);
|
||||
return {
|
||||
activeChats: privateSearchResults.filter((t) => !t.archived),
|
||||
archivedChats: privateSearchResults.filter((t) => t.archived),
|
||||
activeChats: (searchData ?? []).filter((t) => !t.archived),
|
||||
archivedChats: (searchData ?? []).filter((t) => t.archived),
|
||||
};
|
||||
}
|
||||
|
||||
if (!threadsData) return { activeChats: [], archivedChats: [] };
|
||||
|
||||
const activePrivate = threadsData.threads.filter(
|
||||
(thread) => thread.visibility !== "SEARCH_SPACE"
|
||||
);
|
||||
const archivedPrivate = threadsData.archived_threads.filter(
|
||||
(thread) => thread.visibility !== "SEARCH_SPACE"
|
||||
);
|
||||
|
||||
return { activeChats: activePrivate, archivedChats: archivedPrivate };
|
||||
return {
|
||||
activeChats: threadsData.threads,
|
||||
archivedChats: threadsData.archived_threads,
|
||||
};
|
||||
}, [threadsData, searchData, isSearchMode]);
|
||||
|
||||
const threads = showArchived ? archivedChats : activeChats;
|
||||
|
||||
const handleThreadClick = useCallback(
|
||||
(threadId: number) => {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`);
|
||||
(thread: ThreadListItem) => {
|
||||
activateChatThread({
|
||||
id: thread.id,
|
||||
title: thread.title || "New Chat",
|
||||
searchSpaceId,
|
||||
visibility: thread.visibility,
|
||||
});
|
||||
onOpenChange(false);
|
||||
onCloseMobileSidebar?.();
|
||||
},
|
||||
[router, onOpenChange, searchSpaceId, onCloseMobileSidebar]
|
||||
[activateChatThread, onOpenChange, searchSpaceId, onCloseMobileSidebar]
|
||||
);
|
||||
|
||||
const handleDeleteThread = useCallback(
|
||||
async (threadId: number) => {
|
||||
setDeletingThreadId(threadId);
|
||||
try {
|
||||
await deleteThread(threadId);
|
||||
await deleteThread({ threadId });
|
||||
const fallbackTab = removeChatTab(threadId);
|
||||
toast.success(t("chat_deleted") || "Chat deleted successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||
|
||||
if (currentChatId === threadId) {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => {
|
||||
if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) {
|
||||
router.push(fallbackTab.chatUrl);
|
||||
if (
|
||||
fallbackTab?.type === "chat" &&
|
||||
fallbackTab.chatUrl &&
|
||||
fallbackTab.chatId !== undefined
|
||||
) {
|
||||
activateChatThread({
|
||||
id: fallbackTab.chatId ?? null,
|
||||
title: fallbackTab.title,
|
||||
url: fallbackTab.chatUrl,
|
||||
searchSpaceId: fallbackTab.searchSpaceId ?? searchSpaceId,
|
||||
...(fallbackTab.visibility !== undefined
|
||||
? { visibility: fallbackTab.visibility }
|
||||
: {}),
|
||||
...(fallbackTab.hasComments !== undefined
|
||||
? { hasComments: fallbackTab.hasComments }
|
||||
: {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
|
|
@ -185,22 +196,28 @@ export function AllPrivateChatsSidebarContent({
|
|||
setDeletingThreadId(null);
|
||||
}
|
||||
},
|
||||
[queryClient, searchSpaceId, t, currentChatId, router, onOpenChange, removeChatTab]
|
||||
[
|
||||
activateChatThread,
|
||||
deleteThread,
|
||||
t,
|
||||
currentChatId,
|
||||
router,
|
||||
onOpenChange,
|
||||
removeChatTab,
|
||||
searchSpaceId,
|
||||
]
|
||||
);
|
||||
|
||||
const handleToggleArchive = useCallback(
|
||||
async (threadId: number, currentlyArchived: boolean) => {
|
||||
setArchivingThreadId(threadId);
|
||||
try {
|
||||
await updateThread(threadId, { archived: !currentlyArchived });
|
||||
await archiveThread({ threadId, archived: !currentlyArchived });
|
||||
toast.success(
|
||||
currentlyArchived
|
||||
? t("chat_unarchived") || "Chat restored"
|
||||
: t("chat_archived") || "Chat archived"
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||
} catch (error) {
|
||||
console.error("Error archiving thread:", error);
|
||||
toast.error(t("error_archiving_chat") || "Failed to archive chat");
|
||||
|
|
@ -208,7 +225,7 @@ export function AllPrivateChatsSidebarContent({
|
|||
setArchivingThreadId(null);
|
||||
}
|
||||
},
|
||||
[queryClient, searchSpaceId, t]
|
||||
[archiveThread, t]
|
||||
);
|
||||
|
||||
const handleStartRename = useCallback((threadId: number, title: string) => {
|
||||
|
|
@ -221,14 +238,12 @@ export function AllPrivateChatsSidebarContent({
|
|||
if (!renamingThread || !newTitle.trim()) return;
|
||||
setIsRenaming(true);
|
||||
try {
|
||||
await updateThread(renamingThread.id, { title: newTitle.trim() });
|
||||
toast.success(t("chat_renamed") || "Chat renamed");
|
||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["threads", searchSpaceId, "detail", String(renamingThread.id)],
|
||||
await renameThread({
|
||||
threadId: renamingThread.id,
|
||||
title: newTitle.trim(),
|
||||
previousTitle: renamingThread.title,
|
||||
});
|
||||
toast.success(t("chat_renamed") || "Chat renamed");
|
||||
} catch (error) {
|
||||
console.error("Error renaming thread:", error);
|
||||
toast.error(t("error_renaming_chat") || "Failed to rename chat");
|
||||
|
|
@ -238,7 +253,7 @@ export function AllPrivateChatsSidebarContent({
|
|||
setRenamingThread(null);
|
||||
setNewTitle("");
|
||||
}
|
||||
}, [renamingThread, newTitle, queryClient, searchSpaceId, t]);
|
||||
}, [renamingThread, newTitle, renameThread, t]);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchQuery("");
|
||||
|
|
@ -265,7 +280,7 @@ export function AllPrivateChatsSidebarContent({
|
|||
<span className="sr-only">{t("close") || "Close"}</span>
|
||||
</Button>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
|
||||
<h2 className="text-lg font-semibold">{t("chats") || "Chats"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
|
|
@ -353,8 +368,10 @@ export function AllPrivateChatsSidebarContent({
|
|||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (wasLongPress()) return;
|
||||
handleThreadClick(thread.id);
|
||||
handleThreadClick(thread);
|
||||
}}
|
||||
onMouseEnter={() => prefetchChatThread(thread.id)}
|
||||
onFocus={() => prefetchChatThread(thread.id)}
|
||||
onTouchStart={() => {
|
||||
pendingThreadIdRef.current = thread.id;
|
||||
longPressHandlers.onTouchStart();
|
||||
|
|
@ -366,11 +383,12 @@ export function AllPrivateChatsSidebarContent({
|
|||
"h-auto w-full justify-start gap-2 overflow-hidden px-2 py-1.5 text-left font-normal",
|
||||
"group-hover/item:bg-accent group-hover/item:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
thread.visibility === "SEARCH_SPACE" && "pr-9",
|
||||
isActive && "bg-accent text-accent-foreground",
|
||||
isBusy && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{thread.title || "New Chat"}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{thread.title || "New Chat"}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip delayDuration={600}>
|
||||
|
|
@ -378,17 +396,22 @@ export function AllPrivateChatsSidebarContent({
|
|||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => handleThreadClick(thread.id)}
|
||||
onClick={() => handleThreadClick(thread)}
|
||||
onMouseEnter={() => prefetchChatThread(thread.id)}
|
||||
onFocus={() => prefetchChatThread(thread.id)}
|
||||
disabled={isBusy}
|
||||
className={cn(
|
||||
"h-auto w-full justify-start gap-2 overflow-hidden px-2 py-1.5 text-left font-normal",
|
||||
"group-hover/item:bg-accent group-hover/item:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
thread.visibility === "SEARCH_SPACE" && "pr-9",
|
||||
isActive && "bg-accent text-accent-foreground",
|
||||
isBusy && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{thread.title || "New Chat"}</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{thread.title || "New Chat"}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start">
|
||||
|
|
@ -407,64 +430,83 @@ export function AllPrivateChatsSidebarContent({
|
|||
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
|
||||
isMobile
|
||||
? "opacity-0"
|
||||
: openDropdownId === thread.id
|
||||
: thread.visibility === "SEARCH_SPACE" || openDropdownId === thread.id
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover/item:opacity-100"
|
||||
)}
|
||||
>
|
||||
<DropdownMenu
|
||||
open={openDropdownId === thread.id}
|
||||
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<div className="relative h-6 w-6">
|
||||
{thread.visibility === "SEARCH_SPACE" ? (
|
||||
<Users
|
||||
aria-label={t("shared_chat") || "Shared chat"}
|
||||
className={cn(
|
||||
"pointer-events-auto h-6 w-6 hover:bg-transparent",
|
||||
openDropdownId === thread.id && "bg-accent hover:bg-accent"
|
||||
"absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 text-muted-foreground/50",
|
||||
!isMobile &&
|
||||
(openDropdownId === thread.id
|
||||
? "opacity-0"
|
||||
: "opacity-100 group-hover/item:opacity-0")
|
||||
)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40 z-80">
|
||||
{!thread.archived && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStartRename(thread.id, thread.title || "New Chat")}
|
||||
/>
|
||||
) : null}
|
||||
<DropdownMenu
|
||||
open={openDropdownId === thread.id}
|
||||
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"pointer-events-auto h-6 w-6 hover:bg-transparent",
|
||||
openDropdownId === thread.id && "bg-accent hover:bg-accent",
|
||||
!isMobile &&
|
||||
openDropdownId !== thread.id &&
|
||||
"opacity-0 group-hover/item:opacity-100"
|
||||
)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<span>{t("rename") || "Rename"}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleToggleArchive(thread.id, thread.archived)}
|
||||
disabled={isArchiving}
|
||||
>
|
||||
{thread.archived ? (
|
||||
<>
|
||||
<RotateCcwIcon className="mr-2 h-4 w-4" />
|
||||
<span>{t("unarchive") || "Restore"}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArchiveIcon className="mr-2 h-4 w-4" />
|
||||
<span>{t("archive") || "Archive"}</span>
|
||||
</>
|
||||
{isDeleting ? (
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40 z-80">
|
||||
{!thread.archived && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleStartRename(thread.id, thread.title || "New Chat")
|
||||
}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<span>{t("rename") || "Rename"}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t("delete") || "Delete"}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleToggleArchive(thread.id, thread.archived)}
|
||||
disabled={isArchiving}
|
||||
>
|
||||
{thread.archived ? (
|
||||
<>
|
||||
<RotateCcwIcon className="mr-2 h-4 w-4" />
|
||||
<span>{t("unarchive") || "Restore"}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArchiveIcon className="mr-2 h-4 w-4" />
|
||||
<span>{t("archive") || "Archive"}</span>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t("delete") || "Delete"}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -486,7 +528,7 @@ export function AllPrivateChatsSidebarContent({
|
|||
<p className="text-xs text-muted-foreground">
|
||||
{showArchived
|
||||
? t("no_archived_chats") || "No archived chats"
|
||||
: t("no_chats") || "No private chats"}
|
||||
: t("no_chats") || "No chats"}
|
||||
</p>
|
||||
{!showArchived && (
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/70">
|
||||
|
|
@ -527,16 +569,17 @@ export function AllPrivateChatsSidebarContent({
|
|||
<Button
|
||||
onClick={handleConfirmRename}
|
||||
disabled={isRenaming || !newTitle.trim()}
|
||||
className="gap-2"
|
||||
className="relative"
|
||||
>
|
||||
<span className={isRenaming ? "opacity-0" : undefined}>
|
||||
{t("rename") || "Rename"}
|
||||
</span>
|
||||
{isRenaming ? (
|
||||
<>
|
||||
<Spinner size="xs" />
|
||||
<span>{t("renaming") || "Renaming"}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{t("rename") || "Rename"}</span>
|
||||
)}
|
||||
<Spinner
|
||||
size="xs"
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||
/>
|
||||
) : null}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
@ -545,21 +588,17 @@ export function AllPrivateChatsSidebarContent({
|
|||
);
|
||||
}
|
||||
|
||||
export function AllPrivateChatsSidebar({
|
||||
export function AllChatsSidebar({
|
||||
open,
|
||||
onOpenChange,
|
||||
searchSpaceId,
|
||||
onCloseMobileSidebar,
|
||||
}: AllPrivateChatsSidebarProps) {
|
||||
}: AllChatsSidebarProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
|
||||
return (
|
||||
<SidebarSlideOutPanel
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
ariaLabel={t("chats") || "Private Chats"}
|
||||
>
|
||||
<AllPrivateChatsSidebarContent
|
||||
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("chats") || "Chats"}>
|
||||
<AllChatsSidebarContent
|
||||
onOpenChange={onOpenChange}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onCloseMobileSidebar={onCloseMobileSidebar}
|
||||
|
|
@ -1,568 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSetAtom } from "jotai";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ChevronLeft,
|
||||
MessageCircleMore,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
RotateCcwIcon,
|
||||
Search,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { removeChatTabAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/animated-tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import {
|
||||
deleteThread,
|
||||
fetchThreads,
|
||||
searchThreads,
|
||||
updateThread,
|
||||
} from "@/lib/chat/thread-persistence";
|
||||
import { formatThreadTimestamp } from "@/lib/format-date";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||
|
||||
export interface AllSharedChatsSidebarContentProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
searchSpaceId: string;
|
||||
onCloseMobileSidebar?: () => void;
|
||||
}
|
||||
|
||||
interface AllSharedChatsSidebarProps extends AllSharedChatsSidebarContentProps {
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export function AllSharedChatsSidebarContent({
|
||||
onOpenChange,
|
||||
searchSpaceId,
|
||||
onCloseMobileSidebar,
|
||||
}: AllSharedChatsSidebarContentProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const isMobile = useIsMobile();
|
||||
const removeChatTab = useSetAtom(removeChatTabAtom);
|
||||
|
||||
const currentChatId = Array.isArray(params.chat_id)
|
||||
? Number(params.chat_id[0])
|
||||
: params.chat_id
|
||||
? Number(params.chat_id)
|
||||
: null;
|
||||
const [deletingThreadId, setDeletingThreadId] = useState<number | null>(null);
|
||||
const [archivingThreadId, setArchivingThreadId] = useState<number | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||
const [renamingThread, setRenamingThread] = useState<{ id: number; title: string } | null>(null);
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
||||
|
||||
const pendingThreadIdRef = useRef<number | null>(null);
|
||||
const { handlers: longPressHandlers, wasLongPress } = useLongPress(
|
||||
useCallback(() => {
|
||||
if (pendingThreadIdRef.current !== null) {
|
||||
setOpenDropdownId(pendingThreadIdRef.current);
|
||||
}
|
||||
}, [])
|
||||
);
|
||||
|
||||
const isSearchMode = !!debouncedSearchQuery.trim();
|
||||
|
||||
const {
|
||||
data: threadsData,
|
||||
error: threadsError,
|
||||
isLoading: isLoadingThreads,
|
||||
} = useQuery({
|
||||
queryKey: ["all-threads", searchSpaceId],
|
||||
queryFn: () => fetchThreads(Number(searchSpaceId)),
|
||||
enabled: !!searchSpaceId && !isSearchMode,
|
||||
});
|
||||
|
||||
const {
|
||||
data: searchData,
|
||||
error: searchError,
|
||||
isLoading: isLoadingSearch,
|
||||
} = useQuery({
|
||||
queryKey: ["search-threads", searchSpaceId, debouncedSearchQuery],
|
||||
queryFn: () => searchThreads(Number(searchSpaceId), debouncedSearchQuery.trim()),
|
||||
enabled: !!searchSpaceId && isSearchMode,
|
||||
});
|
||||
|
||||
// Filter to only shared chats (SEARCH_SPACE visibility)
|
||||
const { activeChats, archivedChats } = useMemo(() => {
|
||||
if (isSearchMode) {
|
||||
const sharedSearchResults = (searchData ?? []).filter(
|
||||
(thread) => thread.visibility === "SEARCH_SPACE"
|
||||
);
|
||||
return {
|
||||
activeChats: sharedSearchResults.filter((t) => !t.archived),
|
||||
archivedChats: sharedSearchResults.filter((t) => t.archived),
|
||||
};
|
||||
}
|
||||
|
||||
if (!threadsData) return { activeChats: [], archivedChats: [] };
|
||||
|
||||
const activeShared = threadsData.threads.filter(
|
||||
(thread) => thread.visibility === "SEARCH_SPACE"
|
||||
);
|
||||
const archivedShared = threadsData.archived_threads.filter(
|
||||
(thread) => thread.visibility === "SEARCH_SPACE"
|
||||
);
|
||||
|
||||
return { activeChats: activeShared, archivedChats: archivedShared };
|
||||
}, [threadsData, searchData, isSearchMode]);
|
||||
|
||||
const threads = showArchived ? archivedChats : activeChats;
|
||||
|
||||
const handleThreadClick = useCallback(
|
||||
(threadId: number) => {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat/${threadId}`);
|
||||
onOpenChange(false);
|
||||
onCloseMobileSidebar?.();
|
||||
},
|
||||
[router, onOpenChange, searchSpaceId, onCloseMobileSidebar]
|
||||
);
|
||||
|
||||
const handleDeleteThread = useCallback(
|
||||
async (threadId: number) => {
|
||||
setDeletingThreadId(threadId);
|
||||
try {
|
||||
await deleteThread(threadId);
|
||||
const fallbackTab = removeChatTab(threadId);
|
||||
toast.success(t("chat_deleted") || "Chat deleted successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||
|
||||
if (currentChatId === threadId) {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => {
|
||||
if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) {
|
||||
router.push(fallbackTab.chatUrl);
|
||||
return;
|
||||
}
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
}, 250);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting thread:", error);
|
||||
toast.error(t("error_deleting_chat") || "Failed to delete chat");
|
||||
} finally {
|
||||
setDeletingThreadId(null);
|
||||
}
|
||||
},
|
||||
[queryClient, searchSpaceId, t, currentChatId, router, onOpenChange, removeChatTab]
|
||||
);
|
||||
|
||||
const handleToggleArchive = useCallback(
|
||||
async (threadId: number, currentlyArchived: boolean) => {
|
||||
setArchivingThreadId(threadId);
|
||||
try {
|
||||
await updateThread(threadId, { archived: !currentlyArchived });
|
||||
toast.success(
|
||||
currentlyArchived
|
||||
? t("chat_unarchived") || "Chat restored"
|
||||
: t("chat_archived") || "Chat archived"
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||
} catch (error) {
|
||||
console.error("Error archiving thread:", error);
|
||||
toast.error(t("error_archiving_chat") || "Failed to archive chat");
|
||||
} finally {
|
||||
setArchivingThreadId(null);
|
||||
}
|
||||
},
|
||||
[queryClient, searchSpaceId, t]
|
||||
);
|
||||
|
||||
const handleStartRename = useCallback((threadId: number, title: string) => {
|
||||
setRenamingThread({ id: threadId, title });
|
||||
setNewTitle(title);
|
||||
setShowRenameDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmRename = useCallback(async () => {
|
||||
if (!renamingThread || !newTitle.trim()) return;
|
||||
setIsRenaming(true);
|
||||
try {
|
||||
await updateThread(renamingThread.id, { title: newTitle.trim() });
|
||||
toast.success(t("chat_renamed") || "Chat renamed");
|
||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["threads", searchSpaceId, "detail", String(renamingThread.id)],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error renaming thread:", error);
|
||||
toast.error(t("error_renaming_chat") || "Failed to rename chat");
|
||||
} finally {
|
||||
setIsRenaming(false);
|
||||
setShowRenameDialog(false);
|
||||
setRenamingThread(null);
|
||||
setNewTitle("");
|
||||
}
|
||||
}, [renamingThread, newTitle, queryClient, searchSpaceId, t]);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchQuery("");
|
||||
}, []);
|
||||
|
||||
const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads;
|
||||
const error = isSearchMode ? searchError : threadsError;
|
||||
|
||||
const activeCount = activeChats.length;
|
||||
const archivedCount = archivedChats.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="shrink-0 p-3 pb-1.5 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{isMobile && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full text-muted-foreground hover:text-accent-foreground"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="sr-only">{t("close") || "Close"}</span>
|
||||
</Button>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("search_chats") || "Search chats..."}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 border-0 bg-muted pl-8 pr-7 text-sm shadow-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 h-5 w-5 -translate-y-1/2 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isSearchMode && (
|
||||
<Tabs
|
||||
value={showArchived ? "archived" : "active"}
|
||||
onValueChange={(value) => setShowArchived(value === "archived")}
|
||||
className="shrink-0 mx-3 mt-1.5"
|
||||
>
|
||||
<TabsList stretch showBottomBorder size="sm">
|
||||
<TabsTrigger value="active">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MessageCircleMore className="h-3.5 w-3.5" />
|
||||
<span>Active</span>
|
||||
<span className="inline-flex h-4.5 min-w-4.5 items-center justify-center rounded-full bg-primary/20 px-1 text-[10px] font-medium text-muted-foreground">
|
||||
{activeCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="archived">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ArchiveIcon className="h-3.5 w-3.5" />
|
||||
<span>Archived</span>
|
||||
<span className="inline-flex h-4.5 min-w-4.5 items-center justify-center rounded-full bg-primary/20 px-1 text-[10px] font-medium text-muted-foreground">
|
||||
{archivedCount}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1.5">
|
||||
{isLoading ? (
|
||||
<div className="space-y-1">
|
||||
{[75, 90, 55, 80, 65, 85].map((titleWidth) => (
|
||||
<div
|
||||
key={`skeleton-${titleWidth}`}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5"
|
||||
>
|
||||
<Skeleton className="h-4 w-4 shrink-0 rounded" />
|
||||
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-sm text-destructive">
|
||||
{t("error_loading_chats") || "Error loading chats"}
|
||||
</div>
|
||||
) : threads.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{threads.map((thread) => {
|
||||
const isDeleting = deletingThreadId === thread.id;
|
||||
const isArchiving = archivingThreadId === thread.id;
|
||||
const isBusy = isDeleting || isArchiving;
|
||||
const isActive = currentChatId === thread.id;
|
||||
|
||||
return (
|
||||
<div key={thread.id} className="group/item relative w-full">
|
||||
{isMobile ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (wasLongPress()) return;
|
||||
handleThreadClick(thread.id);
|
||||
}}
|
||||
onTouchStart={() => {
|
||||
pendingThreadIdRef.current = thread.id;
|
||||
longPressHandlers.onTouchStart();
|
||||
}}
|
||||
onTouchEnd={longPressHandlers.onTouchEnd}
|
||||
onTouchMove={longPressHandlers.onTouchMove}
|
||||
disabled={isBusy}
|
||||
className={cn(
|
||||
"h-auto w-full justify-start gap-2 overflow-hidden rounded-md px-2 py-1.5 text-sm text-left",
|
||||
"group-hover/item:bg-accent group-hover/item:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
isActive && "bg-accent text-accent-foreground",
|
||||
isBusy && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{thread.title || "New Chat"}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip delayDuration={600}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => handleThreadClick(thread.id)}
|
||||
disabled={isBusy}
|
||||
className={cn(
|
||||
"h-auto w-full justify-start gap-2 overflow-hidden rounded-md px-2 py-1.5 text-sm text-left",
|
||||
"group-hover/item:bg-accent group-hover/item:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
isActive && "bg-accent text-accent-foreground",
|
||||
isBusy && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{thread.title || "New Chat"}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start">
|
||||
<p>
|
||||
{t("updated") || "Updated"}: {formatThreadTimestamp(thread.updatedAt)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-0 top-0 bottom-0 flex items-center rounded-r-md pl-6 pr-1",
|
||||
isActive
|
||||
? "bg-gradient-to-l from-accent from-60% to-transparent"
|
||||
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
|
||||
isMobile
|
||||
? "opacity-0"
|
||||
: openDropdownId === thread.id
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover/item:opacity-100"
|
||||
)}
|
||||
>
|
||||
<DropdownMenu
|
||||
open={openDropdownId === thread.id}
|
||||
onOpenChange={(isOpen) => setOpenDropdownId(isOpen ? thread.id : null)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"pointer-events-auto h-6 w-6 hover:bg-transparent",
|
||||
openDropdownId === thread.id && "bg-accent hover:bg-accent"
|
||||
)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40 z-80">
|
||||
{!thread.archived && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStartRename(thread.id, thread.title || "New Chat")}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<span>{t("rename") || "Rename"}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleToggleArchive(thread.id, thread.archived)}
|
||||
disabled={isArchiving}
|
||||
>
|
||||
{thread.archived ? (
|
||||
<>
|
||||
<RotateCcwIcon className="mr-2 h-4 w-4" />
|
||||
<span>{t("unarchive") || "Restore"}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArchiveIcon className="mr-2 h-4 w-4" />
|
||||
<span>{t("archive") || "Archive"}</span>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleDeleteThread(thread.id)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t("delete") || "Delete"}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : isSearchMode ? (
|
||||
<div className="text-center py-8">
|
||||
<Search className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("no_chats_found") || "No chats found"}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/70">
|
||||
{t("try_different_search") || "Try a different search term"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Users className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{showArchived
|
||||
? t("no_archived_chats") || "No archived chats"
|
||||
: t("no_shared_chats") || "No shared chats"}
|
||||
</p>
|
||||
{!showArchived && (
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/70">
|
||||
Share a chat to collaborate with your team
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<span>{t("rename_chat") || "Rename Chat"}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("rename_chat_description") || "Enter a new name for this conversation."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder={t("chat_title_placeholder") || "Chat title"}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !isRenaming && newTitle.trim()) {
|
||||
handleConfirmRename();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DialogFooter className="flex sm:justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowRenameDialog(false)}
|
||||
disabled={isRenaming}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmRename}
|
||||
disabled={isRenaming || !newTitle.trim()}
|
||||
className="gap-2"
|
||||
>
|
||||
{isRenaming ? (
|
||||
<>
|
||||
<Spinner size="xs" />
|
||||
<span>{t("renaming") || "Renaming"}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{t("rename") || "Rename"}</span>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AllSharedChatsSidebar({
|
||||
open,
|
||||
onOpenChange,
|
||||
searchSpaceId,
|
||||
onCloseMobileSidebar,
|
||||
}: AllSharedChatsSidebarProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
|
||||
return (
|
||||
<SidebarSlideOutPanel
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
ariaLabel={t("shared_chats") || "Shared Chats"}
|
||||
>
|
||||
<AllSharedChatsSidebarContent
|
||||
onOpenChange={onOpenChange}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onCloseMobileSidebar={onCloseMobileSidebar}
|
||||
/>
|
||||
</SidebarSlideOutPanel>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,10 +18,12 @@ import { cn } from "@/lib/utils";
|
|||
interface ChatListItemProps {
|
||||
name: string;
|
||||
isActive?: boolean;
|
||||
isShared?: boolean;
|
||||
archived?: boolean;
|
||||
dropdownOpen?: boolean;
|
||||
onDropdownOpenChange?: (open: boolean) => void;
|
||||
onClick?: () => void;
|
||||
onPrefetch?: () => void;
|
||||
onRename?: () => void;
|
||||
onArchive?: () => void;
|
||||
onDelete?: () => void;
|
||||
|
|
@ -34,6 +36,7 @@ export function ChatListItem({
|
|||
dropdownOpen: controlledOpen,
|
||||
onDropdownOpenChange,
|
||||
onClick,
|
||||
onPrefetch,
|
||||
onRename,
|
||||
onArchive,
|
||||
onDelete,
|
||||
|
|
@ -60,6 +63,8 @@ export function ChatListItem({
|
|||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleClick}
|
||||
onMouseEnter={onPrefetch}
|
||||
onFocus={onPrefetch}
|
||||
{...(isMobile ? longPressHandlers : {})}
|
||||
className={cn(
|
||||
"h-auto w-full justify-start gap-2 overflow-hidden px-2 py-1.5 text-left font-normal",
|
||||
|
|
@ -68,7 +73,7 @@ export function ChatListItem({
|
|||
isActive && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{animatedName}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{animatedName}</span>
|
||||
</Button>
|
||||
|
||||
{/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */}
|
||||
|
|
|
|||
|
|
@ -619,7 +619,6 @@ function AuthenticatedDocumentsSidebarBase({
|
|||
searchSpaceId,
|
||||
excludePatterns: matched.excludePatterns ?? DEFAULT_EXCLUDE_PATTERNS,
|
||||
fileExtensions: matched.fileExtensions ?? Array.from(getSupportedExtensionsSet()),
|
||||
enableSummary: false,
|
||||
rootFolderId: folder.id,
|
||||
});
|
||||
toast.success(`Re-scan complete: ${matched.name}`);
|
||||
|
|
|
|||
|
|
@ -19,17 +19,15 @@ interface MobileSidebarProps {
|
|||
navItems: NavItem[];
|
||||
onNavItemClick?: (item: NavItem) => void;
|
||||
chats: ChatItem[];
|
||||
sharedChats?: ChatItem[];
|
||||
activeChatId?: number | null;
|
||||
onNewChat: () => void;
|
||||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatPrefetch?: (chat: ChatItem) => void;
|
||||
onChatRename?: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onChatArchive?: (chat: ChatItem) => void;
|
||||
onViewAllSharedChats?: () => void;
|
||||
onViewAllPrivateChats?: () => void;
|
||||
isSharedChatsPanelOpen?: boolean;
|
||||
isPrivateChatsPanelOpen?: boolean;
|
||||
onViewAllChats?: () => void;
|
||||
isChatsPanelOpen?: boolean;
|
||||
user: User;
|
||||
onSettings?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
|
|
@ -69,17 +67,15 @@ export function MobileSidebar({
|
|||
navItems,
|
||||
onNavItemClick,
|
||||
chats,
|
||||
sharedChats,
|
||||
activeChatId,
|
||||
onNewChat,
|
||||
onChatSelect,
|
||||
onChatPrefetch,
|
||||
onChatRename,
|
||||
onChatDelete,
|
||||
onChatArchive,
|
||||
onViewAllSharedChats,
|
||||
onViewAllPrivateChats,
|
||||
isSharedChatsPanelOpen = false,
|
||||
isPrivateChatsPanelOpen = false,
|
||||
onViewAllChats,
|
||||
isChatsPanelOpen = false,
|
||||
user,
|
||||
onSettings,
|
||||
onManageMembers,
|
||||
|
|
@ -152,34 +148,25 @@ export function MobileSidebar({
|
|||
navItems={navItems}
|
||||
onNavItemClick={handleNavItemClick}
|
||||
chats={chats}
|
||||
sharedChats={sharedChats}
|
||||
activeChatId={activeChatId}
|
||||
onNewChat={() => {
|
||||
onNewChat();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
onChatSelect={handleChatSelect}
|
||||
onChatPrefetch={onChatPrefetch}
|
||||
onChatRename={onChatRename}
|
||||
onChatDelete={onChatDelete}
|
||||
onChatArchive={onChatArchive}
|
||||
onViewAllSharedChats={
|
||||
onViewAllSharedChats
|
||||
onViewAllChats={
|
||||
onViewAllChats
|
||||
? () => {
|
||||
onOpenChange(false);
|
||||
onViewAllSharedChats();
|
||||
onViewAllChats();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onViewAllPrivateChats={
|
||||
onViewAllPrivateChats
|
||||
? () => {
|
||||
onOpenChange(false);
|
||||
onViewAllPrivateChats();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
isSharedChatsPanelOpen={isSharedChatsPanelOpen}
|
||||
isPrivateChatsPanelOpen={isPrivateChatsPanelOpen}
|
||||
isChatsPanelOpen={isChatsPanelOpen}
|
||||
user={user}
|
||||
onSettings={
|
||||
onSettings
|
||||
|
|
|
|||
|
|
@ -67,17 +67,15 @@ interface SidebarProps {
|
|||
navItems: NavItem[];
|
||||
onNavItemClick?: (item: NavItem) => void;
|
||||
chats: ChatItem[];
|
||||
sharedChats?: ChatItem[];
|
||||
activeChatId?: number | null;
|
||||
onNewChat: () => void;
|
||||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatPrefetch?: (chat: ChatItem) => void;
|
||||
onChatRename?: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onChatArchive?: (chat: ChatItem) => void;
|
||||
onViewAllSharedChats?: () => void;
|
||||
onViewAllPrivateChats?: () => void;
|
||||
isSharedChatsPanelOpen?: boolean;
|
||||
isPrivateChatsPanelOpen?: boolean;
|
||||
onViewAllChats?: () => void;
|
||||
isChatsPanelOpen?: boolean;
|
||||
user: User;
|
||||
onSettings?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
|
|
@ -106,17 +104,15 @@ export function Sidebar({
|
|||
navItems,
|
||||
onNavItemClick,
|
||||
chats,
|
||||
sharedChats = [],
|
||||
activeChatId,
|
||||
onNewChat,
|
||||
onChatSelect,
|
||||
onChatPrefetch,
|
||||
onChatRename,
|
||||
onChatDelete,
|
||||
onChatArchive,
|
||||
onViewAllSharedChats,
|
||||
onViewAllPrivateChats,
|
||||
isSharedChatsPanelOpen = false,
|
||||
isPrivateChatsPanelOpen = false,
|
||||
onViewAllChats,
|
||||
isChatsPanelOpen = false,
|
||||
user,
|
||||
onSettings,
|
||||
onManageMembers,
|
||||
|
|
@ -264,73 +260,20 @@ export function Sidebar({
|
|||
<div className="flex-1 w-full" />
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col gap-1 pt-2 w-full min-h-0 overflow-hidden">
|
||||
{/* Shared Chats Section - takes only space needed, max 50% */}
|
||||
<SidebarSection
|
||||
title={t("shared_chats")}
|
||||
defaultOpen={true}
|
||||
fillHeight={false}
|
||||
className="shrink-0 max-h-[50%] flex flex-col"
|
||||
alwaysShowAction={!disableTooltips && isSharedChatsPanelOpen}
|
||||
action={
|
||||
onViewAllSharedChats ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onViewAllSharedChats}
|
||||
className="h-auto cursor-pointer whitespace-nowrap bg-transparent p-0 text-xs font-medium text-muted-foreground/60 transition-colors hover:bg-transparent hover:text-muted-foreground"
|
||||
>
|
||||
{!disableTooltips && isSharedChatsPanelOpen ? t("hide") : t("show_all")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{isLoadingChats ? (
|
||||
<ChatListSkeletonRows />
|
||||
) : sharedChats.length > 0 ? (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div
|
||||
className={`flex flex-col gap-0.5 max-h-full overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent ${sharedChats.length > 4 ? "pb-2" : ""}`}
|
||||
>
|
||||
{sharedChats.slice(0, 20).map((chat) => (
|
||||
<ChatListItem
|
||||
key={chat.id}
|
||||
name={chat.name}
|
||||
isActive={chat.id === activeChatId}
|
||||
archived={chat.archived}
|
||||
dropdownOpen={openDropdownChatId === chat.id}
|
||||
onDropdownOpenChange={(open) => setOpenDropdownChatId(open ? chat.id : null)}
|
||||
onClick={() => onChatSelect(chat)}
|
||||
onRename={() => onChatRename?.(chat)}
|
||||
onArchive={() => onChatArchive?.(chat)}
|
||||
onDelete={() => onChatDelete?.(chat)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Gradient fade indicator when more than 4 items */}
|
||||
{sharedChats.length > 4 && (
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-5 bg-gradient-to-t from-sidebar/80 to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-2 py-1 text-sm text-muted-foreground/60">{t("no_shared_chats")}</p>
|
||||
)}
|
||||
</SidebarSection>
|
||||
|
||||
{/* Private Chats Section - fills remaining space */}
|
||||
<SidebarSection
|
||||
title={t("chats")}
|
||||
title={t("recents")}
|
||||
defaultOpen={true}
|
||||
fillHeight={true}
|
||||
alwaysShowAction={!disableTooltips && isPrivateChatsPanelOpen}
|
||||
alwaysShowAction={!disableTooltips && isChatsPanelOpen}
|
||||
action={
|
||||
onViewAllPrivateChats ? (
|
||||
onViewAllChats ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onViewAllPrivateChats}
|
||||
onClick={onViewAllChats}
|
||||
className="h-auto cursor-pointer whitespace-nowrap bg-transparent p-0 text-xs font-medium text-muted-foreground/60 transition-colors hover:bg-transparent hover:text-muted-foreground"
|
||||
>
|
||||
{!disableTooltips && isPrivateChatsPanelOpen ? t("hide") : t("show_all")}
|
||||
{!disableTooltips && isChatsPanelOpen ? t("hide") : t("show_all")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
|
|
@ -347,10 +290,12 @@ export function Sidebar({
|
|||
key={chat.id}
|
||||
name={chat.name}
|
||||
isActive={chat.id === activeChatId}
|
||||
isShared={chat.visibility === "SEARCH_SPACE"}
|
||||
archived={chat.archived}
|
||||
dropdownOpen={openDropdownChatId === chat.id}
|
||||
onDropdownOpenChange={(open) => setOpenDropdownChatId(open ? chat.id : null)}
|
||||
onClick={() => onChatSelect(chat)}
|
||||
onPrefetch={() => onChatPrefetch?.(chat)}
|
||||
onRename={() => onChatRename?.(chat)}
|
||||
onArchive={() => onChatArchive?.(chat)}
|
||||
onDelete={() => onChatDelete?.(chat)}
|
||||
|
|
|
|||
|
|
@ -39,12 +39,12 @@ export function SidebarSection({
|
|||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center group/section shrink-0 px-2 py-1">
|
||||
<div className="flex items-center group/section shrink-0 px-4 py-1">
|
||||
<CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-accent-foreground transition-colors min-w-0">
|
||||
<span className="truncate">{title}</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 transition-transform duration-200",
|
||||
"h-3.5 w-3.5 shrink-0 transition-[color,opacity,transform] duration-200 opacity-100 md:opacity-0 md:group-hover/section:opacity-100",
|
||||
isOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -139,14 +139,14 @@ export function SidebarUserProfile({
|
|||
const { locale, setLocale } = useLocaleContext();
|
||||
const { isDesktop } = usePlatform();
|
||||
const isDesktopViewport = useMediaQuery("(min-width: 768px)");
|
||||
const { os, primary } = usePrimaryDownload();
|
||||
const { os, primary, isMobileOS } = usePrimaryDownload();
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
const bgColor = getUserAvatarColor(user.email);
|
||||
const initials = getUserInitials(user.email);
|
||||
const displayName = user.name || user.email.split("@")[0];
|
||||
const downloadUrl = primary?.url ?? GITHUB_RELEASES_URL;
|
||||
const downloadLabel = t("download_for_os", { os });
|
||||
const showDownloadCta = !isDesktop && isDesktopViewport;
|
||||
const showDownloadCta = !isDesktop && !isMobileOS && isDesktopViewport;
|
||||
|
||||
const handleLanguageChange = (newLocale: "en" | "es" | "pt" | "hi" | "zh") => {
|
||||
setLocale(newLocale);
|
||||
|
|
@ -221,18 +221,15 @@ export function SidebarUserProfile({
|
|||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-48" side="right" align="end" sideOffset={8}>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium">{displayName}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
<DropdownMenuLabel className="px-2 py-1 font-normal">
|
||||
<div className="min-w-0">
|
||||
{/* <p className="truncate text-sm font-medium">{displayName}</p> */}
|
||||
<p className="truncate text-xs font-semibold leading-tight text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={onUserSettings}>
|
||||
<UserCog className="h-4 w-4" />
|
||||
{t("user_settings")}
|
||||
|
|
@ -327,14 +324,14 @@ export function SidebarUserProfile({
|
|||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<p className="select-none px-2 py-1.5 text-xs text-muted-foreground/50">
|
||||
<p className="select-none px-2 py-1 text-xs leading-tight text-muted-foreground/50">
|
||||
v{APP_VERSION}
|
||||
</p>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{!isDesktop && (
|
||||
{!isDesktop && !isMobileOS && (
|
||||
<DropdownMenuItem asChild className="font-medium">
|
||||
<a href={downloadUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Download className="h-4 w-4" strokeWidth={2.5} />
|
||||
|
|
@ -406,18 +403,15 @@ export function SidebarUserProfile({
|
|||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-48" side="top" align="center" sideOffset={4}>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium">{displayName}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
<DropdownMenuLabel className="px-2 py-1 font-normal">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{displayName}</p>
|
||||
<p className="truncate text-xs font-semibold leading-tight text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={onUserSettings}>
|
||||
<UserCog className="h-4 w-4" />
|
||||
{t("user_settings")}
|
||||
|
|
@ -512,7 +506,7 @@ export function SidebarUserProfile({
|
|||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<p className="select-none px-2 py-1.5 text-xs text-muted-foreground/50">
|
||||
<p className="select-none px-2 py-1 text-xs leading-tight text-muted-foreground/50">
|
||||
v{APP_VERSION}
|
||||
</p>
|
||||
</DropdownMenuSubContent>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
export { AllPrivateChatsSidebar, AllPrivateChatsSidebarContent } from "./AllPrivateChatsSidebar";
|
||||
export { AllSharedChatsSidebar, AllSharedChatsSidebarContent } from "./AllSharedChatsSidebar";
|
||||
export { AllChatsSidebar, AllChatsSidebarContent } from "./AllChatsSidebar";
|
||||
export { ChatListItem } from "./ChatListItem";
|
||||
export { DocumentsSidebar } from "./DocumentsSidebar";
|
||||
export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar";
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { cn } from "@/lib/utils";
|
|||
|
||||
interface TabBarProps {
|
||||
onTabSwitch?: (tab: Tab) => void;
|
||||
onTabPrefetch?: (tab: Tab) => void;
|
||||
onNewChat?: () => void;
|
||||
leftActions?: React.ReactNode;
|
||||
rightActions?: React.ReactNode;
|
||||
|
|
@ -36,6 +37,7 @@ function nextTabListScrollLeft(input: {
|
|||
|
||||
export function TabBar({
|
||||
onTabSwitch,
|
||||
onTabPrefetch,
|
||||
onNewChat,
|
||||
leftActions,
|
||||
rightActions,
|
||||
|
|
@ -71,6 +73,15 @@ export function TabBar({
|
|||
[activeTabId, switchTab, onTabSwitch]
|
||||
);
|
||||
|
||||
const handleTabPrefetch = useCallback(
|
||||
(tab: Tab) => {
|
||||
if (tab.type === "chat") {
|
||||
onTabPrefetch?.(tab);
|
||||
}
|
||||
},
|
||||
[onTabPrefetch]
|
||||
);
|
||||
|
||||
const handleTabClose = useCallback(
|
||||
(e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -195,7 +206,11 @@ export function TabBar({
|
|||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => handleTabClick(tab)}
|
||||
onMouseEnter={() => setHoveredTabIndex(index)}
|
||||
onMouseEnter={() => {
|
||||
setHoveredTabIndex(index);
|
||||
handleTabPrefetch(tab);
|
||||
}}
|
||||
onFocus={() => handleTabPrefetch(tab)}
|
||||
onMouseLeave={() => setHoveredTabIndex(null)}
|
||||
className={cn(
|
||||
"h-full w-full justify-start overflow-hidden px-3 text-left text-[13px] font-medium transition-colors duration-150",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import { CornerDownLeft, Lightbulb } from "lucide-react";
|
||||
import { memo, useCallback } from "react";
|
||||
import {
|
||||
FilePlus2,
|
||||
Search,
|
||||
Settings2,
|
||||
type LucideIcon,
|
||||
WandSparkles,
|
||||
Workflow,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { CHAT_EXAMPLE_CATEGORIES } from "@/lib/chat/example-prompts";
|
||||
|
||||
interface ChatExamplePromptsProps {
|
||||
|
|
@ -12,6 +19,13 @@ interface ChatExamplePromptsProps {
|
|||
onSelect: (prompt: string) => void;
|
||||
}
|
||||
|
||||
const CATEGORY_ICONS: Record<string, LucideIcon> = {
|
||||
search: Search,
|
||||
create: FilePlus2,
|
||||
automate: Workflow,
|
||||
tools: Settings2,
|
||||
};
|
||||
|
||||
const ExamplePromptButton = memo(function ExamplePromptButton({
|
||||
prompt,
|
||||
onSelect,
|
||||
|
|
@ -26,50 +40,72 @@ const ExamplePromptButton = memo(function ExamplePromptButton({
|
|||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleClick}
|
||||
className="h-auto w-full items-start justify-start gap-2.5 whitespace-normal rounded-md border bg-background px-3 py-2 text-left font-normal text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
className="h-auto w-full items-start justify-start whitespace-normal rounded-lg bg-transparent px-2.5 py-1.5 text-left font-normal text-muted-foreground shadow-none hover:bg-foreground/10 hover:text-foreground sm:rounded-xl sm:px-3 sm:py-2"
|
||||
>
|
||||
<CornerDownLeft
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/70"
|
||||
/>
|
||||
<span className="min-w-0 text-pretty text-sm">{prompt}</span>
|
||||
<span className="min-w-0 text-pretty text-xs sm:text-sm">{prompt}</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
export function ChatExamplePrompts({ onSelect }: ChatExamplePromptsProps) {
|
||||
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
|
||||
const activeCategory = CHAT_EXAMPLE_CATEGORIES.find(
|
||||
(category) => category.id === activeCategoryId
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-3 w-full select-none rounded-xl border border-dashed bg-muted/30 p-3 sm:p-4">
|
||||
<div className="mb-2 flex items-center gap-2 px-1">
|
||||
<Lightbulb aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Not sure where to start? Try one of these
|
||||
</p>
|
||||
</div>
|
||||
<Tabs defaultValue={CHAT_EXAMPLE_CATEGORIES[0].id} className="w-full">
|
||||
<div className="overflow-x-auto pb-1">
|
||||
<TabsList className="h-9 w-max">
|
||||
{CHAT_EXAMPLE_CATEGORIES.map((category) => (
|
||||
<TabsTrigger key={category.id} value={category.id} className="text-xs">
|
||||
{category.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<div className="mt-2 w-full select-none sm:mt-3">
|
||||
{activeCategory ? null : (
|
||||
<div className="pb-1">
|
||||
<div className="mx-auto flex max-w-full flex-wrap items-center justify-center gap-1.5 px-0.5 sm:gap-2">
|
||||
{CHAT_EXAMPLE_CATEGORIES.map((category) => {
|
||||
const Icon = CATEGORY_ICONS[category.id] ?? WandSparkles;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={category.id}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setActiveCategoryId(category.id)}
|
||||
className="h-8 rounded-lg bg-muted px-3 text-xs font-medium text-muted-foreground shadow-sm shadow-black/5 hover:bg-foreground/10 hover:text-foreground dark:shadow-black/10 sm:h-10 sm:rounded-xl sm:px-4 sm:text-sm"
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-3.5 sm:size-4" />
|
||||
{category.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{CHAT_EXAMPLE_CATEGORIES.map((category) => (
|
||||
<TabsContent key={category.id} value={category.id} className="mt-3">
|
||||
<ScrollArea className="max-h-48">
|
||||
<ul className="flex flex-col gap-2 pr-2">
|
||||
{category.prompts.map((prompt) => (
|
||||
<li key={prompt}>
|
||||
<ExamplePromptButton prompt={prompt} onSelect={onSelect} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{activeCategory ? (
|
||||
<div className="overflow-hidden rounded-lg border border-input bg-muted shadow-sm shadow-black/5 dark:shadow-black/10 sm:rounded-xl">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs font-medium text-foreground sm:text-sm">
|
||||
<span className="truncate">{activeCategory.label}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setActiveCategoryId(null)}
|
||||
aria-label="Close example prompts"
|
||||
className="size-7 shrink-0 rounded-full text-muted-foreground hover:bg-foreground/10 hover:text-foreground sm:size-8"
|
||||
>
|
||||
<X aria-hidden="true" className="size-3.5 sm:size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="max-h-52 sm:max-h-64">
|
||||
<ul className="divide-y px-2 pb-2 sm:px-3 sm:pb-3">
|
||||
{activeCategory.prompts.map((prompt) => (
|
||||
<li key={prompt} className="py-0.5 sm:py-1">
|
||||
<ExamplePromptButton prompt={prompt} onSelect={onSelect} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +1,21 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Earth, User, Users } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { createPublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useUpdateThreadVisibility } from "@/hooks/use-thread-mutations";
|
||||
import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service";
|
||||
import {
|
||||
type ChatVisibility,
|
||||
type ThreadRecord,
|
||||
updateThreadVisibility,
|
||||
} from "@/lib/chat/thread-persistence";
|
||||
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatShareButtonProps {
|
||||
|
|
@ -54,7 +51,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
|
||||
// Use Jotai atom for visibility (single source of truth)
|
||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||
const setThreadVisibility = useSetAtom(setThreadVisibilityAtom);
|
||||
const { mutateAsync: updateVisibility } = useUpdateThreadVisibility(thread?.search_space_id ?? 0);
|
||||
|
||||
// Snapshot creation mutation
|
||||
const { mutateAsync: createSnapshot, isPending: isCreatingSnapshot } = useAtomValue(
|
||||
|
|
@ -80,8 +77,9 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
});
|
||||
const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0;
|
||||
|
||||
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
|
||||
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
|
||||
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop.
|
||||
// Unknown visibility should not be presented as private while thread detail is still resolving.
|
||||
const currentVisibility = currentThreadState.visibility ?? thread?.visibility;
|
||||
|
||||
const handleVisibilityChange = useCallback(
|
||||
async (newVisibility: ChatVisibility) => {
|
||||
|
|
@ -90,30 +88,23 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
return;
|
||||
}
|
||||
|
||||
// Update Jotai atom immediately for instant UI feedback
|
||||
setThreadVisibility(newVisibility);
|
||||
|
||||
try {
|
||||
await updateThreadVisibility(thread.id, newVisibility);
|
||||
|
||||
// Refetch threads list to update sidebar
|
||||
await queryClient.refetchQueries({
|
||||
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads",
|
||||
const updatedThread = await updateVisibility({
|
||||
thread,
|
||||
visibility: newVisibility,
|
||||
});
|
||||
|
||||
onVisibilityChange?.(newVisibility);
|
||||
onVisibilityChange?.(updatedThread.visibility);
|
||||
toast.success(
|
||||
newVisibility === "SEARCH_SPACE" ? "Chat shared with search space" : "Chat is now private"
|
||||
);
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to update visibility:", error);
|
||||
// Revert Jotai state on error
|
||||
setThreadVisibility(thread.visibility ?? "PRIVATE");
|
||||
toast.error("Failed to update sharing settings");
|
||||
}
|
||||
},
|
||||
[thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility]
|
||||
[thread, currentVisibility, onVisibilityChange, updateVisibility]
|
||||
);
|
||||
|
||||
const handleCreatePublicLink = useCallback(async () => {
|
||||
|
|
@ -130,7 +121,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
}, [thread, createSnapshot, queryClient]);
|
||||
|
||||
// Don't show if no thread (new chat that hasn't been created yet)
|
||||
if (!thread) {
|
||||
if (!thread || currentVisibility === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ export function AgentModelManager({ searchSpaceId }: AgentModelManagerProps) {
|
|||
<h3 className="text-sm md:text-base font-semibold mb-2">No Models Yet</h3>
|
||||
<p className="text-[11px] md:text-xs text-muted-foreground max-w-sm mb-4">
|
||||
{canCreate
|
||||
? "Add your first model to power document summarization, chat, and other agent capabilities"
|
||||
? "Add your first model to power chat, reports, and other agent capabilities"
|
||||
: "No models have been added to this space yet. Contact a space owner to add one"}
|
||||
</p>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -55,15 +55,6 @@ const ROLE_DESCRIPTIONS = {
|
|||
prefKey: "agent_llm_id" as const,
|
||||
configType: "llm" as const,
|
||||
},
|
||||
document_summary: {
|
||||
icon: FileText,
|
||||
title: "Document Summary LLM",
|
||||
description: "Handles document summarization and research synthesis",
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted",
|
||||
prefKey: "document_summary_llm_id" as const,
|
||||
configType: "llm" as const,
|
||||
},
|
||||
image_generation: {
|
||||
icon: ImageIcon,
|
||||
title: "Image Generation Model",
|
||||
|
|
@ -137,7 +128,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
|
||||
const [assignments, setAssignments] = useState<Record<string, number | null>>(() => ({
|
||||
agent_llm_id: preferences.agent_llm_id ?? null,
|
||||
document_summary_llm_id: preferences.document_summary_llm_id ?? null,
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? null,
|
||||
vision_llm_config_id: preferences.vision_llm_config_id ?? null,
|
||||
}));
|
||||
|
|
@ -148,13 +138,11 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
useEffect(() => {
|
||||
setAssignments({
|
||||
agent_llm_id: preferences.agent_llm_id ?? null,
|
||||
document_summary_llm_id: preferences.document_summary_llm_id ?? null,
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? null,
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? null,
|
||||
vision_llm_config_id: preferences.vision_llm_config_id ?? null,
|
||||
});
|
||||
}, [
|
||||
preferences.agent_llm_id,
|
||||
preferences.document_summary_llm_id,
|
||||
preferences.image_generation_config_id,
|
||||
preferences.vision_llm_config_id,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -139,7 +139,6 @@ export function DocumentUploadTab({
|
|||
const [files, setFiles] = useState<FileWithId[]>([]);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [accordionValue, setAccordionValue] = useState<string>("");
|
||||
const [shouldSummarize, setShouldSummarize] = useState(false);
|
||||
const [useVisionLlm, setUseVisionLlm] = useState(false);
|
||||
const [processingMode, setProcessingMode] = useState<ProcessingMode>("basic");
|
||||
const [uploadDocumentMutation] = useAtom(uploadDocumentMutationAtom);
|
||||
|
|
@ -366,7 +365,6 @@ export function DocumentUploadTab({
|
|||
search_space_id: Number(searchSpaceId),
|
||||
relative_paths: batch.map((e) => e.relativePath),
|
||||
root_folder_id: rootFolderId,
|
||||
enable_summary: shouldSummarize,
|
||||
use_vision_llm: useVisionLlm,
|
||||
processing_mode: processingMode,
|
||||
}
|
||||
|
|
@ -414,7 +412,6 @@ export function DocumentUploadTab({
|
|||
{
|
||||
files: rawFiles,
|
||||
search_space_id: Number(searchSpaceId),
|
||||
should_summarize: shouldSummarize,
|
||||
use_vision_llm: useVisionLlm,
|
||||
processing_mode: processingMode,
|
||||
},
|
||||
|
|
@ -696,15 +693,6 @@ export function DocumentUploadTab({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className={toggleRowClass}>
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium text-sm">Enable AI Summary</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Improves search quality but adds latency
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={shouldSummarize} onCheckedChange={setShouldSummarize} />
|
||||
</div>
|
||||
|
||||
<div className={toggleRowClass}>
|
||||
<div className="space-y-0.5">
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { type FolderSyncProgress, uploadFolderScan } from "@/lib/folder-sync-upload";
|
||||
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
|
||||
|
||||
|
|
@ -46,7 +45,6 @@ export function FolderWatchDialog({
|
|||
initialFolder,
|
||||
}: FolderWatchDialogProps) {
|
||||
const [selectedFolder, setSelectedFolder] = useState<SelectedFolder | null>(null);
|
||||
const [shouldSummarize, setShouldSummarize] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [progress, setProgress] = useState<FolderSyncProgress | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
|
@ -91,7 +89,6 @@ export function FolderWatchDialog({
|
|||
searchSpaceId,
|
||||
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
|
||||
fileExtensions: supportedExtensions,
|
||||
enableSummary: shouldSummarize,
|
||||
onProgress: setProgress,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
|
@ -108,7 +105,6 @@ export function FolderWatchDialog({
|
|||
|
||||
toast.success(`Watching folder: ${selectedFolder.name}`);
|
||||
setSelectedFolder(null);
|
||||
setShouldSummarize(false);
|
||||
setProgress(null);
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
|
|
@ -126,7 +122,6 @@ export function FolderWatchDialog({
|
|||
}, [
|
||||
selectedFolder,
|
||||
searchSpaceId,
|
||||
shouldSummarize,
|
||||
supportedExtensions,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
|
|
@ -136,8 +131,7 @@ export function FolderWatchDialog({
|
|||
(nextOpen: boolean) => {
|
||||
if (!nextOpen && !submitting) {
|
||||
setSelectedFolder(null);
|
||||
setShouldSummarize(false);
|
||||
setProgress(null);
|
||||
setProgress(null);
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
},
|
||||
|
|
@ -206,15 +200,6 @@ export function FolderWatchDialog({
|
|||
|
||||
{selectedFolder && (
|
||||
<>
|
||||
<div className="flex items-center justify-between rounded-lg bg-slate-400/5 dark:bg-white/5 p-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium text-sm">Enable AI Summary</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Improves search quality but adds latency
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={shouldSummarize} onCheckedChange={setShouldSummarize} />
|
||||
</div>
|
||||
|
||||
{progressLabel && (
|
||||
<div className="rounded-lg bg-slate-400/5 dark:bg-white/5 px-3 py-2">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { JsonView } from "@/components/json-view";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { automationCreateRequest } from "@/contracts/types/automation.types";
|
||||
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
|
||||
|
|
@ -351,19 +352,21 @@ function JsonEditor({ initialValue, onSave, onCancel }: JsonEditorProps) {
|
|||
/>
|
||||
</div>
|
||||
{issues.length > 0 && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-destructive">
|
||||
<AlertCircle className="h-3.5 w-3.5" aria-hidden />
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle aria-hidden />
|
||||
<AlertTitle>
|
||||
{issues.length} issue{issues.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
<ul className="mt-1.5 space-y-0.5 text-xs text-destructive/90 list-disc list-inside">
|
||||
{issues.map((issue) => (
|
||||
<li key={issue} className="font-mono">
|
||||
{issue}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-inside list-disc">
|
||||
{issues.map((issue) => (
|
||||
<li key={issue} className="font-mono">
|
||||
{issue}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
||||
|
|
|
|||
|
|
@ -43,9 +43,12 @@ function SelectTrigger({
|
|||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
matchTriggerWidth = true,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
|
||||
matchTriggerWidth?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
|
|
@ -64,6 +67,7 @@ function SelectContent({
|
|||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
matchTriggerWidth &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ export const searchSourceConnector = z.object({
|
|||
is_active: z.boolean().default(true),
|
||||
last_indexed_at: z.string().nullable(),
|
||||
config: z.record(z.string(), z.any()),
|
||||
enable_summary: z.boolean().default(false),
|
||||
enable_vision_llm: z.boolean().default(false),
|
||||
periodic_indexing_enabled: z.boolean(),
|
||||
indexing_frequency_minutes: z.number().nullable(),
|
||||
|
|
@ -98,7 +97,6 @@ export const createConnectorRequest = z.object({
|
|||
is_active: true,
|
||||
last_indexed_at: true,
|
||||
config: true,
|
||||
enable_summary: true,
|
||||
enable_vision_llm: true,
|
||||
periodic_indexing_enabled: true,
|
||||
indexing_frequency_minutes: true,
|
||||
|
|
@ -124,7 +122,6 @@ export const updateConnectorRequest = z.object({
|
|||
is_active: true,
|
||||
last_indexed_at: true,
|
||||
config: true,
|
||||
enable_summary: true,
|
||||
enable_vision_llm: true,
|
||||
periodic_indexing_enabled: true,
|
||||
indexing_frequency_minutes: true,
|
||||
|
|
|
|||
|
|
@ -130,7 +130,6 @@ export const processingModeEnum = z.enum(["basic", "premium"]);
|
|||
export const uploadDocumentRequest = z.object({
|
||||
files: z.array(z.instanceof(File)),
|
||||
search_space_id: z.number(),
|
||||
should_summarize: z.boolean().default(false),
|
||||
use_vision_llm: z.boolean().default(false),
|
||||
processing_mode: processingModeEnum.default("basic"),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -384,11 +384,9 @@ export const getGlobalVisionLLMConfigsResponse = z.array(globalVisionLLMConfig);
|
|||
|
||||
export const llmPreferences = z.object({
|
||||
agent_llm_id: z.union([z.number(), z.null()]).optional(),
|
||||
document_summary_llm_id: z.union([z.number(), z.null()]).optional(),
|
||||
image_generation_config_id: z.union([z.number(), z.null()]).optional(),
|
||||
vision_llm_config_id: z.union([z.number(), z.null()]).optional(),
|
||||
agent_llm: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||
document_summary_llm: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||
image_generation_config: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||
vision_llm_config: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||
});
|
||||
|
|
@ -409,7 +407,6 @@ export const updateLLMPreferencesRequest = z.object({
|
|||
search_space_id: z.number(),
|
||||
data: llmPreferences.pick({
|
||||
agent_llm_id: true,
|
||||
document_summary_llm_id: true,
|
||||
image_generation_config_id: true,
|
||||
vision_llm_config_id: true,
|
||||
}),
|
||||
|
|
|
|||
79
surfsense_web/hooks/use-activate-chat-thread.ts
Normal file
79
surfsense_web/hooks/use-activate-chat-thread.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"use client";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
import { setCurrentThreadMetadataAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { syncChatTabAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
|
||||
import { prefetchThreadData } from "./use-thread-queries";
|
||||
|
||||
interface ActivateChatThreadInput {
|
||||
id: number | null;
|
||||
title?: string;
|
||||
url?: string;
|
||||
searchSpaceId: number | string;
|
||||
visibility?: ChatVisibility;
|
||||
hasComments?: boolean;
|
||||
}
|
||||
|
||||
function getSearchSpaceId(searchSpaceId: number | string): number {
|
||||
const parsed =
|
||||
typeof searchSpaceId === "number" ? searchSpaceId : Number.parseInt(searchSpaceId, 10);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function getChatUrl(searchSpaceId: number | string, threadId: number | null): string {
|
||||
return threadId
|
||||
? `/dashboard/${searchSpaceId}/new-chat/${threadId}`
|
||||
: `/dashboard/${searchSpaceId}/new-chat`;
|
||||
}
|
||||
|
||||
export function useActivateChatThread() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const syncChatTab = useSetAtom(syncChatTabAtom);
|
||||
const setCurrentThreadMetadata = useSetAtom(setCurrentThreadMetadataAtom);
|
||||
|
||||
const prefetchChatThread = useCallback(
|
||||
(threadId: number | null | undefined) => {
|
||||
if (typeof threadId === "number" && threadId > 0) {
|
||||
prefetchThreadData(queryClient, threadId);
|
||||
}
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
const activateChatThread = useCallback(
|
||||
({ id, title, url, searchSpaceId, visibility, hasComments }: ActivateChatThreadInput) => {
|
||||
const numericSearchSpaceId = getSearchSpaceId(searchSpaceId);
|
||||
const chatUrl = url ?? getChatUrl(searchSpaceId, id);
|
||||
|
||||
syncChatTab({
|
||||
chatId: id,
|
||||
title: id ? title : (title ?? "New Chat"),
|
||||
chatUrl,
|
||||
searchSpaceId: numericSearchSpaceId,
|
||||
...(visibility !== undefined ? { visibility } : {}),
|
||||
...(hasComments !== undefined ? { hasComments } : {}),
|
||||
});
|
||||
|
||||
setCurrentThreadMetadata({
|
||||
id,
|
||||
searchSpaceId: numericSearchSpaceId,
|
||||
...(visibility !== undefined ? { visibility } : {}),
|
||||
...(hasComments !== undefined ? { hasComments } : {}),
|
||||
});
|
||||
|
||||
if (id) {
|
||||
prefetchThreadData(queryClient, id);
|
||||
}
|
||||
|
||||
router.push(chatUrl);
|
||||
},
|
||||
[queryClient, router, setCurrentThreadMetadata, syncChatTab]
|
||||
);
|
||||
|
||||
return { activateChatThread, prefetchChatThread };
|
||||
}
|
||||
|
|
@ -24,7 +24,6 @@ export function useConnectorsSync(searchSpaceId: number | string | null) {
|
|||
is_active: true,
|
||||
last_indexed_at: c.lastIndexedAt ? new Date(c.lastIndexedAt).toISOString() : null,
|
||||
config: (c.config as Record<string, unknown>) ?? {},
|
||||
enable_summary: c.enableSummary,
|
||||
periodic_indexing_enabled: c.periodicIndexingEnabled,
|
||||
indexing_frequency_minutes: c.indexingFrequencyMinutes ?? null,
|
||||
next_scheduled_at: c.nextScheduledAt ? new Date(c.nextScheduledAt).toISOString() : null,
|
||||
|
|
|
|||
158
surfsense_web/hooks/use-thread-mutations.ts
Normal file
158
surfsense_web/hooks/use-thread-mutations.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
currentThreadAtom,
|
||||
patchCurrentThreadMetadataAtom,
|
||||
resetCurrentThreadAtom,
|
||||
} from "@/atoms/chat/current-thread.atom";
|
||||
import {
|
||||
moveThreadArchiveState,
|
||||
patchThreadEverywhere,
|
||||
removeThreadEverywhere,
|
||||
replaceThreadEverywhere,
|
||||
} from "@/lib/chat/thread-cache";
|
||||
import {
|
||||
type ChatVisibility,
|
||||
deleteThread,
|
||||
type ThreadRecord,
|
||||
updateThread,
|
||||
updateThreadVisibility,
|
||||
} from "@/lib/chat/thread-persistence";
|
||||
|
||||
type SearchSpaceKey = number | string;
|
||||
|
||||
interface VisibilityVariables {
|
||||
thread: ThreadRecord;
|
||||
visibility: ChatVisibility;
|
||||
}
|
||||
|
||||
interface RenameVariables {
|
||||
threadId: number;
|
||||
title: string;
|
||||
previousTitle?: string;
|
||||
}
|
||||
|
||||
interface ArchiveVariables {
|
||||
threadId: number;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
interface DeleteVariables {
|
||||
threadId: number;
|
||||
}
|
||||
|
||||
interface VisibilityRollback {
|
||||
threadId: number;
|
||||
visibility: ChatVisibility;
|
||||
}
|
||||
|
||||
interface RenameRollback {
|
||||
threadId: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface ArchiveRollback {
|
||||
threadId: number;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
export function useUpdateThreadVisibility(searchSpaceId: SearchSpaceKey) {
|
||||
const queryClient = useQueryClient();
|
||||
const currentThread = useAtomValue(currentThreadAtom);
|
||||
const patchCurrentThreadMetadata = useSetAtom(patchCurrentThreadMetadataAtom);
|
||||
|
||||
return useMutation<ThreadRecord, Error, VisibilityVariables, VisibilityRollback>({
|
||||
mutationFn: ({ thread, visibility }) => updateThreadVisibility(thread.id, visibility),
|
||||
onMutate: ({ thread, visibility }) => {
|
||||
const previousVisibility = thread.visibility ?? "PRIVATE";
|
||||
|
||||
patchThreadEverywhere(queryClient, searchSpaceId, thread.id, { visibility });
|
||||
if (currentThread.id === thread.id) {
|
||||
patchCurrentThreadMetadata({ id: thread.id, visibility });
|
||||
}
|
||||
|
||||
return { threadId: thread.id, visibility: previousVisibility };
|
||||
},
|
||||
onError: (_error, _variables, rollback) => {
|
||||
if (!rollback) return;
|
||||
patchThreadEverywhere(queryClient, searchSpaceId, rollback.threadId, {
|
||||
visibility: rollback.visibility,
|
||||
});
|
||||
if (currentThread.id === rollback.threadId) {
|
||||
patchCurrentThreadMetadata({
|
||||
id: rollback.threadId,
|
||||
visibility: rollback.visibility,
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: (thread) => {
|
||||
replaceThreadEverywhere(queryClient, searchSpaceId, thread);
|
||||
if (currentThread.id === thread.id) {
|
||||
patchCurrentThreadMetadata({
|
||||
id: thread.id,
|
||||
visibility: thread.visibility,
|
||||
...(thread.has_comments !== undefined ? { hasComments: thread.has_comments } : {}),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRenameThread(searchSpaceId: SearchSpaceKey) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<ThreadRecord, Error, RenameVariables, RenameRollback>({
|
||||
mutationFn: ({ threadId, title }) => updateThread(threadId, { title }),
|
||||
onMutate: ({ threadId, title, previousTitle }) => {
|
||||
patchThreadEverywhere(queryClient, searchSpaceId, threadId, { title });
|
||||
return { threadId, title: previousTitle };
|
||||
},
|
||||
onError: (_error, _variables, rollback) => {
|
||||
if (!rollback || rollback.title === undefined) return;
|
||||
patchThreadEverywhere(queryClient, searchSpaceId, rollback.threadId, {
|
||||
title: rollback.title,
|
||||
});
|
||||
},
|
||||
onSuccess: (thread) => {
|
||||
replaceThreadEverywhere(queryClient, searchSpaceId, thread);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useArchiveThread(searchSpaceId: SearchSpaceKey) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<ThreadRecord, Error, ArchiveVariables, ArchiveRollback>({
|
||||
mutationFn: ({ threadId, archived }) => updateThread(threadId, { archived }),
|
||||
onMutate: ({ threadId, archived }) => {
|
||||
moveThreadArchiveState(queryClient, searchSpaceId, threadId, archived);
|
||||
return { threadId, archived: !archived };
|
||||
},
|
||||
onError: (_error, _variables, rollback) => {
|
||||
if (!rollback) return;
|
||||
moveThreadArchiveState(queryClient, searchSpaceId, rollback.threadId, rollback.archived);
|
||||
},
|
||||
onSuccess: (thread) => {
|
||||
replaceThreadEverywhere(queryClient, searchSpaceId, thread);
|
||||
moveThreadArchiveState(queryClient, searchSpaceId, thread.id, thread.archived);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteThread(searchSpaceId: SearchSpaceKey) {
|
||||
const queryClient = useQueryClient();
|
||||
const currentThread = useAtomValue(currentThreadAtom);
|
||||
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
|
||||
|
||||
return useMutation<void, Error, DeleteVariables>({
|
||||
mutationFn: ({ threadId }) => deleteThread(threadId),
|
||||
onSuccess: (_data, { threadId }) => {
|
||||
removeThreadEverywhere(queryClient, searchSpaceId, threadId);
|
||||
if (currentThread.id === threadId) {
|
||||
resetCurrentThread();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
52
surfsense_web/hooks/use-thread-queries.ts
Normal file
52
surfsense_web/hooks/use-thread-queries.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { type QueryClient, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getThreadFull,
|
||||
getThreadMessages,
|
||||
type ThreadHistoryLoadResponse,
|
||||
type ThreadRecord,
|
||||
} from "@/lib/chat/thread-persistence";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
const THREAD_DETAIL_STALE_TIME_MS = 60 * 1000;
|
||||
const THREAD_MESSAGES_STALE_TIME_MS = 30 * 1000;
|
||||
|
||||
function isValidThreadId(threadId: number | null | undefined): threadId is number {
|
||||
return typeof threadId === "number" && threadId > 0;
|
||||
}
|
||||
|
||||
export function useThreadDetail(threadId: number | null | undefined) {
|
||||
return useQuery<ThreadRecord>({
|
||||
queryKey: cacheKeys.threads.detail(threadId ?? 0),
|
||||
queryFn: () => getThreadFull(threadId as number),
|
||||
enabled: isValidThreadId(threadId),
|
||||
staleTime: THREAD_DETAIL_STALE_TIME_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function useThreadMessages(threadId: number | null | undefined) {
|
||||
return useQuery<ThreadHistoryLoadResponse>({
|
||||
queryKey: cacheKeys.threads.messages(threadId ?? 0),
|
||||
queryFn: () => getThreadMessages(threadId as number),
|
||||
enabled: isValidThreadId(threadId),
|
||||
staleTime: THREAD_MESSAGES_STALE_TIME_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function prefetchThreadData(queryClient: QueryClient, threadId: number): void {
|
||||
if (!isValidThreadId(threadId)) return;
|
||||
|
||||
void Promise.all([
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: cacheKeys.threads.detail(threadId),
|
||||
queryFn: () => getThreadFull(threadId),
|
||||
staleTime: THREAD_DETAIL_STALE_TIME_MS,
|
||||
}),
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: cacheKeys.threads.messages(threadId),
|
||||
queryFn: () => getThreadMessages(threadId),
|
||||
staleTime: THREAD_MESSAGES_STALE_TIME_MS,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
|
@ -126,8 +126,7 @@ class DocumentsApiService {
|
|||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const { files, search_space_id, should_summarize, use_vision_llm, processing_mode } =
|
||||
parsedRequest.data;
|
||||
const { files, search_space_id, use_vision_llm, processing_mode } = parsedRequest.data;
|
||||
const UPLOAD_BATCH_SIZE = 5;
|
||||
|
||||
const batches: File[][] = [];
|
||||
|
|
@ -145,7 +144,6 @@ class DocumentsApiService {
|
|||
const formData = new FormData();
|
||||
for (const file of batch) formData.append("files", file);
|
||||
formData.append("search_space_id", String(search_space_id));
|
||||
formData.append("should_summarize", String(should_summarize));
|
||||
formData.append("use_vision_llm", String(use_vision_llm));
|
||||
formData.append("processing_mode", processing_mode);
|
||||
|
||||
|
|
@ -420,7 +418,6 @@ class DocumentsApiService {
|
|||
search_space_id: number;
|
||||
relative_paths: string[];
|
||||
root_folder_id?: number | null;
|
||||
enable_summary?: boolean;
|
||||
use_vision_llm?: boolean;
|
||||
processing_mode?: "basic" | "premium";
|
||||
},
|
||||
|
|
@ -436,7 +433,6 @@ class DocumentsApiService {
|
|||
if (metadata.root_folder_id != null) {
|
||||
formData.append("root_folder_id", String(metadata.root_folder_id));
|
||||
}
|
||||
formData.append("enable_summary", String(metadata.enable_summary ?? false));
|
||||
formData.append("use_vision_llm", String(metadata.use_vision_llm ?? false));
|
||||
formData.append("processing_mode", metadata.processing_mode ?? "basic");
|
||||
|
||||
|
|
|
|||
|
|
@ -23,44 +23,40 @@ export const CHAT_EXAMPLE_CATEGORIES: ChatExampleCategory[] = [
|
|||
id: "search",
|
||||
label: "Search & Summarize",
|
||||
prompts: [
|
||||
"Summarize the key points across all the documents in this space.",
|
||||
"What do my files say about [topic]? Answer with citations.",
|
||||
"Find every mention of [keyword] and list the sources.",
|
||||
"Give me a cited briefing on the documents I added this week.",
|
||||
"Compare these two documents and highlight the differences.",
|
||||
"Summarize the key points across all the documents in this space",
|
||||
"What do my files say about [topic]? Answer with citations",
|
||||
"Find every mention of [keyword] and list the sources",
|
||||
"Compare these two documents and highlight the differences",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "create",
|
||||
label: "Create",
|
||||
prompts: [
|
||||
"Write a cited research report on [topic] from my documents.",
|
||||
"Turn this folder into a two-host podcast I can listen to.",
|
||||
"Create a slide deck and a narrated video overview from these sources.",
|
||||
"Generate an image to illustrate [concept] for my report.",
|
||||
"Tailor my resume to this job description so it gets past ATS and lands an interview.",
|
||||
"Write a cited research report on [topic] from my documents",
|
||||
"Turn this folder into a two-host podcast I can listen to",
|
||||
"Create a slide deck and a narrated video overview from these sources",
|
||||
"Tailor my resume to this job description so it gets past ATS and lands an interview",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "automate",
|
||||
label: "Automate",
|
||||
prompts: [
|
||||
"Email me a daily brief of new documents in my knowledge base every morning.",
|
||||
"When a PDF lands in my Research folder, generate a cited AI summary.",
|
||||
"Generate a weekly status report from my Slack and Gmail every Friday.",
|
||||
"Build an automation that turns new meeting notes into minutes with action items.",
|
||||
"Run a monthly competitor analysis report and save it to my workspace.",
|
||||
"Email me a daily brief of new documents in my knowledge base every morning",
|
||||
"When a PDF lands in my Research folder, generate a cited AI summary",
|
||||
"Generate a weekly status report from my Slack and Gmail every Friday",
|
||||
"Build an automation that turns new meeting notes into minutes with action items",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
label: "Across your tools",
|
||||
prompts: [
|
||||
"Search across my Notion, Slack, Google Drive and Gmail for [topic].",
|
||||
"Post this research summary to my Notion workspace.",
|
||||
"Send these meeting action items to our team Slack channel.",
|
||||
"Create a Jira ticket from this bug report.",
|
||||
"Open a Linear issue from this feature request.",
|
||||
"Search across my Notion, Slack, Google Drive and Gmail for [topic]",
|
||||
"Post this research summary to my Notion workspace",
|
||||
"Send these meeting action items to our team Slack channel",
|
||||
"Create a Jira ticket from this bug report",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
250
surfsense_web/lib/chat/thread-cache.ts
Normal file
250
surfsense_web/lib/chat/thread-cache.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import type {
|
||||
ThreadListItem,
|
||||
ThreadListResponse,
|
||||
ThreadRecord,
|
||||
} from "@/lib/chat/thread-persistence";
|
||||
|
||||
type SearchSpaceKey = number | string;
|
||||
|
||||
type ThreadMetadataPatch = Partial<ThreadRecord> &
|
||||
Partial<ThreadListItem> & {
|
||||
has_comments?: boolean;
|
||||
};
|
||||
|
||||
function isSameSearchSpace(keyValue: unknown, searchSpaceId: SearchSpaceKey): boolean {
|
||||
return String(keyValue) === String(searchSpaceId);
|
||||
}
|
||||
|
||||
function isThreadListResponse(value: unknown): value is ThreadListResponse {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
Array.isArray((value as ThreadListResponse).threads) &&
|
||||
Array.isArray((value as ThreadListResponse).archived_threads)
|
||||
);
|
||||
}
|
||||
|
||||
function isThreadListItemArray(value: unknown): value is ThreadListItem[] {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function listItemPatchFromMetadata(patch: ThreadMetadataPatch): Partial<ThreadListItem> {
|
||||
const listPatch: Partial<ThreadListItem> = {};
|
||||
|
||||
if (patch.title !== undefined) listPatch.title = patch.title;
|
||||
if (patch.archived !== undefined) listPatch.archived = patch.archived;
|
||||
if (patch.visibility !== undefined) listPatch.visibility = patch.visibility;
|
||||
if (patch.created_by_id !== undefined) listPatch.created_by_id = patch.created_by_id;
|
||||
if (patch.created_at !== undefined) listPatch.createdAt = patch.created_at;
|
||||
if (patch.updated_at !== undefined) listPatch.updatedAt = patch.updated_at;
|
||||
if (patch.createdAt !== undefined) listPatch.createdAt = patch.createdAt;
|
||||
if (patch.updatedAt !== undefined) listPatch.updatedAt = patch.updatedAt;
|
||||
|
||||
return listPatch;
|
||||
}
|
||||
|
||||
function patchListItem(
|
||||
item: ThreadListItem,
|
||||
threadId: number,
|
||||
patch: ThreadMetadataPatch
|
||||
): ThreadListItem {
|
||||
if (item.id !== threadId) return item;
|
||||
return {
|
||||
...item,
|
||||
...listItemPatchFromMetadata(patch),
|
||||
};
|
||||
}
|
||||
|
||||
function patchThreadListResponse(
|
||||
response: ThreadListResponse,
|
||||
threadId: number,
|
||||
patch: ThreadMetadataPatch
|
||||
): ThreadListResponse {
|
||||
return {
|
||||
...response,
|
||||
threads: response.threads.map((item) => patchListItem(item, threadId, patch)),
|
||||
archived_threads: response.archived_threads.map((item) => patchListItem(item, threadId, patch)),
|
||||
};
|
||||
}
|
||||
|
||||
function patchThreadListItems(
|
||||
items: ThreadListItem[],
|
||||
threadId: number,
|
||||
patch: ThreadMetadataPatch
|
||||
): ThreadListItem[] {
|
||||
return items.map((item) => patchListItem(item, threadId, patch));
|
||||
}
|
||||
|
||||
function patchThreadRecord(
|
||||
record: ThreadRecord,
|
||||
threadId: number,
|
||||
patch: ThreadMetadataPatch
|
||||
): ThreadRecord {
|
||||
if (record.id !== threadId) return record;
|
||||
return {
|
||||
...record,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function threadListQueryFilter(searchSpaceId: SearchSpaceKey) {
|
||||
return {
|
||||
predicate: ({ queryKey }: { queryKey: QueryKey }) =>
|
||||
Array.isArray(queryKey) &&
|
||||
queryKey[0] === "threads" &&
|
||||
isSameSearchSpace(queryKey[1], searchSpaceId),
|
||||
};
|
||||
}
|
||||
|
||||
function allThreadsQueryFilter(searchSpaceId: SearchSpaceKey) {
|
||||
return {
|
||||
predicate: ({ queryKey }: { queryKey: QueryKey }) =>
|
||||
Array.isArray(queryKey) &&
|
||||
queryKey[0] === "all-threads" &&
|
||||
isSameSearchSpace(queryKey[1], searchSpaceId),
|
||||
};
|
||||
}
|
||||
|
||||
function searchThreadsQueryFilter(searchSpaceId: SearchSpaceKey) {
|
||||
return {
|
||||
predicate: ({ queryKey }: { queryKey: QueryKey }) =>
|
||||
Array.isArray(queryKey) &&
|
||||
queryKey[0] === "search-threads" &&
|
||||
isSameSearchSpace(queryKey[1], searchSpaceId),
|
||||
};
|
||||
}
|
||||
|
||||
function threadDetailQueryFilter(threadId: number) {
|
||||
return {
|
||||
predicate: ({ queryKey }: { queryKey: QueryKey }) =>
|
||||
Array.isArray(queryKey) &&
|
||||
queryKey[0] === "threads" &&
|
||||
queryKey[1] === "detail" &&
|
||||
Number(queryKey[2]) === threadId,
|
||||
};
|
||||
}
|
||||
|
||||
function threadMessagesQueryFilter(threadId: number) {
|
||||
return {
|
||||
predicate: ({ queryKey }: { queryKey: QueryKey }) =>
|
||||
Array.isArray(queryKey) &&
|
||||
queryKey[0] === "threads" &&
|
||||
queryKey[1] === "messages" &&
|
||||
Number(queryKey[2]) === threadId,
|
||||
};
|
||||
}
|
||||
|
||||
function updateThreadListResponse(
|
||||
queryClient: QueryClient,
|
||||
filter: ReturnType<typeof threadListQueryFilter>,
|
||||
threadId: number,
|
||||
patch: ThreadMetadataPatch
|
||||
): void {
|
||||
queryClient.setQueriesData<ThreadListResponse>(filter, (old) => {
|
||||
if (!isThreadListResponse(old)) return old;
|
||||
return patchThreadListResponse(old, threadId, patch);
|
||||
});
|
||||
}
|
||||
|
||||
export function patchThreadEverywhere(
|
||||
queryClient: QueryClient,
|
||||
searchSpaceId: SearchSpaceKey,
|
||||
threadId: number,
|
||||
patch: ThreadMetadataPatch
|
||||
): void {
|
||||
updateThreadListResponse(queryClient, threadListQueryFilter(searchSpaceId), threadId, patch);
|
||||
updateThreadListResponse(queryClient, allThreadsQueryFilter(searchSpaceId), threadId, patch);
|
||||
|
||||
queryClient.setQueriesData<ThreadListItem[]>(searchThreadsQueryFilter(searchSpaceId), (old) => {
|
||||
if (!isThreadListItemArray(old)) return old;
|
||||
return patchThreadListItems(old, threadId, patch);
|
||||
});
|
||||
|
||||
queryClient.setQueriesData<ThreadRecord>(threadDetailQueryFilter(threadId), (old) => {
|
||||
if (!old) return old;
|
||||
return patchThreadRecord(old, threadId, patch);
|
||||
});
|
||||
}
|
||||
|
||||
export function replaceThreadEverywhere(
|
||||
queryClient: QueryClient,
|
||||
searchSpaceId: SearchSpaceKey,
|
||||
thread: ThreadRecord
|
||||
): void {
|
||||
patchThreadEverywhere(queryClient, searchSpaceId, thread.id, thread);
|
||||
}
|
||||
|
||||
export function removeThreadEverywhere(
|
||||
queryClient: QueryClient,
|
||||
searchSpaceId: SearchSpaceKey,
|
||||
threadId: number
|
||||
): void {
|
||||
const removeFromListResponse = (old: ThreadListResponse | undefined) => {
|
||||
if (!isThreadListResponse(old)) return old;
|
||||
return {
|
||||
...old,
|
||||
threads: old.threads.filter((thread) => thread.id !== threadId),
|
||||
archived_threads: old.archived_threads.filter((thread) => thread.id !== threadId),
|
||||
};
|
||||
};
|
||||
|
||||
queryClient.setQueriesData<ThreadListResponse>(
|
||||
threadListQueryFilter(searchSpaceId),
|
||||
removeFromListResponse
|
||||
);
|
||||
queryClient.setQueriesData<ThreadListResponse>(
|
||||
allThreadsQueryFilter(searchSpaceId),
|
||||
removeFromListResponse
|
||||
);
|
||||
queryClient.setQueriesData<ThreadListItem[]>(searchThreadsQueryFilter(searchSpaceId), (old) => {
|
||||
if (!isThreadListItemArray(old)) return old;
|
||||
return old.filter((thread) => thread.id !== threadId);
|
||||
});
|
||||
queryClient.removeQueries(threadDetailQueryFilter(threadId));
|
||||
queryClient.removeQueries(threadMessagesQueryFilter(threadId));
|
||||
}
|
||||
|
||||
export function moveThreadArchiveState(
|
||||
queryClient: QueryClient,
|
||||
searchSpaceId: SearchSpaceKey,
|
||||
threadId: number,
|
||||
archived: boolean
|
||||
): void {
|
||||
const moveInListResponse = (old: ThreadListResponse | undefined) => {
|
||||
if (!isThreadListResponse(old)) return old;
|
||||
|
||||
const activeWithoutThread = old.threads.filter((thread) => thread.id !== threadId);
|
||||
const archivedWithoutThread = old.archived_threads.filter((thread) => thread.id !== threadId);
|
||||
const existing =
|
||||
old.threads.find((thread) => thread.id === threadId) ??
|
||||
old.archived_threads.find((thread) => thread.id === threadId);
|
||||
|
||||
if (!existing) return old;
|
||||
|
||||
const updated = { ...existing, archived };
|
||||
|
||||
return {
|
||||
...old,
|
||||
threads: archived ? activeWithoutThread : [updated, ...activeWithoutThread],
|
||||
archived_threads: archived ? [updated, ...archivedWithoutThread] : archivedWithoutThread,
|
||||
};
|
||||
};
|
||||
|
||||
queryClient.setQueriesData<ThreadListResponse>(
|
||||
threadListQueryFilter(searchSpaceId),
|
||||
moveInListResponse
|
||||
);
|
||||
queryClient.setQueriesData<ThreadListResponse>(
|
||||
allThreadsQueryFilter(searchSpaceId),
|
||||
moveInListResponse
|
||||
);
|
||||
queryClient.setQueriesData<ThreadListItem[]>(searchThreadsQueryFilter(searchSpaceId), (old) => {
|
||||
if (!isThreadListItemArray(old)) return old;
|
||||
return old.map((thread) => (thread.id === threadId ? { ...thread, archived } : thread));
|
||||
});
|
||||
queryClient.setQueriesData<ThreadRecord>(threadDetailQueryFilter(threadId), (old) => {
|
||||
if (!old || old.id !== threadId) return old;
|
||||
return { ...old, archived };
|
||||
});
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
export type OSInfo = {
|
||||
os: "macOS" | "Windows" | "Linux";
|
||||
os: "macOS" | "Windows" | "Linux" | "Android" | "iOS";
|
||||
arch: "arm64" | "x64";
|
||||
};
|
||||
|
||||
|
|
@ -12,7 +12,13 @@ export function useUserOS(): OSInfo {
|
|||
let os: OSInfo["os"] = "macOS";
|
||||
let arch: OSInfo["arch"] = "x64";
|
||||
|
||||
if (/Windows/i.test(ua)) {
|
||||
if (/Android/i.test(ua)) {
|
||||
os = "Android";
|
||||
arch = "arm64";
|
||||
} else if (/iPhone|iPad|iPod/i.test(ua)) {
|
||||
os = "iOS";
|
||||
arch = "arm64";
|
||||
} else if (/Windows/i.test(ua)) {
|
||||
os = "Windows";
|
||||
arch = "x64";
|
||||
} else if (/Linux/i.test(ua)) {
|
||||
|
|
@ -88,9 +94,11 @@ export const GITHUB_RELEASES_URL = "https://github.com/MODSetter/SurfSense/relea
|
|||
export function usePrimaryDownload() {
|
||||
const { os, arch } = useUserOS();
|
||||
const assets = useLatestRelease();
|
||||
const isMobileOS = os === "Android" || os === "iOS";
|
||||
|
||||
const { primary, alternatives } = useMemo(() => {
|
||||
if (assets.length === 0) return { primary: null, alternatives: [] };
|
||||
if (isMobileOS) return { primary: null, alternatives: assets };
|
||||
|
||||
const matchers: Record<string, (n: string) => boolean> = {
|
||||
Windows: (n) => n.endsWith(".exe"),
|
||||
|
|
@ -102,7 +110,7 @@ export function usePrimaryDownload() {
|
|||
const primary = assets.find((a) => match(a.name)) ?? null;
|
||||
const alternatives = assets.filter((a) => a !== primary);
|
||||
return { primary, alternatives };
|
||||
}, [assets, os, arch]);
|
||||
}, [assets, os, arch, isMobileOS]);
|
||||
|
||||
return { os, arch, assets, primary, alternatives };
|
||||
return { os, arch, assets, primary, alternatives, isMobileOS };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ export interface FolderSyncParams {
|
|||
searchSpaceId: number;
|
||||
excludePatterns: string[];
|
||||
fileExtensions: string[];
|
||||
enableSummary: boolean;
|
||||
processingMode?: "basic" | "premium";
|
||||
rootFolderId?: number | null;
|
||||
onProgress?: (progress: FolderSyncProgress) => void;
|
||||
|
|
@ -62,8 +61,7 @@ async function uploadBatchesWithConcurrency(
|
|||
folderName: string;
|
||||
searchSpaceId: number;
|
||||
rootFolderId: number | null;
|
||||
enableSummary: boolean;
|
||||
processingMode?: "basic" | "premium";
|
||||
processingMode?: "basic" | "premium";
|
||||
signal?: AbortSignal;
|
||||
onBatchComplete?: (filesInBatch: number) => void;
|
||||
}
|
||||
|
|
@ -100,7 +98,6 @@ async function uploadBatchesWithConcurrency(
|
|||
search_space_id: params.searchSpaceId,
|
||||
relative_paths: batch.map((e) => e.relativePath),
|
||||
root_folder_id: resolvedRootFolderId,
|
||||
enable_summary: params.enableSummary,
|
||||
processing_mode: params.processingMode,
|
||||
},
|
||||
params.signal
|
||||
|
|
@ -147,7 +144,6 @@ export async function uploadFolderScan(params: FolderSyncParams): Promise<number
|
|||
searchSpaceId,
|
||||
excludePatterns,
|
||||
fileExtensions,
|
||||
enableSummary,
|
||||
processingMode,
|
||||
signal,
|
||||
} = params;
|
||||
|
|
@ -193,8 +189,7 @@ export async function uploadFolderScan(params: FolderSyncParams): Promise<number
|
|||
folderName,
|
||||
searchSpaceId,
|
||||
rootFolderId: rootFolderId ?? null,
|
||||
enableSummary,
|
||||
processingMode,
|
||||
processingMode,
|
||||
signal,
|
||||
onBatchComplete: (count) => {
|
||||
uploaded += count;
|
||||
|
|
|
|||
|
|
@ -19,10 +19,8 @@ function stableEntries(obj: Record<string, unknown> | null | undefined): unknown
|
|||
export const cacheKeys = {
|
||||
// New chat threads (assistant-ui)
|
||||
threads: {
|
||||
list: (searchSpaceId: number) => ["threads", searchSpaceId] as const,
|
||||
detail: (threadId: number) => ["threads", "detail", threadId] as const,
|
||||
search: (searchSpaceId: number, query: string) =>
|
||||
["threads", "search", searchSpaceId, query] as const,
|
||||
messages: (threadId: number) => ["threads", "messages", threadId] as const,
|
||||
},
|
||||
documents: {
|
||||
globalQueryParams: (queries: GetDocumentsRequest["queryParams"]) =>
|
||||
|
|
|
|||
|
|
@ -650,13 +650,15 @@
|
|||
"created": "Created"
|
||||
},
|
||||
"sidebar": {
|
||||
"chats": "Private Chats",
|
||||
"recents": "Recents",
|
||||
"chats": "Chats",
|
||||
"shared_chats": "Shared Chats",
|
||||
"search_chats": "Search chats",
|
||||
"no_chats_found": "No chats found",
|
||||
"no_shared_chats": "No shared chats",
|
||||
"shared_chat": "Shared chat",
|
||||
"view_all_shared_chats": "View all shared chats",
|
||||
"view_all_private_chats": "View all private chats",
|
||||
"view_all_chats": "View all chats",
|
||||
"show_all": "Show all",
|
||||
"hide": "Hide",
|
||||
"no_chats": "No chats",
|
||||
|
|
|
|||
|
|
@ -650,13 +650,15 @@
|
|||
"created": "Creado"
|
||||
},
|
||||
"sidebar": {
|
||||
"chats": "Chats privados",
|
||||
"recents": "Recientes",
|
||||
"chats": "Chats",
|
||||
"shared_chats": "Chats compartidos",
|
||||
"search_chats": "Buscar chats",
|
||||
"no_chats_found": "No se encontraron chats",
|
||||
"no_shared_chats": "No hay chats compartidos",
|
||||
"shared_chat": "Chat compartido",
|
||||
"view_all_shared_chats": "Ver todos los chats compartidos",
|
||||
"view_all_private_chats": "Ver todos los chats privados",
|
||||
"view_all_chats": "Ver todos los chats",
|
||||
"show_all": "Ver todo",
|
||||
"hide": "Ocultar",
|
||||
"no_chats": "Sin chats",
|
||||
|
|
|
|||
|
|
@ -650,13 +650,15 @@
|
|||
"created": "बनाया गया"
|
||||
},
|
||||
"sidebar": {
|
||||
"chats": "निजी चैट",
|
||||
"recents": "हालिया",
|
||||
"chats": "चैट",
|
||||
"shared_chats": "साझा चैट",
|
||||
"search_chats": "चैट खोजें",
|
||||
"no_chats_found": "कोई चैट नहीं मिला",
|
||||
"no_shared_chats": "कोई साझा चैट नहीं",
|
||||
"shared_chat": "साझा चैट",
|
||||
"view_all_shared_chats": "सभी साझा चैट देखें",
|
||||
"view_all_private_chats": "सभी निजी चैट देखें",
|
||||
"view_all_chats": "सभी चैट देखें",
|
||||
"show_all": "सभी देखें",
|
||||
"hide": "छिपाएँ",
|
||||
"no_chats": "कोई चैट नहीं",
|
||||
|
|
|
|||
|
|
@ -650,13 +650,15 @@
|
|||
"created": "Criado"
|
||||
},
|
||||
"sidebar": {
|
||||
"chats": "Chats privados",
|
||||
"recents": "Recentes",
|
||||
"chats": "Chats",
|
||||
"shared_chats": "Chats compartilhados",
|
||||
"search_chats": "Pesquisar chats",
|
||||
"no_chats_found": "Nenhum chat encontrado",
|
||||
"no_shared_chats": "Nenhum chat compartilhado",
|
||||
"shared_chat": "Chat compartilhado",
|
||||
"view_all_shared_chats": "Ver todos os chats compartilhados",
|
||||
"view_all_private_chats": "Ver todos os chats privados",
|
||||
"view_all_chats": "Ver todos os chats",
|
||||
"show_all": "Ver tudo",
|
||||
"hide": "Ocultar",
|
||||
"no_chats": "Nenhum chat",
|
||||
|
|
|
|||
|
|
@ -634,13 +634,15 @@
|
|||
"created": "创建于"
|
||||
},
|
||||
"sidebar": {
|
||||
"chats": "私人对话",
|
||||
"recents": "最近",
|
||||
"chats": "对话",
|
||||
"shared_chats": "共享对话",
|
||||
"search_chats": "搜索对话...",
|
||||
"no_chats_found": "未找到对话",
|
||||
"no_shared_chats": "暂无共享对话",
|
||||
"shared_chat": "共享对话",
|
||||
"view_all_shared_chats": "查看所有共享对话",
|
||||
"view_all_private_chats": "查看所有私人对话",
|
||||
"view_all_chats": "查看所有对话",
|
||||
"show_all": "查看全部",
|
||||
"hide": "隐藏",
|
||||
"no_chats": "无对话",
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ export const searchSourceConnectorTable = table("search_source_connectors")
|
|||
isIndexable: boolean().from("is_indexable"),
|
||||
lastIndexedAt: number().optional().from("last_indexed_at"),
|
||||
config: json(),
|
||||
enableSummary: boolean().from("enable_summary"),
|
||||
periodicIndexingEnabled: boolean().from("periodic_indexing_enabled"),
|
||||
indexingFrequencyMinutes: number().optional().from("indexing_frequency_minutes"),
|
||||
nextScheduledAt: number().optional().from("next_scheduled_at"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue