@protobase/ui 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app/app.js CHANGED
@@ -14,7 +14,7 @@ import { CreatePage } from "./create/create-page.js";
14
14
  import { ListPage } from "./list-page.js";
15
15
  import { userMenuFromMeta } from "./user-menu-from-meta.js";
16
16
  import { navFromMeta } from "./nav-from-meta.js";
17
- import { ProjectUiProvider } from "./pages/project-ui.js";
17
+ import { ProjectUiProvider, useProjectUi } from "./pages/project-ui.js";
18
18
  import { ComposedPage } from "./pages/composed-page.js";
19
19
  import { RecordPage } from "./record-page.js";
20
20
  import { useGlobalSearch } from "./search/use-global-search.js";
@@ -47,6 +47,9 @@ const Routes = ({ workspace, sidebarMode }) => {
47
47
  const model = route.resource ? meta.resources[route.resource] : void 0;
48
48
  const record = useRecord(route.resource ?? "", route.key ?? "", Boolean(model && route.key && route.key !== "new"));
49
49
  const title = model && record.data ? recordTitle(model, view, record.data.record, route.key ?? "") : route.key;
50
+ const { shell } = useProjectUi();
51
+ const Actions = shell?.actions;
52
+ const RightPanel = shell?.rightPanel;
50
53
  const breadcrumb = breadcrumbFor({
51
54
  basePath,
52
55
  route,
@@ -74,6 +77,8 @@ const Routes = ({ workspace, sidebarMode }) => {
74
77
  groups: search.groups,
75
78
  onSelect: open
76
79
  } : void 0,
80
+ actions: Actions && /* @__PURE__ */ jsx(Actions, {}),
81
+ rightPanel: RightPanel && /* @__PURE__ */ jsx(RightPanel, {}),
77
82
  children: !route.resource ? /* @__PURE__ */ jsx(Notice, {
78
83
  title: "Choose a resource",
79
84
  children: "Pick one from the sidebar."
@@ -14,11 +14,16 @@ export type ActionContext = {
14
14
  export type ActionHandler = (context: ActionContext) => void | Promise<void>;
15
15
  /**
16
16
  * The project's own React code for composed pages: custom components by the name their layout declares
17
- * (`component('UsageChart')`), and handlers for named actions that have no built-in behaviour.
17
+ * (`component('UsageChart')`), handlers for named actions that have no built-in behaviour, and the shell's slots.
18
18
  */
19
19
  export type ProjectUi = {
20
20
  components?: Record<string, ComponentType<any>>;
21
21
  actions?: Record<string, ActionHandler>;
22
+ /** Rendered in the shell on every page: `actions` at the end of the top bar, `rightPanel` beside the page. */
23
+ shell?: {
24
+ actions?: ComponentType;
25
+ rightPanel?: ComponentType;
26
+ };
22
27
  };
23
28
  /** Typed identity, for `export default defineUi({ components, actions })` in `protobase.ui.tsx`. */
24
29
  export declare const defineUi: (ui: ProjectUi) => ProjectUi;
@@ -0,0 +1,15 @@
1
+ import { DockItem } from "./model.js";
2
+ import { DockItemHandlers } from "./dock-item.js";
3
+ //#region src/assistant-dock/assistant-dock.d.ts
4
+ export type AssistantDockProps = DockItemHandlers & {
5
+ /** The chat in order: messages and the cards of its tasks and queries. */
6
+ items: DockItem[];
7
+ /** The organization's credits; Approve needs the plan's quote. */
8
+ balance: number;
9
+ replying?: boolean;
10
+ onSend?: (text: string) => void;
11
+ onClose?: () => void;
12
+ };
13
+ /** The assistant beside the app: chat, plans to approve, the queue, progress, results and query tables. */
14
+ export declare const AssistantDock: ({ items, balance, replying, onSend, onClose, onRequestChanges, ...handlers }: AssistantDockProps) => import("react").JSX.Element;
15
+ //#endregion
@@ -0,0 +1,81 @@
1
+ import { Button } from "../primitives/button.js";
2
+ import { Composer } from "./composer.js";
3
+ import { formatCredits } from "./model.js";
4
+ import { DockItemView } from "./dock-item.js";
5
+ import { useEffect, useRef, useState } from "react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ import { Sparkles, X } from "lucide-react";
8
+ //#region src/assistant-dock/assistant-dock.tsx
9
+ /** The assistant beside the app: chat, plans to approve, the queue, progress, results and query tables. */
10
+ const AssistantDock = ({ items, balance, replying, onSend, onClose, onRequestChanges, ...handlers }) => {
11
+ const list = useRef(null);
12
+ const input = useRef(null);
13
+ const [revising, setRevising] = useState(false);
14
+ useEffect(() => {
15
+ const element = list.current;
16
+ if (element) element.scrollTop = element.scrollHeight;
17
+ }, [items.length]);
18
+ const requestChanges = (taskId) => {
19
+ setRevising(true);
20
+ input.current?.focus();
21
+ onRequestChanges?.(taskId);
22
+ };
23
+ const send = (text) => {
24
+ setRevising(false);
25
+ onSend?.(text);
26
+ };
27
+ return /* @__PURE__ */ jsxs("aside", {
28
+ "aria-label": "Assistant",
29
+ className: "flex h-full w-[380px] max-w-full shrink-0 flex-col border-l border-border bg-surface",
30
+ children: [
31
+ /* @__PURE__ */ jsxs("header", {
32
+ className: "flex h-12 shrink-0 items-center gap-2 border-b border-border bg-background px-4",
33
+ children: [
34
+ /* @__PURE__ */ jsx(Sparkles, { className: "size-4 text-primary" }),
35
+ /* @__PURE__ */ jsx("h2", {
36
+ className: "flex-1 text-[13px] font-semibold",
37
+ children: "Assistant"
38
+ }),
39
+ /* @__PURE__ */ jsx("span", {
40
+ className: "text-xs font-medium text-muted-foreground",
41
+ children: formatCredits(balance)
42
+ }),
43
+ onClose && /* @__PURE__ */ jsx(Button, {
44
+ variant: "ghost",
45
+ size: "sm",
46
+ className: "w-7 px-0",
47
+ "aria-label": "Close",
48
+ onClick: onClose,
49
+ children: /* @__PURE__ */ jsx(X, { className: "size-4" })
50
+ })
51
+ ]
52
+ }),
53
+ /* @__PURE__ */ jsxs("div", {
54
+ ref: list,
55
+ className: "flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4",
56
+ children: [items.length === 0 && /* @__PURE__ */ jsx("p", {
57
+ className: "m-auto max-w-60 text-center text-[13px] text-muted-foreground",
58
+ children: "Ask for a change to this app, or a question about its data."
59
+ }), items.map((item) => /* @__PURE__ */ jsx(DockItemView, {
60
+ item,
61
+ balance,
62
+ handlers: {
63
+ ...handlers,
64
+ onRequestChanges: requestChanges
65
+ }
66
+ }, item.id))]
67
+ }),
68
+ /* @__PURE__ */ jsx("footer", {
69
+ className: "shrink-0 border-t border-border bg-background p-3",
70
+ children: /* @__PURE__ */ jsx(Composer, {
71
+ inputRef: input,
72
+ onSend: send,
73
+ replying,
74
+ placeholder: revising ? "What should change in the plan?" : "Ask for a change or about your data..."
75
+ })
76
+ })
77
+ ]
78
+ });
79
+ };
80
+ //#endregion
81
+ export { AssistantDock };
@@ -0,0 +1,9 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { jsx } from "react/jsx-runtime";
3
+ //#region src/assistant-dock/chat-message.tsx
4
+ const ChatMessage = ({ from, text }) => /* @__PURE__ */ jsx("div", {
5
+ className: cn("whitespace-pre-wrap break-words text-[13px]", from === "user" ? "ml-8 self-end rounded-lg rounded-br-sm bg-primary px-3 py-2 text-primary-foreground shadow-sm" : "mr-4 text-foreground"),
6
+ children: text
7
+ });
8
+ //#endregion
9
+ export { ChatMessage };
@@ -0,0 +1,39 @@
1
+ import { Input } from "../primitives/input.js";
2
+ import { Button } from "../primitives/button.js";
3
+ import { useState } from "react";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ import { SendHorizontal } from "lucide-react";
6
+ //#region src/assistant-dock/composer.tsx
7
+ const Composer = ({ onSend, replying, placeholder, inputRef }) => {
8
+ const [text, setText] = useState("");
9
+ const message = text.trim();
10
+ const send = () => {
11
+ if (!message || replying) return;
12
+ onSend?.(message);
13
+ setText("");
14
+ };
15
+ return /* @__PURE__ */ jsxs("form", {
16
+ className: "flex gap-2",
17
+ onSubmit: (event) => {
18
+ event.preventDefault();
19
+ send();
20
+ },
21
+ children: [/* @__PURE__ */ jsx(Input, {
22
+ ref: inputRef,
23
+ "aria-label": "Message",
24
+ placeholder,
25
+ value: text,
26
+ onChange: (event) => setText(event.target.value),
27
+ wrapperClassName: "flex-1"
28
+ }), /* @__PURE__ */ jsx(Button, {
29
+ type: "submit",
30
+ variant: "primary",
31
+ className: "w-8 px-0",
32
+ "aria-label": "Send",
33
+ disabled: !message || replying,
34
+ children: /* @__PURE__ */ jsx(SendHorizontal, { className: "size-4" })
35
+ })]
36
+ });
37
+ };
38
+ //#endregion
39
+ export { Composer };
@@ -0,0 +1,4 @@
1
+ import { PlanCardProps } from "./plan-card.js";
2
+ //#region src/assistant-dock/dock-item.d.ts
3
+ export type DockItemHandlers = Pick<PlanCardProps, 'onApprove' | 'onRequestChanges' | 'onCancel' | 'onUpdatePlan'>;
4
+ //#endregion
@@ -0,0 +1,37 @@
1
+ import { ChatMessage } from "./chat-message.js";
2
+ import { PlanCard } from "./plan-card.js";
3
+ import { ProgressCard } from "./progress-card.js";
4
+ import { QueryCard } from "./query-card.js";
5
+ import { QueueCard } from "./queue-card.js";
6
+ import { ResultCard } from "./result-card.js";
7
+ import { jsx } from "react/jsx-runtime";
8
+ //#region src/assistant-dock/dock-item.tsx
9
+ const DockItemView = ({ item, balance, handlers }) => {
10
+ switch (item.kind) {
11
+ case "user":
12
+ case "assistant": return /* @__PURE__ */ jsx(ChatMessage, {
13
+ from: item.kind,
14
+ text: item.text
15
+ });
16
+ case "queue": return /* @__PURE__ */ jsx(QueueCard, {
17
+ taskId: item.taskId,
18
+ request: item.request,
19
+ ahead: item.ahead,
20
+ waitingOnPlan: item.waitingOnPlan,
21
+ onCancel: handlers.onCancel
22
+ });
23
+ case "progress": return /* @__PURE__ */ jsx(ProgressCard, {
24
+ phase: item.phase,
25
+ steps: item.steps
26
+ });
27
+ case "plan": return /* @__PURE__ */ jsx(PlanCard, {
28
+ plan: item.plan,
29
+ balance,
30
+ ...handlers
31
+ });
32
+ case "result": return /* @__PURE__ */ jsx(ResultCard, { result: item.result });
33
+ case "query": return /* @__PURE__ */ jsx(QueryCard, { query: item.query });
34
+ }
35
+ };
36
+ //#endregion
37
+ export { DockItemView };
@@ -0,0 +1,84 @@
1
+ import { Step } from "../assistant-panel/content.js";
2
+ //#region src/assistant-dock/model.d.ts
3
+ export type PlanSize = 'S' | 'M' | 'L';
4
+ /** A plan as the planner returned it and central priced it. */
5
+ export type Plan = {
6
+ taskId: string;
7
+ /** 1 for the first plan; "Ask for changes" and "Update plan" make the next one. */
8
+ revision: number;
9
+ summary: string;
10
+ steps: Array<{
11
+ title: string;
12
+ files?: string[];
13
+ }>;
14
+ migrations: Array<{
15
+ description: string;
16
+ destructive: boolean;
17
+ }>;
18
+ risks: string[];
19
+ size: PlanSize;
20
+ /** The quote: what Approve holds. */
21
+ credits: number;
22
+ /** The branch moved on GitHub since the plan was made. */
23
+ outdated: boolean;
24
+ /** Tasks behind this one in the app's queue, waiting until it is answered. */
25
+ waiting: number;
26
+ /** `dropped`: the platform dropped it and refunded the deposit (repository replaced, app archived). */
27
+ status: 'awaiting' | 'approved' | 'canceled' | 'superseded' | 'dropped';
28
+ };
29
+ export type JobResult = {
30
+ taskId: string;
31
+ outcome: 'live' | 'failed' | 'capped';
32
+ summary: string;
33
+ commit?: {
34
+ sha: string;
35
+ url?: string;
36
+ };
37
+ /** The migrations the job applied; they stay live when it fails. */
38
+ dbChanges: string[];
39
+ /** A finished change GitHub refused, kept on `protobase/task-<n>`. */
40
+ keptOnBranch?: string;
41
+ /** What was charged: the quote when live, 0 otherwise. */
42
+ credits: number;
43
+ };
44
+ export type QueryResult = {
45
+ sql: string;
46
+ columns: string[];
47
+ rows: unknown[][];
48
+ truncated: boolean;
49
+ };
50
+ export type DockItem = {
51
+ kind: 'user';
52
+ id: string;
53
+ text: string;
54
+ } | {
55
+ kind: 'assistant';
56
+ id: string;
57
+ text: string;
58
+ } | {
59
+ kind: 'queue';
60
+ id: string;
61
+ taskId: string;
62
+ request: string;
63
+ ahead: number;
64
+ waitingOnPlan: boolean;
65
+ } | {
66
+ kind: 'progress';
67
+ id: string;
68
+ taskId: string;
69
+ phase: 'planning' | 'running';
70
+ steps: Step[];
71
+ } | {
72
+ kind: 'plan';
73
+ id: string;
74
+ plan: Plan;
75
+ } | {
76
+ kind: 'result';
77
+ id: string;
78
+ result: JobResult;
79
+ } | {
80
+ kind: 'query';
81
+ id: string;
82
+ query: QueryResult;
83
+ };
84
+ //#endregion
@@ -0,0 +1,26 @@
1
+ //#region src/assistant-dock/model.ts
2
+ const formatCredits = (credits) => `${credits} ${credits === 1 ? "credit" : "credits"}`;
3
+ /** Approve is offered only on a plan awaiting an answer, and only when the balance covers the quote. */
4
+ const approval = (plan, balance) => {
5
+ if (plan.status !== "awaiting") return { enabled: false };
6
+ if (balance < plan.credits) return {
7
+ enabled: false,
8
+ reason: `Needs ${formatCredits(plan.credits)}; the balance is ${formatCredits(balance)}.`
9
+ };
10
+ return { enabled: true };
11
+ };
12
+ const approveLabel = (plan) => `${plan.outdated ? "Approve anyway" : "Approve"} · ${formatCredits(plan.credits)}`;
13
+ const queueText = (ahead, waitingOnPlan) => {
14
+ if (waitingOnPlan) return "Waiting for the plan above to be approved or canceled.";
15
+ if (ahead === 0) return "Next in line.";
16
+ return `Waiting, ${ahead} ${ahead === 1 ? "task" : "tasks"} ahead.`;
17
+ };
18
+ const waitingText = (waiting) => waiting === 0 ? void 0 : `${waiting} ${waiting === 1 ? "task waits" : "tasks wait"} on this plan.`;
19
+ /** A query cell as text: `null` stays visible, objects and arrays as JSON. */
20
+ const formatCell = (value) => {
21
+ if (value === null || value === void 0) return "null";
22
+ if (typeof value === "object") return JSON.stringify(value);
23
+ return String(value);
24
+ };
25
+ //#endregion
26
+ export { approval, approveLabel, formatCell, formatCredits, queueText, waitingText };
@@ -0,0 +1,13 @@
1
+ import { Plan } from "./model.js";
2
+ //#region src/assistant-dock/plan-card.d.ts
3
+ export type PlanCardProps = {
4
+ plan: Plan;
5
+ balance: number;
6
+ onApprove?: (taskId: string) => void;
7
+ /** Asks for the next revision; the dock moves to the chat box for what should change. */
8
+ onRequestChanges?: (taskId: string) => void;
9
+ onCancel?: (taskId: string) => void;
10
+ /** A free re-plan from the moved branch. */
11
+ onUpdatePlan?: (taskId: string) => void;
12
+ };
13
+ //#endregion
@@ -0,0 +1,133 @@
1
+ import { Badge } from "../primitives/badge.js";
2
+ import { Button } from "../primitives/button.js";
3
+ import { approval, approveLabel, formatCredits, waitingText } from "./model.js";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ import { AlertTriangle, GitBranch, ListChecks } from "lucide-react";
6
+ //#region src/assistant-dock/plan-card.tsx
7
+ const statusNote = {
8
+ awaiting: void 0,
9
+ approved: "Approved. The credits are held until the change is live.",
10
+ canceled: "Canceled. The 1-credit deposit was kept.",
11
+ superseded: "Replaced by a newer plan.",
12
+ dropped: "The repository changed, so this plan was dropped and the deposit refunded."
13
+ };
14
+ const PlanCard = ({ plan, balance, onApprove, onRequestChanges, onCancel, onUpdatePlan }) => {
15
+ const approve = approval(plan, balance);
16
+ const awaiting = plan.status === "awaiting";
17
+ const waiting = awaiting ? waitingText(plan.waiting) : void 0;
18
+ return /* @__PURE__ */ jsxs("section", {
19
+ "aria-label": `Plan, revision ${plan.revision}`,
20
+ className: "shrink-0 overflow-hidden rounded-lg border border-primary-border bg-background shadow-sm",
21
+ children: [/* @__PURE__ */ jsxs("header", {
22
+ className: "flex items-center gap-2 bg-primary-soft px-3 py-2",
23
+ children: [
24
+ /* @__PURE__ */ jsx(ListChecks, { className: "size-3.5 text-primary-text" }),
25
+ /* @__PURE__ */ jsxs("h3", {
26
+ className: "flex-1 text-[13px] font-semibold text-primary-text",
27
+ children: ["Plan", plan.revision > 1 && `, revision ${plan.revision}`]
28
+ }),
29
+ /* @__PURE__ */ jsxs(Badge, {
30
+ tone: "blue",
31
+ dot: false,
32
+ children: ["Size ", plan.size]
33
+ }),
34
+ /* @__PURE__ */ jsx("span", {
35
+ className: "text-xs font-medium text-primary-text",
36
+ children: formatCredits(plan.credits)
37
+ })
38
+ ]
39
+ }), /* @__PURE__ */ jsxs("div", {
40
+ className: "space-y-2.5 px-3 py-2.5",
41
+ children: [
42
+ /* @__PURE__ */ jsx("p", {
43
+ className: "text-[13px]",
44
+ children: plan.summary
45
+ }),
46
+ plan.steps.length > 0 && /* @__PURE__ */ jsx("ol", {
47
+ className: "list-decimal space-y-1 pl-5 text-xs text-muted-foreground",
48
+ children: plan.steps.map((step) => /* @__PURE__ */ jsx("li", { children: step.title }, step.title))
49
+ }),
50
+ plan.migrations.length > 0 && /* @__PURE__ */ jsxs("div", {
51
+ className: "space-y-1",
52
+ children: [/* @__PURE__ */ jsx("div", {
53
+ className: "text-xs font-semibold",
54
+ children: "Database changes"
55
+ }), /* @__PURE__ */ jsx("ul", {
56
+ className: "space-y-1",
57
+ children: plan.migrations.map((migration) => /* @__PURE__ */ jsxs("li", {
58
+ className: "flex items-start gap-2 text-xs",
59
+ children: [/* @__PURE__ */ jsx("span", {
60
+ className: "min-w-0 flex-1",
61
+ children: migration.description
62
+ }), migration.destructive && /* @__PURE__ */ jsx(Badge, {
63
+ tone: "red",
64
+ children: "Destructive"
65
+ })]
66
+ }, migration.description))
67
+ })]
68
+ }),
69
+ plan.risks.length > 0 && /* @__PURE__ */ jsx("ul", {
70
+ className: "space-y-1",
71
+ children: plan.risks.map((risk) => /* @__PURE__ */ jsxs("li", {
72
+ className: "flex items-start gap-2 text-xs text-warning-text",
73
+ children: [/* @__PURE__ */ jsx(AlertTriangle, { className: "mt-0.5 size-3 shrink-0" }), risk]
74
+ }, risk))
75
+ }),
76
+ awaiting && plan.outdated && /* @__PURE__ */ jsxs("p", {
77
+ role: "status",
78
+ className: "flex items-start gap-2 rounded-md bg-warning-soft px-2.5 py-2 text-xs text-warning-text",
79
+ children: [/* @__PURE__ */ jsx(GitBranch, { className: "mt-0.5 size-3 shrink-0" }), "The branch moved on GitHub since this plan was made. Approving runs it on the new code."]
80
+ }),
81
+ waiting && /* @__PURE__ */ jsxs("p", {
82
+ className: "text-xs text-muted-foreground",
83
+ children: [waiting, " Cancel frees the queue."]
84
+ }),
85
+ statusNote[plan.status] && /* @__PURE__ */ jsx("p", {
86
+ className: "text-xs text-muted-foreground",
87
+ children: statusNote[plan.status]
88
+ }),
89
+ awaiting && /* @__PURE__ */ jsxs("div", {
90
+ className: "space-y-2 pt-0.5",
91
+ children: [
92
+ /* @__PURE__ */ jsx(Button, {
93
+ variant: "primary",
94
+ className: "w-full",
95
+ disabled: !approve.enabled,
96
+ onClick: () => onApprove?.(plan.taskId),
97
+ children: approveLabel(plan)
98
+ }),
99
+ approve.reason && /* @__PURE__ */ jsx("p", {
100
+ className: "text-xs text-danger-text",
101
+ children: approve.reason
102
+ }),
103
+ /* @__PURE__ */ jsxs("div", {
104
+ className: "flex gap-2",
105
+ children: [
106
+ plan.outdated && /* @__PURE__ */ jsx(Button, {
107
+ size: "sm",
108
+ className: "flex-1",
109
+ onClick: () => onUpdatePlan?.(plan.taskId),
110
+ children: "Update plan"
111
+ }),
112
+ /* @__PURE__ */ jsx(Button, {
113
+ size: "sm",
114
+ className: "flex-1",
115
+ onClick: () => onRequestChanges?.(plan.taskId),
116
+ children: "Ask for changes"
117
+ }),
118
+ /* @__PURE__ */ jsx(Button, {
119
+ size: "sm",
120
+ variant: "ghost",
121
+ onClick: () => onCancel?.(plan.taskId),
122
+ children: "Cancel"
123
+ })
124
+ ]
125
+ })
126
+ ]
127
+ })
128
+ ]
129
+ })]
130
+ });
131
+ };
132
+ //#endregion
133
+ export { PlanCard };
@@ -0,0 +1,18 @@
1
+ import { StepList } from "../assistant-panel/step-list.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/assistant-dock/progress-card.tsx
4
+ const titles = {
5
+ planning: "Planning",
6
+ running: "Making the change"
7
+ };
8
+ /** What the planning run or the job is doing, step by step. */
9
+ const ProgressCard = ({ phase, steps }) => /* @__PURE__ */ jsxs("section", {
10
+ "aria-label": titles[phase],
11
+ className: "shrink-0 space-y-2 rounded-lg border border-border-strong bg-background px-3 py-2.5 shadow-sm",
12
+ children: [/* @__PURE__ */ jsx("h3", {
13
+ className: "text-xs font-semibold",
14
+ children: titles[phase]
15
+ }), /* @__PURE__ */ jsx(StepList, { steps })]
16
+ });
17
+ //#endregion
18
+ export { ProgressCard };
@@ -0,0 +1,52 @@
1
+ import { formatCell } from "./model.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { Database } from "lucide-react";
4
+ //#region src/assistant-dock/query-card.tsx
5
+ /** A read-only query the chat ran, with its rows as a table. */
6
+ const QueryCard = ({ query }) => /* @__PURE__ */ jsxs("section", {
7
+ "aria-label": "Query result",
8
+ className: "shrink-0 overflow-hidden rounded-lg border border-border-strong bg-background shadow-sm",
9
+ children: [
10
+ /* @__PURE__ */ jsxs("header", {
11
+ className: "flex items-start gap-2 bg-surface px-3 py-2",
12
+ children: [/* @__PURE__ */ jsx(Database, { className: "mt-0.5 size-3.5 shrink-0 text-muted-foreground" }), /* @__PURE__ */ jsx("code", {
13
+ className: "min-w-0 flex-1 whitespace-pre-wrap break-words font-mono text-[11px] text-foreground",
14
+ children: query.sql
15
+ })]
16
+ }),
17
+ query.rows.length === 0 ? /* @__PURE__ */ jsx("p", {
18
+ className: "px-3 py-2 text-xs text-muted-foreground",
19
+ children: "No rows."
20
+ }) : /* @__PURE__ */ jsx("div", {
21
+ className: "max-h-64 overflow-auto",
22
+ children: /* @__PURE__ */ jsxs("table", {
23
+ className: "w-full border-collapse text-left text-[11px]",
24
+ children: [/* @__PURE__ */ jsx("thead", {
25
+ className: "sticky top-0 bg-background",
26
+ children: /* @__PURE__ */ jsx("tr", { children: query.columns.map((column) => /* @__PURE__ */ jsx("th", {
27
+ scope: "col",
28
+ className: "border-b border-border px-2 py-1 font-semibold whitespace-nowrap",
29
+ children: column
30
+ }, column)) })
31
+ }), /* @__PURE__ */ jsx("tbody", { children: query.rows.map((row, index) => /* @__PURE__ */ jsx("tr", {
32
+ className: "border-b border-border last:border-b-0",
33
+ children: row.map((value, column) => /* @__PURE__ */ jsx("td", {
34
+ className: value === null ? "px-2 py-1 whitespace-nowrap text-faint-foreground" : "px-2 py-1 whitespace-nowrap",
35
+ children: formatCell(value)
36
+ }, column))
37
+ }, index)) })]
38
+ })
39
+ }),
40
+ /* @__PURE__ */ jsxs("footer", {
41
+ className: "border-t border-border px-3 py-1.5 text-[11px] text-muted-foreground",
42
+ children: [
43
+ query.rows.length,
44
+ " ",
45
+ query.rows.length === 1 ? "row" : "rows",
46
+ query.truncated && ", cut off at the limit"
47
+ ]
48
+ })
49
+ ]
50
+ });
51
+ //#endregion
52
+ export { QueryCard };
@@ -0,0 +1,31 @@
1
+ import { Button } from "../primitives/button.js";
2
+ import { queueText } from "./model.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { Clock } from "lucide-react";
5
+ //#region src/assistant-dock/queue-card.tsx
6
+ /** A task waiting in the app's queue, with its deposit billed. */
7
+ const QueueCard = ({ taskId, request, ahead, waitingOnPlan, onCancel }) => /* @__PURE__ */ jsxs("section", {
8
+ "aria-label": "Queued change",
9
+ className: "flex shrink-0 items-start gap-2.5 rounded-lg border border-border-strong bg-background px-3 py-2.5 shadow-sm",
10
+ children: [
11
+ /* @__PURE__ */ jsx(Clock, { className: "mt-0.5 size-3.5 shrink-0 text-muted-foreground" }),
12
+ /* @__PURE__ */ jsxs("div", {
13
+ className: "min-w-0 flex-1 space-y-0.5",
14
+ children: [/* @__PURE__ */ jsx("div", {
15
+ className: "truncate text-[13px] font-medium",
16
+ children: request
17
+ }), /* @__PURE__ */ jsx("div", {
18
+ className: "text-xs text-muted-foreground",
19
+ children: queueText(ahead, waitingOnPlan)
20
+ })]
21
+ }),
22
+ /* @__PURE__ */ jsx(Button, {
23
+ variant: "ghost",
24
+ size: "sm",
25
+ onClick: () => onCancel?.(taskId),
26
+ children: "Cancel"
27
+ })
28
+ ]
29
+ });
30
+ //#endregion
31
+ export { QueueCard };
@@ -0,0 +1,77 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { formatCredits } from "./model.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { CircleCheck, CircleX, GitCommitHorizontal } from "lucide-react";
5
+ //#region src/assistant-dock/result-card.tsx
6
+ const headings = {
7
+ live: "Live now",
8
+ failed: "The change failed",
9
+ capped: "The change hit its limit"
10
+ };
11
+ /** How a job ended: live with its commit, or failed or capped at no cost, with the database changes that stay live. */
12
+ const ResultCard = ({ result }) => {
13
+ const live = result.outcome === "live";
14
+ const Icon = live ? CircleCheck : CircleX;
15
+ return /* @__PURE__ */ jsxs("section", {
16
+ "aria-label": headings[result.outcome],
17
+ className: cn("shrink-0 overflow-hidden rounded-lg border bg-background shadow-sm", live ? "border-success/40" : "border-danger/40"),
18
+ children: [/* @__PURE__ */ jsxs("header", {
19
+ className: cn("flex items-center gap-2 px-3 py-2", live ? "bg-success-soft text-success-text" : "bg-danger-soft text-danger-text"),
20
+ children: [
21
+ /* @__PURE__ */ jsx(Icon, { className: "size-3.5" }),
22
+ /* @__PURE__ */ jsx("h3", {
23
+ className: "flex-1 text-[13px] font-semibold",
24
+ children: headings[result.outcome]
25
+ }),
26
+ /* @__PURE__ */ jsx("span", {
27
+ className: "text-xs font-medium",
28
+ children: live ? formatCredits(result.credits) : "No charge"
29
+ })
30
+ ]
31
+ }), /* @__PURE__ */ jsxs("div", {
32
+ className: "space-y-2.5 px-3 py-2.5",
33
+ children: [
34
+ /* @__PURE__ */ jsx("p", {
35
+ className: "text-[13px]",
36
+ children: result.summary
37
+ }),
38
+ result.commit && /* @__PURE__ */ jsxs("div", {
39
+ className: "flex items-center gap-2 text-xs text-muted-foreground",
40
+ children: [/* @__PURE__ */ jsx(GitCommitHorizontal, { className: "size-3.5" }), result.commit.url ? /* @__PURE__ */ jsx("a", {
41
+ className: "font-mono text-primary-text hover:underline",
42
+ href: result.commit.url,
43
+ target: "_blank",
44
+ rel: "noreferrer",
45
+ children: result.commit.sha.slice(0, 7)
46
+ }) : /* @__PURE__ */ jsx("span", {
47
+ className: "font-mono",
48
+ children: result.commit.sha.slice(0, 7)
49
+ })]
50
+ }),
51
+ result.keptOnBranch && /* @__PURE__ */ jsxs("p", {
52
+ className: "text-xs text-muted-foreground",
53
+ children: [
54
+ "GitHub did not accept the push. The code is kept on the branch ",
55
+ /* @__PURE__ */ jsx("code", {
56
+ className: "font-mono",
57
+ children: result.keptOnBranch
58
+ }),
59
+ "."
60
+ ]
61
+ }),
62
+ result.dbChanges.length > 0 && /* @__PURE__ */ jsxs("div", {
63
+ className: "space-y-1",
64
+ children: [/* @__PURE__ */ jsx("div", {
65
+ className: "text-xs font-semibold",
66
+ children: live ? "Database changes" : "Database changes that stay live"
67
+ }), /* @__PURE__ */ jsx("ul", {
68
+ className: "space-y-0.5 font-mono text-[11px] text-muted-foreground",
69
+ children: result.dbChanges.map((change) => /* @__PURE__ */ jsx("li", { children: change }, change))
70
+ })]
71
+ })
72
+ ]
73
+ })]
74
+ });
75
+ };
76
+ //#endregion
77
+ export { ResultCard };
@@ -0,0 +1,7 @@
1
+ //#region src/assistant-panel/content.d.ts
2
+ export type StepState = 'done' | 'running' | 'failed';
3
+ export type Step = {
4
+ label: string;
5
+ state: StepState;
6
+ };
7
+ //#endregion
@@ -0,0 +1,30 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { Spinner } from "../primitives/spinner.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { Check, X } from "lucide-react";
5
+ //#region src/assistant-panel/step-list.tsx
6
+ const StepList = ({ steps }) => /* @__PURE__ */ jsx("ul", {
7
+ className: "shrink-0 space-y-2",
8
+ children: steps.map((step) => /* @__PURE__ */ jsxs("li", {
9
+ className: "flex items-start gap-2.5 text-[13px]",
10
+ children: [/* @__PURE__ */ jsxs("span", {
11
+ className: "mt-0.5 flex size-4 shrink-0 items-center justify-center",
12
+ children: [
13
+ step.state === "running" && /* @__PURE__ */ jsx(Spinner, { className: "size-3.5 text-primary" }),
14
+ step.state === "done" && /* @__PURE__ */ jsx(Check, {
15
+ className: "size-3.5 text-success",
16
+ strokeWidth: 2.5
17
+ }),
18
+ step.state === "failed" && /* @__PURE__ */ jsx(X, {
19
+ className: "size-3.5 text-danger",
20
+ strokeWidth: 2.5
21
+ })
22
+ ]
23
+ }), /* @__PURE__ */ jsx("span", {
24
+ className: cn(step.state === "running" ? "text-foreground" : "text-muted-foreground", step.state === "failed" && "text-danger-text"),
25
+ children: step.label
26
+ })]
27
+ }, step.label))
28
+ });
29
+ //#endregion
30
+ export { StepList };
@@ -12,6 +12,7 @@ const useAuth = () => {
12
12
  const AuthProvider = ({ session: given, children }) => {
13
13
  const [state, setState] = useState({ kind: "loading" });
14
14
  const [passwordReset, setPasswordReset] = useState(false);
15
+ const [socialProviders, setSocialProviders] = useState([]);
15
16
  const markSignedOut = useCallback(() => setState((current) => current.kind === "signed-in" ? { kind: "signed-out" } : current), []);
16
17
  const signedOut = useRef(markSignedOut);
17
18
  signedOut.current = markSignedOut;
@@ -20,6 +21,7 @@ const AuthProvider = ({ session: given, children }) => {
20
21
  setState({ kind: "loading" });
21
22
  session.status().then(async (status) => {
22
23
  setPasswordReset(status.passwordReset);
24
+ setSocialProviders(status.socialProviders);
23
25
  if (status.needsAdmin) return setState({ kind: "needs-admin" });
24
26
  const user = await session.session();
25
27
  setState(user ? {
@@ -36,6 +38,7 @@ const AuthProvider = ({ session: given, children }) => {
36
38
  state,
37
39
  session,
38
40
  passwordReset,
41
+ socialProviders,
39
42
  markSignedOut,
40
43
  recheck,
41
44
  signIn: async (email, password) => {
@@ -44,6 +47,9 @@ const AuthProvider = ({ session: given, children }) => {
44
47
  user: await session.signIn(email, password)
45
48
  });
46
49
  },
50
+ signInSocial: async (provider) => {
51
+ window.location.assign(await session.signInSocial(provider, window.location.href));
52
+ },
47
53
  signOut: async () => {
48
54
  await session.signOut();
49
55
  setState({ kind: "signed-out" });
@@ -52,6 +58,7 @@ const AuthProvider = ({ session: given, children }) => {
52
58
  state,
53
59
  session,
54
60
  passwordReset,
61
+ socialProviders,
55
62
  markSignedOut,
56
63
  recheck
57
64
  ]);
@@ -2,72 +2,88 @@ import { Input } from "../primitives/input.js";
2
2
  import { Button } from "../primitives/button.js";
3
3
  import { AuthLayout } from "./auth-layout.js";
4
4
  import { useState } from "react";
5
- import { jsx, jsxs } from "react/jsx-runtime";
5
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
6
6
  //#region src/auth/sign-in-form.tsx
7
- const SignInForm = ({ onSubmit, busy, error, workspace, notice, onForgotPassword }) => {
7
+ const SignInForm = ({ onSubmit, busy, error, workspace, notice, onForgotPassword, onContinueWithGitHub }) => {
8
8
  const [email, setEmail] = useState("");
9
9
  const [password, setPassword] = useState("");
10
10
  return /* @__PURE__ */ jsxs(AuthLayout, {
11
11
  title: "Sign in to Protobase",
12
12
  description: workspace,
13
- children: [notice && /* @__PURE__ */ jsx("p", {
14
- role: "status",
15
- className: "mb-4 rounded-md bg-success-soft px-3 py-2 text-xs font-medium text-success-text",
16
- children: notice
17
- }), /* @__PURE__ */ jsxs("form", {
18
- className: "flex flex-col gap-4",
19
- onSubmit: (event) => {
20
- event.preventDefault();
21
- onSubmit(email.trim(), password);
22
- },
23
- children: [
24
- /* @__PURE__ */ jsxs("label", {
25
- className: "flex flex-col gap-1.5 text-[13px] font-medium",
26
- children: ["Email", /* @__PURE__ */ jsx(Input, {
27
- type: "email",
28
- name: "email",
29
- autoComplete: "username",
30
- required: true,
31
- autoFocus: true,
32
- value: email,
33
- invalid: Boolean(error),
34
- onChange: (event) => setEmail(event.target.value),
35
- className: "min-h-6"
36
- })]
37
- }),
38
- /* @__PURE__ */ jsxs("label", {
39
- className: "flex flex-col gap-1.5 text-[13px] font-medium",
40
- children: ["Password", /* @__PURE__ */ jsx(Input, {
41
- type: "password",
42
- name: "password",
43
- autoComplete: "current-password",
44
- required: true,
45
- value: password,
46
- invalid: Boolean(error),
47
- onChange: (event) => setPassword(event.target.value),
48
- className: "min-h-6"
49
- })]
50
- }),
51
- error && /* @__PURE__ */ jsx("p", {
52
- role: "alert",
53
- className: "text-xs font-medium text-danger-text",
54
- children: error
55
- }),
56
- /* @__PURE__ */ jsx(Button, {
57
- variant: "primary",
58
- type: "submit",
59
- loading: busy,
60
- className: "min-h-10",
61
- children: "Sign in"
62
- }),
63
- onForgotPassword && /* @__PURE__ */ jsx("button", {
64
- type: "button",
65
- onClick: onForgotPassword,
66
- className: "self-center text-[13px] font-medium text-primary-text hover:underline",
67
- children: "Forgot password?"
68
- })
69
- ]
70
- })]
13
+ children: [
14
+ notice && /* @__PURE__ */ jsx("p", {
15
+ role: "status",
16
+ className: "mb-4 rounded-md bg-success-soft px-3 py-2 text-xs font-medium text-success-text",
17
+ children: notice
18
+ }),
19
+ onContinueWithGitHub && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(Button, {
20
+ onClick: onContinueWithGitHub,
21
+ disabled: busy,
22
+ className: "min-h-10 w-full",
23
+ children: "Continue with GitHub"
24
+ }), /* @__PURE__ */ jsxs("div", {
25
+ className: "my-4 flex items-center gap-3 text-xs text-muted-foreground",
26
+ children: [
27
+ /* @__PURE__ */ jsx("span", { className: "h-px flex-1 bg-border" }),
28
+ "or",
29
+ /* @__PURE__ */ jsx("span", { className: "h-px flex-1 bg-border" })
30
+ ]
31
+ })] }),
32
+ /* @__PURE__ */ jsxs("form", {
33
+ className: "flex flex-col gap-4",
34
+ onSubmit: (event) => {
35
+ event.preventDefault();
36
+ onSubmit(email.trim(), password);
37
+ },
38
+ children: [
39
+ /* @__PURE__ */ jsxs("label", {
40
+ className: "flex flex-col gap-1.5 text-[13px] font-medium",
41
+ children: ["Email", /* @__PURE__ */ jsx(Input, {
42
+ type: "email",
43
+ name: "email",
44
+ autoComplete: "username",
45
+ required: true,
46
+ autoFocus: true,
47
+ value: email,
48
+ invalid: Boolean(error),
49
+ onChange: (event) => setEmail(event.target.value),
50
+ className: "min-h-6"
51
+ })]
52
+ }),
53
+ /* @__PURE__ */ jsxs("label", {
54
+ className: "flex flex-col gap-1.5 text-[13px] font-medium",
55
+ children: ["Password", /* @__PURE__ */ jsx(Input, {
56
+ type: "password",
57
+ name: "password",
58
+ autoComplete: "current-password",
59
+ required: true,
60
+ value: password,
61
+ invalid: Boolean(error),
62
+ onChange: (event) => setPassword(event.target.value),
63
+ className: "min-h-6"
64
+ })]
65
+ }),
66
+ error && /* @__PURE__ */ jsx("p", {
67
+ role: "alert",
68
+ className: "text-xs font-medium text-danger-text",
69
+ children: error
70
+ }),
71
+ /* @__PURE__ */ jsx(Button, {
72
+ variant: "primary",
73
+ type: "submit",
74
+ loading: busy,
75
+ className: "min-h-10",
76
+ children: "Sign in"
77
+ }),
78
+ onForgotPassword && /* @__PURE__ */ jsx("button", {
79
+ type: "button",
80
+ onClick: onForgotPassword,
81
+ className: "self-center text-[13px] font-medium text-primary-text hover:underline",
82
+ children: "Forgot password?"
83
+ })
84
+ ]
85
+ })
86
+ ]
71
87
  });
72
88
  };
73
89
  //#endregion
@@ -11,14 +11,14 @@ const signInMessage = (error) => {
11
11
  return error.message;
12
12
  };
13
13
  const SignInPage = ({ workspace, notice, onForgotPassword }) => {
14
- const { signIn } = useAuth();
14
+ const { signIn, signInSocial, socialProviders } = useAuth();
15
15
  const [busy, setBusy] = useState(false);
16
16
  const [error, setError] = useState();
17
- const submit = async (email, password) => {
17
+ const attempt = async (start) => {
18
18
  setBusy(true);
19
19
  setError(void 0);
20
20
  try {
21
- await signIn(email, password);
21
+ await start();
22
22
  } catch (failure) {
23
23
  if (!(failure instanceof AuthError)) throw failure;
24
24
  setError(signInMessage(failure));
@@ -31,7 +31,8 @@ const SignInPage = ({ workspace, notice, onForgotPassword }) => {
31
31
  workspace,
32
32
  notice,
33
33
  onForgotPassword,
34
- onSubmit: (email, password) => void submit(email, password)
34
+ onContinueWithGitHub: socialProviders.includes("github") ? () => void attempt(() => signInSocial("github")) : void 0,
35
+ onSubmit: (email, password) => void attempt(() => signIn(email, password))
35
36
  });
36
37
  };
37
38
  //#endregion
package/dist/index.d.ts CHANGED
@@ -9,4 +9,6 @@ import { useRecord } from "./data/use-record.js";
9
9
  import { useFacets, useHistogram, useSeries } from "./data/use-aggregates.js";
10
10
  import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
11
11
  import { LayoutRecord, useLayoutRecord } from "./app/pages/record-scope.js";
12
- export { type ActionContext, type ActionHandler, ApiProvider, type ApiProviderProps, App, type AppProps, type LayoutRecord, type MetaData, type ProjectUi, defineUi, isConflict, useClient, useFacets, useHistogram, useLayoutRecord, useList, useMeta, useRecord, useSeries, useUpdateRecord };
12
+ import { DockItem, JobResult, Plan, PlanSize, QueryResult } from "./assistant-dock/model.js";
13
+ import { AssistantDock, AssistantDockProps } from "./assistant-dock/assistant-dock.js";
14
+ export { type ActionContext, type ActionHandler, ApiProvider, type ApiProviderProps, App, type AppProps, AssistantDock, type AssistantDockProps, type DockItem, type JobResult, type LayoutRecord, type MetaData, type Plan, type PlanSize, type ProjectUi, type QueryResult, defineUi, isConflict, useClient, useFacets, useHistogram, useLayoutRecord, useList, useMeta, useRecord, useSeries, useUpdateRecord };
package/dist/index.js CHANGED
@@ -7,4 +7,5 @@ import { defineUi } from "./app/pages/project-ui.js";
7
7
  import { useList } from "./data/use-list.js";
8
8
  import { isConflict, useUpdateRecord } from "./data/use-update-record.js";
9
9
  import { App } from "./app/app.js";
10
- export { ApiProvider, App, defineUi, isConflict, useClient, useFacets, useHistogram, useLayoutRecord, useList, useMeta, useRecord, useSeries, useUpdateRecord };
10
+ import { AssistantDock } from "./assistant-dock/assistant-dock.js";
11
+ export { ApiProvider, App, AssistantDock, defineUi, isConflict, useClient, useFacets, useHistogram, useLayoutRecord, useList, useMeta, useRecord, useSeries, useUpdateRecord };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/ui",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "The Protobase admin app and the React hooks and components for custom UI.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -38,9 +38,9 @@
38
38
  "react-dom": "^19.3.0",
39
39
  "recharts": "^3.10.1",
40
40
  "tailwindcss": "^4.3.3",
41
- "@protobase/client": "0.1.0",
42
- "@protobase/schema": "0.1.0",
43
- "@protobase/layout": "0.1.0"
41
+ "@protobase/client": "0.1.2",
42
+ "@protobase/schema": "0.1.2",
43
+ "@protobase/layout": "0.1.2"
44
44
  },
45
45
  "scripts": {
46
46
  "build": "tsdown"