Merge upstream/dev

This commit is contained in:
CREDO23 2026-06-05 19:18:12 +02:00
commit 8bdfd00a15
191 changed files with 3301 additions and 4079 deletions

View file

@ -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>
);

View file

@ -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>
)}

View file

@ -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">

View file

@ -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>

View file

@ -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">

View file

@ -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";
}

View file

@ -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);
}

View file

@ -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,
});

View file

@ -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>
</>
);
}

View file

@ -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}
/>
)}
/>
);
}

View file

@ -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>
);

View file

@ -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>
</>
);
}

View file

@ -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>

View file

@ -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>
);

View file

@ -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>
);

View file

@ -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>
) : (

View file

@ -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>
)}

View file

@ -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>

View file

@ -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}

View file

@ -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) => {

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
);

View file

@ -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}

View file

@ -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>

View file

@ -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>

View file

@ -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);

View file

@ -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

View file

@ -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} />
)}
/>
);
}

View file

@ -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>
);

View file

@ -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>
);

View file

@ -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>
);
}

View file

@ -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,
},
});

View file

@ -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>
);
}

View file

@ -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 />

View file

@ -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,
},
});

View file

@ -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">

View file

@ -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);

View file

@ -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[];

View file

@ -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>
);

View file

@ -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}
/>

View file

@ -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>
);
};

View file

@ -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>

View file

@ -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>

View file

@ -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,

View file

@ -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>

View file

@ -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}

View file

@ -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>
);

View file

@ -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 (

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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;

View file

@ -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>
);

View file

@ -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}

View file

@ -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}

View file

@ -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>
);
}

View file

@ -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 */}

View file

@ -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}`);

View file

@ -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

View file

@ -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)}

View file

@ -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"
)}
/>

View file

@ -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>

View file

@ -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";

View file

@ -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",

View file

@ -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>
);
}
}

View file

@ -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;
}

View file

@ -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>

View file

@ -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,
]);

View file

@ -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">

View file

@ -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">

View file

@ -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}>

View file

@ -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"
)}
>

View file

@ -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,

View file

@ -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"),
});

View file

@ -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,
}),

View 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 };
}

View file

@ -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,

View 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();
}
},
});
}

View 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,
}),
]);
}

View file

@ -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");

View file

@ -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",
],
},
];

View 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 };
});
}

View file

@ -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 };
}

View file

@ -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;

View file

@ -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"]) =>

View file

@ -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",

View file

@ -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",

View file

@ -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": "कोई चैट नहीं",

View file

@ -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",

View file

@ -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": "无对话",

View file

@ -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"),