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:
Saoud Rizwan
2026-07-17 18:22:57 -07:00
committed by GitHub
parent d1837366c0
commit 7274d8badc
24 changed files with 2964 additions and 486 deletions
+158
View File
@@ -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."
+145
View File
@@ -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}'"
+6
View File
@@ -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>
);
}
+35 -1
View File
@@ -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=="],
+21
View File
@@ -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;
+22
View File
@@ -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);
}
+56
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+52 -4
View File
@@ -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"
}
}
+88
View File
@@ -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>
);
+204
View File
@@ -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();
});
});
+24
View File
@@ -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");
});
});
+1
View File
@@ -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",
+15
View File
@@ -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"]
}
+14 -2
View File
@@ -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"
]
}
+1 -1
View File
@@ -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}"],
},
});