@@ -794,24 +990,36 @@ function PromptDetailPane({
);
}
- const schema = normalizeObjectSchema(detail.inputSchema);
- const backendCliArgs = promptSchemaQuery.data?.backends?.find(
+ const scratch = isScratchPrompt(detail);
+ const activeTab =
+ scratch && (tab === "source" || tab === "schema") ? "runner" : tab;
+ const tabs = scratch
+ ? [
+ { id: "runner", label: "Run", icon: UiPlay },
+ { id: "runs", label: "Runs", icon: UiTerminal },
+ ]
+ : [
+ { id: "runner", label: "Run", icon: UiPlay },
+ { id: "schema", label: "Schema", icon: UiListTree },
+ { id: "source", label: "Source", icon: UiCode2 },
+ { id: "runs", label: "Runs", icon: UiTerminal },
+ ];
+ const schema = scratch
+ ? undefined
+ : normalizeObjectSchema(detail.inputSchema);
+ const backendCliArgs = promptSchema?.backends?.find(
(backend) => backend.backend === runtime.backend,
)?.args;
+ const promptReady =
+ !scratch ||
+ Boolean(runtime.prompt?.user?.trim()) ||
+ Boolean(runtime.prompt?.attachments?.length);
+ const runtimeRowsError = validateRuntimeRows(runtimeRows);
return (
-
+
{Boolean(error) && (
@@ -819,21 +1027,26 @@ function PromptDetailPane({
{errorMessage(error)}
)}
- {tab === "source" ? (
+ {activeTab === "runner" && activeBatch ? (
+
+ ) : activeTab === "source" ? (
- ) : tab === "runs" ? (
-
- ) : tab === "schema" ? (
-
) : (
@@ -842,26 +1055,44 @@ function PromptDetailPane({
key={detail.id}
value={runtime}
onChange={onRuntimeChange}
+ runtimeControls={
+
+ }
models={promptSelectableModels(models)}
tools={tools}
secretSelector={CAPTAIN_SECRET_SELECTOR}
variables={variables}
onVariablesChange={onVariablesChange}
onVariablesValidityChange={onVariablesValidityChange}
+ enableAttachments
{...(permissionCatalog ? { permissionCatalog } : {})}
{...(schema ? { variablesSchema: schema } : {})}
{...(backendCliArgs
? { cliOptions: { schema: backendCliArgs } }
: {})}
+ {...(scratch
+ ? {
+ promptLabel: "Scratch prompt",
+ promptPlaceholder: "Write a one-off prompt",
+ }
+ : {})}
/>
@@ -966,35 +1202,35 @@ function PromptSourceMarkdownEditor({
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
-
+
+
);
}
function RunnerOutput({
- renderResult,
+ previewResult,
activeRunID,
}: {
- renderResult?: PromptRenderResult;
+ previewResult?: PromptPreviewResult;
activeRunID?: string;
}) {
if (activeRunID) {
return
;
}
- if (renderResult) {
+ if (previewResult) {
return (
- {renderResult.validationError && (
+ {previewResult.validationError && (
- {renderResult.validationError}
+ {previewResult.validationError}
)}
-
+
);
@@ -1006,77 +1242,28 @@ function RunnerOutput({
);
}
-// SchemaPreview browses the prompt's input and output JSON schemas read-only,
-// via clicky-ui's SchemaViewer. Absent schemas (most prompts declare no
-// output.schema) degrade to an empty state rather than an error.
-function SchemaPreview({
- inputSchema,
- outputSchema,
-}: {
- inputSchema?: JsonSchemaObject;
- outputSchema?: JsonSchemaObject;
-}) {
- return (
-
-
- }
- schema={inputSchema}
- emptyLabel="This prompt declares no input schema."
- />
-
- }
- schema={outputSchema}
- emptyLabel="This prompt declares no output schema."
- />
-
- );
-}
-
-function SchemaPanel({
- title,
- icon,
- schema,
- emptyLabel,
+function CreatePromptModal({
+ open,
+ ...props
}: {
- title: string;
- icon: ReactNode;
- schema?: JsonSchemaObject;
- emptyLabel: string;
+ open: boolean;
+ onClose: () => void;
+ sources: Array<{ id: string; label: string }>;
+ createOp?: ResolvedOperation;
+ seedContent?: string;
+ onCreated: (prompt: PromptDetail) => void;
}) {
- return (
-
-
- {icon}
- {title}
-
- {schema ? (
-
-
-
- ) : (
-
- {emptyLabel}
-
- )}
-
- );
+ if (!open) return null;
+ return
;
}
-function CreatePromptModal({
- open,
+function CreatePromptModalForm({
onClose,
sources,
createOp,
seedContent,
onCreated,
}: {
- open: boolean;
onClose: () => void;
sources: Array<{ id: string; label: string }>;
createOp?: ResolvedOperation;
@@ -1085,20 +1272,13 @@ function CreatePromptModal({
}) {
const [name, setName] = useState("");
const [relPath, setRelPath] = useState("");
- const [target, setTarget] = useState("");
- const [content, setContent] = useState(defaultPromptContent(""));
+ const [target, setTarget] = useState(() => sources[0]?.id ?? "");
+ const [content, setContent] = useState(
+ () => seedContent || defaultPromptContent(""),
+ );
const [loading, setLoading] = useState(false);
const [error, setError] = useState
();
- useEffect(() => {
- if (!open) return;
- setName("");
- setRelPath("");
- setTarget(sources[0]?.id ?? "");
- setContent(seedContent || defaultPromptContent(""));
- setError(undefined);
- }, [open, seedContent, sources]);
-
async function submit() {
if (!createOp) return;
setLoading(true);
@@ -1124,7 +1304,7 @@ function CreatePromptModal({
return (
setRelPath(event.target.value)}
className="h-control-h w-full rounded-md border border-border bg-background px-density-3 text-sm outline-none focus:ring-2 focus:ring-ring"
@@ -1196,67 +1377,14 @@ function CreatePromptModal({
);
}
-function resolvePromptOps(operations: ResolvedOperation[]): PromptOps {
- return {
- list: findPromptOperation(operations, "list"),
- get: findPromptOperation(operations, "get"),
- create: findPromptOperation(operations, "create"),
- update: findPromptOperation(operations, "update"),
- delete: findPromptOperation(operations, "delete"),
- render: findPromptOperation(operations, "action", "render"),
- run: findPromptOperation(operations, "action", "run"),
- };
-}
-
-function findPromptOperation(
- operations: ResolvedOperation[],
- verb: NonNullable["verb"],
- actionName?: string,
-) {
- return operations.find((op) => {
- const meta = op.operation["x-clicky"];
- if (!meta || meta.verb !== verb) return false;
- if (actionName && meta.actionName !== actionName) return false;
- const surface = (meta.surface || "").toLowerCase();
- const command = (meta.command || "").toLowerCase();
- const path = op.path.toLowerCase();
- return (
- surface === "prompt" ||
- surface === "prompts" ||
- command === "prompt" ||
- command.startsWith("prompt ") ||
- path.includes("/prompt")
- );
- });
-}
-
-function requiredOperation(op: ResolvedOperation | undefined, name: string) {
- if (!op) throw new Error(`Prompt ${name} operation is not available.`);
- return op;
-}
-
-async function fetchPromptList(
- op: ResolvedOperation,
- params: { source: SourceFilter; query: string },
-) {
- const response = await apiClient.executeCommand(
- op.path,
- op.method,
- { source: params.source, query: params.query },
- { Accept: "application/json" },
- );
- return unwrapResponse(response);
-}
-
-async function fetchChatModels() {
- const response = await fetch("/api/chat/models", {
- headers: { Accept: "application/json" },
- });
- if (!response.ok) {
- const message = await response.text();
- throw new Error(message || `Model catalog failed with ${response.status}`);
- }
- return (await response.json()) as ChatModel[];
+function createPromptModalKey({
+ seedContent,
+ sources,
+}: {
+ seedContent?: string;
+ sources: Array<{ id: string; label: string }>;
+}) {
+ return `${sources[0]?.id ?? ""}:${seedContent ?? ""}`;
}
async function fetchPermissionCatalog() {
@@ -1289,6 +1417,7 @@ type PromptSchemaBackend = {
type PromptSchemaDoc = {
schemaVersion: number;
backends?: PromptSchemaBackend[];
+ models?: ChatModel[];
spec?: JsonSchemaObject;
};
@@ -1371,19 +1500,17 @@ async function executePromptOperation(
return unwrapResponse(response);
}
-function unwrapResponse(response: ExecutionResponse): T {
- if (!response.success) {
- throw new Error(response.error || response.output || "Operation failed.");
- }
- return response.parsed as T;
-}
-
function resolveOperationPath(path: string, params: Record) {
let next = path;
for (const [key, value] of Object.entries(params)) {
- next = next.replace(`{${key}}`, encodeURIComponent(value));
+ if (value) {
+ next = next.replace(`{${key}}`, encodeURIComponent(value));
+ } else {
+ next = next.replace(new RegExp(`/\\{${key}\\}`, "g"), "");
+ next = next.replace(`{${key}}`, "");
+ }
}
- return next;
+ return next.replace(/\/{2,}/g, "/");
}
function paramsWithPathValues(
@@ -1410,6 +1537,14 @@ function uniqueWritableSources(prompts: PromptSummary[]) {
return out;
}
+function isScratchPrompt(detail: PromptDetail | undefined) {
+ return detail?.id === SCRATCH_PROMPT_ID;
+}
+
+function promptActionParams(detail: PromptDetail) {
+ return { id: isScratchPrompt(detail) ? "" : detail.id };
+}
+
function normalizeObjectSchema(
schema: Record | undefined,
): JsonSchemaObject | undefined {
@@ -1435,18 +1570,24 @@ function runtimePayload(runtime: AISpecRuntimeValue, models: ChatModel[]) {
return normalizeSpecRuntimePayload(
buildAISpecRuntimePayload(runtime),
models,
+ runtime.backend,
);
}
function normalizeSpecRuntimePayload(
payload: Record,
models: ChatModel[],
+ backend?: string,
) {
const spec = payload.spec;
if (!spec || typeof spec !== "object" || Array.isArray(spec)) return payload;
const specRecord = { ...(spec as Record) };
if (typeof specRecord.model === "string") {
- const selected = normalizeRuntimeModel(specRecord.model, models);
+ const selected = normalizeRuntimeModel(
+ specRecord.model,
+ models,
+ typeof specRecord.backend === "string" ? specRecord.backend : backend,
+ );
if (selected.model && selected.model !== specRecord.model) {
if (typeof specRecord.id !== "string" || !specRecord.id.trim()) {
specRecord.id = specRecord.model;
@@ -1463,90 +1604,12 @@ function normalizeSpecRuntimePayload(
return { ...payload, spec: specRecord };
}
-// Seeds the runtime spec's backend from the prompt (explicit, else inferred from
-// the model). The PromptRunEditor derives the family/mode picker from spec.backend.
-function runtimeSelectionFromPrompt(prompt: PromptSummary): AISpecRuntimeValue {
- const backend =
- prompt.backend?.trim() || inferBackendFromModel(prompt.model || "");
- return backend ? { backend } : {};
-}
-
-function inferBackendFromModel(model: string) {
- const value = model.trim().toLowerCase();
- if (!value) return "";
- if (value.startsWith("anthropic/")) return "anthropic";
- if (value.startsWith("openai/")) return "openai";
- if (value.startsWith("googleai/")) return "gemini";
- if (value.startsWith("deepseek/") || value.startsWith("deepseek-"))
- return "deepseek";
- if (value.startsWith("claude-agent-")) return "claude-agent";
- if (value.startsWith("claude-code-")) return "claude-cli";
- if (value.startsWith("codex")) return "codex-cli";
- if (value.startsWith("gemini-cli-")) return "gemini-cli";
- if (value.startsWith("claude-")) return "anthropic";
- if (value.startsWith("gemini-") || value.startsWith("models/gemini-"))
- return "gemini";
- if (
- value.startsWith("gpt-") ||
- value.startsWith("o1") ||
- value.startsWith("o3") ||
- value.startsWith("o4")
- ) {
- return "openai";
- }
- return "";
-}
-
function promptSelectableModels(models: ChatModel[]) {
return models.map((model) =>
model.configured === false ? { ...model, configured: true } : model,
);
}
-function normalizeRuntimeModel(model: string, models: ChatModel[]) {
- const id = model.trim();
- if (!id) return { model: "", backend: "" };
-
- const selected = models.find((entry) => entry.id === id);
- if (!selected) return { model: id, backend: "" };
-
- const backend = providerToBackend(selected.provider);
- if (
- selected.provider === "anthropic" ||
- selected.provider === "openai" ||
- selected.provider === "googleai" ||
- selected.provider === "deepseek"
- ) {
- return { model: stripProviderPrefix(id), backend };
- }
- if (selected.provider === "codex-cli" && id.startsWith("codex-")) {
- return { model: id.slice("codex-".length), backend };
- }
- return { model: id, backend };
-}
-
-function providerToBackend(provider: string) {
- switch (provider) {
- case "googleai":
- return "gemini";
- case "anthropic":
- case "openai":
- case "deepseek":
- case "claude-agent":
- case "claude-cli":
- case "codex-cli":
- case "gemini-cli":
- return provider;
- default:
- return "";
- }
-}
-
-function stripProviderPrefix(model: string) {
- const slash = model.indexOf("/");
- return slash >= 0 ? model.slice(slash + 1) : model;
-}
-
function defaultPromptContent(name: string) {
const promptName = name.trim() || "new prompt";
return `---
diff --git a/pkg/cli/webapp/src/ProviderDefaultsControls.tsx b/pkg/cli/webapp/src/ProviderDefaultsControls.tsx
new file mode 100644
index 0000000..8480570
--- /dev/null
+++ b/pkg/cli/webapp/src/ProviderDefaultsControls.tsx
@@ -0,0 +1,235 @@
+import { useMemo, useReducer, useState } from "react";
+import { Button } from "@flanksource/clicky-ui/components";
+
+export type ProviderModel = {
+ id: string;
+ label?: string;
+ supportedEfforts?: string[];
+ defaultEffort?: string;
+};
+
+export type ProviderAdapter = {
+ backend: string;
+ type: "api" | "cli";
+ authenticated: boolean;
+ binary?: string;
+ modelCount: number;
+ models?: string[];
+ modelDetails?: ProviderModel[];
+};
+
+export type ProviderDefault = {
+ agent: string;
+ model: string;
+ effort: string;
+ configured: boolean;
+};
+
+const PROVIDER_AGENTS: Record = {
+ anthropic: ["anthropic", "claude-cli", "claude-agent", "claude-cmux"],
+ openai: ["openai", "codex-cli", "codex-agent", "codex-cmux"],
+ gemini: ["gemini", "gemini-cli"],
+ deepseek: ["deepseek"],
+};
+
+const ALL_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"];
+
+export function ProviderDefaultsControls({
+ defaults,
+ ...props
+}: {
+ provider: string;
+ defaults: ProviderDefault;
+ adapters: ProviderAdapter[];
+ active: boolean;
+ onRefresh: () => Promise;
+}) {
+ return (
+
+ );
+}
+
+function ProviderDefaultsForm({
+ provider,
+ defaults,
+ adapters,
+ active,
+ onRefresh,
+}: {
+ provider: string;
+ defaults: ProviderDefault;
+ adapters: ProviderAdapter[];
+ active: boolean;
+ onRefresh: () => Promise;
+}) {
+ const [selection, updateSelection] = useReducer(
+ (
+ current: Pick,
+ next: Partial>,
+ ) => ({ ...current, ...next }),
+ defaults,
+ );
+ const { agent, model, effort } = selection;
+ const [pending, setPending] = useState<"defaults" | "active" | null>(null);
+ const [status, setStatus] = useState<{ tone: "success" | "error"; text: string } | null>(null);
+ const agentOptions = PROVIDER_AGENTS[provider] ?? [provider];
+ const models = useMemo(() => modelsForAgent(adapters, agent, model), [adapters, agent, model]);
+ const selectedModel = models.find((candidate) => candidate.id === model);
+ const effortOptions = selectedModel?.supportedEfforts ?? ALL_EFFORTS;
+ const modelAvailable = models.some((candidate) => candidate.id === model && candidate.available);
+
+ function changeAgent(nextAgent: string) {
+ const nextModel = modelsForAgent(adapters, nextAgent, "").find((candidate) => candidate.available);
+ updateSelection({
+ agent: nextAgent,
+ model: nextModel?.id ?? "",
+ effort: nextModel?.defaultEffort ?? "",
+ });
+ setStatus(null);
+ }
+
+ function changeModel(nextModel: string) {
+ updateSelection({
+ model: nextModel,
+ effort: models.find((candidate) => candidate.id === nextModel)?.defaultEffort ?? "",
+ });
+ setStatus(null);
+ }
+
+ async function saveDefaults() {
+ setPending("defaults");
+ setStatus(null);
+ try {
+ await putJSON(`/api/captain/ai/providers/${encodeURIComponent(provider)}/defaults`, { agent, model, effort });
+ setStatus({ tone: "success", text: "Provider defaults saved." });
+ await onRefresh();
+ } catch (error) {
+ setStatus({ tone: "error", text: errorMessage(error) });
+ } finally {
+ setPending(null);
+ }
+ }
+
+ async function setActiveProvider() {
+ setPending("active");
+ setStatus(null);
+ try {
+ await putJSON("/api/captain/ai/default-provider", { provider });
+ setStatus({ tone: "success", text: `${provider} is now the default provider.` });
+ await onRefresh();
+ } catch (error) {
+ setStatus({ tone: "error", text: errorMessage(error) });
+ } finally {
+ setPending(null);
+ }
+ }
+
+ return (
+
+
+
+
Provider defaults
+
Used only when a run leaves the field unspecified.
+
+ {active ? (
+
Active default
+ ) : (
+
void setActiveProvider()}>
+ {pending === "active" ? "Saving..." : "Set as default"}
+
+ )}
+
+
+
+
+
+ void saveDefaults()}>
+ {pending === "defaults" ? "Saving..." : "Save defaults"}
+
+
+ {!modelAvailable && (
+
Configure this runtime first to load a valid model catalog.
+ )}
+ {status &&
{status.text}
}
+
+ );
+}
+
+type ModelOption = ProviderModel & { available: boolean };
+
+function modelsForAgent(adapters: ProviderAdapter[], agent: string, current: string): ModelOption[] {
+ const adapter = adapters.find((candidate) => candidate.backend === agent);
+ const models = adapter?.modelDetails?.length
+ ? adapter.modelDetails
+ : (adapter?.models ?? []).map((id) => ({ id }));
+ const options = models.map((model) => ({ ...model, available: true }));
+ if (current && !options.some((candidate) => candidate.id === current)) {
+ options.unshift({ id: current, available: false });
+ }
+ return options;
+}
+
+function agentLabel(agent: string, adapters: ProviderAdapter[]) {
+ const adapter = adapters.find((candidate) => candidate.backend === agent);
+ const ready = adapter?.authenticated && (adapter.type === "api" || Boolean(adapter.binary));
+ return `${agent}${ready ? " — ready" : " — needs setup"}`;
+}
+
+async function putJSON(path: string, body: unknown) {
+ const response = await fetch(path, {
+ method: "PUT",
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!response.ok) {
+ const message = (await response.text()).trim();
+ throw new Error(message || `Configuration failed with ${response.status}`);
+ }
+ return response.json() as Promise;
+}
+
+function errorMessage(error: unknown) {
+ return error instanceof Error ? error.message : String(error);
+}
diff --git a/pkg/cli/webapp/src/RunningPrompts.tsx b/pkg/cli/webapp/src/RunningPrompts.tsx
index c01eace..e590792 100644
--- a/pkg/cli/webapp/src/RunningPrompts.tsx
+++ b/pkg/cli/webapp/src/RunningPrompts.tsx
@@ -9,7 +9,7 @@ const TASK_BASE = "/api/captain";
* of kind "prompt". Badge is a compact header indicator; RunsTab is the full
* cross-run list backed by clicky's TaskManager. Both share the same task API.
*/
-function Badge({ onSelectRun }: { onSelectRun: (id: string) => void }) {
+export function RunningPromptsBadge({ onSelectRun }: { onSelectRun: (id: string) => void }) {
const { runs } = useTaskRuns({ kind: "prompt", status: "running", basePath: TASK_BASE });
const [open, setOpen] = useState(false);
@@ -47,7 +47,7 @@ function Badge({ onSelectRun }: { onSelectRun: (id: string) => void }) {
);
}
-function RunsTab({
+export function RunningPromptsRunsTab({
activeRunID,
onSelectRun,
}: {
@@ -65,5 +65,3 @@ function RunsTab({
);
}
-
-export const RunningPrompts = { Badge, RunsTab };
diff --git a/pkg/cli/webapp/src/SessionBrowser.tsx b/pkg/cli/webapp/src/SessionBrowser.tsx
index 76ad58b..c6440cf 100644
--- a/pkg/cli/webapp/src/SessionBrowser.tsx
+++ b/pkg/cli/webapp/src/SessionBrowser.tsx
@@ -1,17 +1,32 @@
-import { useState, type ReactNode } from "react";
-import { useQuery } from "@tanstack/react-query";
+import { useMemo, useState } from "react";
+import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import {
AppShell,
Button,
SearchInput,
SegmentedControl,
- Switch,
+ type AppShellProps,
type AppShellNavSection,
} from "@flanksource/clicky-ui/components";
-import { SessionViewer } from "@flanksource/clicky-ui/ai";
-import { CAPTAIN_SIDEBAR_COLLAPSE_KEY } from "./shell";
+import {
+ SessionChatComposer,
+ SessionContextMeter,
+ SessionInspector,
+ getSessionMetadata,
+} from "@flanksource/clicky-ui/ai";
+import { CAPTAIN_SIDEBAR_COLLAPSE_KEY, withProjectScope } from "./shellHelpers";
import { TimingBadge } from "./TimingBadge";
import type { TimingMetric } from "./serverTiming";
+import {
+ SessionTable,
+ type DashboardSort,
+ type SortDirection,
+} from "./SessionTable";
+import {
+ compareSessions,
+ defaultSortDirection,
+ groupSessionsByProject,
+} from "./sessionTableHelpers";
import {
SOURCE_OPTIONS,
errorMessage,
@@ -19,17 +34,16 @@ import {
fetchSession,
formatCompactNumber,
formatCost,
- formatTime,
- healthClassName,
- sessionCostTotal,
- sessionTitle,
- sessionToolCount,
- unifiedSessionTitle,
+ mergeSessionListPages,
type SessionDashboard,
+ type SessionGetItem,
+ type SessionGetResult,
type SessionRecord,
+ type ProjectScope,
type SourceFilter,
- type UnifiedSession,
} from "./sessionData";
+import { mergeSessionMessages, useSessionChat } from "./hooks/useSessionChat";
+import { sessionResultCollection } from "./sessionCollection";
type Navigate = (to: string, opts?: { replace?: boolean }) => void;
@@ -37,7 +51,9 @@ type SessionBrowserProps = {
selectedId?: string;
onNavigate: Navigate;
navSections: AppShellNavSection[];
- actions: ReactNode;
+ actions: AppShellProps["actions"];
+ search: AppShellProps["search"];
+ projectScope: ProjectScope;
};
export function SessionBrowser({
@@ -45,6 +61,8 @@ export function SessionBrowser({
onNavigate,
navSections,
actions,
+ search,
+ projectScope,
}: SessionBrowserProps) {
return selectedId ? (
) : (
-
+
);
}
@@ -66,11 +92,15 @@ function SessionDetailPage({
onNavigate,
navSections,
actions,
+ search,
+ projectScope,
}: {
selectedId: string;
onNavigate: Navigate;
navSections: AppShellNavSection[];
- actions: ReactNode;
+ actions: AppShellProps["actions"];
+ search: AppShellProps["search"];
+ projectScope: ProjectScope;
}) {
const detailQuery = useQuery({
queryKey: ["session", selectedId],
@@ -84,19 +114,29 @@ function SessionDetailPage({
navSections={navSections}
collapsedStorageKey={CAPTAIN_SIDEBAR_COLLAPSE_KEY}
actions={actions}
+ search={search}
bodyHeader={
}
bodyActions={
- onNavigate("/sessions")}>
+
+ onNavigate(withProjectScope("/sessions", projectScope))
+ }
+ >
← Sessions
- void detailQuery.refetch()}>
+ void detailQuery.refetch()}
+ >
Refresh
@@ -104,9 +144,10 @@ function SessionDetailPage({
contentClassName="p-0 overflow-hidden"
>
detailQuery.refetch()}
/>
);
@@ -116,20 +157,32 @@ function SessionListPage({
onNavigate,
navSections,
actions,
+ search,
+ projectScope,
}: {
onNavigate: Navigate;
navSections: AppShellNavSection[];
- actions: ReactNode;
+ actions: AppShellProps["actions"];
+ search: AppShellProps["search"];
+ projectScope: ProjectScope;
}) {
const [source, setSource] = useState("all");
- const [allProjects, setAllProjects] = useState(false);
const [query, setQuery] = useState("");
- const listQuery = useQuery({
- queryKey: ["sessions", source, allProjects, query],
- queryFn: () => fetchLiveSessions({ source, allProjects, query }),
+ const listQuery = useInfiniteQuery({
+ queryKey: ["sessions", source, projectScope, query],
+ queryFn: ({ pageParam }) =>
+ fetchLiveSessions({
+ source,
+ project: projectScope,
+ query,
+ cursor: pageParam || undefined,
+ }),
+ initialPageParam: "",
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
});
- const sessions = listQuery.data?.sessions ?? [];
+ const result = mergeSessionListPages(listQuery.data?.pages ?? []);
+ const sessions = result?.sessions ?? [];
return (
Sessions }
bodyActions={
-