@checkstack/ai-frontend 0.1.0
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/CHANGELOG.md +75 -0
- package/package.json +32 -0
- package/src/components/AppliedCardView.tsx +36 -0
- package/src/components/ConfirmCardView.tsx +117 -0
- package/src/components/DiffView.tsx +84 -0
- package/src/components/SideBySideDiff.tsx +120 -0
- package/src/index.tsx +22 -0
- package/src/lib/chat-state.test.ts +213 -0
- package/src/lib/chat-state.ts +231 -0
- package/src/lib/line-diff.test.ts +87 -0
- package/src/lib/line-diff.ts +206 -0
- package/src/lib/mode-toggle.logic.test.ts +64 -0
- package/src/lib/mode-toggle.logic.ts +57 -0
- package/src/lib/model-options.logic.test.ts +55 -0
- package/src/lib/model-options.logic.ts +31 -0
- package/src/lib/new-chat.logic.test.ts +84 -0
- package/src/lib/new-chat.logic.ts +62 -0
- package/src/lib/stream-parser.test.ts +241 -0
- package/src/lib/stream-parser.ts +286 -0
- package/src/lib/use-chat-turn.ts +163 -0
- package/src/pages/ChatPage.tsx +661 -0
- package/tsconfig.json +23 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# @checkstack/ai-frontend
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9dcc848: AI chat UX: ordered turns, readable diffs, persistent errors, auto-titles, decision acknowledgments, and a smarter topical guard.
|
|
8
|
+
|
|
9
|
+
- Turns render as ordered parts (text / tool-call status / confirm card) in chronological order, with inline tool-error lines and a mid-turn "Thinking..." indicator, instead of one text blob plus a flat tool list. The confirm card and tool-step parts no longer vanish after a turn finishes (hydration seeds once per conversation id via `useInitOnceForKey`, so background refetches are no-ops).
|
|
10
|
+
- Errors persist: in-stream provider errors are lifted into the chat hook's durable error state and shown in a dismissible banner with selectable text and a Copy button (single-line digest, full text on hover); it clears on send / open / new chat. The backend installs an `onError` handler that logs the provider's full HTTP response and returns a readable message, and normalizes the model message history (drop empty rows, merge consecutive same-role rows, strip a leading non-user row) so a single provider hiccup can no longer brick a conversation.
|
|
11
|
+
- Confirm/applied card diffs render as a GitHub-style split diff (line-number gutters, per-line tint, word-level highlighting, an "Expand" pop-out). `computeFieldDiff` recurses into arrays element-wise so a single changed leaf is pinpointed instead of dumping whole serialized arrays.
|
|
12
|
+
- Conversations auto-title after the first user message (cheap `generateText` reusing the turn's model, fire-and-forget, heuristic fallback). "New chat" opens immediately and reuses an empty untitled draft instead of spawning duplicates; "Delete" is a soft archive (`archived_at` on `ai_conversations`, data retained). A clean model picker always renders a `Select` of `[defaultModel, ...availableModels]` de-duplicated.
|
|
13
|
+
- The assistant acknowledges a confirm-card decision (a new `decision` mode -> `streamDecision`) instead of going silent after an apply/decline; the decision note is derived server-side from the stored proposal and is ephemeral.
|
|
14
|
+
- A cheap topical pre-classifier short-circuits off-topic turns with a canned refusal (fail-open, spend recorded). It marks meta/capability/greeting/how-to questions as ON_TOPIC; only clearly unrelated requests (coding help, creative writing, trivia) are refused.
|
|
15
|
+
- The chat agent no longer emits duplicate proposals for one request: propose/auto-apply results carry an explicit model-facing "stop and wait" note, and a per-turn `<tool>:<argsHash>` dedupe short-circuits repeated identical mutating calls.
|
|
16
|
+
- Assistant messages render through the shared `<MarkdownBlock>`: it now parses a SAFE subset of raw HTML (`rehype-raw` + `rehype-sanitize`) so native `<details>`/`<summary>` widgets render, and enables `remark-gfm` so GFM tables, strikethrough, and autolinks render (the assistant often summarizes drafts as tables).
|
|
17
|
+
|
|
18
|
+
State and scale: the archive marker, titles, and permission mode all live in the shared `ai_conversations` table, read identically on every pod; the classifier holds no state and its spend is recorded in the shared `ai_spend` ledger. No new pod-local state.
|
|
19
|
+
|
|
20
|
+
This is a beta minor.
|
|
21
|
+
|
|
22
|
+
- 9dcc848: Add the AI platform: a transport-agnostic tool spine, an OAuth Authorization Server + read-only MCP server, a propose/apply flow with audit log, a streaming in-app chat agent, per-conversation permission modes, per-integration spend caps, and user-scoped tool authorization.
|
|
23
|
+
|
|
24
|
+
Two new packages, `@checkstack/ai-common` (the `AiTool` contract, `read`/`mutate`/`destructive` effect classification, the `ai.*` access rules, the OpenAI-compatible connection shape, and the wire contracts) and `@checkstack/ai-backend` (the tool registry, extension points, principal-to-tool resolver, shared zod-to-JSON-Schema serializer, and all transports). The OpenAI-compatible integration provider registers through the existing integration provider extension point, so its API key is stored in the Secrets Vault and configured in the generic Connections UI.
|
|
25
|
+
|
|
26
|
+
What ships:
|
|
27
|
+
|
|
28
|
+
- Tool spine and extension points: `aiToolExtensionPoint.registerTool` (hand-authored composite tools) and `aiToolProjectionExtensionPoint.expose` (opt-in projections of existing oRPC procedures). Authorization mirrors `autoAuthMiddleware` exactly - a tool is surfaced only when every `requiredAccessRules` entry is satisfied, so a scope-narrowed principal can only ever see fewer tools.
|
|
29
|
+
- OAuth + MCP: Checkstack can act as its own OAuth 2.1 Authorization Server (authorization code + PKCE, consent screen, Dynamic Client Registration) and expose a read-only MCP server over Streamable HTTP at `/api/ai/mcp`. Off by default, enabled by the admin `ai.mcp-oauth` setting. A Bearer OAuth-token branch is added to the auth strategy; token scopes are intersected live with the bound user's access rules on every call. A shared-Postgres rate limiter throttles the DCR endpoint per client IP. `getMcpOAuthSettings` / `setMcpOAuthSettings` contracts added to `@checkstack/auth-common`. A minimal OAuth consent page (`/auth/oauth-consent`) renders the requesting client and scopes.
|
|
30
|
+
- Propose/apply + audit: a transport-agnostic two-step service - `propose` re-checks authz, runs the tool's `dryRun` without mutating, and returns a single-use proposal token (the `proposed` audit row IS the token store, 10-minute TTL, atomic single-use); `apply` re-parses the server-stored payload, re-checks authz, and atomically commits. The `ai_tool_calls` audit table records every call across both transports with a SHA-256 args hash (never raw arguments) and stamps who proposed and who applied. An `ai.toolCalled` event carries metadata only.
|
|
31
|
+
- In-app chat: a server-side, provider-agnostic Vercel AI SDK agent loop (OpenAI, Azure, OpenRouter, Ollama, vLLM, LM Studio, ...). The model provider is built on the backend from the integration credentials, so the API key never leaves the backend. The loop offers only resolver-allowed tools, auto-runs read tools (re-entering the live router as the logged-in user) and routes mutating / destructive tools through propose/apply. Durable conversation persistence (`ai_conversations`, `ai_messages`, owner-scoped RPCs) plus a streaming chat UI with a confirm-card component and per-integration model picker.
|
|
32
|
+
- Per-conversation permission mode (Claude-Code-style approve/auto), a durable `permission_mode` column on `ai_conversations` (default `approve`). `read` always auto-runs in both modes; `mutate` inherits the mode (auto-applies server-side in `auto`, confirm-carded in `approve`); `destructive` ALWAYS requires the human `applyTool` in both modes. Security invariant (structural + tested): the mode is consulted only on the `mutate` branch, so no `(effect, mode)` pair routes a destructive tool to auto-apply.
|
|
33
|
+
- Per-integration LLM spend cap (optional `spendCap` = `tokenBudget` + `windowMinutes`, default OFF). Spend is tracked in a shared-Postgres `ai_spend` ledger; enforcement is a rolling-window SUM run before each turn (HTTP 429 over budget). Per-principal tool rate-limit budgets are a rolling COUNT over `ai_tool_calls`, enforced on both transports. An absent / empty / incomplete `spendCap` is treated as "no cap" rather than rejected.
|
|
34
|
+
- Full tool-call replay: `ai_messages.model_messages` (jsonb) persists the canonical AI-SDK `ResponseMessage[]` per turn and replays them verbatim on the next turn; legacy rows fall back to text-only replay.
|
|
35
|
+
- Enforced no-secret-leak scrubbing: `appendMessage` runs `scrubContent` on every write, redacting credential-shaped keys and high-confidence credential values; a canary regression test asserts injected secrets are stripped. A hardening test suite asserts no secret appears in any AI-surface DTO and that handler-side authz holds when the model misbehaves.
|
|
36
|
+
- Provider correctness: the chat provider uses `@ai-sdk/openai-compatible`'s `chatModel` (plain `/chat/completions`), so OpenAI-compatible gateways (OpenRouter, DeepSeek, Ollama, vLLM) no longer reject turns with `invalid_prompt`; `@ai-sdk/openai` is removed.
|
|
37
|
+
|
|
38
|
+
BREAKING CHANGES:
|
|
39
|
+
|
|
40
|
+
- The `AiTool` contract (`@checkstack/ai-common`) gained a `TRpc` type parameter, and both `dryRun` and `execute` now receive a USER-SCOPED `rpcClient` arg bound to the originating user. Every plugin procedure a tool calls re-enters the live router AS THAT USER, so handler-side authorization (access rules AND per-resource/team scope) is enforced exactly as a direct UI/RPC call - closing a prior privilege-escalation where tools captured a trusted service client at construction. A hand-authored tool MUST resolve its plugin client from this per-call arg and MUST NOT capture a trusted service client at factory scope. Tool factories that previously took `{ rpcClient }` should drop that parameter.
|
|
41
|
+
- `AiToolProjectionExtensionPoint.expose` no longer takes a second `pluginMetadata` argument; the owning metadata lives on `input.sourcePluginMetadata`. Callers must drop the second argument.
|
|
42
|
+
|
|
43
|
+
State and scale: conversations, messages, the audit log, proposal tokens, the rate-limit counter, and the spend ledger all live in shared Postgres, so every pod answers identically and the agent loop is resumable on any pod. The only pod-local state is the live MCP connection registry (bookkeeping, never a source of truth). Cross-pod conversation readback, the spend cap, and the tool budget are verified by env-gated two-pod integration tests.
|
|
44
|
+
|
|
45
|
+
This is a beta minor.
|
|
46
|
+
|
|
47
|
+
### Patch Changes
|
|
48
|
+
|
|
49
|
+
- 9dcc848: Move primary navigation into a left sidebar, and serve the user guide in-app.
|
|
50
|
+
|
|
51
|
+
Feature navigation (a ~20-item user-menu dropdown) now lives in a persistent left sidebar (a slide-over drawer on mobile), grouped by section with the active route highlighted; the user menu keeps only account actions. A route opts into the sidebar with new `nav` metadata (`{ group, icon, label?, order?, accessRule? }`) on its registration, co-located with path + access + title. The sidebar filters entries with the same access check as page guards. `@checkstack/common` gains `isAccessRuleSatisfied` and a centralized set of in-app doc slugs (`APP_DOC_SLUGS` + `docsPath`, with a test asserting each resolves to a real docs page); `@checkstack/auth-frontend` exports `useAccessRules`.
|
|
52
|
+
|
|
53
|
+
The backend now serves the Astro Starlight docs build same-origin at `/checkstack/*` (the same artifact deployed to GitHub Pages), so the user guide is available inside the app including for self-hosted / air-gapped installs (served verbatim, no rebuild, no link rewriting; from `CHECKSTACK_DOCS_DIST`, before the SPA catch-all, degrading gracefully when absent; the Docker image builds and ships `docs/dist`; Vite proxies `/checkstack` in dev). The "Docs" link is a shell-owned external sidebar entry under the Documentation group (book icon), opening `/checkstack/user-guide/` in a new tab; the group renders even when no plugin route contributes to it.
|
|
54
|
+
|
|
55
|
+
BREAKING (plugin authors): `UserMenuItemsSlot` is no longer the way to add navigation - registering a top user-menu item no longer surfaces it anywhere. Add `nav` to the page's route instead. `UserMenuItemsBottomSlot` (account items) is unchanged. All bundled plugins have been migrated.
|
|
56
|
+
|
|
57
|
+
This is a beta minor.
|
|
58
|
+
|
|
59
|
+
- Updated dependencies [9dcc848]
|
|
60
|
+
- Updated dependencies [9dcc848]
|
|
61
|
+
- Updated dependencies [9dcc848]
|
|
62
|
+
- Updated dependencies [9dcc848]
|
|
63
|
+
- Updated dependencies [9dcc848]
|
|
64
|
+
- Updated dependencies [9dcc848]
|
|
65
|
+
- Updated dependencies [9dcc848]
|
|
66
|
+
- Updated dependencies [9dcc848]
|
|
67
|
+
- Updated dependencies [9dcc848]
|
|
68
|
+
- Updated dependencies [9dcc848]
|
|
69
|
+
- Updated dependencies [9dcc848]
|
|
70
|
+
- Updated dependencies [9dcc848]
|
|
71
|
+
- @checkstack/ai-common@0.1.0
|
|
72
|
+
- @checkstack/ui@1.13.0
|
|
73
|
+
- @checkstack/common@0.13.0
|
|
74
|
+
- @checkstack/frontend-api@0.7.0
|
|
75
|
+
- @checkstack/integration-common@0.7.0
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/ai-frontend",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "Elastic-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.tsx",
|
|
7
|
+
"checkstack": {
|
|
8
|
+
"type": "frontend"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"typecheck": "tsgo -b",
|
|
12
|
+
"lint": "bun run lint:code",
|
|
13
|
+
"lint:code": "eslint . --max-warnings 0",
|
|
14
|
+
"test": "bun test"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@checkstack/ai-common": "0.0.0",
|
|
18
|
+
"@checkstack/common": "0.12.0",
|
|
19
|
+
"@checkstack/frontend-api": "0.6.0",
|
|
20
|
+
"@checkstack/integration-common": "0.6.0",
|
|
21
|
+
"@checkstack/ui": "1.12.0",
|
|
22
|
+
"lucide-react": "^1.17.0",
|
|
23
|
+
"react": "^18.3.1",
|
|
24
|
+
"react-router-dom": "^7.16.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"typescript": "^5.0.0",
|
|
28
|
+
"@types/react": "^18.2.0",
|
|
29
|
+
"@checkstack/tsconfig": "0.0.7",
|
|
30
|
+
"@checkstack/scripts": "0.3.4"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Card, CardHeader, CardTitle, CardContent, Badge } from "@checkstack/ui";
|
|
2
|
+
import { CheckCircle2 } from "lucide-react";
|
|
3
|
+
import type { AppliedCard } from "../lib/stream-parser";
|
|
4
|
+
import { DiffView } from "./DiffView";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Read-only card for a change the assistant AUTO-APPLIED (auto mode). The change
|
|
8
|
+
* already took effect, so there are no Apply/Decline buttons - this exists so the
|
|
9
|
+
* operator ALWAYS sees what was changed or created, even when no confirmation was
|
|
10
|
+
* required. For an update it shows the before -> after diff; for a create it shows
|
|
11
|
+
* the created object.
|
|
12
|
+
*/
|
|
13
|
+
export function AppliedCardView({ card }: { card: AppliedCard }) {
|
|
14
|
+
const hasDiff = card.diff && card.diff.length > 0;
|
|
15
|
+
return (
|
|
16
|
+
<Card className="border-primary/40">
|
|
17
|
+
<CardHeader className="flex flex-row items-center gap-2">
|
|
18
|
+
<CheckCircle2 className="w-4 h-4 text-primary" />
|
|
19
|
+
<CardTitle className="text-sm">Applied: {card.toolName}</CardTitle>
|
|
20
|
+
<Badge variant="secondary">applied</Badge>
|
|
21
|
+
</CardHeader>
|
|
22
|
+
<CardContent className="space-y-3">
|
|
23
|
+
<p className="text-sm text-muted-foreground">{card.summary}</p>
|
|
24
|
+
{hasDiff ? (
|
|
25
|
+
<div className="rounded bg-muted p-2">
|
|
26
|
+
<DiffView diff={card.diff ?? []} />
|
|
27
|
+
</div>
|
|
28
|
+
) : card.result !== undefined && card.result !== null ? (
|
|
29
|
+
<pre className="text-xs bg-muted rounded p-2 overflow-auto max-h-48">
|
|
30
|
+
{JSON.stringify(card.result, null, 2)}
|
|
31
|
+
</pre>
|
|
32
|
+
) : null}
|
|
33
|
+
</CardContent>
|
|
34
|
+
</Card>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Card,
|
|
4
|
+
CardHeader,
|
|
5
|
+
CardTitle,
|
|
6
|
+
CardContent,
|
|
7
|
+
Button,
|
|
8
|
+
Badge,
|
|
9
|
+
} from "@checkstack/ui";
|
|
10
|
+
import { usePluginClient } from "@checkstack/frontend-api";
|
|
11
|
+
import { AiApi } from "@checkstack/ai-common";
|
|
12
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
13
|
+
import { ShieldAlert, Check, X } from "lucide-react";
|
|
14
|
+
import type { ConfirmCard } from "../lib/stream-parser";
|
|
15
|
+
import { DiffView } from "./DiffView";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Renders a CONFIRM CARD for a mutate/destructive tool the model proposed. The
|
|
19
|
+
* model never silently mutates: nothing happens until the operator clicks
|
|
20
|
+
* Apply, which consumes the single-use proposal token via `applyTool`.
|
|
21
|
+
*
|
|
22
|
+
* After the operator applies OR declines, `onDecision` is fired so the page can
|
|
23
|
+
* stream the model's acknowledgment of the outcome (the conversation continues
|
|
24
|
+
* instead of dead-ending on "waiting for your confirmation").
|
|
25
|
+
*/
|
|
26
|
+
export function ConfirmCardView({
|
|
27
|
+
card,
|
|
28
|
+
onDecision,
|
|
29
|
+
}: {
|
|
30
|
+
card: ConfirmCard;
|
|
31
|
+
onDecision?: (decision: {
|
|
32
|
+
token: string;
|
|
33
|
+
decision: "apply" | "decline";
|
|
34
|
+
}) => void;
|
|
35
|
+
}) {
|
|
36
|
+
const ai = usePluginClient(AiApi);
|
|
37
|
+
const [state, setState] = useState<"pending" | "applied" | "declined">(
|
|
38
|
+
"pending",
|
|
39
|
+
);
|
|
40
|
+
const [error, setError] = useState<string | undefined>();
|
|
41
|
+
|
|
42
|
+
const applyMutation = ai.applyTool.useMutation({
|
|
43
|
+
onSuccess: () => {
|
|
44
|
+
setState("applied");
|
|
45
|
+
// Apply committed server-side; now have the model acknowledge it.
|
|
46
|
+
onDecision?.({ token: card.token, decision: "apply" });
|
|
47
|
+
},
|
|
48
|
+
onError: (error_: unknown) =>
|
|
49
|
+
setError(extractErrorMessage(error_, "Apply failed")),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const onDecline = () => {
|
|
53
|
+
setState("declined");
|
|
54
|
+
onDecision?.({ token: card.token, decision: "decline" });
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const destructive = card.effect === "destructive";
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Card className={destructive ? "border-destructive/50" : "border-primary/40"}>
|
|
61
|
+
<CardHeader className="flex flex-row items-center gap-2">
|
|
62
|
+
<ShieldAlert
|
|
63
|
+
className={`w-4 h-4 ${destructive ? "text-destructive" : "text-primary"}`}
|
|
64
|
+
/>
|
|
65
|
+
<CardTitle className="text-sm">
|
|
66
|
+
Confirm: {card.toolName}
|
|
67
|
+
</CardTitle>
|
|
68
|
+
<Badge variant={destructive ? "destructive" : "secondary"}>
|
|
69
|
+
{card.effect}
|
|
70
|
+
</Badge>
|
|
71
|
+
</CardHeader>
|
|
72
|
+
<CardContent className="space-y-3">
|
|
73
|
+
<p className="text-sm text-muted-foreground">{card.summary}</p>
|
|
74
|
+
{/* For an update we show the before -> after diff (what changes);
|
|
75
|
+
otherwise the full ready-to-apply payload (what will be created). */}
|
|
76
|
+
{card.diff && card.diff.length > 0 ? (
|
|
77
|
+
<div className="max-h-72 overflow-auto rounded bg-muted p-2">
|
|
78
|
+
<DiffView diff={card.diff} />
|
|
79
|
+
</div>
|
|
80
|
+
) : (
|
|
81
|
+
<pre className="text-xs bg-muted rounded p-2 overflow-auto max-h-48">
|
|
82
|
+
{JSON.stringify(card.payload, null, 2)}
|
|
83
|
+
</pre>
|
|
84
|
+
)}
|
|
85
|
+
{error ? (
|
|
86
|
+
<p className="text-xs text-destructive">{error}</p>
|
|
87
|
+
) : null}
|
|
88
|
+
{state === "pending" ? (
|
|
89
|
+
<div className="flex gap-2">
|
|
90
|
+
<Button
|
|
91
|
+
size="sm"
|
|
92
|
+
variant={destructive ? "destructive" : "primary"}
|
|
93
|
+
disabled={applyMutation.isPending}
|
|
94
|
+
onClick={() => applyMutation.mutate({ token: card.token })}
|
|
95
|
+
>
|
|
96
|
+
<Check className="w-3.5 h-3.5 mr-1" />
|
|
97
|
+
{applyMutation.isPending ? "Applying..." : "Apply"}
|
|
98
|
+
</Button>
|
|
99
|
+
<Button
|
|
100
|
+
size="sm"
|
|
101
|
+
variant="outline"
|
|
102
|
+
disabled={applyMutation.isPending}
|
|
103
|
+
onClick={onDecline}
|
|
104
|
+
>
|
|
105
|
+
<X className="w-3.5 h-3.5 mr-1" />
|
|
106
|
+
Decline
|
|
107
|
+
</Button>
|
|
108
|
+
</div>
|
|
109
|
+
) : (
|
|
110
|
+
<p className="text-xs font-medium">
|
|
111
|
+
{state === "applied" ? "Applied." : "Declined."}
|
|
112
|
+
</p>
|
|
113
|
+
)}
|
|
114
|
+
</CardContent>
|
|
115
|
+
</Card>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Maximize2 } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
} from "@checkstack/ui";
|
|
10
|
+
import type { FieldDiff } from "../lib/stream-parser";
|
|
11
|
+
import { SideBySideDiff } from "./SideBySideDiff";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Format a diff value as text for the side-by-side view. Strings render as-is (so
|
|
15
|
+
* a script keeps its line breaks); objects/arrays are pretty-printed multi-line
|
|
16
|
+
* JSON. A missing side (added or removed field) becomes an empty document so the
|
|
17
|
+
* other column shows the whole value as added/removed.
|
|
18
|
+
*/
|
|
19
|
+
function formatValue(value: unknown): string {
|
|
20
|
+
if (value === undefined) return "";
|
|
21
|
+
if (typeof value === "string") return value;
|
|
22
|
+
return JSON.stringify(value, null, 2);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The list of per-field side-by-side diffs (shared by the inline + popout view). */
|
|
26
|
+
function DiffBody({ diff }: { diff: FieldDiff[] }) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="space-y-3">
|
|
29
|
+
{diff.map((entry) => (
|
|
30
|
+
<div key={entry.path} className="space-y-1">
|
|
31
|
+
<div className="break-all font-mono text-xs font-medium text-foreground">
|
|
32
|
+
{entry.path}
|
|
33
|
+
</div>
|
|
34
|
+
<SideBySideDiff
|
|
35
|
+
before={formatValue(entry.before)}
|
|
36
|
+
after={formatValue(entry.after)}
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Render a before -> after field diff so the operator always sees exactly what an
|
|
46
|
+
* update changes, on both the confirm card (approve mode) and the applied card
|
|
47
|
+
* (auto mode). Each changed field renders a GitHub-style split diff (see
|
|
48
|
+
* {@link SideBySideDiff}). A small "Expand" control pops the full diff into a
|
|
49
|
+
* large dialog, since the inline chat bubble is narrow.
|
|
50
|
+
*/
|
|
51
|
+
export function DiffView({ diff }: { diff: FieldDiff[] }) {
|
|
52
|
+
const [expanded, setExpanded] = useState(false);
|
|
53
|
+
if (diff.length === 0) return null;
|
|
54
|
+
return (
|
|
55
|
+
<div className="space-y-1">
|
|
56
|
+
<div className="flex justify-end">
|
|
57
|
+
<Button
|
|
58
|
+
type="button"
|
|
59
|
+
variant="ghost"
|
|
60
|
+
size="sm"
|
|
61
|
+
className="h-6 gap-1 px-2 text-xs text-muted-foreground"
|
|
62
|
+
onClick={() => setExpanded(true)}
|
|
63
|
+
>
|
|
64
|
+
<Maximize2 className="h-3.5 w-3.5" />
|
|
65
|
+
Expand
|
|
66
|
+
</Button>
|
|
67
|
+
</div>
|
|
68
|
+
<DiffBody diff={diff} />
|
|
69
|
+
<Dialog open={expanded} onOpenChange={setExpanded}>
|
|
70
|
+
<DialogContent
|
|
71
|
+
size="full"
|
|
72
|
+
className="flex max-h-[85dvh] flex-col overflow-hidden"
|
|
73
|
+
>
|
|
74
|
+
<DialogHeader>
|
|
75
|
+
<DialogTitle>Proposed changes</DialogTitle>
|
|
76
|
+
</DialogHeader>
|
|
77
|
+
<div className="flex-1 overflow-auto pr-1">
|
|
78
|
+
<DiffBody diff={diff} />
|
|
79
|
+
</div>
|
|
80
|
+
</DialogContent>
|
|
81
|
+
</Dialog>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Fragment } from "react";
|
|
2
|
+
import { computeLineDiff, type DiffLine, type DiffSegment } from "../lib/line-diff";
|
|
3
|
+
|
|
4
|
+
type Side = "left" | "right";
|
|
5
|
+
type Kind = DiffLine["kind"];
|
|
6
|
+
|
|
7
|
+
/** Line-level background tint for one side, GitHub-style (red left, green right). */
|
|
8
|
+
function lineBg({ side, kind }: { side: Side; kind: Kind }): string {
|
|
9
|
+
if (kind === "equal") return "";
|
|
10
|
+
if (side === "left") {
|
|
11
|
+
return kind === "added" ? "bg-muted/30" : "bg-destructive/10";
|
|
12
|
+
}
|
|
13
|
+
return kind === "removed" ? "bg-muted/30" : "bg-success/10";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Line-number gutter text colour, matching the line tint. */
|
|
17
|
+
function gutterText({ side, kind }: { side: Side; kind: Kind }): string {
|
|
18
|
+
if (kind === "equal") return "text-muted-foreground/70";
|
|
19
|
+
if (side === "left") {
|
|
20
|
+
return kind === "added" ? "text-muted-foreground/30" : "text-destructive/70";
|
|
21
|
+
}
|
|
22
|
+
return kind === "removed" ? "text-muted-foreground/30" : "text-success/80";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The `+` / `-` change marker for one side. */
|
|
26
|
+
function marker({ side, kind }: { side: Side; kind: Kind }): string {
|
|
27
|
+
if (kind === "equal") return "";
|
|
28
|
+
if (side === "left") return kind === "added" ? "" : "-";
|
|
29
|
+
return kind === "removed" ? "" : "+";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Render a side's segments, strongly highlighting the changed tokens. */
|
|
33
|
+
function Segments({
|
|
34
|
+
segments,
|
|
35
|
+
side,
|
|
36
|
+
}: {
|
|
37
|
+
segments: DiffSegment[] | null;
|
|
38
|
+
side: Side;
|
|
39
|
+
}) {
|
|
40
|
+
if (!segments) return null;
|
|
41
|
+
const strong = side === "left" ? "bg-destructive/30" : "bg-success/40";
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
{segments.map((segment, index) =>
|
|
45
|
+
segment.emphasis ? (
|
|
46
|
+
<span key={index} className={`rounded-sm ${strong}`}>
|
|
47
|
+
{segment.text}
|
|
48
|
+
</span>
|
|
49
|
+
) : (
|
|
50
|
+
<Fragment key={index}>{segment.text}</Fragment>
|
|
51
|
+
),
|
|
52
|
+
)}
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function Gutter({ no, side, kind }: { no: number | null; side: Side; kind: Kind }) {
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
className={`select-none px-2 text-right tabular-nums ${gutterText({
|
|
61
|
+
side,
|
|
62
|
+
kind,
|
|
63
|
+
})} ${lineBg({ side, kind })} ${side === "right" ? "border-l border-border" : ""}`}
|
|
64
|
+
>
|
|
65
|
+
{no ?? ""}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function Code({
|
|
71
|
+
segments,
|
|
72
|
+
side,
|
|
73
|
+
kind,
|
|
74
|
+
}: {
|
|
75
|
+
segments: DiffSegment[] | null;
|
|
76
|
+
side: Side;
|
|
77
|
+
kind: Kind;
|
|
78
|
+
}) {
|
|
79
|
+
return (
|
|
80
|
+
<div className={`flex gap-1 px-1 ${lineBg({ side, kind })}`}>
|
|
81
|
+
<span aria-hidden className="w-2 shrink-0 select-none text-center opacity-70">
|
|
82
|
+
{marker({ side, kind })}
|
|
83
|
+
</span>
|
|
84
|
+
<pre className="m-0 min-w-0 flex-1 whitespace-pre-wrap break-words">
|
|
85
|
+
<Segments segments={segments} side={side} />
|
|
86
|
+
</pre>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Render a before -> after change as a GitHub-style SPLIT diff: old text in the
|
|
93
|
+
* left column, new on the right, line-number gutters, `+`/`-` markers, per-line
|
|
94
|
+
* red/green tint, and word-level highlight of the exact changed tokens (via
|
|
95
|
+
* {@link computeLineDiff}). Lightweight - no editor stack. Long lines wrap; the
|
|
96
|
+
* columns share the available width.
|
|
97
|
+
*/
|
|
98
|
+
export function SideBySideDiff({
|
|
99
|
+
before,
|
|
100
|
+
after,
|
|
101
|
+
}: {
|
|
102
|
+
before: string;
|
|
103
|
+
after: string;
|
|
104
|
+
}) {
|
|
105
|
+
const rows = computeLineDiff({ before, after });
|
|
106
|
+
return (
|
|
107
|
+
<div className="overflow-hidden rounded-md border border-border bg-card">
|
|
108
|
+
<div className="grid grid-cols-[auto_1fr_auto_1fr] font-mono text-xs leading-relaxed">
|
|
109
|
+
{rows.map((row, index) => (
|
|
110
|
+
<Fragment key={index}>
|
|
111
|
+
<Gutter no={row.leftNo} side="left" kind={row.kind} />
|
|
112
|
+
<Code segments={row.left} side="left" kind={row.kind} />
|
|
113
|
+
<Gutter no={row.rightNo} side="right" kind={row.kind} />
|
|
114
|
+
<Code segments={row.right} side="right" kind={row.kind} />
|
|
115
|
+
</Fragment>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createFrontendPlugin } from "@checkstack/frontend-api";
|
|
2
|
+
import { aiRoutes, pluginMetadata, aiAccess } from "@checkstack/ai-common";
|
|
3
|
+
import { Sparkles } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
export default createFrontendPlugin({
|
|
6
|
+
metadata: pluginMetadata,
|
|
7
|
+
routes: [
|
|
8
|
+
{
|
|
9
|
+
route: aiRoutes.routes.chat,
|
|
10
|
+
load: () =>
|
|
11
|
+
import("./pages/ChatPage").then((m) => ({ default: m.ChatPage })),
|
|
12
|
+
title: "AI assistant",
|
|
13
|
+
accessRule: aiAccess.chatUse,
|
|
14
|
+
nav: {
|
|
15
|
+
group: "Workspace",
|
|
16
|
+
icon: Sparkles,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
apis: [],
|
|
21
|
+
extensions: [],
|
|
22
|
+
});
|