mirror of
https://github.com/cline/cline.git
synced 2026-08-29 03:52:41 +08:00
feat(ui): add agent chat components, Storybook, and npm releases (#12374)
* feat(ui): add shared agent chat components and Storybook * ci(ui): add standalone npm publishing * docs(ui): keep release commands environment-neutral * refactor(ui): simplify package validation * refactor(ui): tighten package and release contracts * docs(ui): remove duplicate install guidance * ci(ui): make publishing workflow manual-only
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
---
|
||||
name: publish-ui
|
||||
description: Prepare, validate, and publish standalone @cline/ui npm releases. Use when bumping the UI package version, publishing latest or next through ui-publish.yml, checking UI release readiness, or completing the one-time npm trusted-publishing bootstrap.
|
||||
---
|
||||
|
||||
# Publish UI
|
||||
|
||||
Release `@cline/ui` independently from the Cline SDK runtime packages.
|
||||
|
||||
## Release contract
|
||||
|
||||
- Version source: `sdk/packages/ui/package.json`.
|
||||
- Workflow: `.github/workflows/ui-publish.yml`.
|
||||
- The package keeps `internal: true` only to stay out of the SDK's shared
|
||||
version/publish scripts. It is still a public npm package because
|
||||
`private: false` and `publishConfig.access: public` control npm publication.
|
||||
- `latest` is the production channel. `next` is an opt-in preview channel.
|
||||
- Use prerelease versions such as `0.2.0-next.0` for `next`; do not publish a
|
||||
version intended for `latest` under the preview tag because npm versions
|
||||
cannot be republished.
|
||||
- There is no UI Git tag, GitHub release, schedule, or Slack announcement.
|
||||
- The workflow runs only by manual dispatch. Every release attempt runs the UI
|
||||
quality checks before publishing and requires `confirm_publish=publish` from
|
||||
`main`.
|
||||
- The publish job and npm trust relationship use the protected `Publish`
|
||||
environment.
|
||||
- Every npm publication needs a new semver version; npm versions are immutable.
|
||||
- Always ask before pushing commits, triggering the publish workflow, changing
|
||||
npm trust settings, or running a local publish command.
|
||||
|
||||
## Normal release
|
||||
|
||||
1. Inspect the branch, current version, npm state, and UI changes.
|
||||
|
||||
```sh
|
||||
git status --short --branch
|
||||
node -p "require('./sdk/packages/ui/package.json').version"
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
git log --oneline --no-merges -- \
|
||||
sdk/packages/ui apps/examples/desktop-app/webview/components/views/chat \
|
||||
.github/workflows/ui-publish.yml
|
||||
```
|
||||
|
||||
2. Ask for the npm channel and version together. For `latest`, ask for patch,
|
||||
minor, major, or an explicit version. For `next`, require an explicit
|
||||
prerelease version such as `0.2.0-next.0`. Do not guess. Update only
|
||||
`sdk/packages/ui/package.json` and its workspace version in `bun.lock`. Do
|
||||
not run the SDK version command.
|
||||
|
||||
3. Validate the release candidate.
|
||||
|
||||
```sh
|
||||
bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
|
||||
bun -F @cline/ui typecheck
|
||||
bun -F @cline/ui test
|
||||
bun -F @cline/ui test:package
|
||||
bun -F @cline/ui build-storybook
|
||||
bun -F @cline/code test:chat-ui
|
||||
```
|
||||
|
||||
The packed-package test installs the tarball with Bun/React 19 and with
|
||||
npm/Node/React 18.
|
||||
Inspect `bun pm pack --dry-run` when the exported file set changed.
|
||||
|
||||
4. Commit the version bump separately from feature work. Ask before pushing.
|
||||
|
||||
```sh
|
||||
git add sdk/packages/ui/package.json bun.lock
|
||||
git commit -m "chore(ui): release vX.Y.Z"
|
||||
git push origin HEAD
|
||||
```
|
||||
|
||||
5. After the release commit reaches `main`, restate the selected npm tag and ask
|
||||
for explicit publish approval. Then trigger and watch the standalone
|
||||
workflow:
|
||||
|
||||
```sh
|
||||
run_url=$(gh workflow run ui-publish.yml --ref main \
|
||||
-f npm_tag=latest \
|
||||
-f confirm_publish=publish)
|
||||
test -n "$run_url"
|
||||
run_id=${run_url##*/}
|
||||
gh run watch "$run_id" --exit-status
|
||||
```
|
||||
|
||||
Use `npm_tag=next` only for a deliberate preview. Do not report success until
|
||||
the workflow succeeds and npm shows the exact version under the selected tag.
|
||||
|
||||
```sh
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
```
|
||||
|
||||
## One-time npm bootstrap
|
||||
|
||||
Use this only while `npm view @cline/ui` returns `E404`. npm requires the
|
||||
package to exist before its GitHub trusted publisher can be configured.
|
||||
|
||||
1. Merge the package and `ui-publish.yml` to `main`. Start from a clean,
|
||||
reviewed `main` checkout. Verify authentication, account 2FA, and write
|
||||
access to the `@cline` npm organization. The `npm trust` command in step 4
|
||||
requires npm CLI 11.15 or newer; the automated trusted-publishing workflow
|
||||
itself enforces npm 11.5.1 or newer.
|
||||
|
||||
```sh
|
||||
npm --version
|
||||
npm whoami
|
||||
npm view @cline/ui version
|
||||
```
|
||||
|
||||
If npm is older than 11.15, ask before upgrading with
|
||||
`npm install -g npm@^11.15.0`.
|
||||
|
||||
2. Run the normal release validation in step 3 above. Then build, pack, test,
|
||||
and inspect the exact initial tarball. Record the absolute archive path
|
||||
printed by the final command.
|
||||
|
||||
```sh
|
||||
bun -F @cline/ui build
|
||||
pack_dir=$(mktemp -d)
|
||||
(cd sdk/packages/ui && bun pm pack --ignore-scripts --destination "$pack_dir" --quiet)
|
||||
tarball=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
test -n "$tarball"
|
||||
bun sdk/packages/ui/scripts/smoke-package.ts "$tarball"
|
||||
tar -tzf "$tarball"
|
||||
printf 'Bootstrap archive: %s\n' "$tarball"
|
||||
```
|
||||
|
||||
3. Ask for explicit approval, then publish the initial version publicly under
|
||||
`latest`:
|
||||
|
||||
```sh
|
||||
npm publish /absolute/path/from-step-2.tgz --access public --tag latest
|
||||
```
|
||||
|
||||
4. Ask separately before configuring the standalone workflow as the trusted
|
||||
publisher:
|
||||
|
||||
```sh
|
||||
npm trust github @cline/ui \
|
||||
--repo cline/cline \
|
||||
--file ui-publish.yml \
|
||||
--env Publish \
|
||||
--allow-publish
|
||||
```
|
||||
|
||||
5. Verify both package state and trust. Every later release uses the workflow;
|
||||
do not add a long-lived npm token.
|
||||
|
||||
```sh
|
||||
npm view @cline/ui dist-tags versions --json
|
||||
npm trust list @cline/ui
|
||||
```
|
||||
|
||||
## Final report
|
||||
|
||||
Report the version and npm tag, release commit, whether anything was pushed,
|
||||
workflow URL or bootstrap result, npm verification, and tests/builds run. If
|
||||
the package still returns `E404`, state that bootstrap remains required.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Publish UI"
|
||||
short_description: "Prepare and publish the Cline UI package"
|
||||
default_prompt: "Use $publish-ui to prepare and publish a new @cline/ui npm release."
|
||||
@@ -0,0 +1,145 @@
|
||||
name: ui-publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
npm_tag:
|
||||
description: "npm distribution tag"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- next
|
||||
- latest
|
||||
default: next
|
||||
confirm_publish:
|
||||
description: 'Type "publish" to publish @cline/ui to npm'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: UI quality and package checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.3.13"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.x"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --filter @cline/ui --filter @cline/code --frozen-lockfile
|
||||
|
||||
- name: Typecheck UI
|
||||
run: bun -F @cline/ui typecheck
|
||||
|
||||
- name: Test UI
|
||||
run: bun -F @cline/ui test
|
||||
|
||||
- name: Build Storybook
|
||||
run: bun -F @cline/ui build-storybook
|
||||
|
||||
- name: Build UI package
|
||||
run: bun -F @cline/ui build
|
||||
|
||||
- name: Test desktop chat integration
|
||||
run: bun -F @cline/code test:chat-ui
|
||||
|
||||
- name: Pack publish artifact
|
||||
id: pack
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pack_dir="$RUNNER_TEMP/ui-npm-pack"
|
||||
mkdir -p "$pack_dir"
|
||||
cd sdk/packages/ui
|
||||
bun pm pack --ignore-scripts --destination "$pack_dir" --quiet
|
||||
archive=$(find "$pack_dir" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
test -n "$archive"
|
||||
echo "archive=$archive" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Test packed package
|
||||
env:
|
||||
UI_PACKAGE_ARCHIVE: ${{ steps.pack.outputs.archive }}
|
||||
run: bun sdk/packages/ui/scripts/smoke-package.ts "$UI_PACKAGE_ARCHIVE"
|
||||
|
||||
- name: Upload publish artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ui-npm-package
|
||||
path: ${{ runner.temp }}/ui-npm-pack/*.tgz
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
publish:
|
||||
name: Publish @cline/ui
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
github.repository == 'cline/cline' &&
|
||||
github.ref == 'refs/heads/main' &&
|
||||
inputs.confirm_publish == 'publish' &&
|
||||
!endsWith(github.actor, '[bot]')
|
||||
needs: quality
|
||||
runs-on: ubuntu-latest
|
||||
environment: Publish
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.x"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Download publish artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ui-npm-package
|
||||
path: ${{ runner.temp }}/ui-npm-pack
|
||||
|
||||
- name: Verify publish tooling
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm_version=$(npm --version)
|
||||
echo "npm ${npm_version}"
|
||||
node -e 'const [major, minor, patch] = process.argv[1].split(".").map(Number); if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) { console.error("npm 11.5.1 or newer is required for trusted publishing"); process.exit(1); }' "$npm_version"
|
||||
|
||||
- name: Publish package
|
||||
shell: bash
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
NPM_TAG: ${{ inputs.npm_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
archive=$(find "$RUNNER_TEMP/ui-npm-pack" -maxdepth 1 -name '*.tgz' -print -quit)
|
||||
if [ -z "$archive" ]; then
|
||||
echo "UI package archive was not downloaded"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version=$(tar -xOf "$archive" package/package.json | node -e 'let input=""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(input).version))')
|
||||
if npm view "@cline/ui@${version}" version >/dev/null 2>&1; then
|
||||
echo "@cline/ui@${version} already exists; bump sdk/packages/ui/package.json before publishing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
npm publish "$archive" --tag "$NPM_TAG" --access public
|
||||
echo "Published @cline/ui@${version} with npm tag '${NPM_TAG}'"
|
||||
@@ -3,9 +3,12 @@
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
"predev:web": "bun run build:ui",
|
||||
"dev:web": "next dev webview -p 3125 --turbo",
|
||||
"dev:sidecar": "bun run sidecar/index.ts",
|
||||
"dev": "tauri dev",
|
||||
"prebuild": "bun run build:ui",
|
||||
"build": "bun run bun.mts",
|
||||
"build:sidecar": "mkdir -p dist/sidecar && bun build ./sidecar/index.ts --outfile ./dist/sidecar/index.js --target bun",
|
||||
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
|
||||
@@ -16,7 +19,10 @@
|
||||
"package:desktop:windows": "bun run scripts/package-desktop.ts --platform windows",
|
||||
"package:desktop:linux": "bun run scripts/package-desktop.ts --platform linux",
|
||||
"start": "next start webview",
|
||||
"pretypecheck": "bun run build:ui",
|
||||
"typecheck": "tsc -p tsconfig.dev.json --noEmit",
|
||||
"pretest:chat-ui": "bun run build:ui",
|
||||
"test:chat-ui": "vitest run webview/components/views/chat/chat-messages.test.tsx --config vitest.config.ts",
|
||||
"clean": "rm -rf webview/.next webview/out node_modules dist && (cd src-tauri && rm -rf target node_modules dist)"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "@cline/ui/theme/index.css";
|
||||
@import "@cline/ui/components/agent-chat.css";
|
||||
|
||||
@source "../../node_modules/streamdown/dist";
|
||||
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Message as AgentMessage,
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
ConversationViewport,
|
||||
MessageAction,
|
||||
MessageActions,
|
||||
MessageContent,
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
ToolActivity,
|
||||
ToolActivityCode,
|
||||
ToolActivityContent,
|
||||
ToolActivityDetails,
|
||||
ToolActivityTrigger,
|
||||
} from "@cline/ui/components/agent-chat";
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
BrainIcon,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Copy,
|
||||
FileEdit,
|
||||
@@ -20,15 +35,7 @@ import {
|
||||
SquareTerminalIcon,
|
||||
UndoIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { memo, useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import type { ChatMessage, ChatSessionStatus } from "@/lib/chat-schema";
|
||||
@@ -86,11 +93,9 @@ type AskQuestionRequestItem = {
|
||||
};
|
||||
|
||||
const IS_DEBUG = process.env.NODE_ENV === "test";
|
||||
const STICKY_BOTTOM_THRESHOLD_PX = 24;
|
||||
const SCROLL_TO_BOTTOM_BUTTON_THRESHOLD_PX = 120;
|
||||
|
||||
function ChatMessagesImpl({
|
||||
sessionId: _sessionId,
|
||||
sessionId,
|
||||
status,
|
||||
chatTransportState = "connecting",
|
||||
isSessionSwitching = false,
|
||||
@@ -105,9 +110,6 @@ function ChatMessagesImpl({
|
||||
onRestoreCheckpoint,
|
||||
onForkSession,
|
||||
}: ChatMessagesProps) {
|
||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const shouldStickToBottomRef = useRef(true);
|
||||
const hasMessages = messages.length > 0;
|
||||
const lastErrorMessage = [...messages]
|
||||
.reverse()
|
||||
@@ -115,7 +117,6 @@ function ChatMessagesImpl({
|
||||
const shouldShowErrorBanner =
|
||||
Boolean(error) && (!lastErrorMessage || lastErrorMessage.content !== error);
|
||||
const [showSwitchTransition, setShowSwitchTransition] = useState(false);
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const [toolApprovalActions, setToolApprovalActions] = useState<
|
||||
Record<string, "approving" | "rejecting">
|
||||
>({});
|
||||
@@ -140,23 +141,6 @@ function ChatMessagesImpl({
|
||||
const showIdleDetails =
|
||||
!hasMessages && !isSessionSwitching && !showSwitchTransition;
|
||||
|
||||
const getViewport = useCallback(() => {
|
||||
return scrollAreaRef.current;
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(
|
||||
(behavior: ScrollBehavior = "smooth") => {
|
||||
const viewport = getViewport();
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
shouldStickToBottomRef.current = true;
|
||||
viewport.scrollTo({ top: viewport.scrollHeight, behavior });
|
||||
setShowScrollToBottom((prev) => (prev ? false : prev));
|
||||
},
|
||||
[getViewport],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSessionSwitching) {
|
||||
setShowSwitchTransition((prev) => (prev ? false : prev));
|
||||
@@ -170,50 +154,6 @@ function ChatMessagesImpl({
|
||||
};
|
||||
}, [isSessionSwitching]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = getViewport();
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateScrollToBottomVisibility = () => {
|
||||
const distanceFromBottom =
|
||||
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||
shouldStickToBottomRef.current =
|
||||
distanceFromBottom <= STICKY_BOTTOM_THRESHOLD_PX;
|
||||
const shouldShow =
|
||||
distanceFromBottom > SCROLL_TO_BOTTOM_BUTTON_THRESHOLD_PX;
|
||||
setShowScrollToBottom((prev) =>
|
||||
prev === shouldShow ? prev : shouldShow,
|
||||
);
|
||||
};
|
||||
|
||||
updateScrollToBottomVisibility();
|
||||
viewport.addEventListener("scroll", updateScrollToBottomVisibility);
|
||||
|
||||
return () => {
|
||||
viewport.removeEventListener("scroll", updateScrollToBottomVisibility);
|
||||
};
|
||||
}, [getViewport]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!shouldStickToBottomRef.current) {
|
||||
return;
|
||||
}
|
||||
scrollToBottom("auto");
|
||||
}, [scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const content = scrollContentRef.current;
|
||||
if (!content || typeof ResizeObserver === "undefined") return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (shouldStickToBottomRef.current) scrollToBottom("auto");
|
||||
});
|
||||
resizeObserver.observe(content);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeRequestIds = new Set(
|
||||
pendingToolApprovals.map((item) => item.requestId),
|
||||
@@ -377,17 +317,19 @@ function ChatMessagesImpl({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-0 min-w-0">
|
||||
<div
|
||||
className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto"
|
||||
ref={scrollAreaRef}
|
||||
<Conversation
|
||||
className="h-full min-h-0 min-w-0"
|
||||
key={sessionId ?? "new-chat"}
|
||||
>
|
||||
<ConversationViewport
|
||||
aria-label="Agent conversation"
|
||||
className="h-full min-h-0 min-w-0"
|
||||
>
|
||||
<div
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"relative mx-auto min-h-full w-full min-w-0 max-w-full overflow-x-hidden",
|
||||
showIdleDetails ? "p-0" : "px-6 py-6",
|
||||
)}
|
||||
ref={scrollContentRef}
|
||||
>
|
||||
{showIdleDetails ? null : (
|
||||
<div className="flex min-h-full w-full min-w-0 flex-col gap-2 overflow-x-hidden">
|
||||
@@ -497,21 +439,10 @@ function ChatMessagesImpl({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{showScrollToBottom ? (
|
||||
<Button
|
||||
className="absolute bottom-4 right-4 z-20 size-9 rounded-full shadow-sm"
|
||||
onClick={() => scrollToBottom("smooth")}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
<span className="sr-only">Scroll to bottom</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</ConversationContent>
|
||||
</ConversationViewport>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -746,8 +677,6 @@ function MessageBubble({
|
||||
isUser && Boolean(onCopyRawText || checkpoint);
|
||||
const keepUserActionsVisible = restorePending || Boolean(restoreError);
|
||||
const keepAssistantActionsVisible = forkPending || Boolean(forkError);
|
||||
const hiddenActionButtonsClassName =
|
||||
"pointer-events-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100";
|
||||
|
||||
if (message.role === "tool") {
|
||||
return <ToolMessageBlock message={message} />;
|
||||
@@ -760,159 +689,102 @@ function MessageBubble({
|
||||
const reasoningContent = message.reasoning?.trim() || "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0",
|
||||
isUser ? "justify-end" : "w-full justify-start",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group max-w-full min-w-0 wrap-break-word text-sm",
|
||||
isUser && "flex max-w-[85%] flex-col items-end gap-1 md:max-w-[50%]",
|
||||
!isUser && "flex flex-col items-start gap-2 overflow-hidden",
|
||||
!isUser && !isError && "text-foreground",
|
||||
isError &&
|
||||
"bg-destructive/10 border border-destructive/40 text-destructive",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-full min-w-0 space-y-2 overflow-hidden wrap-break-word",
|
||||
isUser && "rounded-sm bg-card p-2 text-foreground/80",
|
||||
)}
|
||||
>
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
streaming={isStreaming}
|
||||
/>
|
||||
) : null}
|
||||
<AgentMessage from={message.role}>
|
||||
<MessageContent className="space-y-2 wrap-break-word">
|
||||
{reasoningContent || message.reasoningRedacted ? (
|
||||
<ReasoningBlock
|
||||
content={reasoningContent}
|
||||
redacted={message.reasoningRedacted === true}
|
||||
streaming={isStreaming}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="my-1 min-w-0 max-w-full wrap-break-word">
|
||||
<MemoizedMarkdown
|
||||
content={displayContent || " "}
|
||||
streaming={isStreaming && message.role === "assistant"}
|
||||
/>
|
||||
</div>
|
||||
<div className="my-1 min-w-0 max-w-full wrap-break-word">
|
||||
<MemoizedMarkdown
|
||||
content={displayContent || " "}
|
||||
streaming={isStreaming && message.role === "assistant"}
|
||||
/>
|
||||
</div>
|
||||
{shouldRenderUserActions ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex h-6 items-center justify-end">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-end gap-2",
|
||||
keepUserActionsVisible
|
||||
? "pointer-events-auto opacity-100"
|
||||
: hiddenActionButtonsClassName,
|
||||
)}
|
||||
</MessageContent>
|
||||
|
||||
{shouldRenderUserActions ? (
|
||||
<>
|
||||
<MessageActions visible={keepUserActionsVisible}>
|
||||
{onCopyRawText ? (
|
||||
<MessageAction
|
||||
label={wasCopied ? "Copied user message" : "Copy user message"}
|
||||
onClick={onCopyRawText}
|
||||
title={wasCopied ? "Copied" : "Copy message"}
|
||||
>
|
||||
{onCopyRawText ? (
|
||||
<Button
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
wasCopied ? "Copied user message" : "Copy user message"
|
||||
}
|
||||
onClick={onCopyRawText}
|
||||
size="sm"
|
||||
title={wasCopied ? "Copied" : "Copy message"}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{wasCopied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{checkpoint ? (
|
||||
<Button
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
aria-label="Restore checkpoint"
|
||||
disabled={restoreDisabled || restorePending}
|
||||
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
|
||||
size="sm"
|
||||
title="Restore checkpoint"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{restorePending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<UndoIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{restoreError ? (
|
||||
<div className="text-right text-xs text-destructive">
|
||||
{restoreError}
|
||||
</div>
|
||||
{wasCopied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{shouldRenderAssistantActions ? (
|
||||
<div className="flex h-6 items-center hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-0",
|
||||
keepAssistantActionsVisible
|
||||
? "pointer-events-auto opacity-100"
|
||||
: hiddenActionButtonsClassName,
|
||||
)}
|
||||
>
|
||||
{onCopyRawText ? (
|
||||
<Button
|
||||
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
wasCopied
|
||||
? "Copied assistant message"
|
||||
: "Copy assistant message"
|
||||
}
|
||||
onClick={onCopyRawText}
|
||||
size="sm"
|
||||
title={wasCopied ? "Copied" : "Copy raw assistant output"}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{wasCopied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{onForkSession ? (
|
||||
<Button
|
||||
className="h-6 gap-1.5 px-2 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
aria-label="Fork session"
|
||||
disabled={forkPending}
|
||||
onClick={onForkSession}
|
||||
size="sm"
|
||||
title="Fork session - copy full message history into a new session"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{forkPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<SplitIcon className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{forkError ? (
|
||||
<span className="text-[11px] text-destructive">
|
||||
{forkError}
|
||||
</span>
|
||||
) : null}
|
||||
{checkpoint ? (
|
||||
<MessageAction
|
||||
disabled={restoreDisabled || restorePending}
|
||||
label="Restore checkpoint"
|
||||
onClick={() => onRestoreCheckpoint?.(checkpoint.runCount)}
|
||||
title="Restore checkpoint"
|
||||
>
|
||||
{restorePending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<UndoIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
</MessageActions>
|
||||
{restoreError ? (
|
||||
<div className="text-right text-xs text-destructive">
|
||||
{restoreError}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{shouldRenderAssistantActions ? (
|
||||
<MessageActions visible={keepAssistantActionsVisible}>
|
||||
{onCopyRawText ? (
|
||||
<MessageAction
|
||||
label={
|
||||
wasCopied
|
||||
? "Copied assistant message"
|
||||
: "Copy assistant message"
|
||||
}
|
||||
onClick={onCopyRawText}
|
||||
title={wasCopied ? "Copied" : "Copy raw assistant output"}
|
||||
>
|
||||
{wasCopied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
{onForkSession ? (
|
||||
<MessageAction
|
||||
disabled={forkPending}
|
||||
label="Fork session"
|
||||
onClick={onForkSession}
|
||||
title="Fork session - copy full message history into a new session"
|
||||
>
|
||||
{forkPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<SplitIcon className="h-3 w-3" />
|
||||
)}
|
||||
</MessageAction>
|
||||
) : null}
|
||||
{forkError ? (
|
||||
<span className="text-[11px] text-destructive">{forkError}</span>
|
||||
) : null}
|
||||
</MessageActions>
|
||||
) : null}
|
||||
</AgentMessage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -925,48 +797,18 @@ function ReasoningBlock({
|
||||
redacted: boolean;
|
||||
streaming?: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const panelId = useId();
|
||||
const displayContent = content || (redacted ? "[redacted]" : "");
|
||||
if (!displayContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-2">
|
||||
<Button
|
||||
aria-controls={panelId}
|
||||
aria-expanded={expanded}
|
||||
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-foreground/70 hover:bg-transparent hover:text-foreground has-[>svg]:px-0 dark:hover:bg-transparent dark:hover:text-foreground"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<BrainIcon aria-hidden="true" className="size-4" />
|
||||
<span>{streaming ? "Thinking" : "Thought process"}</span>
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="text-xs font-normal text-muted-foreground"
|
||||
>
|
||||
{streaming ? "In progress" : "Complete"}
|
||||
</span>
|
||||
<span aria-hidden="true" className="shrink-0 text-muted-foreground">
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
{expanded ? (
|
||||
<div
|
||||
className="mt-1.5 min-w-0 max-w-full rounded-lg border border-border/70 bg-muted/30 p-3 text-sm leading-relaxed text-muted-foreground"
|
||||
id={panelId}
|
||||
>
|
||||
<MemoizedMarkdown content={displayContent} streaming={streaming} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Reasoning isStreaming={streaming}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
<MemoizedMarkdown content={displayContent} streaming={streaming} />
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1347,8 +1189,6 @@ function buildToolSummaryFromMeta(
|
||||
}
|
||||
|
||||
function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const panelId = useId();
|
||||
const payload = parseToolPayload(message.content);
|
||||
const toolName = message.meta?.toolName || payload?.toolName || "tool";
|
||||
const hookEventName = message.meta?.hookEventName;
|
||||
@@ -1380,94 +1220,50 @@ function ToolMessageBlock({ message }: { message: ChatMessage }) {
|
||||
const resultPreview = payload?.isError ? formatToolValue(payload.result) : "";
|
||||
const hasExpandedSections =
|
||||
details.length > 0 || Boolean(inputPreview || resultPreview);
|
||||
const summaryContent = (
|
||||
<>
|
||||
{payload?.isError ? (
|
||||
<AlertCircle className="size-4 text-destructive/80" />
|
||||
) : (
|
||||
<Icon className="size-4" />
|
||||
)}
|
||||
<span className="min-w-0 wrap-break-word">{summary.label}</span>
|
||||
{summary.diff ? (
|
||||
<span className="shrink-0 font-mono text-xs">
|
||||
<span className="text-chart-2">+{summary.diff.additions}</span>{" "}
|
||||
<span className="text-destructive">-{summary.diff.deletions}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="my-2 flex w-full min-w-0 justify-start">
|
||||
<div
|
||||
className={cn("min-w-0 max-w-full overflow-hidden rounded-xl text-sm")}
|
||||
>
|
||||
{hasExpandedSections ? (
|
||||
<Button
|
||||
aria-controls={panelId}
|
||||
aria-expanded={expanded}
|
||||
className="h-auto min-h-0 max-w-full justify-start gap-2 whitespace-normal px-0 py-1 text-left text-sm font-medium text-primary hover:bg-transparent hover:text-primary/80 has-[>svg]:px-0 dark:hover:bg-transparent dark:hover:text-primary/80"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{summaryContent}
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{expanded ? (
|
||||
<ChevronDown className="size-4" />
|
||||
) : (
|
||||
<ChevronRight className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex max-w-full items-center justify-start gap-2 py-1 text-left text-sm font-medium text-primary">
|
||||
{summaryContent}
|
||||
</div>
|
||||
)}
|
||||
{expanded ? (
|
||||
<div
|
||||
className="mt-1.5 min-w-0 max-w-full overflow-x-hidden pl-8 text-sm text-muted-foreground"
|
||||
id={panelId}
|
||||
>
|
||||
{hasExpandedSections ? (
|
||||
<div className="space-y-1">
|
||||
{details.map((detail) => (
|
||||
<div
|
||||
className="wrap-break-word"
|
||||
key={`${message.id}_${detail}`}
|
||||
>
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{inputPreview ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground/80">
|
||||
Input
|
||||
</div>
|
||||
<pre className="max-h-52 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
|
||||
{inputPreview}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{resultPreview ? (
|
||||
payload?.isError ? (
|
||||
<div className="mt-1">
|
||||
<span className="text-destructive">{resultPreview}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<pre className="max-h-64 max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap wrap-break-word rounded-md border border-border/70 bg-background/60 p-2 text-sm leading-relaxed text-foreground">
|
||||
{resultPreview}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
<ToolActivity expandable={hasExpandedSections}>
|
||||
<ToolActivityTrigger
|
||||
additions={summary.diff?.additions}
|
||||
deletions={summary.diff?.deletions}
|
||||
icon={
|
||||
payload?.isError ? (
|
||||
<AlertCircle className="size-4 text-destructive/80" />
|
||||
) : (
|
||||
<Icon className="size-4" />
|
||||
)
|
||||
}
|
||||
label={summary.label}
|
||||
status={payload?.isError ? "error" : inProgress ? "running" : "success"}
|
||||
/>
|
||||
<ToolActivityContent>
|
||||
{details.length > 0 ? (
|
||||
<ToolActivityDetails>
|
||||
{details.map((detail) => (
|
||||
<div key={`${message.id}_${detail}`}>{detail}</div>
|
||||
))}
|
||||
</ToolActivityDetails>
|
||||
) : null}
|
||||
{inputPreview ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground/80">
|
||||
Input
|
||||
</div>
|
||||
<ToolActivityCode className="text-sm">
|
||||
{inputPreview}
|
||||
</ToolActivityCode>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{resultPreview ? (
|
||||
payload?.isError ? (
|
||||
<div className="mt-1 text-destructive">{resultPreview}</div>
|
||||
) : (
|
||||
<ToolActivityCode className="max-h-64 text-sm">
|
||||
{resultPreview}
|
||||
</ToolActivityCode>
|
||||
)
|
||||
) : null}
|
||||
</ToolActivityContent>
|
||||
</ToolActivity>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -731,11 +731,31 @@
|
||||
},
|
||||
"sdk/packages/ui": {
|
||||
"name": "@cline/ui",
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
"@fontsource/azeret-mono": "^5.2.9",
|
||||
"@storybook/addon-a11y": "^9.1.17",
|
||||
"@storybook/addon-docs": "^9.1.17",
|
||||
"@storybook/react-vite": "^9.1.6",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"jsdom": "^26.0.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"storybook": "^9.1.17",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^7.1.11",
|
||||
"vitest": "^4.0.18",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.3.0 <20",
|
||||
"tailwindcss": ">=4.0.0 <5",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"react",
|
||||
"tailwindcss",
|
||||
],
|
||||
},
|
||||
@@ -1624,6 +1644,8 @@
|
||||
|
||||
"@mapbox/node-pre-gyp": ["@mapbox/node-pre-gyp@2.0.3", "", { "dependencies": { "consola": "^3.2.3", "detect-libc": "^2.0.0", "https-proxy-agent": "^7.0.5", "node-fetch": "^2.6.7", "nopt": "^8.0.0", "semver": "^7.5.3", "tar": "^7.4.0" }, "bin": { "node-pre-gyp": "bin/node-pre-gyp" } }, "sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg=="],
|
||||
|
||||
"@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="],
|
||||
|
||||
"@mermaid-js/parser": ["@mermaid-js/parser@1.2.0", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA=="],
|
||||
|
||||
"@microsoft/fast-element": ["@microsoft/fast-element@1.14.0", "", {}, "sha512-zXvuSOzvsu8zDTy9eby8ix8VqLop2rwKRgp++ZN2kTCsoB3+QJVoaGD2T/Cyso2ViZQFXNpiNCVKfnmxBvmWkQ=="],
|
||||
@@ -2302,12 +2324,18 @@
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@storybook/addon-a11y": ["@storybook/addon-a11y@9.1.20", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^9.1.20" } }, "sha512-VFZ34y4ApmFwIzPRs2OJrG6jtYhM5y91eCZLTlR/HMGQciKF4TdOJHjj+5vf91SOER5UDcLizXetpiUowiZSgw=="],
|
||||
|
||||
"@storybook/addon-docs": ["@storybook/addon-docs@9.1.20", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "9.1.20", "@storybook/icons": "^1.4.0", "@storybook/react-dom-shim": "9.1.20", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^9.1.20" } }, "sha512-eUIOd4u/p9994Nkv8Avn6r/xmS7D+RNmhmu6KGROefN3myLe3JfhSdimal2wDFe/h/OUNZ/LVVKMZrya9oEfKQ=="],
|
||||
|
||||
"@storybook/builder-vite": ["@storybook/builder-vite@9.1.20", "", { "dependencies": { "@storybook/csf-plugin": "9.1.20", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^9.1.20", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-cdU3Q2/wEaT8h+mApFToRiF/0hYKH1eAkD0scQn67aODgp7xnkr0YHcdA+8w0Uxd2V7U8crV/cmT/HD0ELVOGw=="],
|
||||
|
||||
"@storybook/csf-plugin": ["@storybook/csf-plugin@9.1.20", "", { "dependencies": { "unplugin": "^1.3.1" }, "peerDependencies": { "storybook": "^9.1.20" } }, "sha512-HHgk50YQhML7mT01Mzf9N7lNMFHWN4HwwRP90kPT9Ct+Jhx7h3LBDbdmWjI96HwujcpY7eoYdTfpB1Sw8Z7nBQ=="],
|
||||
|
||||
"@storybook/global": ["@storybook/global@5.0.0", "", {}, "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ=="],
|
||||
|
||||
"@storybook/icons": ["@storybook/icons@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" } }, "sha512-hcFZIjW8yQz8O8//2WTIXylm5Xsgc+lW9ISLgUk1xGmptIJQRdlhVIXCpSyLrQaaRiyhQRaVg7l3BD9S216BHw=="],
|
||||
|
||||
"@storybook/react": ["@storybook/react@9.1.20", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/react-dom-shim": "9.1.20" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "storybook": "^9.1.20", "typescript": ">= 4.9.x" }, "optionalPeers": ["typescript"] }, "sha512-TJhqzggs7HCvLhTXKfx8HodnVq9YizsB2J31s9v6olU0UCxbCY+FYaCF+XdE8qUCyefGRZgHKzGBIczJ/q9e2g=="],
|
||||
|
||||
"@storybook/react-dom-shim": ["@storybook/react-dom-shim@9.1.20", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", "storybook": "^9.1.20" } }, "sha512-UYdZavfPwHEqCKMqPssUOlyFVZiJExLxnSHwkICSZBmw3gxXJcp1aXWs7PvoZdWz2K4ztl3IcKErXXHeiY6w+A=="],
|
||||
@@ -2578,6 +2606,8 @@
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/mdx": ["@types/mdx@2.0.14", "", {}, "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg=="],
|
||||
|
||||
"@types/mocha": ["@types/mocha@10.0.10", "", {}, "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
@@ -2826,6 +2856,8 @@
|
||||
|
||||
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
|
||||
|
||||
"axe-core": ["axe-core@4.12.1", "", {}, "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA=="],
|
||||
|
||||
"axios": ["axios@1.16.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A=="],
|
||||
|
||||
"azure-devops-node-api": ["azure-devops-node-api@12.5.0", "", { "dependencies": { "tunnel": "0.0.6", "typed-rest-client": "^1.8.4" } }, "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og=="],
|
||||
@@ -5194,6 +5226,8 @@
|
||||
|
||||
"@cline/code/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@cline/ui/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"@cline/vscode-rollout/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
|
||||
|
||||
"@cline/vscode-rollout/@types/vscode": ["@types/vscode@1.84.0", "", {}, "sha512-lCGOSrhT3cL+foUEqc8G1PVZxoDbiMmxgnUZZTEnHF4mC47eKAUtBGAuMLY6o6Ua8PAuNCoKXbqPmJd1JYnQfg=="],
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
addons: ["@storybook/addon-a11y", "@storybook/addon-docs"],
|
||||
core: {
|
||||
allowedHosts: ["localhost", "127.0.0.1"],
|
||||
},
|
||||
framework: "@storybook/react-vite",
|
||||
async viteFinal(viteConfig) {
|
||||
viteConfig.plugins = [...(viteConfig.plugins ?? []), tailwindcss()];
|
||||
return viteConfig;
|
||||
},
|
||||
typescript: {
|
||||
check: true,
|
||||
reactDocgen: "react-docgen-typescript",
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,22 @@
|
||||
@import "@fontsource-variable/schibsted-grotesk";
|
||||
@import "@fontsource/azeret-mono/latin.css";
|
||||
@import "tailwindcss";
|
||||
@import "../theme/index.css";
|
||||
@import "../components/agent-chat/agent-chat.css";
|
||||
|
||||
@source "../components";
|
||||
@source "../stories";
|
||||
@source ".";
|
||||
|
||||
html,
|
||||
body,
|
||||
#storybook-root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.cline-storybook-surface {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Decorator, Preview } from "@storybook/react-vite";
|
||||
import "./preview.css";
|
||||
|
||||
const withClineTheme: Decorator = (Story, context) => {
|
||||
const isDark = context.globals.theme === "dark";
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
return (
|
||||
<div className="cline-storybook-surface">
|
||||
<Story />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
decorators: [withClineTheme],
|
||||
parameters: {
|
||||
backgrounds: { disable: true },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
layout: "fullscreen",
|
||||
viewport: {
|
||||
viewports: {
|
||||
chatPanel: {
|
||||
name: "Chat panel",
|
||||
styles: { height: "800px", width: "700px" },
|
||||
type: "desktop",
|
||||
},
|
||||
mobile: {
|
||||
name: "Mobile",
|
||||
styles: { height: "844px", width: "390px" },
|
||||
type: "mobile",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
globalTypes: {
|
||||
theme: {
|
||||
description: "Cline color theme",
|
||||
defaultValue: "dark",
|
||||
toolbar: {
|
||||
dynamicTitle: true,
|
||||
icon: "circlehollow",
|
||||
items: [
|
||||
{ title: "Light", value: "light" },
|
||||
{ title: "Dark", value: "dark" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
+306
-71
@@ -1,14 +1,17 @@
|
||||
# `@cline/ui` adoption primer
|
||||
|
||||
This guide is for Cline engineering teams that want a web application to share
|
||||
the Cline visual language without copying desktop styles or adopting desktop
|
||||
product structure.
|
||||
the Cline visual language and agent-chat presentation without copying desktop
|
||||
styles or adopting desktop product structure.
|
||||
|
||||
## The short version
|
||||
|
||||
`@cline/ui` is a CSS theme foundation, not a React component library.
|
||||
`@cline/ui` has two opt-in layers:
|
||||
|
||||
It provides:
|
||||
1. A shared CSS theme built around standard shadcn/Tailwind semantic names.
|
||||
2. Reusable React presentation primitives for common agent-chat interfaces.
|
||||
|
||||
The theme provides:
|
||||
|
||||
- Light and dark semantic colors
|
||||
- Standard shadcn token names
|
||||
@@ -19,53 +22,58 @@ It provides:
|
||||
- Tailwind v4 mappings
|
||||
- Optional global, interaction, and Markdown styles
|
||||
|
||||
The first component surface provides:
|
||||
|
||||
- Sticky agent-conversation structure and a scroll-to-latest affordance
|
||||
- User, assistant, system, status, and error message presentation
|
||||
- Message actions with accessible labels and focus behavior
|
||||
- Controlled or uncontrolled reasoning disclosures
|
||||
- Static or expandable tool activity with running, success, and error states
|
||||
- Empty-conversation presentation
|
||||
|
||||
Each application continues to own:
|
||||
|
||||
- Components and page layouts
|
||||
- Navigation and product behavior
|
||||
- Font-file loading
|
||||
- Framework and runtime integration
|
||||
- Shell layout and viewport rules
|
||||
- Product-specific animation
|
||||
- Deliberate product overrides
|
||||
- Runtime message and tool schemas
|
||||
- Session, provider, transport, streaming, and persistence behavior
|
||||
- Markdown rendering and external-link/image policy
|
||||
- Approval and follow-up-question orchestration
|
||||
- Checkpoint, fork, clipboard, and toast behavior
|
||||
- Page layouts, navigation, and product workflows
|
||||
- Font-file loading and framework integration
|
||||
- Product-specific animation and deliberate visual overrides
|
||||
|
||||
This gives Cline web products a shared visual vocabulary without requiring
|
||||
identical screens.
|
||||
This boundary gives Cline products a shared visual and interaction language
|
||||
without turning `@cline/ui` into a second agent runtime.
|
||||
|
||||
## Current status
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@cline/ui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"internal": true
|
||||
}
|
||||
```
|
||||
`@cline/ui` is configured for public npm publication with its own version and
|
||||
manual release workflow. Check availability with `npm view @cline/ui version`;
|
||||
an `E404` means the first release is still pending. The API is pre-stable, so
|
||||
production consumers should pin exact versions and review compatibility notes
|
||||
when updating.
|
||||
|
||||
The package is currently available only inside the Cline monorepo. It is not
|
||||
published to npm and is not part of the public SDK release.
|
||||
|
||||
Desktop is the first production-shaped consumer. The next milestone is adoption
|
||||
by a second Cline web application so the contract can be tested outside the
|
||||
environment that created it.
|
||||
Desktop is the first production-shaped consumer of both the theme and shared
|
||||
chat primitives. Storybook is the reference catalog for isolated component
|
||||
states. Hub and other agent interfaces are candidates for the next adoption
|
||||
pass once their runtime and Markdown adapters are mapped explicitly.
|
||||
|
||||
## Choose an adoption level
|
||||
|
||||
| Goal | Import | Tailwind required |
|
||||
| --- | --- | --- |
|
||||
| Use only light/dark CSS variables | `@cline/ui/theme/tokens.css` | No |
|
||||
| Use tokens through Tailwind utilities | `tokens.css` then `theme.css` | Tailwind v4 |
|
||||
| Use the complete theme and shared base behavior | `@cline/ui/theme/index.css` | Tailwind v4 |
|
||||
| Goal | Import | Tailwind required | React required |
|
||||
| --- | --- | --- | --- |
|
||||
| Use only light/dark CSS variables | `@cline/ui/theme/tokens.css` | No | No |
|
||||
| Use tokens through Tailwind utilities | `tokens.css` then `theme.css` | Tailwind v4 | No |
|
||||
| Use the complete theme and shared base behavior | `@cline/ui/theme/index.css` | Tailwind v4 | No |
|
||||
| Compose shared agent-chat presentation | `@cline/ui/components/agent-chat` plus its CSS | No, if tokens are mapped in plain CSS | React 18.3 or 19 |
|
||||
|
||||
The package also exports `base.css` separately for consumers that want its
|
||||
global, Markdown, scrollbar, selection, cursor, and native `color-scheme`
|
||||
behavior.
|
||||
The package exports `base.css` separately for consumers that want its global,
|
||||
Markdown, scrollbar, selection, cursor, and native `color-scheme` behavior.
|
||||
|
||||
There is no root JavaScript export and no `@cline/ui/theme` shorthand. Use the
|
||||
explicit CSS paths documented below.
|
||||
explicit paths documented here so dependencies remain visible.
|
||||
|
||||
## Monorepo setup
|
||||
## Install inside the Cline monorepo
|
||||
|
||||
Add the workspace dependency:
|
||||
|
||||
@@ -80,6 +88,47 @@ Add the workspace dependency:
|
||||
Run the repository's normal package installation workflow after updating the
|
||||
manifest and lockfile.
|
||||
|
||||
## Install from npm in another repository
|
||||
|
||||
After the initial release is available, install the latest production UI
|
||||
release. The `--exact` flag records the resolved version instead of a range:
|
||||
|
||||
```bash
|
||||
bun add --exact @cline/ui
|
||||
```
|
||||
|
||||
The package is ESM. Its React entry point targets browser applications. Install
|
||||
only the prerequisites for the layer being adopted:
|
||||
|
||||
```bash
|
||||
# Required only for agent-chat components
|
||||
bun add react@^19 react-dom@^19
|
||||
|
||||
# Required for the documented Tailwind-backed theme and Cline fonts
|
||||
bun add @fontsource-variable/schibsted-grotesk @fontsource/azeret-mono
|
||||
bun add --dev tailwindcss
|
||||
```
|
||||
|
||||
Applications already on React 18.3 can retain that compatible version.
|
||||
Tokens-only consumers do not need React or Tailwind.
|
||||
|
||||
Commit the consuming repository's lockfile so builds continue using the same
|
||||
resolved version. Use the package manager's update command when the team
|
||||
intentionally wants to move to a newer release:
|
||||
|
||||
```bash
|
||||
bun update @cline/ui
|
||||
```
|
||||
|
||||
For deliberate previews, UI releases can publish an unstable `next` npm tag:
|
||||
|
||||
```bash
|
||||
bun add --exact @cline/ui@next
|
||||
```
|
||||
|
||||
Do not use `next` for production applications. UI versions move independently
|
||||
from the runtime SDK packages.
|
||||
|
||||
## Option 1: complete Tailwind v4 theme
|
||||
|
||||
Import fonts and Tailwind before the complete theme:
|
||||
@@ -116,7 +165,7 @@ owns document, Markdown, scrollbar, or cursor behavior:
|
||||
@import "@cline/ui/theme/theme.css";
|
||||
```
|
||||
|
||||
If the application later opts into the shared base behavior, import
|
||||
If the application later opts into shared base behavior, import
|
||||
`@cline/ui/theme/base.css` after `theme.css`.
|
||||
|
||||
## Option 3: framework-neutral tokens
|
||||
@@ -147,6 +196,179 @@ For native controls that should follow the selected theme:
|
||||
}
|
||||
```
|
||||
|
||||
## Add the agent-chat components
|
||||
|
||||
With the complete Tailwind theme, import the component styles afterward:
|
||||
|
||||
```css
|
||||
@import "@cline/ui/theme/index.css";
|
||||
@import "@cline/ui/components/agent-chat.css";
|
||||
```
|
||||
|
||||
Without Tailwind, import the framework-neutral tokens and component styles,
|
||||
then apply the shared font family at an app or chat root (tokens define font
|
||||
values but do not apply document typography):
|
||||
|
||||
```css
|
||||
@import "@cline/ui/theme/tokens.css";
|
||||
@import "@cline/ui/components/agent-chat.css";
|
||||
|
||||
.agent-chat-root {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
```
|
||||
|
||||
Then compose the presentation around the consuming application's own data:
|
||||
|
||||
```tsx
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
type AgentMessageRole,
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
ConversationViewport,
|
||||
Message,
|
||||
MessageActions,
|
||||
MessageAction,
|
||||
MessageContent,
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
ToolActivity,
|
||||
ToolActivityContent,
|
||||
ToolActivityTrigger,
|
||||
} from "@cline/ui/components/agent-chat";
|
||||
|
||||
type ProductMessage = {
|
||||
id: string;
|
||||
role: "human" | "agent" | "system" | "error";
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
isStreaming?: boolean;
|
||||
};
|
||||
|
||||
const roleMap: Record<ProductMessage["role"], AgentMessageRole> = {
|
||||
human: "user",
|
||||
agent: "assistant",
|
||||
system: "system",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
type AgentTranscriptProps = {
|
||||
conversationId: string;
|
||||
messages: ProductMessage[];
|
||||
onCopy: (message: ProductMessage) => void;
|
||||
renderMarkdown: (content: string) => ReactNode;
|
||||
};
|
||||
|
||||
export function AgentTranscript({
|
||||
conversationId,
|
||||
messages,
|
||||
onCopy,
|
||||
renderMarkdown,
|
||||
}: AgentTranscriptProps) {
|
||||
return (
|
||||
<Conversation
|
||||
className="agent-chat-root"
|
||||
key={conversationId}
|
||||
style={{ height: "32rem" }}
|
||||
>
|
||||
<ConversationViewport aria-label="Agent conversation">
|
||||
<ConversationContent>
|
||||
{messages.map((message) => (
|
||||
<Message from={roleMap[message.role]} key={message.id}>
|
||||
<MessageContent>
|
||||
{message.reasoning ? (
|
||||
<Reasoning isStreaming={message.isStreaming}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
{renderMarkdown(message.reasoning)}
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
) : null}
|
||||
|
||||
{renderMarkdown(message.content)}
|
||||
</MessageContent>
|
||||
|
||||
<MessageActions>
|
||||
<MessageAction label="Copy message" onClick={() => onCopy(message)}>
|
||||
Copy
|
||||
</MessageAction>
|
||||
</MessageActions>
|
||||
</Message>
|
||||
))}
|
||||
|
||||
<ToolActivity expandable>
|
||||
<ToolActivityTrigger
|
||||
label="Edited 2 files"
|
||||
additions={24}
|
||||
deletions={8}
|
||||
status="success"
|
||||
/>
|
||||
<ToolActivityContent>Normalized tool details</ToolActivityContent>
|
||||
</ToolActivity>
|
||||
</ConversationContent>
|
||||
</ConversationViewport>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The explicit height keeps this standalone example scrollable. In a real shell,
|
||||
an equivalent bounded flex layout works too: every ancestor in the height chain
|
||||
must allow shrinking (commonly `min-height: 0`) and the conversation must fill
|
||||
the available height.
|
||||
|
||||
The example intentionally injects a consumer-owned `renderMarkdown`. Different
|
||||
products currently have different Streamdown plugins, syntax-highlighting
|
||||
budgets, link-confirmation behavior, and image policies. The React `key` resets
|
||||
conversation-local state when the active session changes. The shared package
|
||||
standardizes the surrounding presentation without silently changing those
|
||||
security and product decisions.
|
||||
|
||||
Map runtime roles and tool states at the consumer boundary. Do not make the UI
|
||||
package depend on `@cline/core`, the Vercel AI SDK, desktop schemas, or transport
|
||||
events.
|
||||
|
||||
## Explore components in Storybook
|
||||
|
||||
From the Cline repository root:
|
||||
|
||||
```bash
|
||||
bun -F @cline/ui storybook
|
||||
```
|
||||
|
||||
Open `http://localhost:6006`. The toolbar switches light/dark mode and offers
|
||||
representative chat and mobile viewports. Stories cover:
|
||||
|
||||
- Theme colors, typography, radii, and controls
|
||||
- Complete and empty conversations
|
||||
- User, assistant, and error messages
|
||||
- Collapsed, expanded, and streaming reasoning
|
||||
- Pending, running, successful, and failed tool activity
|
||||
- Expandable and static tool summaries
|
||||
|
||||
In the repository's agent sandbox, bind to a forwarded host and unused port:
|
||||
|
||||
```bash
|
||||
bun -F @cline/ui storybook -- --host 0.0.0.0 --port 3490 --exact-port
|
||||
```
|
||||
|
||||
Build the production Storybook bundle with:
|
||||
|
||||
```bash
|
||||
bun -F @cline/ui build-storybook
|
||||
```
|
||||
|
||||
Storybook is the isolated component reference. Real application builds remain
|
||||
the integration test for runtime adapters and product CSS.
|
||||
|
||||
The catalog currently runs from a Cline monorepo checkout. Story sources and
|
||||
configuration are not included in the npm package, and the catalog is not
|
||||
hosted yet.
|
||||
|
||||
## Token usage
|
||||
|
||||
Product components should use semantic tokens:
|
||||
@@ -164,9 +386,9 @@ Product components should use semantic tokens:
|
||||
}
|
||||
```
|
||||
|
||||
Use the small `--brand-*` palette and `--primary-emphasis` for branded
|
||||
artwork or deliberate emphasis. Normal product controls should prefer semantic
|
||||
tokens so they continue to work across light, dark, and future theme layers.
|
||||
Use the small `--brand-*` palette and `--primary-emphasis` for branded artwork
|
||||
or deliberate emphasis. Normal controls should prefer semantic tokens so they
|
||||
continue to work across light, dark, and future theme layers.
|
||||
|
||||
## Product overrides
|
||||
|
||||
@@ -184,8 +406,9 @@ Import the package first, then override standard semantic values:
|
||||
}
|
||||
```
|
||||
|
||||
Do not copy `tokens.css` into the consuming application. Explicit overrides
|
||||
make product differences reviewable and allow future package upgrades.
|
||||
Do not copy `tokens.css` or component CSS into the consuming application.
|
||||
Explicit overrides make product differences reviewable and allow future
|
||||
package upgrades.
|
||||
|
||||
## Consumer-owned behavior
|
||||
|
||||
@@ -194,69 +417,81 @@ Keep the following outside `@cline/ui`:
|
||||
- Next, Tauri, VS Code, and runtime-specific behavior
|
||||
- `#__next`, viewport locking, and shell layout
|
||||
- Application routes and information architecture
|
||||
- Desktop session, workspace, and sidecar behavior
|
||||
- Product-specific animation keyframes
|
||||
- Session, workspace, provider, and sidecar behavior
|
||||
- Runtime event normalization and persistence
|
||||
- Tool-name classification and raw tool-payload parsing
|
||||
- Approval and question request orchestration
|
||||
- Product-specific actions and animation
|
||||
- Components that have not been proven reusable by multiple products
|
||||
|
||||
The package should standardize visual language, not erase product boundaries.
|
||||
The package should standardize repeated visual and interaction language, not
|
||||
erase product boundaries.
|
||||
|
||||
## Adoption checklist
|
||||
|
||||
- [ ] Add `@cline/ui` through `workspace:*`.
|
||||
- [ ] Choose `workspace:*` or a pinned npm version.
|
||||
- [ ] Commit the consuming project's lockfile.
|
||||
- [ ] Choose tokens-only, Tailwind mappings, or the complete theme.
|
||||
- [ ] Load the required font files.
|
||||
- [ ] Import files in the documented order.
|
||||
- [ ] Import `agent-chat.css` when using the React primitives.
|
||||
- [ ] Install React 18.3 or 19 when using the React primitives.
|
||||
- [ ] Map product message/tool models at the package boundary.
|
||||
- [ ] Use the stable conversation identifier as the `Conversation` React `key`.
|
||||
- [ ] Keep Markdown and link/image policy explicit in the consumer.
|
||||
- [ ] Confirm the application's `.dark` behavior.
|
||||
- [ ] Put deliberate overrides after package imports.
|
||||
- [ ] Remove copied local tokens instead of maintaining two sources.
|
||||
- [ ] Build the application in development and production.
|
||||
- [ ] Compare representative screens in light and dark modes.
|
||||
- [ ] Exercise focus, hover, disabled, and native-control states.
|
||||
- [ ] Record required overrides and any missing shared token.
|
||||
- [ ] Exercise focus, hover, disabled, streaming, and error states.
|
||||
- [ ] Check the same states in Storybook.
|
||||
- [ ] Record required overrides and missing shared behavior.
|
||||
|
||||
## Contract and compatibility expectations
|
||||
|
||||
Until the package has a stable version, contract changes should:
|
||||
|
||||
- Include a compatibility note
|
||||
- Run package validation and tests
|
||||
- Run the package build, typechecking, and tests
|
||||
- Build Storybook
|
||||
- Build every active consumer
|
||||
- Include light/dark visual evidence when values change
|
||||
- Avoid renaming standard shadcn/Tailwind variables
|
||||
- Keep `tokens.css` framework-neutral
|
||||
- Keep product-specific layout and runtime behavior out of the package
|
||||
- Keep component props independent of product runtime schemas
|
||||
- Keep product-specific layout and orchestration out of the package
|
||||
|
||||
Removing or changing the meaning of a semantic token should eventually be
|
||||
treated as a breaking change. Additive tokens and entry points can be introduced
|
||||
compatibly.
|
||||
Removing or changing the meaning of a semantic token or component prop should
|
||||
eventually be treated as a breaking change. Additive tokens, props, and entry
|
||||
points can be introduced compatibly.
|
||||
|
||||
## Publication status and next steps
|
||||
## Release and stability roadmap
|
||||
|
||||
Separate repositories cannot install `@cline/ui` from npm yet. Publication
|
||||
requires more than removing `private: true`.
|
||||
The npm package solves cross-repository distribution. The remaining work is to
|
||||
validate and stabilize the public contract.
|
||||
|
||||
Recommended sequence:
|
||||
|
||||
1. Adopt the package in a second Cline web application.
|
||||
2. Assign design and engineering owners.
|
||||
3. Define compatibility, browser, Tailwind, and deprecation policies.
|
||||
4. Add token-only and Tailwind consumer fixtures.
|
||||
5. Add representative light/dark visual regression coverage.
|
||||
6. Verify all exports from a packed artifact in a clean consumer.
|
||||
7. Select an initial version and release cadence.
|
||||
8. Add changelog, provenance, and npm release automation.
|
||||
9. Publish a prerelease before declaring the contract stable.
|
||||
1. Adopt the theme and chat primitives in a second production-shaped Cline app.
|
||||
2. Record where that app needs adapters or deliberate variations.
|
||||
3. Assign design and engineering owners.
|
||||
4. Define browser, React, Tailwind, compatibility, and deprecation policies.
|
||||
5. Add screenshot regression coverage for representative Storybook states.
|
||||
6. Expand clean-consumer fixtures as supported frameworks are proven.
|
||||
7. Define the compatibility point at which the API can be treated as stable.
|
||||
|
||||
Shared React components can later live under `@cline/ui/components`, but that
|
||||
should be a separate proposal based on repeated needs across applications. The
|
||||
CSS token entry point should remain usable without React.
|
||||
Likely follow-up components should be driven by repeated needs. Approval cards,
|
||||
follow-up questions, attachments, and prompt composers are candidates, but their
|
||||
current product contracts should be compared before standardizing them.
|
||||
|
||||
## Useful references
|
||||
|
||||
- [Package README](./README.md)
|
||||
- [Agent-chat components](./components/agent-chat/index.tsx)
|
||||
- [Agent-chat styles](./components/agent-chat/agent-chat.css)
|
||||
- [Tokens](./theme/tokens.css)
|
||||
- [Tailwind mappings](./theme/theme.css)
|
||||
- [Optional base styles](./theme/base.css)
|
||||
- [Complete theme](./theme/index.css)
|
||||
- [Package manifest](./package.json)
|
||||
- [Desktop integration test](../../../apps/examples/desktop-app/webview/styles/theme-integration.test.ts)
|
||||
- [Desktop theme integration test (monorepo)](https://github.com/cline/cline/blob/main/apps/examples/desktop-app/webview/styles/theme-integration.test.ts)
|
||||
|
||||
+140
-33
@@ -1,26 +1,44 @@
|
||||
# `@cline/ui`
|
||||
|
||||
Shared, framework-independent UI foundations for Cline web products. The
|
||||
package is internal to this monorepo while the first consumers settle the
|
||||
contract; it is not part of the public SDK release yet.
|
||||
Shared visual foundations and reusable React presentation primitives for Cline
|
||||
web products. The package lets teams adopt the same semantic theme and agent
|
||||
chat language without adopting another product's routes, state, or runtime.
|
||||
|
||||
See the [adoption primer](./ADOPTION.md) for integration choices, copy-paste
|
||||
setup, consumer responsibilities, and publication status.
|
||||
The package is configured for public npm releases on its own version and
|
||||
release cycle. Its API is still pre-stable, so consumers should pin an exact
|
||||
version and review compatibility notes when updating. Check availability with
|
||||
`npm view @cline/ui version`; an `E404` means the first release is still pending.
|
||||
|
||||
## Theme entry points
|
||||
See the [adoption primer](./ADOPTION.md) for complete setup instructions,
|
||||
component examples, boundaries, and release status.
|
||||
|
||||
| Import | Contents | Requires Tailwind |
|
||||
## Install
|
||||
|
||||
After the initial release is available:
|
||||
|
||||
```bash
|
||||
bun add --exact @cline/ui
|
||||
```
|
||||
|
||||
Use `@cline/ui@next` only for deliberate previews. Monorepo consumers use
|
||||
`"@cline/ui": "workspace:*"` instead.
|
||||
|
||||
## Entry points
|
||||
|
||||
| Import | Contents | Runtime requirement |
|
||||
| --- | --- | --- |
|
||||
| `@cline/ui/theme/tokens.css` | Light/dark custom properties only; no native `color-scheme` policy | No |
|
||||
| `@cline/ui/theme/theme.css` | Tailwind v4 semantic mapping and dark variant | Yes |
|
||||
| `@cline/ui/theme/base.css` | Optional base, Markdown, scrollbar, selection, and cursor styles; import after tokens and theme | Yes |
|
||||
| `@cline/ui/theme/index.css` | Complete theme: tokens, Tailwind mapping, and base styles | Yes |
|
||||
| `@cline/ui/theme/tokens.css` | Light/dark custom properties only | CSS |
|
||||
| `@cline/ui/theme/theme.css` | Tailwind v4 semantic mapping and dark variant | Tailwind v4 |
|
||||
| `@cline/ui/theme/base.css` | Optional document, Markdown, scrollbar, selection, and cursor styles | Tailwind v4 |
|
||||
| `@cline/ui/theme/index.css` | Complete theme: tokens, Tailwind mapping, and base styles | Tailwind v4 |
|
||||
| `@cline/ui/components/agent-chat` | Conversation, message, reasoning, action, and tool-activity React primitives | React 18.3 or 19 |
|
||||
| `@cline/ui/components/agent-chat.css` | Framework-neutral styles for the agent-chat primitives | Theme tokens |
|
||||
|
||||
The token-only entry point has no React, Tailwind, font-package, or desktop
|
||||
runtime dependency. Apps provide Schibsted Grotesk and Azeret Mono themselves,
|
||||
which lets each bundler control font loading and asset emission.
|
||||
The token entry point has no React, Tailwind, font-package, or desktop runtime
|
||||
dependency. Apps provide Schibsted Grotesk and Azeret Mono themselves, which
|
||||
lets each bundler control font loading and asset emission.
|
||||
|
||||
## Usage
|
||||
## Theme usage
|
||||
|
||||
For a Tailwind v4 app, import framework and consumer dependencies first:
|
||||
|
||||
@@ -31,7 +49,7 @@ For a Tailwind v4 app, import framework and consumer dependencies first:
|
||||
@import "@cline/ui/theme/index.css";
|
||||
```
|
||||
|
||||
An app that only needs the framework-neutral values can import just:
|
||||
An app that only needs framework-neutral values can import:
|
||||
|
||||
```css
|
||||
@import "@cline/ui/theme/tokens.css";
|
||||
@@ -39,27 +57,116 @@ An app that only needs the framework-neutral values can import just:
|
||||
|
||||
The theme follows the standard shadcn semantic contract (`--background`,
|
||||
`--foreground`, `--card`, `--primary`, `--border`, `--ring`, charts, and
|
||||
sidebar surfaces) and Tailwind theme names (`--font-sans`, `--font-mono`,
|
||||
`--font-weight-*`, and `--text-*`). This means shadcn components and normal
|
||||
Tailwind utilities inherit the Cline defaults without `cline-*` adapters.
|
||||
sidebar surfaces) and Tailwind theme names. This means shadcn components and
|
||||
normal Tailwind utilities inherit Cline defaults without custom adapters.
|
||||
|
||||
Brand artwork may use the small extension set (`--primary-emphasis` and the
|
||||
`--brand-*` palette). Product components should prefer semantic variables.
|
||||
`--brand-*` palette). Product controls should prefer semantic variables.
|
||||
|
||||
## Agent-chat usage
|
||||
|
||||
Agent-chat consumers must provide React 18.3 or 19. Install React in the
|
||||
consuming application if it is not already present:
|
||||
|
||||
```bash
|
||||
bun add react@^19 react-dom@^19
|
||||
```
|
||||
|
||||
Applications already on React 18.3 can retain that compatible version.
|
||||
|
||||
In the application's global CSS, import the component styles after at least the
|
||||
theme tokens:
|
||||
|
||||
```css
|
||||
@import "@cline/ui/theme/tokens.css";
|
||||
@import "@cline/ui/components/agent-chat.css";
|
||||
```
|
||||
|
||||
Then import the React components:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationEmptyState,
|
||||
ConversationScrollButton,
|
||||
ConversationViewport,
|
||||
Message,
|
||||
MessageAction,
|
||||
MessageActions,
|
||||
MessageContent,
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
ToolActivity,
|
||||
ToolActivityCode,
|
||||
ToolActivityContent,
|
||||
ToolActivityDetails,
|
||||
ToolActivityTrigger,
|
||||
} from "@cline/ui/components/agent-chat";
|
||||
```
|
||||
|
||||
`Conversation` owns sticky scrolling, `Message` owns role presentation,
|
||||
`Reasoning` and `ToolActivity` provide accessible disclosures, and the smaller
|
||||
action, empty-state, detail, and code primitives fill out common transcript
|
||||
states. Give each conversation a bounded height through an explicit height or
|
||||
a complete flex/min-height chain so its viewport can scroll.
|
||||
|
||||
These are presentation primitives, not an agent SDK. Consumers map their own
|
||||
message and tool schemas into the components and retain their own Markdown,
|
||||
transport, approvals, persistence, and product actions.
|
||||
|
||||
## Storybook
|
||||
|
||||
Run the interactive component catalog from the repository root:
|
||||
|
||||
```bash
|
||||
bun -F @cline/ui storybook
|
||||
```
|
||||
|
||||
Then open `http://localhost:6006`. Build the static catalog with:
|
||||
|
||||
```bash
|
||||
bun -F @cline/ui build-storybook
|
||||
```
|
||||
|
||||
In the repository's agent sandbox, bind to a forwarded host and unused port:
|
||||
|
||||
```bash
|
||||
bun -F @cline/ui storybook -- --host 0.0.0.0 --port 3490 --exact-port
|
||||
```
|
||||
|
||||
The catalog includes the theme foundations and representative agent-chat
|
||||
states in light, dark, desktop, and narrow viewports.
|
||||
|
||||
Storybook currently runs from a Cline monorepo checkout. It is not hosted or
|
||||
included in the npm package; deployment can be added once the catalog and
|
||||
ownership model settle.
|
||||
|
||||
## Layering and compatibility
|
||||
|
||||
- Import the Cline theme after Tailwind so its default typography values win.
|
||||
- Override `:root` or `.dark` after the package import for a deliberate product
|
||||
variation; do not rename the default contract.
|
||||
- `base.css` is optional because it includes opinionated Markdown and global
|
||||
interaction styles. When importing files individually, load `tokens.css`,
|
||||
then `theme.css`, then `base.css`. Token-only consumers do not receive resets
|
||||
or `color-scheme`; import the base layer or declare `color-scheme` locally so
|
||||
native controls follow the selected light/dark theme.
|
||||
- Shell-specific layout such as `#__next`, viewport locking, and app animation
|
||||
keyframes stays with each consumer.
|
||||
- Contract changes should include a compatibility note and a consumer build.
|
||||
- Import `agent-chat.css` after theme tokens.
|
||||
- Override `:root` or `.dark` after package imports for deliberate product
|
||||
variations; do not rename the default semantic contract.
|
||||
- `base.css` is optional because it contains opinionated Markdown and global
|
||||
interaction styles.
|
||||
- Shell layout, routes, provider/session state, and runtime behavior stay with
|
||||
each consumer.
|
||||
- Contract changes should include a compatibility note, package tests, a
|
||||
Storybook build, and at least one real consumer build.
|
||||
|
||||
Tailwind theme variables are CSS-first and designed to be shared through an
|
||||
imported stylesheet. See the
|
||||
[Tailwind theme variable documentation](https://tailwindcss.com/docs/theme#sharing-across-projects).
|
||||
## Releases
|
||||
|
||||
The standalone `ui-publish.yml` workflow validates the package and publishes
|
||||
only after a manual dispatch from `main`. Production releases use the npm
|
||||
`latest` tag; deliberate previews use `next`. UI releases do not trigger the
|
||||
SDK release, GitHub releases, or Slack announcements.
|
||||
|
||||
Maintainers use the repository's `publish-ui` skill for the initial bootstrap
|
||||
and later releases.
|
||||
|
||||
The install command above pins the resolved release. Commit the consumer
|
||||
lockfile and update deliberately. The package is ESM and its React components
|
||||
target browser applications. A complete Tailwind theme also requires Tailwind
|
||||
v4 and the two font packages shown above.
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
@layer components {
|
||||
.cline-chat-conversation {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.cline-chat-conversation-viewport {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cline-chat-conversation-content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cline-chat-empty-state {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 16rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem;
|
||||
box-sizing: border-box;
|
||||
color: var(--muted-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cline-chat-empty-state-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.cline-chat-empty-state h3,
|
||||
.cline-chat-empty-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cline-chat-empty-state h3 {
|
||||
color: var(--foreground);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.cline-chat-empty-state p {
|
||||
margin-top: 0.25rem;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.cline-chat-scroll-button {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9999px;
|
||||
background: var(--secondary);
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 0.12);
|
||||
color: var(--secondary-foreground);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cline-chat-scroll-button:hover {
|
||||
background: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.cline-chat-message {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
font-size: var(--text-sm);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cline-chat-message[data-role="user"] {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.cline-chat-message-content {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.cline-chat-message[data-role="user"] > .cline-chat-message-content {
|
||||
max-width: 85%;
|
||||
padding: 0.5rem;
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
background: var(--card);
|
||||
color: color-mix(in oklab, var(--foreground) 80%, transparent);
|
||||
}
|
||||
|
||||
.cline-chat-message[data-role="error"] > .cline-chat-message-content {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid color-mix(in oklab, var(--destructive) 40%, transparent);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in oklab, var(--destructive) 10%, transparent);
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.cline-chat-message[data-role="system"] > .cline-chat-message-content,
|
||||
.cline-chat-message[data-role="status"] > .cline-chat-message-content {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.cline-chat-message-actions {
|
||||
display: flex;
|
||||
min-height: 1.5rem;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.cline-chat-message:hover > .cline-chat-message-actions,
|
||||
.cline-chat-message:focus-within > .cline-chat-message-actions,
|
||||
.cline-chat-message-actions[data-visible="true"] {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.cline-chat-message-action {
|
||||
display: inline-flex;
|
||||
min-width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0 0.5rem;
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
font: inherit;
|
||||
font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cline-chat-message-action:hover {
|
||||
background: var(--accent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.cline-chat-message-action:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cline-chat-reasoning {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.cline-chat-reasoning-trigger,
|
||||
.cline-chat-tool-trigger {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
min-height: 1.75rem;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: color-mix(in oklab, var(--foreground) 70%, transparent);
|
||||
font: inherit;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
button.cline-chat-reasoning-trigger,
|
||||
button.cline-chat-tool-trigger {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.cline-chat-reasoning-trigger:disabled,
|
||||
button.cline-chat-tool-trigger:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cline-chat-reasoning-trigger:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.cline-chat-reasoning-status {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
.cline-chat-disclosure-icon {
|
||||
flex: 0 0 auto;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
[aria-expanded="true"] > .cline-chat-disclosure-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.cline-chat-reasoning-content {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
margin-top: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid color-mix(in oklab, var(--border) 70%, transparent);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in oklab, var(--muted) 30%, transparent);
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.625;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cline-chat-tool {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.cline-chat-tool-trigger {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.cline-chat-tool-trigger[data-status="error"] {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.cline-chat-tool-trigger[data-status="pending"] {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
button.cline-chat-tool-trigger:hover {
|
||||
color: color-mix(in oklab, var(--primary) 80%, transparent);
|
||||
}
|
||||
|
||||
button.cline-chat-tool-trigger[data-status="error"]:hover {
|
||||
color: color-mix(in oklab, var(--destructive) 80%, transparent);
|
||||
}
|
||||
|
||||
.cline-chat-tool-icon {
|
||||
display: inline-grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.cline-chat-tool-label {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cline-chat-tool-diff {
|
||||
flex: 0 0 auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.cline-chat-tool-diff [data-diff="additions"] {
|
||||
color: var(--chart-2);
|
||||
}
|
||||
|
||||
.cline-chat-tool-diff [data-diff="deletions"] {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
.cline-chat-tool-progress {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
flex: 0 0 auto;
|
||||
border: 2px solid color-mix(in oklab, var(--primary) 25%, transparent);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 9999px;
|
||||
animation: cline-chat-spin 800ms linear infinite;
|
||||
}
|
||||
|
||||
.cline-chat-tool-content {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
margin-top: 0.375rem;
|
||||
padding-left: 2rem;
|
||||
overflow-x: hidden;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.cline-chat-tool-details {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cline-chat-tool-code {
|
||||
max-width: 100%;
|
||||
max-height: 13rem;
|
||||
margin: 0.5rem 0 0;
|
||||
padding: 0.5rem;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
border: 1px solid color-mix(in oklab, var(--border) 70%, transparent);
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
background: color-mix(in oklab, var(--background) 60%, transparent);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.625;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cline-chat-conversation-viewport:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.cline-chat-scroll-button:focus-visible,
|
||||
.cline-chat-message-action:focus-visible,
|
||||
.cline-chat-reasoning-trigger:focus-visible,
|
||||
.cline-chat-tool-trigger:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.cline-chat-message[data-role="user"] > .cline-chat-message-content {
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cline-chat-message-actions,
|
||||
.cline-chat-disclosure-icon {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.cline-chat-tool-progress {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.cline-chat-message-actions {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cline-chat-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ButtonHTMLAttributes,
|
||||
createContext,
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
type RefCallback,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
const STICK_TO_BOTTOM_THRESHOLD_PX = 24;
|
||||
const SCROLL_BUTTON_THRESHOLD_PX = 120;
|
||||
|
||||
function classNames(...values: Array<string | undefined | false>): string {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: Ref<T> | undefined, value: T | null): void {
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
return;
|
||||
}
|
||||
if (ref) {
|
||||
ref.current = value;
|
||||
}
|
||||
}
|
||||
|
||||
type ConversationContextValue = {
|
||||
setContent: (element: HTMLDivElement | null) => void;
|
||||
setViewport: (element: HTMLDivElement | null) => void;
|
||||
showScrollButton: boolean;
|
||||
scrollToBottom: (behavior?: ScrollBehavior) => void;
|
||||
};
|
||||
|
||||
const ConversationContext = createContext<ConversationContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
function useConversation(): ConversationContextValue {
|
||||
const context = useContext(ConversationContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"Conversation components must be rendered inside Conversation",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export type ConversationProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Conversation = forwardRef<HTMLDivElement, ConversationProps>(
|
||||
({ children, className, ...props }, ref) => {
|
||||
const [viewport, setViewport] = useState<HTMLDivElement | null>(null);
|
||||
const [content, setContent] = useState<HTMLDivElement | null>(null);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const shouldStickToBottom = useRef(true);
|
||||
const isProgrammaticScroll = useRef(false);
|
||||
const lastProgrammaticScrollTop = useRef(0);
|
||||
const programmaticScrollTimer = useRef<number | null>(null);
|
||||
|
||||
const clearProgrammaticScroll = useCallback(() => {
|
||||
if (programmaticScrollTimer.current !== null) {
|
||||
window.clearTimeout(programmaticScrollTimer.current);
|
||||
programmaticScrollTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateScrollPosition = useCallback(() => {
|
||||
if (!viewport) return;
|
||||
const distance =
|
||||
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||
if (isProgrammaticScroll.current) {
|
||||
if (viewport.scrollTop + 1 < lastProgrammaticScrollTop.current) {
|
||||
isProgrammaticScroll.current = false;
|
||||
clearProgrammaticScroll();
|
||||
} else {
|
||||
lastProgrammaticScrollTop.current = viewport.scrollTop;
|
||||
shouldStickToBottom.current = true;
|
||||
setShowScrollButton(false);
|
||||
if (distance <= STICK_TO_BOTTOM_THRESHOLD_PX) {
|
||||
isProgrammaticScroll.current = false;
|
||||
clearProgrammaticScroll();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
shouldStickToBottom.current = distance <= STICK_TO_BOTTOM_THRESHOLD_PX;
|
||||
setShowScrollButton(distance > SCROLL_BUTTON_THRESHOLD_PX);
|
||||
}, [clearProgrammaticScroll, viewport]);
|
||||
|
||||
const scrollToBottom = useCallback(
|
||||
(behavior: ScrollBehavior = "smooth") => {
|
||||
if (!viewport) return;
|
||||
clearProgrammaticScroll();
|
||||
const prefersReducedMotion =
|
||||
behavior === "smooth" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const effectiveBehavior = prefersReducedMotion ? "auto" : behavior;
|
||||
const isSmooth = effectiveBehavior === "smooth";
|
||||
isProgrammaticScroll.current = isSmooth;
|
||||
lastProgrammaticScrollTop.current = viewport.scrollTop;
|
||||
shouldStickToBottom.current = true;
|
||||
viewport.scrollTo({
|
||||
top: viewport.scrollHeight,
|
||||
behavior: effectiveBehavior,
|
||||
});
|
||||
setShowScrollButton(false);
|
||||
if (!isSmooth) return;
|
||||
programmaticScrollTimer.current = window.setTimeout(() => {
|
||||
isProgrammaticScroll.current = false;
|
||||
programmaticScrollTimer.current = null;
|
||||
updateScrollPosition();
|
||||
}, 1500);
|
||||
},
|
||||
[clearProgrammaticScroll, updateScrollPosition, viewport],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewport) return;
|
||||
updateScrollPosition();
|
||||
viewport.addEventListener("scroll", updateScrollPosition);
|
||||
const cancelProgrammaticScroll = () => {
|
||||
if (!isProgrammaticScroll.current) return;
|
||||
isProgrammaticScroll.current = false;
|
||||
clearProgrammaticScroll();
|
||||
updateScrollPosition();
|
||||
};
|
||||
viewport.addEventListener("touchstart", cancelProgrammaticScroll, {
|
||||
passive: true,
|
||||
});
|
||||
viewport.addEventListener("pointerdown", cancelProgrammaticScroll, {
|
||||
passive: true,
|
||||
});
|
||||
const cancelProgrammaticScrollOnKeydown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
[
|
||||
"ArrowDown",
|
||||
"ArrowUp",
|
||||
"End",
|
||||
"Home",
|
||||
"PageDown",
|
||||
"PageUp",
|
||||
" ",
|
||||
].includes(event.key)
|
||||
) {
|
||||
cancelProgrammaticScroll();
|
||||
}
|
||||
};
|
||||
viewport.addEventListener("keydown", cancelProgrammaticScrollOnKeydown);
|
||||
viewport.addEventListener("wheel", cancelProgrammaticScroll, {
|
||||
passive: true,
|
||||
});
|
||||
return () => {
|
||||
viewport.removeEventListener("scroll", updateScrollPosition);
|
||||
viewport.removeEventListener("touchstart", cancelProgrammaticScroll);
|
||||
viewport.removeEventListener("pointerdown", cancelProgrammaticScroll);
|
||||
viewport.removeEventListener(
|
||||
"keydown",
|
||||
cancelProgrammaticScrollOnKeydown,
|
||||
);
|
||||
viewport.removeEventListener("wheel", cancelProgrammaticScroll);
|
||||
};
|
||||
}, [clearProgrammaticScroll, updateScrollPosition, viewport]);
|
||||
|
||||
useEffect(() => () => clearProgrammaticScroll(), [clearProgrammaticScroll]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!viewport || !content) return;
|
||||
scrollToBottom("auto");
|
||||
}, [content, scrollToBottom, viewport]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!content || !viewport || typeof ResizeObserver === "undefined")
|
||||
return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (shouldStickToBottom.current) {
|
||||
scrollToBottom("auto");
|
||||
} else {
|
||||
updateScrollPosition();
|
||||
}
|
||||
});
|
||||
observer.observe(content);
|
||||
observer.observe(viewport);
|
||||
return () => observer.disconnect();
|
||||
}, [content, scrollToBottom, updateScrollPosition, viewport]);
|
||||
|
||||
const value = useMemo<ConversationContextValue>(
|
||||
() => ({
|
||||
scrollToBottom,
|
||||
setContent,
|
||||
setViewport,
|
||||
showScrollButton,
|
||||
}),
|
||||
[scrollToBottom, showScrollButton],
|
||||
);
|
||||
|
||||
return (
|
||||
<ConversationContext.Provider value={value}>
|
||||
<div
|
||||
className={classNames("cline-chat-conversation", className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ConversationContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Conversation.displayName = "Conversation";
|
||||
|
||||
export type ConversationViewportProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"role"
|
||||
>;
|
||||
|
||||
export const ConversationViewport = forwardRef<
|
||||
HTMLDivElement,
|
||||
ConversationViewportProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
"aria-label": ariaLabel = "Agent conversation",
|
||||
"aria-live": ariaLive = "polite",
|
||||
className,
|
||||
tabIndex = 0,
|
||||
...props
|
||||
},
|
||||
forwardedRef,
|
||||
) => {
|
||||
const { setViewport } = useConversation();
|
||||
const ref = useCallback<RefCallback<HTMLDivElement>>(
|
||||
(element) => {
|
||||
setViewport(element);
|
||||
assignRef(forwardedRef, element);
|
||||
},
|
||||
[forwardedRef, setViewport],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
aria-label={ariaLabel}
|
||||
aria-live={ariaLive}
|
||||
className={classNames("cline-chat-conversation-viewport", className)}
|
||||
ref={ref}
|
||||
role="log"
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ConversationViewport.displayName = "ConversationViewport";
|
||||
|
||||
export type ConversationContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ConversationContent = forwardRef<
|
||||
HTMLDivElement,
|
||||
ConversationContentProps
|
||||
>(({ className, ...props }, forwardedRef) => {
|
||||
const { setContent } = useConversation();
|
||||
const ref = useCallback<RefCallback<HTMLDivElement>>(
|
||||
(element) => {
|
||||
setContent(element);
|
||||
assignRef(forwardedRef, element);
|
||||
},
|
||||
[forwardedRef, setContent],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames("cline-chat-conversation-content", className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
ConversationContent.displayName = "ConversationContent";
|
||||
|
||||
export type ConversationEmptyStateProps = HTMLAttributes<HTMLDivElement> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
children,
|
||||
className,
|
||||
description = "Start a conversation to see messages here.",
|
||||
icon,
|
||||
title = "No messages yet",
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div className={classNames("cline-chat-empty-state", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon ? (
|
||||
<div className="cline-chat-empty-state-icon">{icon}</div>
|
||||
) : null}
|
||||
<div>
|
||||
<h3>{title}</h3>
|
||||
{description ? <p>{description}</p> : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ConversationScrollButtonProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"type"
|
||||
>;
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
"aria-label": ariaLabel = "Scroll to latest message",
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { scrollToBottom, showScrollButton } = useConversation();
|
||||
if (!showScrollButton) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
aria-label={ariaLabel}
|
||||
className={classNames("cline-chat-scroll-button", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
if (!event.defaultPrevented) scrollToBottom();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{children ?? <ChevronDownIcon />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type AgentMessageRole =
|
||||
| "user"
|
||||
| "assistant"
|
||||
| "system"
|
||||
| "status"
|
||||
| "error";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: AgentMessageRole;
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
{...props}
|
||||
className={classNames("cline-chat-message", className)}
|
||||
data-role={from}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={classNames("cline-chat-message-content", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
visible?: boolean;
|
||||
};
|
||||
|
||||
export const MessageActions = ({
|
||||
className,
|
||||
visible = false,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div
|
||||
{...props}
|
||||
className={classNames("cline-chat-message-actions", className)}
|
||||
data-visible={visible || undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageActionProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"type"
|
||||
> & {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const MessageAction = ({
|
||||
"aria-label": ariaLabel,
|
||||
className,
|
||||
label,
|
||||
...props
|
||||
}: MessageActionProps) => (
|
||||
<button
|
||||
{...props}
|
||||
aria-label={ariaLabel ?? label}
|
||||
className={classNames("cline-chat-message-action", className)}
|
||||
type="button"
|
||||
/>
|
||||
);
|
||||
|
||||
type DisclosureState = {
|
||||
isOpen: boolean;
|
||||
panelId: string;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
type ReasoningContextValue = DisclosureState & {
|
||||
isStreaming: boolean;
|
||||
};
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
|
||||
function useReasoning(): ReasoningContextValue {
|
||||
const context = useContext(ReasoningContext);
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be rendered inside Reasoning");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export type ReasoningProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"onChange"
|
||||
> & {
|
||||
isStreaming?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const Reasoning = ({
|
||||
className,
|
||||
defaultOpen = false,
|
||||
isStreaming = false,
|
||||
onOpenChange,
|
||||
open,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
const panelId = useId();
|
||||
const isOpen = open ?? internalOpen;
|
||||
const setIsOpen = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (open === undefined) setInternalOpen(nextOpen);
|
||||
onOpenChange?.(nextOpen);
|
||||
},
|
||||
[onOpenChange, open],
|
||||
);
|
||||
const value = useMemo(
|
||||
() => ({ isOpen, isStreaming, panelId, setIsOpen }),
|
||||
[isOpen, isStreaming, panelId, setIsOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider value={value}>
|
||||
<div
|
||||
{...props}
|
||||
className={classNames("cline-chat-reasoning", className)}
|
||||
data-streaming={isStreaming || undefined}
|
||||
/>
|
||||
</ReasoningContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type ReasoningTriggerProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"aria-controls" | "aria-expanded" | "type"
|
||||
> & {
|
||||
completeLabel?: string;
|
||||
streamingLabel?: string;
|
||||
};
|
||||
|
||||
export const ReasoningTrigger = ({
|
||||
children,
|
||||
className,
|
||||
completeLabel = "Thought process",
|
||||
onClick,
|
||||
streamingLabel = "Thinking",
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const { isOpen, isStreaming, panelId, setIsOpen } = useReasoning();
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
aria-controls={panelId}
|
||||
aria-expanded={isOpen}
|
||||
className={classNames("cline-chat-reasoning-trigger", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
if (!event.defaultPrevented) setIsOpen(!isOpen);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon />
|
||||
<span>{isStreaming ? streamingLabel : completeLabel}</span>
|
||||
<span aria-live="polite" className="cline-chat-reasoning-status">
|
||||
{isStreaming ? "In progress" : "Complete"}
|
||||
</span>
|
||||
<ChevronDownIcon className="cline-chat-disclosure-icon" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type ReasoningContentProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"hidden" | "id"
|
||||
>;
|
||||
|
||||
export const ReasoningContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ReasoningContentProps) => {
|
||||
const { isOpen, panelId } = useReasoning();
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={classNames("cline-chat-reasoning-content", className)}
|
||||
id={panelId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type ToolActivityStatus = "pending" | "running" | "success" | "error";
|
||||
|
||||
type ToolActivityContextValue = DisclosureState & {
|
||||
expandable: boolean;
|
||||
};
|
||||
|
||||
const ToolActivityContext = createContext<ToolActivityContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
function useToolActivity(): ToolActivityContextValue {
|
||||
const context = useContext(ToolActivityContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"ToolActivity components must be rendered inside ToolActivity",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export type ToolActivityProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"onChange"
|
||||
> & {
|
||||
expandable?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const ToolActivity = ({
|
||||
className,
|
||||
defaultOpen = false,
|
||||
expandable = true,
|
||||
onOpenChange,
|
||||
open,
|
||||
...props
|
||||
}: ToolActivityProps) => {
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
const panelId = useId();
|
||||
const isOpen = expandable && (open ?? internalOpen);
|
||||
const setIsOpen = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!expandable) return;
|
||||
if (open === undefined) setInternalOpen(nextOpen);
|
||||
onOpenChange?.(nextOpen);
|
||||
},
|
||||
[expandable, onOpenChange, open],
|
||||
);
|
||||
const value = useMemo(
|
||||
() => ({ expandable, isOpen, panelId, setIsOpen }),
|
||||
[expandable, isOpen, panelId, setIsOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<ToolActivityContext.Provider value={value}>
|
||||
<div
|
||||
{...props}
|
||||
className={classNames("cline-chat-tool", className)}
|
||||
data-expandable={expandable || undefined}
|
||||
/>
|
||||
</ToolActivityContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type ToolActivityTriggerProps = Omit<
|
||||
HTMLAttributes<HTMLElement>,
|
||||
"aria-controls" | "aria-expanded"
|
||||
> & {
|
||||
icon?: ReactNode;
|
||||
label: ReactNode;
|
||||
status?: ToolActivityStatus;
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const ToolActivityTrigger = ({
|
||||
additions,
|
||||
children,
|
||||
className,
|
||||
deletions,
|
||||
disabled = false,
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
status = "success",
|
||||
...props
|
||||
}: ToolActivityTriggerProps) => {
|
||||
const { expandable, isOpen, panelId, setIsOpen } = useToolActivity();
|
||||
const content = children ?? (
|
||||
<>
|
||||
{icon ? <span className="cline-chat-tool-icon">{icon}</span> : null}
|
||||
<span className="cline-chat-tool-label">{label}</span>
|
||||
{additions !== undefined || deletions !== undefined ? (
|
||||
<span className="cline-chat-tool-diff">
|
||||
{additions !== undefined ? (
|
||||
<span data-diff="additions">+{additions}</span>
|
||||
) : null}{" "}
|
||||
{deletions !== undefined ? (
|
||||
<span data-diff="deletions">-{deletions}</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
{status === "running" || status === "pending" ? (
|
||||
<output aria-label={status} className="cline-chat-tool-progress" />
|
||||
) : null}
|
||||
{expandable ? (
|
||||
<ChevronDownIcon className="cline-chat-disclosure-icon" />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const handleClick = (event: ReactMouseEvent<HTMLElement>) => {
|
||||
onClick?.(event);
|
||||
if (expandable && !event.defaultPrevented) setIsOpen(!isOpen);
|
||||
};
|
||||
const triggerClassName = classNames("cline-chat-tool-trigger", className);
|
||||
|
||||
if (expandable) {
|
||||
return (
|
||||
<button
|
||||
{...(props as ButtonHTMLAttributes<HTMLButtonElement>)}
|
||||
aria-controls={panelId}
|
||||
aria-expanded={isOpen}
|
||||
className={triggerClassName}
|
||||
data-status={status}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...(props as HTMLAttributes<HTMLDivElement>)}
|
||||
className={triggerClassName}
|
||||
data-status={status}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ToolActivityContentProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"hidden" | "id"
|
||||
>;
|
||||
|
||||
export const ToolActivityContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ToolActivityContentProps) => {
|
||||
const { expandable, isOpen, panelId } = useToolActivity();
|
||||
if (!expandable || !isOpen) return null;
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={classNames("cline-chat-tool-content", className)}
|
||||
id={panelId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type ToolActivityDetailsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ToolActivityDetails = ({
|
||||
className,
|
||||
...props
|
||||
}: ToolActivityDetailsProps) => (
|
||||
<div
|
||||
className={classNames("cline-chat-tool-details", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolActivityCodeProps = HTMLAttributes<HTMLPreElement>;
|
||||
|
||||
export const ToolActivityCode = ({
|
||||
className,
|
||||
...props
|
||||
}: ToolActivityCodeProps) => (
|
||||
<pre className={classNames("cline-chat-tool-code", className)} {...props} />
|
||||
);
|
||||
|
||||
function ChevronDownIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="none"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
>
|
||||
<path
|
||||
d="m6 9 6 6 6-6"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BrainIcon() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
>
|
||||
<path
|
||||
d="M9.5 4.5A3 3 0 0 0 4 6a3 3 0 0 0 .5 5.9A3.5 3.5 0 0 0 8 17h1.5m5-12.5A3 3 0 0 1 20 6a3 3 0 0 1-.5 5.9A3.5 3.5 0 0 1 16 17h-1.5M9.5 4.5V20m5-15.5V20M9.5 9H7m7.5 3H17m-7.5 4H7m7.5 1h2"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.75"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,24 @@
|
||||
{
|
||||
"name": "@cline/ui",
|
||||
"version": "0.0.0",
|
||||
"description": "Shared Cline web theme and UI foundations",
|
||||
"version": "0.1.0",
|
||||
"description": "Shared Cline web theme and reusable agent UI components",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline",
|
||||
"directory": "sdk/packages/ui"
|
||||
},
|
||||
"private": true,
|
||||
"private": false,
|
||||
"internal": true,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./components/agent-chat": {
|
||||
"types": "./dist/components/agent-chat/index.d.ts",
|
||||
"import": "./dist/components/agent-chat/index.js"
|
||||
},
|
||||
"./components/agent-chat.css": "./components/agent-chat/agent-chat.css",
|
||||
"./theme/index.css": "./theme/index.css",
|
||||
"./theme/tokens.css": "./theme/tokens.css",
|
||||
"./theme/theme.css": "./theme/theme.css",
|
||||
@@ -18,23 +26,63 @@
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"components/agent-chat/agent-chat.css",
|
||||
"components/agent-chat/index.tsx",
|
||||
"dist",
|
||||
"theme",
|
||||
"ADOPTION.md",
|
||||
"README.md"
|
||||
],
|
||||
"sideEffects": [
|
||||
"./components/**/*.css",
|
||||
"./theme/*.css"
|
||||
],
|
||||
"keywords": [
|
||||
"cline",
|
||||
"ui",
|
||||
"design-system",
|
||||
"react",
|
||||
"tailwindcss",
|
||||
"agent-chat"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"build": "bun scripts/validate-theme.ts",
|
||||
"build": "bun scripts/validate-theme.ts && bun tsc -p tsconfig.build.json",
|
||||
"prepack": "bun run build",
|
||||
"storybook": "storybook dev",
|
||||
"build-storybook": "storybook build --output-dir tmp/storybook-static",
|
||||
"test:package": "bun run build && bun scripts/smoke-package.ts",
|
||||
"typecheck": "bun tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run --config vitest.config.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.3.0 <20",
|
||||
"tailwindcss": ">=4.0.0 <5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"tailwindcss": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
"@fontsource/azeret-mono": "^5.2.9",
|
||||
"@storybook/addon-a11y": "^9.1.17",
|
||||
"@storybook/addon-docs": "^9.1.17",
|
||||
"@storybook/react-vite": "^9.1.6",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"jsdom": "^26.0.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"storybook": "^9.1.17",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^7.1.11",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
|
||||
const packageRoot = join(import.meta.dir, "..");
|
||||
const importCheck =
|
||||
'import { Conversation, Message } from "@cline/ui/components/agent-chat"; const css = import.meta.resolve("@cline/ui/components/agent-chat.css"); const tokens = import.meta.resolve("@cline/ui/theme/tokens.css"); if (!Conversation || !Message || !css || !tokens) process.exit(1);';
|
||||
|
||||
async function run(command: string[], cwd: string): Promise<void> {
|
||||
const child = Bun.spawn(command, {
|
||||
cwd,
|
||||
stderr: "inherit",
|
||||
stdout: "inherit",
|
||||
});
|
||||
const exitCode = await child.exited;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`${command.join(" ")} exited with ${exitCode}`);
|
||||
}
|
||||
}
|
||||
|
||||
function createConsumer(root: string): void {
|
||||
mkdirSync(root, { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
`${JSON.stringify({ name: "cline-ui-smoke", private: true, type: "module" }, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const temporaryRoot = mkdtempSync(join(tmpdir(), "cline-ui-package-"));
|
||||
|
||||
try {
|
||||
let archive = process.argv[2] ? resolve(process.argv[2]) : undefined;
|
||||
if (!archive) {
|
||||
const packDirectory = join(temporaryRoot, "pack");
|
||||
mkdirSync(packDirectory, { recursive: true });
|
||||
await run(
|
||||
[
|
||||
process.execPath,
|
||||
"pm",
|
||||
"pack",
|
||||
"--ignore-scripts",
|
||||
"--destination",
|
||||
packDirectory,
|
||||
],
|
||||
packageRoot,
|
||||
);
|
||||
const archiveName = readdirSync(packDirectory).find((name) =>
|
||||
name.endsWith(".tgz"),
|
||||
);
|
||||
if (!archiveName) throw new Error("bun pm pack did not create an archive");
|
||||
archive = join(packDirectory, archiveName);
|
||||
}
|
||||
|
||||
const bunConsumer = join(temporaryRoot, "bun-consumer");
|
||||
createConsumer(bunConsumer);
|
||||
await run(
|
||||
[process.execPath, "add", "--ignore-scripts", archive, "react@19.2.4"],
|
||||
bunConsumer,
|
||||
);
|
||||
await run([process.execPath, "-e", importCheck], bunConsumer);
|
||||
|
||||
const npmConsumer = join(temporaryRoot, "npm-consumer");
|
||||
createConsumer(npmConsumer);
|
||||
await run(
|
||||
[
|
||||
"npm",
|
||||
"install",
|
||||
"--ignore-scripts",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
archive,
|
||||
"react@18.3.1",
|
||||
],
|
||||
npmConsumer,
|
||||
);
|
||||
await run(["node", "--input-type=module", "-e", importCheck], npmConsumer);
|
||||
console.log(
|
||||
`Verified packed ${basename(archive)} with Bun/React 19 and npm/Node/React 18`,
|
||||
);
|
||||
} finally {
|
||||
rmSync(temporaryRoot, { force: true, recursive: true });
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { Meta } from "@storybook/react-vite";
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationEmptyState,
|
||||
ConversationScrollButton,
|
||||
ConversationViewport,
|
||||
Message,
|
||||
MessageAction,
|
||||
MessageActions,
|
||||
MessageContent,
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
ToolActivity,
|
||||
ToolActivityCode,
|
||||
ToolActivityContent,
|
||||
ToolActivityDetails,
|
||||
ToolActivityTrigger,
|
||||
} from "../components/agent-chat";
|
||||
|
||||
const meta: Meta<typeof Conversation> = {
|
||||
title: "Agent chat/Primitives",
|
||||
component: Conversation,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Composable presentation primitives for agent conversations. Products retain transport, schemas, Markdown policy, approvals, and tool-result normalization.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
function SearchIcon() {
|
||||
return <span aria-hidden="true">⌕</span>;
|
||||
}
|
||||
|
||||
function TerminalIcon() {
|
||||
return <span aria-hidden="true">›_</span>;
|
||||
}
|
||||
|
||||
function EditIcon() {
|
||||
return <span aria-hidden="true">✎</span>;
|
||||
}
|
||||
|
||||
function ChatFrame({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-[680px] min-w-[320px] bg-background">
|
||||
<Conversation>
|
||||
<ConversationViewport aria-label="Example agent conversation">
|
||||
<ConversationContent className="mx-auto max-w-3xl p-6">
|
||||
{children}
|
||||
</ConversationContent>
|
||||
</ConversationViewport>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CompleteConversation = () => (
|
||||
<ChatFrame>
|
||||
<Message from="user">
|
||||
<MessageContent>
|
||||
Can you find the settings screen and align it with our shared theme?
|
||||
</MessageContent>
|
||||
<MessageActions>
|
||||
<MessageAction label="Copy user message" title="Copy message">
|
||||
Copy
|
||||
</MessageAction>
|
||||
</MessageActions>
|
||||
</Message>
|
||||
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<Reasoning>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
I should inspect the existing navigation and map its surfaces to the
|
||||
semantic theme contract before changing layout.
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
</MessageContent>
|
||||
|
||||
<ToolActivity expandable>
|
||||
<ToolActivityTrigger
|
||||
icon={<SearchIcon />}
|
||||
label="Explored 3 files"
|
||||
status="success"
|
||||
/>
|
||||
<ToolActivityContent>
|
||||
<ToolActivityDetails>
|
||||
<div>settings-view.tsx</div>
|
||||
<div>agent-sidebar.tsx</div>
|
||||
<div>tokens.css</div>
|
||||
</ToolActivityDetails>
|
||||
</ToolActivityContent>
|
||||
</ToolActivity>
|
||||
|
||||
<ToolActivity expandable>
|
||||
<ToolActivityTrigger
|
||||
additions={42}
|
||||
deletions={18}
|
||||
icon={<EditIcon />}
|
||||
label="Edited settings-view.tsx"
|
||||
status="success"
|
||||
/>
|
||||
<ToolActivityContent>
|
||||
<ToolActivityCode>
|
||||
{"+ background: var(--background);\n- background: #111;"}
|
||||
</ToolActivityCode>
|
||||
</ToolActivityContent>
|
||||
</ToolActivity>
|
||||
|
||||
<MessageContent>
|
||||
<p>
|
||||
Done. Settings now uses the shared background, card, border, and
|
||||
typography tokens in both light and dark modes.
|
||||
</p>
|
||||
<ul className="cline-markdown list-disc pl-5">
|
||||
<li>Aligned navigation surfaces</li>
|
||||
<li>Preserved product-specific settings behavior</li>
|
||||
<li>Verified keyboard focus states</li>
|
||||
</ul>
|
||||
</MessageContent>
|
||||
<MessageActions>
|
||||
<MessageAction label="Copy assistant message" title="Copy response">
|
||||
Copy
|
||||
</MessageAction>
|
||||
</MessageActions>
|
||||
</Message>
|
||||
</ChatFrame>
|
||||
);
|
||||
|
||||
export const Streaming = () => (
|
||||
<ChatFrame>
|
||||
<Message from="user">
|
||||
<MessageContent>Run the focused tests.</MessageContent>
|
||||
</Message>
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<Reasoning defaultOpen isStreaming>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
I am checking the package build, component interactions, and the
|
||||
static Storybook output.
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
</MessageContent>
|
||||
<ToolActivity expandable={false}>
|
||||
<ToolActivityTrigger
|
||||
icon={<TerminalIcon />}
|
||||
label="Running bun -F @cline/ui test"
|
||||
status="running"
|
||||
/>
|
||||
</ToolActivity>
|
||||
<MessageContent>All package tests are passing so far…</MessageContent>
|
||||
</Message>
|
||||
</ChatFrame>
|
||||
);
|
||||
|
||||
export const ToolStates = () => (
|
||||
<ChatFrame>
|
||||
{(
|
||||
[
|
||||
["Waiting to edit theme.css", "pending"],
|
||||
["Running component tests", "running"],
|
||||
["Updated 2 files", "success"],
|
||||
["Command failed with exit code 1", "error"],
|
||||
] as const
|
||||
).map(([label, status]) => (
|
||||
<ToolActivity expandable={status !== "pending"} key={status}>
|
||||
<ToolActivityTrigger
|
||||
icon={<TerminalIcon />}
|
||||
label={label}
|
||||
status={status}
|
||||
/>
|
||||
<ToolActivityContent>
|
||||
<ToolActivityCode>
|
||||
{status === "error"
|
||||
? "Error: expected --background token"
|
||||
: "@cline/ui theme contract is valid"}
|
||||
</ToolActivityCode>
|
||||
</ToolActivityContent>
|
||||
</ToolActivity>
|
||||
))}
|
||||
</ChatFrame>
|
||||
);
|
||||
|
||||
export const Empty = () => (
|
||||
<ChatFrame>
|
||||
<ConversationEmptyState
|
||||
description="Send a prompt to begin an agent session."
|
||||
icon={<span className="text-3xl">✦</span>}
|
||||
title="What should we build?"
|
||||
/>
|
||||
</ChatFrame>
|
||||
);
|
||||
|
||||
export const ErrorMessage = () => (
|
||||
<ChatFrame>
|
||||
<Message from="error">
|
||||
<MessageContent>
|
||||
The agent connection was interrupted. Your conversation is safe; retry
|
||||
when the connection is restored.
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</ChatFrame>
|
||||
);
|
||||
|
||||
export const DisabledControls = () => (
|
||||
<ChatFrame>
|
||||
<Message from="assistant">
|
||||
<MessageContent>Actions stay readable when unavailable.</MessageContent>
|
||||
<MessageActions visible>
|
||||
<MessageAction disabled label="Copy message">
|
||||
Copy
|
||||
</MessageAction>
|
||||
</MessageActions>
|
||||
<Reasoning>
|
||||
<ReasoningTrigger disabled />
|
||||
<ReasoningContent>Unavailable reasoning</ReasoningContent>
|
||||
</Reasoning>
|
||||
<ToolActivity>
|
||||
<ToolActivityTrigger disabled label="Tool details unavailable" />
|
||||
<ToolActivityContent>Unavailable tool details</ToolActivityContent>
|
||||
</ToolActivity>
|
||||
</Message>
|
||||
</ChatFrame>
|
||||
);
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Meta } from "@storybook/react-vite";
|
||||
|
||||
const colors = [
|
||||
["Background", "--background"],
|
||||
["Foreground", "--foreground"],
|
||||
["Card", "--card"],
|
||||
["Primary", "--primary"],
|
||||
["Secondary", "--secondary"],
|
||||
["Muted", "--muted"],
|
||||
["Accent", "--accent"],
|
||||
["Destructive", "--destructive"],
|
||||
["Border", "--border"],
|
||||
] as const;
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Foundations/Theme",
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The shared Cline semantic color, typography, radius, and interaction contract. Use the toolbar to compare light and dark modes.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Overview = () => (
|
||||
<main className="mx-auto grid max-w-5xl gap-10 p-8">
|
||||
<header className="space-y-3">
|
||||
<p className="text-sm font-medium text-primary">@cline/ui</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight">
|
||||
Cline visual foundations
|
||||
</h1>
|
||||
<p className="max-w-2xl text-base text-muted-foreground">
|
||||
Semantic values let products share a recognizable visual language while
|
||||
retaining their own layouts and workflows.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Semantic colors</h2>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
{colors.map(([label, token]) => (
|
||||
<div
|
||||
className="overflow-hidden rounded-lg border bg-card"
|
||||
key={token}
|
||||
>
|
||||
<div
|
||||
className="h-20 border-b"
|
||||
style={{ background: `var(${token})` }}
|
||||
/>
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<code className="text-xs text-muted-foreground">{token}</code>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-4 rounded-xl border bg-card p-6">
|
||||
<h2 className="text-xl font-semibold">Typography</h2>
|
||||
<div className="space-y-3">
|
||||
<p className="text-3xl font-semibold">Schibsted Grotesk</p>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Readable product copy with a warm, technical character.
|
||||
</p>
|
||||
<code className="block rounded-md bg-muted p-3 font-mono text-sm">
|
||||
Azeret Mono · npm run build
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-xl border bg-card p-6">
|
||||
<h2 className="text-xl font-semibold">Controls</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
||||
type="button"
|
||||
>
|
||||
Primary
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md border bg-background px-4 py-2 text-sm font-medium"
|
||||
type="button"
|
||||
>
|
||||
Secondary
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
type="button"
|
||||
>
|
||||
Ghost
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
aria-label="Example input"
|
||||
className="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Ask Cline something..."
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
@@ -0,0 +1,204 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
ConversationViewport,
|
||||
Message,
|
||||
MessageContent,
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
ToolActivity,
|
||||
ToolActivityContent,
|
||||
ToolActivityTrigger,
|
||||
} from "../components/agent-chat";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
HTMLElement.prototype.scrollTo = vi.fn();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function render(element: React.ReactNode) {
|
||||
await act(async () => root.render(element));
|
||||
}
|
||||
|
||||
describe("@cline/ui agent chat primitives", () => {
|
||||
it("marks message roles without requiring a runtime message schema", async () => {
|
||||
await render(
|
||||
<Message from="assistant">
|
||||
<MessageContent>Hello from Cline</MessageContent>
|
||||
</Message>,
|
||||
);
|
||||
|
||||
const message = container.querySelector(".cline-chat-message");
|
||||
expect(message?.getAttribute("data-role")).toBe("assistant");
|
||||
expect(message?.textContent).toContain("Hello from Cline");
|
||||
});
|
||||
|
||||
it("gives the scrollable conversation log accessible defaults", async () => {
|
||||
await render(
|
||||
<Conversation>
|
||||
<ConversationViewport>
|
||||
<ConversationContent />
|
||||
</ConversationViewport>
|
||||
</Conversation>,
|
||||
);
|
||||
|
||||
const viewport = container.querySelector(
|
||||
".cline-chat-conversation-viewport",
|
||||
);
|
||||
expect(viewport?.getAttribute("aria-label")).toBe("Agent conversation");
|
||||
expect(viewport?.getAttribute("role")).toBe("log");
|
||||
expect(viewport?.getAttribute("tabindex")).toBe("0");
|
||||
});
|
||||
|
||||
it("exposes an accessible reasoning disclosure", async () => {
|
||||
await render(
|
||||
<Reasoning>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>Inspect the shared contract</ReasoningContent>
|
||||
</Reasoning>,
|
||||
);
|
||||
|
||||
const trigger = container.querySelector("button");
|
||||
const panelId = trigger?.getAttribute("aria-controls");
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(document.getElementById(panelId ?? "")).toBeNull();
|
||||
|
||||
await act(async () => trigger?.click());
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(document.getElementById(panelId ?? "")?.textContent).toContain(
|
||||
"Inspect the shared contract",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders non-expandable tool activity as static content", async () => {
|
||||
await render(
|
||||
<ToolActivity expandable={false}>
|
||||
<ToolActivityTrigger label="Explored workspace" />
|
||||
</ToolActivity>,
|
||||
);
|
||||
|
||||
const summary = container.querySelector(".cline-chat-tool-trigger");
|
||||
expect(summary?.tagName).toBe("DIV");
|
||||
expect(summary?.closest("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("toggles expandable tool details", async () => {
|
||||
await render(
|
||||
<ToolActivity>
|
||||
<ToolActivityTrigger label="Edited 2 files" />
|
||||
<ToolActivityContent>theme.css</ToolActivityContent>
|
||||
</ToolActivity>,
|
||||
);
|
||||
|
||||
const trigger = container.querySelector("button");
|
||||
const panelId = trigger?.getAttribute("aria-controls");
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(document.getElementById(panelId ?? "")).toBeNull();
|
||||
|
||||
await act(async () => trigger?.click());
|
||||
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(document.getElementById(panelId ?? "")?.textContent).toContain(
|
||||
"theme.css",
|
||||
);
|
||||
});
|
||||
|
||||
it("offers a scroll-to-latest action after the reader moves away", async () => {
|
||||
await render(
|
||||
<Conversation>
|
||||
<ConversationViewport>
|
||||
<ConversationContent>Long conversation</ConversationContent>
|
||||
</ConversationViewport>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>,
|
||||
);
|
||||
|
||||
const viewport = container.querySelector(
|
||||
".cline-chat-conversation-viewport",
|
||||
) as HTMLDivElement;
|
||||
const scrollTo = vi.fn();
|
||||
Object.defineProperties(viewport, {
|
||||
clientHeight: { configurable: true, value: 100 },
|
||||
scrollHeight: { configurable: true, value: 500 },
|
||||
scrollTop: { configurable: true, value: 0, writable: true },
|
||||
scrollTo: { configurable: true, value: scrollTo },
|
||||
});
|
||||
|
||||
await act(async () => viewport.dispatchEvent(new Event("scroll")));
|
||||
const button = container.querySelector(
|
||||
'button[aria-label="Scroll to latest message"]',
|
||||
) as HTMLButtonElement;
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
await act(async () => button.click());
|
||||
expect(scrollTo).toHaveBeenCalledWith({ behavior: "smooth", top: 500 });
|
||||
|
||||
viewport.scrollTop = 300;
|
||||
await act(async () => viewport.dispatchEvent(new Event("scroll")));
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Scroll to latest message"]'),
|
||||
).toBeNull();
|
||||
|
||||
viewport.scrollTop = 100;
|
||||
await act(async () => viewport.dispatchEvent(new Event("scroll")));
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Scroll to latest message"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("resets conversation state when its React key changes", async () => {
|
||||
const transcript = (conversationKey: string) => (
|
||||
<Conversation key={conversationKey}>
|
||||
<ConversationViewport>
|
||||
<ConversationContent>
|
||||
Conversation {conversationKey}
|
||||
</ConversationContent>
|
||||
</ConversationViewport>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
);
|
||||
await render(transcript("session-a"));
|
||||
|
||||
const firstViewport = container.querySelector(
|
||||
".cline-chat-conversation-viewport",
|
||||
) as HTMLDivElement;
|
||||
Object.defineProperties(firstViewport, {
|
||||
clientHeight: { configurable: true, value: 100 },
|
||||
scrollHeight: { configurable: true, value: 500 },
|
||||
scrollTop: { configurable: true, value: 0, writable: true },
|
||||
});
|
||||
|
||||
await act(async () => firstViewport.dispatchEvent(new Event("scroll")));
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Scroll to latest message"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
await render(transcript("session-b"));
|
||||
|
||||
const nextViewport = container.querySelector(
|
||||
".cline-chat-conversation-viewport",
|
||||
);
|
||||
expect(nextViewport).not.toBe(firstViewport);
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Scroll to latest message"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const manifestPath = fileURLToPath(new URL("../package.json", import.meta.url));
|
||||
|
||||
describe("@cline/ui package", () => {
|
||||
it("is configured for standalone public npm releases", () => {
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
|
||||
internal?: boolean;
|
||||
license?: string;
|
||||
private?: boolean;
|
||||
publishConfig?: { access?: string };
|
||||
version?: string;
|
||||
};
|
||||
|
||||
expect(manifest.private).toBe(false);
|
||||
expect(manifest.internal).toBe(true);
|
||||
expect(manifest.publishConfig?.access).toBe("public");
|
||||
expect(manifest.license).toBe("Apache-2.0");
|
||||
expect(manifest.version).toMatch(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/);
|
||||
expect(manifest.version).not.toBe("0.0.0");
|
||||
});
|
||||
});
|
||||
@@ -213,6 +213,7 @@ describe("@cline/ui theme contract", () => {
|
||||
readFileSync(join(packageRoot, "package.json"), "utf8"),
|
||||
) as { exports?: Record<string, string> };
|
||||
for (const subpath of [
|
||||
"./components/agent-chat.css",
|
||||
"./theme/index.css",
|
||||
"./theme/tokens.css",
|
||||
"./theme/theme.css",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"noEmit": false,
|
||||
"outDir": "dist/components",
|
||||
"rootDir": "components",
|
||||
"types": ["react", "react-dom"]
|
||||
},
|
||||
"include": ["components/**/*.ts", "components/**/*.tsx"],
|
||||
"exclude": ["components/**/*.stories.ts", "components/**/*.stories.tsx"]
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"noEmit": true,
|
||||
"types": ["bun", "node"]
|
||||
"types": ["bun", "node", "react", "react-dom"]
|
||||
},
|
||||
"include": ["scripts/**/*.ts", "tests/**/*.ts"]
|
||||
"include": [
|
||||
".storybook/**/*.ts",
|
||||
".storybook/**/*.tsx",
|
||||
"components/**/*.ts",
|
||||
"components/**/*.tsx",
|
||||
"scripts/**/*.ts",
|
||||
"stories/**/*.ts",
|
||||
"stories/**/*.tsx",
|
||||
"tests/**/*.ts",
|
||||
"tests/**/*.tsx"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,6 @@ export default defineConfig({
|
||||
root: fileURLToPath(new URL(".", import.meta.url)),
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
include: ["tests/**/*.test.{ts,tsx}"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user