Add Deep Agents UI, pipeline fixes, and agent improvements.

Includes deep-agents-ui integration, rework detection via Gitea,
tool-call sanitization fixes, and startup scripts.
This commit is contained in:
dapa46
2026-06-18 19:33:26 +03:00
parent ba83d0cfe4
commit 0f38d8d588
83 changed files with 22561 additions and 179 deletions
View File
+15
View File
@@ -0,0 +1,15 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "npm" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
- package-ecosystem: "github-actions" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
+94
View File
@@ -0,0 +1,94 @@
# Run formatting on all PRs
name: CI
on:
push:
branches: ["main"]
pull_request:
workflow_dispatch: # Allows triggering the workflow manually in GitHub UI
permissions:
contents: read
# If another push to the same PR or branch happens while this workflow is still running,
# cancel the earlier run in favor of the next run.
#
# There's no point in testing an outdated version of the code. GitHub only allows
# a limited number of job runners to be active at the same time, so it's better to cancel
# pointless jobs early so that more useful jobs can run sooner.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
format:
name: Check formatting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Enable Corepack
run: corepack enable
- name: Use Node.js 20.x
uses: actions/setup-node@v6
with:
node-version: 20.x
cache: "yarn"
- name: Install dependencies
run: yarn install --immutable --mode=skip-build
- name: Check formatting
run: yarn format:check
lint:
name: Check linting
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Enable Corepack
run: corepack enable
- name: Use Node.js 20.x
uses: actions/setup-node@v6
with:
node-version: 20.x
cache: "yarn"
- name: Install dependencies
run: yarn install --immutable --mode=skip-build
- name: Check linting
run: yarn run lint
build:
name: Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Enable Corepack
run: corepack enable
- name: Use Node.js 20.x
uses: actions/setup-node@v6
with:
node-version: 20.x
cache: "yarn"
- name: Install dependencies
run: yarn install --immutable --mode=skip-build
- name: Build
run: yarn build
readme-spelling:
name: Check README spelling
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
with:
ignore_words_file: .codespellignore
path: README.md
check-spelling:
name: Check code spelling
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
with:
ignore_words_file: .codespellignore
path: src
+40
View File
@@ -0,0 +1,40 @@
name: PR Title Lint
permissions:
pull-requests: read
on:
pull_request_target:
types: [opened, edited, synchronize]
jobs:
lint-pr-title:
runs-on: ubuntu-latest
steps:
- name: Validate PR Title
uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
docs
style
refactor
perf
test
build
ci
chore
revert
release
scopes: |
shared
cli
web
open-swe
docs
requireScope: false
ignoreLabels: |
ignore-lint-pr-title
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
+2
View File
@@ -0,0 +1,2 @@
20
+31
View File
@@ -0,0 +1,31 @@
# dependencies
node_modules
.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
coverage
# next.js
.next
out
# production
build
# vercel
.vercel
# misc
*.tsbuildinfo
next-env.d.ts
# lock files
pnpm-lock.yaml
yarn.lock
package-lock.json
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 LangChain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
# 🚀🧠 Deep Agents UI
[Deep Agents](https://github.com/langchain-ai/deepagents) is a simple, open source agent harness that implements a few generally useful tools, including planning (prior to task execution), computer access (giving the able access to a shell and a filesystem), and sub-agent delegation (isolated task execution). This is a UI for interacting with deepagents.
## 🚀 Quickstart
**Install dependencies and run the app**
```bash
git clone https://github.com/langchain-ai/deep-agents-ui.git
cd deep-agents-ui
yarn install
yarn dev
```
**Deploy a Deep Agent**
As an example, see our [Deep Agents quickstarts](https://github.com/langchain-ai/deepagents/tree/main/examples) for examples and run the `deep_research` example.
The `langgraph.json` file has the assistant ID as the key:
```
"graphs": {
"research": "./agent.py:agent"
},
```
Kick off the local LangGraph deployment:
```bash
cd deepagents-quickstarts/deep_research
langgraph dev
```
You will see the local LangGraph deployment log to terminal:
```
╦ ┌─┐┌┐┌┌─┐╔═╗┬─┐┌─┐┌─┐┬ ┬
║ ├─┤││││ ┬║ ╦├┬┘├─┤├─┘├─┤
╩═╝┴ ┴┘└┘└─┘╚═╝┴└─┴ ┴┴ ┴ ┴
- 🚀 API: http://127.0.0.1:2024
- 🎨 Studio UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024
- 📚 API Docs: http://127.0.0.1:2024/docs
...
```
You can get the Deployment URL and Assistant ID from the terminal output and `langgraph.json` file, respectively:
- Deployment URL: <http://127.0.1:2024>
- Assistant ID: `research`
**Open Deep Agents UI** at [http://localhost:3000](http://localhost:3000) and input the Deployment URL and Assistant ID:
- **Deployment URL**: The URL for the LangGraph deployment you are connecting to
- **Assistant ID**: The ID of the assistant or agent you want to use
- [Optional] **LangSmith API Key**: Your LangSmith API key (format: `lsv2_pt_...`). This may be required for accessing deployed LangGraph applications. You can also provide this via the `NEXT_PUBLIC_LANGSMITH_API_KEY` environment variable.
**Usage**
You can interact with the deployment via the chat interface and can edit settings at any time by clicking on the Settings button in the header.
<img width="2039" height="1495" alt="Screenshot 2025-11-17 at 1 11 27PM" src="https://github.com/user-attachments/assets/50e1b5f3-a626-4461-9ad9-90347e471e8c" />
As the deepagent runs, you can see its files in LangGraph state.
<img width="2039" height="1495" alt="Screenshot 2025-11-17 at 1 11 36PM" src="https://github.com/user-attachments/assets/86cc6228-5414-4cf0-90f5-d206d30c005e" />
You can click on any file to view it.
<img width="2039" height="1495" alt="Screenshot 2025-11-17 at 1 11 40PM" src="https://github.com/user-attachments/assets/9883677f-e365-428d-b941-992bdbfa79dd" />
### Optional: Environment Variables
You can optionally set environment variables instead of using the settings dialog:
```env
NEXT_PUBLIC_LANGSMITH_API_KEY="lsv2_xxxx"
```
**Note:** Settings configured in the UI take precedence over environment variables.
### Usage
You can run your Deep Agents in Debug Mode, which will execute the agent step by step. This will allow you to re-run the specific steps of the agent. This is intended to be used alongside the optimizer.
You can also turn off Debug Mode to run the full agent end-to-end.
### 📚 Resources
If the term "Deep Agents" is new to you, check out these videos!
[What are Deep Agents?](https://www.youtube.com/watch?v=433SmtTc0TA)
[Implementing Deep Agents](https://www.youtube.com/watch?v=TTMYJAw5tiA&t=701s)
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+33
View File
@@ -0,0 +1,33 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", ".next", "node_modules"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": [
"error",
{ args: "none", argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
}
);
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+10490
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
{
"name": "deep-agents-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@langchain/core": "^1.1.19",
"@langchain/langgraph": "^1.0.2",
"@langchain/langgraph-sdk": "^1.0.3",
"@radix-ui/colors": "^1.0.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@types/diff": "^5.0.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^9.0.8",
"class-variance-authority": "^0.7.1",
"clsx": "^1.2.1",
"date-fns": "^4.1.0",
"diff": "^8.0.3",
"js-yaml": "^4.1.0",
"lodash": "^4.18.1",
"lucide-react": "^0.539.0",
"next": "^16.2.5",
"nuqs": "^2.8.8",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^3.0.6",
"react-syntax-highlighter": "^15.6.1",
"remark-gfm": "^4.0.0",
"sass": "^1.99.0",
"sonner": "^2.0.7",
"swr": "^2.4.1",
"tailwind-merge": "^2.6",
"use-stick-to-bottom": "^1.1.1",
"uuid": "^9.0.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@headlessui/tailwindcss": "^0.2.2",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.9",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.24",
"eslint": "^9",
"eslint-config-next": "16",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4",
"postcss": "^8.5.6",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.3.0",
"tailwindcss": "^3.4.4",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0"
},
"packageManager": "yarn@1.22.22"
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
plugins: {
"tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {},
},
};
+11
View File
@@ -0,0 +1,11 @@
/**
* @see https://prettier.io/docs/configuration
* @type {import("prettier").Config}
*/
const config = {
endOfLine: "auto",
singleAttributePerLine: true,
plugins: ["prettier-plugin-tailwindcss"],
};
module.exports = config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,545 @@
"use client";
import React, {
useState,
useRef,
useCallback,
useMemo,
FormEvent,
Fragment,
} from "react";
import { Button } from "@/components/ui/button";
import {
Square,
ArrowUp,
CheckCircle,
Clock,
Circle,
FileIcon,
} from "lucide-react";
import { ChatMessage } from "@/app/components/ChatMessage";
import type {
TodoItem,
ToolCall,
ActionRequest,
ReviewConfig,
} from "@/app/types/types";
import { Assistant, Message } from "@langchain/langgraph-sdk";
import { extractStringFromMessageContent } from "@/app/utils/utils";
import { useChatContext } from "@/providers/ChatProvider";
import { cn } from "@/lib/utils";
import { useStickToBottom } from "use-stick-to-bottom";
import { FilesPopover } from "@/app/components/TasksFilesSidebar";
interface ChatInterfaceProps {
assistant: Assistant | null;
}
const getStatusIcon = (status: TodoItem["status"], className?: string) => {
switch (status) {
case "completed":
return (
<CheckCircle
size={16}
className={cn("text-success/80", className)}
/>
);
case "in_progress":
return (
<Clock
size={16}
className={cn("text-warning/80", className)}
/>
);
default:
return (
<Circle
size={16}
className={cn("text-tertiary/70", className)}
/>
);
}
};
export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
const [metaOpen, setMetaOpen] = useState<"tasks" | "files" | null>(null);
const tasksContainerRef = useRef<HTMLDivElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const [input, setInput] = useState("");
const { scrollRef, contentRef } = useStickToBottom();
const {
stream,
messages,
todos,
files,
ui,
setFiles,
isLoading,
isThreadLoading,
interrupt,
sendMessage,
stopStream,
resumeInterrupt,
} = useChatContext();
const submitDisabled = isLoading || !assistant;
const handleSubmit = useCallback(
(e?: FormEvent) => {
if (e) {
e.preventDefault();
}
const messageText = input.trim();
if (!messageText || isLoading || submitDisabled) return;
sendMessage(messageText);
setInput("");
},
[input, isLoading, sendMessage, setInput, submitDisabled]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (submitDisabled) return;
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit, submitDisabled]
);
// TODO: can we make this part of the hook?
const processedMessages = useMemo(() => {
/*
1. Loop through all messages
2. For each AI message, add the AI message, and any tool calls to the messageMap
3. For each tool message, find the corresponding tool call in the messageMap and update the status and output
*/
const messageMap = new Map<
string,
{ message: Message; toolCalls: ToolCall[] }
>();
messages.forEach((message: Message) => {
if (message.type === "ai") {
const toolCallsInMessage: Array<{
id?: string;
function?: { name?: string; arguments?: unknown };
name?: string;
type?: string;
args?: unknown;
input?: unknown;
}> = [];
if (
message.additional_kwargs?.tool_calls &&
Array.isArray(message.additional_kwargs.tool_calls)
) {
toolCallsInMessage.push(...message.additional_kwargs.tool_calls);
} else if (message.tool_calls && Array.isArray(message.tool_calls)) {
toolCallsInMessage.push(
...message.tool_calls.filter(
(toolCall: { name?: string }) => toolCall.name !== ""
)
);
} else if (Array.isArray(message.content)) {
const toolUseBlocks = message.content.filter(
(block: { type?: string }) => block.type === "tool_use"
);
toolCallsInMessage.push(...toolUseBlocks);
}
const toolCallsWithStatus = toolCallsInMessage.map(
(toolCall: {
id?: string;
function?: { name?: string; arguments?: unknown };
name?: string;
type?: string;
args?: unknown;
input?: unknown;
}) => {
const name =
toolCall.function?.name ||
toolCall.name ||
toolCall.type ||
"unknown";
const args =
toolCall.function?.arguments ||
toolCall.args ||
toolCall.input ||
{};
return {
id: toolCall.id || `tool-${Math.random()}`,
name,
args,
status: interrupt ? "interrupted" : ("pending" as const),
} as ToolCall;
}
);
messageMap.set(message.id!, {
message,
toolCalls: toolCallsWithStatus,
});
} else if (message.type === "tool") {
const toolCallId = message.tool_call_id;
if (!toolCallId) {
return;
}
for (const [, data] of messageMap.entries()) {
const toolCallIndex = data.toolCalls.findIndex(
(tc: ToolCall) => tc.id === toolCallId
);
if (toolCallIndex === -1) {
continue;
}
data.toolCalls[toolCallIndex] = {
...data.toolCalls[toolCallIndex],
status: "completed" as const,
result: extractStringFromMessageContent(message),
};
break;
}
} else if (message.type === "human") {
messageMap.set(message.id!, {
message,
toolCalls: [],
});
}
});
const processedArray = Array.from(messageMap.values());
return processedArray.map((data, index) => {
const prevMessage = index > 0 ? processedArray[index - 1].message : null;
return {
...data,
showAvatar: data.message.type !== prevMessage?.type,
};
});
}, [messages, interrupt]);
const groupedTodos = {
in_progress: todos.filter((t) => t.status === "in_progress"),
pending: todos.filter((t) => t.status === "pending"),
completed: todos.filter((t) => t.status === "completed"),
};
const hasTasks = todos.length > 0;
const hasFiles = Object.keys(files).length > 0;
// Parse out any action requests or review configs from the interrupt
const actionRequestsMap: Map<string, ActionRequest> | null = useMemo(() => {
const actionRequests =
interrupt?.value && (interrupt.value as any)["action_requests"];
if (!actionRequests) return new Map<string, ActionRequest>();
return new Map(actionRequests.map((ar: ActionRequest) => [ar.name, ar]));
}, [interrupt]);
const reviewConfigsMap: Map<string, ReviewConfig> | null = useMemo(() => {
const reviewConfigs =
interrupt?.value && (interrupt.value as any)["review_configs"];
if (!reviewConfigs) return new Map<string, ReviewConfig>();
return new Map(
reviewConfigs.map((rc: ReviewConfig) => [rc.actionName, rc])
);
}, [interrupt]);
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div
className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
ref={scrollRef}
>
<div
className="mx-auto w-full max-w-[1024px] px-6 pb-6 pt-4"
ref={contentRef}
>
{isThreadLoading ? (
<div className="flex items-center justify-center p-8">
<p className="text-muted-foreground">Loading...</p>
</div>
) : (
<>
{processedMessages.map((data, index) => {
const messageUi = ui?.filter(
(u: any) => u.metadata?.message_id === data.message.id
);
const isLastMessage = index === processedMessages.length - 1;
return (
<ChatMessage
key={data.message.id}
message={data.message}
toolCalls={data.toolCalls}
isLoading={isLoading}
actionRequestsMap={
isLastMessage ? actionRequestsMap : undefined
}
reviewConfigsMap={
isLastMessage ? reviewConfigsMap : undefined
}
ui={messageUi}
stream={stream}
onResumeInterrupt={resumeInterrupt}
graphId={assistant?.graph_id}
/>
);
})}
</>
)}
</div>
</div>
<div className="flex-shrink-0 bg-background">
<div
className={cn(
"mx-4 mb-6 flex flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-background",
"mx-auto w-[calc(100%-32px)] max-w-[1024px] transition-colors duration-200 ease-in-out"
)}
>
{(hasTasks || hasFiles) && (
<div className="flex max-h-72 flex-col overflow-y-auto border-b border-border bg-sidebar empty:hidden">
{!metaOpen && (
<>
{(() => {
const activeTask = todos.find(
(t) => t.status === "in_progress"
);
const totalTasks = todos.length;
const remainingTasks =
totalTasks - groupedTodos.pending.length;
const isCompleted = totalTasks === remainingTasks;
const tasksTrigger = (() => {
if (!hasTasks) return null;
return (
<button
type="button"
onClick={() =>
setMetaOpen((prev) =>
prev === "tasks" ? null : "tasks"
)
}
className="grid w-full cursor-pointer grid-cols-[auto_auto_1fr] items-center gap-3 px-[18px] py-3 text-left"
aria-expanded={metaOpen === "tasks"}
>
{(() => {
if (isCompleted) {
return [
<CheckCircle
key="icon"
size={16}
className="text-success/80"
/>,
<span
key="label"
className="ml-[1px] min-w-0 truncate text-sm"
>
All tasks completed
</span>,
];
}
if (activeTask != null) {
return [
<div key="icon">
{getStatusIcon(activeTask.status)}
</div>,
<span
key="label"
className="ml-[1px] min-w-0 truncate text-sm"
>
Task{" "}
{totalTasks - groupedTodos.pending.length} of{" "}
{totalTasks}
</span>,
<span
key="content"
className="min-w-0 gap-2 truncate text-sm text-muted-foreground"
>
{activeTask.content}
</span>,
];
}
return [
<Circle
key="icon"
size={16}
className="text-tertiary/70"
/>,
<span
key="label"
className="ml-[1px] min-w-0 truncate text-sm"
>
Task {totalTasks - groupedTodos.pending.length}{" "}
of {totalTasks}
</span>,
];
})()}
</button>
);
})();
const filesTrigger = (() => {
if (!hasFiles) return null;
return (
<button
type="button"
onClick={() =>
setMetaOpen((prev) =>
prev === "files" ? null : "files"
)
}
className="flex flex-shrink-0 cursor-pointer items-center gap-2 px-[18px] py-3 text-left text-sm"
aria-expanded={metaOpen === "files"}
>
<FileIcon size={16} />
Files (State)
<span className="h-4 min-w-4 rounded-full bg-[#2F6868] px-0.5 text-center text-[10px] leading-[16px] text-white">
{Object.keys(files).length}
</span>
</button>
);
})();
return (
<div className="grid grid-cols-[1fr_auto_auto] items-center">
{tasksTrigger}
{filesTrigger}
</div>
);
})()}
</>
)}
{metaOpen && (
<>
<div className="sticky top-0 flex items-stretch bg-sidebar text-sm">
{hasTasks && (
<button
type="button"
className="py-3 pr-4 first:pl-[18px] aria-expanded:font-semibold"
onClick={() =>
setMetaOpen((prev) =>
prev === "tasks" ? null : "tasks"
)
}
aria-expanded={metaOpen === "tasks"}
>
Tasks
</button>
)}
{hasFiles && (
<button
type="button"
className="inline-flex items-center gap-2 py-3 pr-4 first:pl-[18px] aria-expanded:font-semibold"
onClick={() =>
setMetaOpen((prev) =>
prev === "files" ? null : "files"
)
}
aria-expanded={metaOpen === "files"}
>
Files (State)
<span className="h-4 min-w-4 rounded-full bg-[#2F6868] px-0.5 text-center text-[10px] leading-[16px] text-white">
{Object.keys(files).length}
</span>
</button>
)}
<button
aria-label="Close"
className="flex-1"
onClick={() => setMetaOpen(null)}
/>
</div>
<div
ref={tasksContainerRef}
className="px-[18px]"
>
{metaOpen === "tasks" &&
Object.entries(groupedTodos)
.filter(([_, todos]) => todos.length > 0)
.map(([status, todos]) => (
<div
key={status}
className="mb-4"
>
<h3 className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-tertiary">
{
{
pending: "Pending",
in_progress: "In Progress",
completed: "Completed",
}[status]
}
</h3>
<div className="grid grid-cols-[auto_1fr] gap-3 rounded-sm p-1 pl-0 text-sm">
{todos.map((todo, index) => (
<Fragment key={`${status}_${todo.id}_${index}`}>
{getStatusIcon(todo.status, "mt-0.5")}
<span className="break-words text-inherit">
{todo.content}
</span>
</Fragment>
))}
</div>
</div>
))}
{metaOpen === "files" && (
<div className="mb-6">
<FilesPopover
files={files}
setFiles={setFiles}
editDisabled={
isLoading === true || interrupt !== undefined
}
/>
</div>
)}
</div>
</>
)}
</div>
)}
<form
onSubmit={handleSubmit}
className="flex flex-col"
>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isLoading ? "Running..." : "Write your message..."}
className="font-inherit field-sizing-content flex-1 resize-none border-0 bg-transparent px-[18px] pb-[13px] pt-[14px] text-sm leading-7 text-primary outline-none placeholder:text-tertiary"
rows={1}
/>
<div className="flex justify-between gap-2 p-3">
<div className="flex justify-end gap-2">
<Button
type={isLoading ? "button" : "submit"}
variant={isLoading ? "destructive" : "default"}
onClick={isLoading ? stopStream : handleSubmit}
disabled={!isLoading && (submitDisabled || !input.trim())}
>
{isLoading ? (
<>
<Square size={14} />
<span>Stop</span>
</>
) : (
<>
<ArrowUp size={18} />
<span>Send</span>
</>
)}
</Button>
</div>
</div>
</form>
</div>
</div>
</div>
);
});
ChatInterface.displayName = "ChatInterface";
@@ -0,0 +1,200 @@
"use client";
import React, { useMemo, useState, useCallback } from "react";
import { SubAgentIndicator } from "@/app/components/SubAgentIndicator";
import { ToolCallBox } from "@/app/components/ToolCallBox";
import { MarkdownContent } from "@/app/components/MarkdownContent";
import type {
SubAgent,
ToolCall,
ActionRequest,
ReviewConfig,
} from "@/app/types/types";
import { Message } from "@langchain/langgraph-sdk";
import {
extractSubAgentContent,
extractStringFromMessageContent,
} from "@/app/utils/utils";
import { cn } from "@/lib/utils";
interface ChatMessageProps {
message: Message;
toolCalls: ToolCall[];
isLoading?: boolean;
actionRequestsMap?: Map<string, ActionRequest>;
reviewConfigsMap?: Map<string, ReviewConfig>;
ui?: any[];
stream?: any;
onResumeInterrupt?: (value: any) => void;
graphId?: string;
}
export const ChatMessage = React.memo<ChatMessageProps>(
({
message,
toolCalls,
isLoading,
actionRequestsMap,
reviewConfigsMap,
ui,
stream,
onResumeInterrupt,
graphId,
}) => {
const isUser = message.type === "human";
const messageContent = extractStringFromMessageContent(message);
const hasContent = messageContent && messageContent.trim() !== "";
const hasToolCalls = toolCalls.length > 0;
const subAgents = useMemo(() => {
return toolCalls
.filter((toolCall: ToolCall) => {
return (
toolCall.name === "task" &&
toolCall.args["subagent_type"] &&
toolCall.args["subagent_type"] !== "" &&
toolCall.args["subagent_type"] !== null
);
})
.map((toolCall: ToolCall) => {
const subagentType = (toolCall.args as Record<string, unknown>)[
"subagent_type"
] as string;
return {
id: toolCall.id,
name: toolCall.name,
subAgentName: subagentType,
input: toolCall.args,
output: toolCall.result ? { result: toolCall.result } : undefined,
status: toolCall.status,
} as SubAgent;
});
}, [toolCalls]);
const [expandedSubAgents, setExpandedSubAgents] = useState<
Record<string, boolean>
>({});
const isSubAgentExpanded = useCallback(
(id: string) => expandedSubAgents[id] ?? true,
[expandedSubAgents]
);
const toggleSubAgent = useCallback((id: string) => {
setExpandedSubAgents((prev) => ({
...prev,
[id]: prev[id] === undefined ? false : !prev[id],
}));
}, []);
return (
<div
className={cn(
"flex w-full max-w-full overflow-x-hidden",
isUser && "flex-row-reverse"
)}
>
<div
className={cn(
"min-w-0 max-w-full",
isUser ? "max-w-[70%]" : "w-full"
)}
>
{hasContent && (
<div className={cn("relative flex items-end gap-0")}>
<div
className={cn(
"mt-4 overflow-hidden break-words text-sm font-normal leading-[150%]",
isUser
? "rounded-xl rounded-br-none border border-border px-3 py-2 text-foreground"
: "text-primary"
)}
style={
isUser
? { backgroundColor: "var(--color-user-message-bg)" }
: undefined
}
>
{isUser ? (
<p className="m-0 whitespace-pre-wrap break-words text-sm leading-relaxed">
{messageContent}
</p>
) : hasContent ? (
<MarkdownContent content={messageContent} />
) : null}
</div>
</div>
)}
{hasToolCalls && (
<div className="mt-4 flex w-full flex-col">
{toolCalls.map((toolCall: ToolCall) => {
if (toolCall.name === "task") return null;
const toolCallGenUiComponent = ui?.find(
(u) => u.metadata?.tool_call_id === toolCall.id
);
const actionRequest = actionRequestsMap?.get(toolCall.name);
const reviewConfig = reviewConfigsMap?.get(toolCall.name);
return (
<ToolCallBox
key={toolCall.id}
toolCall={toolCall}
uiComponent={toolCallGenUiComponent}
stream={stream}
graphId={graphId}
actionRequest={actionRequest}
reviewConfig={reviewConfig}
onResume={onResumeInterrupt}
isLoading={isLoading}
/>
);
})}
</div>
)}
{!isUser && subAgents.length > 0 && (
<div className="flex w-fit max-w-full flex-col gap-4">
{subAgents.map((subAgent) => (
<div
key={subAgent.id}
className="flex w-full flex-col gap-2"
>
<div className="flex items-end gap-2">
<div className="w-[calc(100%-100px)]">
<SubAgentIndicator
subAgent={subAgent}
onClick={() => toggleSubAgent(subAgent.id)}
isExpanded={isSubAgentExpanded(subAgent.id)}
/>
</div>
</div>
{isSubAgentExpanded(subAgent.id) && (
<div className="w-full max-w-full">
<div className="bg-surface border-border-light rounded-md border p-4">
<h4 className="text-primary/70 mb-2 text-xs font-semibold uppercase tracking-wider">
Input
</h4>
<div className="mb-4">
<MarkdownContent
content={extractSubAgentContent(subAgent.input)}
/>
</div>
{subAgent.output && (
<>
<h4 className="text-primary/70 mb-2 text-xs font-semibold uppercase tracking-wider">
Output
</h4>
<MarkdownContent
content={extractSubAgentContent(subAgent.output)}
/>
</>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
);
ChatMessage.displayName = "ChatMessage";
@@ -0,0 +1,124 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { StandaloneConfig } from "@/lib/config";
interface ConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (config: StandaloneConfig) => void;
initialConfig?: StandaloneConfig;
}
export function ConfigDialog({
open,
onOpenChange,
onSave,
initialConfig,
}: ConfigDialogProps) {
const [deploymentUrl, setDeploymentUrl] = useState(
initialConfig?.deploymentUrl ||
process.env.NEXT_PUBLIC_DEPLOYMENT_URL ||
""
);
const [assistantId, setAssistantId] = useState(
initialConfig?.assistantId || process.env.NEXT_PUBLIC_ASSISTANT_ID || ""
);
const [langsmithApiKey, setLangsmithApiKey] = useState(
initialConfig?.langsmithApiKey ||
process.env.NEXT_PUBLIC_LANGSMITH_API_KEY ||
""
);
useEffect(() => {
if (open && initialConfig) {
setDeploymentUrl(initialConfig.deploymentUrl);
setAssistantId(initialConfig.assistantId);
setLangsmithApiKey(initialConfig.langsmithApiKey || "");
}
}, [open, initialConfig]);
const handleSave = () => {
if (!deploymentUrl || !assistantId) {
alert("Please fill in all required fields");
return;
}
onSave({
deploymentUrl,
assistantId,
langsmithApiKey: langsmithApiKey || undefined,
});
onOpenChange(false);
};
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>Configuration</DialogTitle>
<DialogDescription>
Configure your LangGraph deployment settings. These settings are
saved in your browser&apos;s local storage.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="deploymentUrl">Deployment URL</Label>
<Input
id="deploymentUrl"
placeholder="https://<deployment-url>"
value={deploymentUrl}
onChange={(e) => setDeploymentUrl(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="assistantId">Assistant ID</Label>
<Input
id="assistantId"
placeholder="<assistant-id>"
value={assistantId}
onChange={(e) => setAssistantId(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="langsmithApiKey">
LangSmith API Key{" "}
<span className="text-muted-foreground">(Optional)</span>
</Label>
<Input
id="langsmithApiKey"
type="password"
placeholder="lsv2_pt_..."
value={langsmithApiKey}
onChange={(e) => setLangsmithApiKey(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,296 @@
"use client";
import React, { useMemo, useCallback, useState, useEffect } from "react";
import { FileText, Copy, Download, Edit, Save, X, Loader2 } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { toast } from "sonner";
import { MarkdownContent } from "@/app/components/MarkdownContent";
import type { FileItem } from "@/app/types/types";
import useSWRMutation from "swr/mutation";
const LANGUAGE_MAP: Record<string, string> = {
js: "javascript",
jsx: "javascript",
ts: "typescript",
tsx: "typescript",
py: "python",
rb: "ruby",
go: "go",
rs: "rust",
java: "java",
cpp: "cpp",
c: "c",
cs: "csharp",
php: "php",
swift: "swift",
kt: "kotlin",
scala: "scala",
sh: "bash",
bash: "bash",
zsh: "bash",
json: "json",
xml: "xml",
html: "html",
css: "css",
scss: "scss",
sass: "sass",
less: "less",
sql: "sql",
yaml: "yaml",
yml: "yaml",
toml: "toml",
ini: "ini",
dockerfile: "dockerfile",
makefile: "makefile",
};
export const FileViewDialog = React.memo<{
file: FileItem | null;
onSaveFile: (fileName: string, content: string) => Promise<void>;
onClose: () => void;
editDisabled: boolean;
}>(({ file, onSaveFile, onClose, editDisabled }) => {
const [isEditingMode, setIsEditingMode] = useState(file === null);
const [fileName, setFileName] = useState(String(file?.path || ""));
const [fileContent, setFileContent] = useState(String(file?.content || ""));
const fileUpdate = useSWRMutation(
{ kind: "files-update", fileName, fileContent },
async ({ fileName, fileContent }) => {
if (!fileName || !fileContent) return;
return await onSaveFile(fileName, fileContent);
},
{
onSuccess: () => setIsEditingMode(false),
onError: (error) => toast.error(`Failed to save file: ${error}`),
}
);
useEffect(() => {
setFileName(String(file?.path || ""));
setFileContent(String(file?.content || ""));
setIsEditingMode(file === null);
}, [file]);
const fileExtension = useMemo(() => {
const fileNameStr = String(fileName || "");
return fileNameStr.split(".").pop()?.toLowerCase() || "";
}, [fileName]);
const isMarkdown = useMemo(() => {
return fileExtension === "md" || fileExtension === "markdown";
}, [fileExtension]);
const language = useMemo(() => {
return LANGUAGE_MAP[fileExtension] || "text";
}, [fileExtension]);
const handleCopy = useCallback(() => {
if (fileContent) {
navigator.clipboard.writeText(fileContent);
}
}, [fileContent]);
const handleDownload = useCallback(() => {
if (fileContent && fileName) {
const blob = new Blob([fileContent], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}, [fileContent, fileName]);
const handleEdit = useCallback(() => {
setIsEditingMode(true);
}, []);
const handleCancel = useCallback(() => {
if (file === null) {
onClose();
} else {
setFileName(String(file.path));
setFileContent(String(file.content));
setIsEditingMode(false);
}
}, [file, onClose]);
const fileNameIsValid = useMemo(() => {
return (
fileName.trim() !== "" &&
!fileName.includes("/") &&
!fileName.includes(" ")
);
}, [fileName]);
return (
<Dialog
open={true}
onOpenChange={onClose}
>
<DialogContent className="flex h-[80vh] max-h-[80vh] min-w-[60vw] flex-col p-6">
<DialogTitle className="sr-only">
{file?.path || "New File"}
</DialogTitle>
<div className="mb-4 flex items-center justify-between border-b border-border pb-4">
<div className="flex min-w-0 items-center gap-2">
<FileText className="text-primary/50 h-5 w-5 shrink-0" />
{isEditingMode && file === null ? (
<Input
value={fileName}
onChange={(e) => setFileName(e.target.value)}
placeholder="Enter filename..."
className="text-base font-medium"
aria-invalid={!fileNameIsValid}
/>
) : (
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-base font-medium text-primary">
{file?.path}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{!isEditingMode && (
<>
<Button
onClick={handleEdit}
variant="ghost"
size="sm"
className="h-8 px-2"
disabled={editDisabled}
>
<Edit
size={16}
className="mr-1"
/>
Edit
</Button>
<Button
onClick={handleCopy}
variant="ghost"
size="sm"
className="h-8 px-2"
>
<Copy
size={16}
className="mr-1"
/>
Copy
</Button>
<Button
onClick={handleDownload}
variant="ghost"
size="sm"
className="h-8 px-2"
>
<Download
size={16}
className="mr-1"
/>
Download
</Button>
</>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{isEditingMode ? (
<Textarea
value={fileContent}
onChange={(e) => setFileContent(e.target.value)}
placeholder="Enter file content..."
className="h-full min-h-[400px] resize-none font-mono text-sm"
/>
) : (
<ScrollArea className="bg-surface h-full rounded-md">
<div className="p-4">
{fileContent ? (
isMarkdown ? (
<div className="rounded-md p-6">
<MarkdownContent content={fileContent} />
</div>
) : (
<SyntaxHighlighter
language={language}
style={oneDark}
customStyle={{
margin: 0,
borderRadius: "0.5rem",
fontSize: "0.875rem",
}}
showLineNumbers
wrapLines={true}
lineProps={{
style: {
whiteSpace: "pre-wrap",
},
}}
>
{fileContent}
</SyntaxHighlighter>
)
) : (
<div className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">
File is empty
</p>
</div>
)}
</div>
</ScrollArea>
)}
</div>
{isEditingMode && (
<div className="mt-4 flex justify-end gap-2 border-t border-border pt-4">
<Button
onClick={handleCancel}
variant="outline"
size="sm"
>
<X
size={16}
className="mr-1"
/>
Cancel
</Button>
<Button
onClick={() => fileUpdate.trigger()}
size="sm"
disabled={
fileUpdate.isMutating ||
!fileName.trim() ||
!fileContent.trim() ||
!fileNameIsValid
}
>
{fileUpdate.isMutating ? (
<Loader2
size={16}
className="mr-1 animate-spin"
/>
) : (
<Save
size={16}
className="mr-1"
/>
)}
Save
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
});
FileViewDialog.displayName = "FileViewDialog";
@@ -0,0 +1,135 @@
"use client";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { cn } from "@/lib/utils";
interface MarkdownContentProps {
content: string;
className?: string;
}
export const MarkdownContent = React.memo<MarkdownContentProps>(
({ content, className = "" }) => {
return (
<div
className={cn(
"prose min-w-0 max-w-full overflow-hidden break-words text-sm leading-relaxed text-inherit [&_h1:first-child]:mt-0 [&_h1]:mb-4 [&_h1]:mt-6 [&_h1]:font-semibold [&_h2:first-child]:mt-0 [&_h2]:mb-4 [&_h2]:mt-6 [&_h2]:font-semibold [&_h3:first-child]:mt-0 [&_h3]:mb-4 [&_h3]:mt-6 [&_h3]:font-semibold [&_h4:first-child]:mt-0 [&_h4]:mb-4 [&_h4]:mt-6 [&_h4]:font-semibold [&_h5:first-child]:mt-0 [&_h5]:mb-4 [&_h5]:mt-6 [&_h5]:font-semibold [&_h6:first-child]:mt-0 [&_h6]:mb-4 [&_h6]:mt-6 [&_h6]:font-semibold [&_p:last-child]:mb-0 [&_p]:mb-4",
className
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({
inline,
className,
children,
...props
}: {
inline?: boolean;
className?: string;
children?: React.ReactNode;
}) {
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<SyntaxHighlighter
style={oneDark}
language={match[1]}
PreTag="div"
className="max-w-full rounded-md text-sm"
wrapLines={true}
wrapLongLines={true}
lineProps={{
style: {
wordBreak: "break-all",
whiteSpace: "pre-wrap",
overflowWrap: "break-word",
},
}}
customStyle={{
margin: 0,
maxWidth: "100%",
overflowX: "auto",
fontSize: "0.875rem",
}}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code
className="bg-surface rounded-sm px-1 py-0.5 font-mono text-[0.9em]"
{...props}
>
{children}
</code>
);
},
pre({ children }: { children?: React.ReactNode }) {
return (
<div className="my-4 max-w-full overflow-hidden last:mb-0">
{children}
</div>
);
},
a({
href,
children,
}: {
href?: string;
children?: React.ReactNode;
}) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary no-underline hover:underline"
>
{children}
</a>
);
},
blockquote({ children }: { children?: React.ReactNode }) {
return (
<blockquote className="text-primary/50 my-4 border-l-4 border-border pl-4 italic">
{children}
</blockquote>
);
},
ul({ children }: { children?: React.ReactNode }) {
return (
<ul className="my-4 pl-6 [&>li:last-child]:mb-0 [&>li]:mb-1">
{children}
</ul>
);
},
ol({ children }: { children?: React.ReactNode }) {
return (
<ol className="my-4 pl-6 [&>li:last-child]:mb-0 [&>li]:mb-1">
{children}
</ol>
);
},
table({ children }: { children?: React.ReactNode }) {
return (
<div className="my-4 overflow-x-auto">
<table className="[&_th]:bg-surface w-full border-collapse [&_td]:border [&_td]:border-border [&_td]:p-2 [&_th]:border [&_th]:border-border [&_th]:p-2 [&_th]:text-left [&_th]:font-semibold">
{children}
</table>
</div>
);
},
}}
>
{content}
</ReactMarkdown>
</div>
);
}
);
MarkdownContent.displayName = "MarkdownContent";
@@ -0,0 +1,48 @@
"use client";
import React from "react";
import { Button } from "@/components/ui/button";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { SubAgent } from "@/app/types/types";
interface SubAgentIndicatorProps {
subAgent: SubAgent;
onClick: () => void;
isExpanded?: boolean;
}
export const SubAgentIndicator = React.memo<SubAgentIndicatorProps>(
({ subAgent, onClick, isExpanded = true }) => {
return (
<div className="w-fit max-w-[70vw] overflow-hidden rounded-lg border-none bg-card shadow-none outline-none">
<Button
variant="ghost"
size="sm"
onClick={onClick}
className="flex w-full items-center justify-between gap-2 border-none px-4 py-2 text-left shadow-none outline-none transition-colors duration-200"
>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="font-sans text-[15px] font-bold leading-[140%] tracking-[-0.6px] text-[#3F3F46]">
{subAgent.subAgentName}
</span>
</div>
{isExpanded ? (
<ChevronUp
size={14}
className="shrink-0 text-[#70707B]"
/>
) : (
<ChevronDown
size={14}
className="shrink-0 text-[#70707B]"
/>
)}
</div>
</Button>
</div>
);
}
);
SubAgentIndicator.displayName = "SubAgentIndicator";
@@ -0,0 +1,266 @@
"use client";
import React, {
useMemo,
useCallback,
useState,
useEffect,
useRef,
} from "react";
import {
FileText,
CheckCircle,
Circle,
Clock,
ChevronDown,
} from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { TodoItem, FileItem } from "@/app/types/types";
import { useChatContext } from "@/providers/ChatProvider";
import { cn } from "@/lib/utils";
import { FileViewDialog } from "@/app/components/FileViewDialog";
export function FilesPopover({
files,
setFiles,
editDisabled,
}: {
files: Record<string, string>;
setFiles: (files: Record<string, string>) => Promise<void>;
editDisabled: boolean;
}) {
const [selectedFile, setSelectedFile] = useState<FileItem | null>(null);
const handleSaveFile = useCallback(
async (fileName: string, content: string) => {
await setFiles({ ...files, [fileName]: content });
setSelectedFile({ path: fileName, content: content });
},
[files, setFiles]
);
return (
<>
{Object.keys(files).length === 0 ? (
<div className="flex h-full items-center justify-center p-4 text-center">
<p className="text-xs text-muted-foreground">No files created yet</p>
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(256px,1fr))] gap-2">
{Object.keys(files).map((file) => {
const filePath = String(file);
const rawContent = files[file];
let fileContent: string;
if (
typeof rawContent === "object" &&
rawContent !== null &&
"content" in rawContent
) {
const contentArray = (rawContent as { content: unknown }).content;
if (Array.isArray(contentArray)) {
fileContent = contentArray.join("\n");
} else {
fileContent = String(contentArray || "");
}
} else {
fileContent = String(rawContent || "");
}
return (
<button
key={filePath}
type="button"
onClick={() =>
setSelectedFile({ path: filePath, content: fileContent })
}
className="cursor-pointer space-y-1 truncate rounded-md border border-border px-2 py-3 shadow-sm transition-colors"
style={{
backgroundColor: "var(--color-file-button)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"var(--color-file-button-hover)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor =
"var(--color-file-button)";
}}
>
<FileText
size={24}
className="mx-auto text-muted-foreground"
/>
<span className="mx-auto block w-full truncate break-words text-center text-sm leading-relaxed text-foreground">
{filePath}
</span>
</button>
);
})}
</div>
)}
{selectedFile && (
<FileViewDialog
file={selectedFile}
onSaveFile={handleSaveFile}
onClose={() => setSelectedFile(null)}
editDisabled={editDisabled}
/>
)}
</>
);
}
export const TasksFilesSidebar = React.memo<{
todos: TodoItem[];
files: Record<string, string>;
setFiles: (files: Record<string, string>) => Promise<void>;
}>(({ todos, files, setFiles }) => {
const { isLoading, interrupt } = useChatContext();
const [tasksOpen, setTasksOpen] = useState(false);
const [filesOpen, setFilesOpen] = useState(false);
// Track previous counts to detect when content goes from empty to having items
const prevTodosCount = useRef(todos.length);
const prevFilesCount = useRef(Object.keys(files).length);
// Auto-expand when todos go from empty to having content
useEffect(() => {
if (prevTodosCount.current === 0 && todos.length > 0) {
setTasksOpen(true);
}
prevTodosCount.current = todos.length;
}, [todos.length]);
// Auto-expand when files go from empty to having content
const filesCount = Object.keys(files).length;
useEffect(() => {
if (prevFilesCount.current === 0 && filesCount > 0) {
setFilesOpen(true);
}
prevFilesCount.current = filesCount;
}, [filesCount]);
const getStatusIcon = useCallback((status: TodoItem["status"]) => {
switch (status) {
case "completed":
return (
<CheckCircle
size={12}
className="text-success/80"
/>
);
case "in_progress":
return (
<Clock
size={12}
className="text-warning/80"
/>
);
default:
return (
<Circle
size={10}
className="text-tertiary/70"
/>
);
}
}, []);
const groupedTodos = useMemo(() => {
return {
pending: todos.filter((t) => t.status === "pending"),
in_progress: todos.filter((t) => t.status === "in_progress"),
completed: todos.filter((t) => t.status === "completed"),
};
}, [todos]);
const groupedLabels = {
pending: "Pending",
in_progress: "In Progress",
completed: "Completed",
};
return (
<div className="min-h-0 w-full flex-1">
<div className="font-inter flex h-full w-full flex-col p-0">
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
<div className="flex items-center justify-between px-3 pb-1.5 pt-2">
<span className="text-xs font-semibold tracking-wide text-zinc-600">
AGENT TASKS
</span>
<button
onClick={() => setTasksOpen((v) => !v)}
className={cn(
"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-transform duration-200 hover:bg-muted",
tasksOpen ? "rotate-180" : "rotate-0"
)}
aria-label="Toggle tasks panel"
>
<ChevronDown size={14} />
</button>
</div>
{tasksOpen && (
<div className="bg-muted-secondary rounded-xl px-3 pb-2">
<ScrollArea className="h-full">
{todos.length === 0 ? (
<div className="flex h-full items-center justify-center p-4 text-center">
<p className="text-xs text-muted-foreground">
No tasks created yet
</p>
</div>
) : (
<div className="ml-1 p-0.5">
{Object.entries(groupedTodos).map(([status, todos]) => (
<div className="mb-4">
<h3 className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-tertiary">
{groupedLabels[status as keyof typeof groupedLabels]}
</h3>
{todos.map((todo, index) => (
<div
key={`${status}_${todo.id}_${index}`}
className="mb-1.5 flex items-start gap-2 rounded-sm p-1 text-sm"
>
{getStatusIcon(todo.status)}
<span className="flex-1 break-words leading-relaxed text-inherit">
{todo.content}
</span>
</div>
))}
</div>
))}
</div>
)}
</ScrollArea>
</div>
)}
<div className="flex items-center justify-between px-3 pb-1.5 pt-2">
<span className="text-xs font-semibold tracking-wide text-zinc-600">
FILE SYSTEM
</span>
<button
onClick={() => setFilesOpen((v) => !v)}
className={cn(
"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-transform duration-200 hover:bg-muted",
filesOpen ? "rotate-180" : "rotate-0"
)}
aria-label="Toggle files panel"
>
<ChevronDown size={14} />
</button>
</div>
{filesOpen && (
<FilesPopover
files={files}
setFiles={setFiles}
editDisabled={isLoading === true || interrupt !== undefined}
/>
)}
</div>
</div>
</div>
);
});
TasksFilesSidebar.displayName = "TasksFilesSidebar";
@@ -0,0 +1,369 @@
"use client";
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
import { format } from "date-fns";
import { Loader2, MessageSquare, X } from "lucide-react";
import { useQueryState } from "nuqs";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectContent,
SelectGroup,
SelectLabel,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type { ThreadItem } from "@/app/hooks/useThreads";
import { useThreads } from "@/app/hooks/useThreads";
type StatusFilter = "all" | "idle" | "busy" | "interrupted" | "error";
const GROUP_LABELS = {
interrupted: "Requiring Attention",
today: "Today",
yesterday: "Yesterday",
week: "This Week",
older: "Older",
} as const;
const STATUS_COLORS: Record<ThreadItem["status"], string> = {
idle: "bg-green-500",
busy: "bg-blue-500",
interrupted: "bg-orange-500",
error: "bg-red-600",
};
function getThreadColor(status: ThreadItem["status"]): string {
return STATUS_COLORS[status] ?? "bg-gray-400";
}
function formatTime(date: Date, now = new Date()): string {
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return format(date, "HH:mm");
if (days === 1) return "Yesterday";
if (days < 7) return format(date, "EEEE");
return format(date, "MM/dd");
}
function StatusFilterItem({
status,
label,
badge,
}: {
status: ThreadItem["status"];
label: string;
badge?: number;
}) {
return (
<span className="inline-flex items-center gap-2">
<span
className={cn(
"inline-block size-2 rounded-full",
getThreadColor(status)
)}
/>
{label}
{badge !== undefined && badge > 0 && (
<span className="ml-1 inline-flex items-center justify-center rounded-full bg-red-600 px-1.5 py-0.5 text-xs font-bold leading-none text-white">
{badge}
</span>
)}
</span>
);
}
function ErrorState({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<p className="text-sm text-red-600">Failed to load threads</p>
<p className="mt-1 text-xs text-muted-foreground">{message}</p>
</div>
);
}
function LoadingState() {
return (
<div className="space-y-2 p-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton
key={i}
className="h-16 w-full"
/>
))}
</div>
);
}
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<MessageSquare className="mb-2 h-12 w-12 text-gray-300" />
<p className="text-sm text-muted-foreground">No threads found</p>
</div>
);
}
interface ThreadListProps {
onThreadSelect: (id: string) => void;
onMutateReady?: (mutate: () => void) => void;
onClose?: () => void;
onInterruptCountChange?: (count: number) => void;
}
export function ThreadList({
onThreadSelect,
onMutateReady,
onClose,
onInterruptCountChange,
}: ThreadListProps) {
const [currentThreadId] = useQueryState("threadId");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const threads = useThreads({
status: statusFilter === "all" ? undefined : statusFilter,
limit: 20,
});
const flattened = useMemo(() => {
return threads.data?.flat() ?? [];
}, [threads.data]);
const isLoadingMore =
threads.size > 0 && threads.data?.[threads.size - 1] == null;
const isEmpty = threads.data?.at(0)?.length === 0;
const isReachingEnd = isEmpty || (threads.data?.at(-1)?.length ?? 0) < 20;
// Group threads by time and status
const grouped = useMemo(() => {
const now = new Date();
const groups: Record<keyof typeof GROUP_LABELS, ThreadItem[]> = {
interrupted: [],
today: [],
yesterday: [],
week: [],
older: [],
};
flattened.forEach((thread) => {
if (thread.status === "interrupted") {
groups.interrupted.push(thread);
return;
}
const diff = now.getTime() - thread.updatedAt.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
groups.today.push(thread);
} else if (days === 1) {
groups.yesterday.push(thread);
} else if (days < 7) {
groups.week.push(thread);
} else {
groups.older.push(thread);
}
});
return groups;
}, [flattened]);
const interruptedCount = useMemo(() => {
return flattened.filter((t) => t.status === "interrupted").length;
}, [flattened]);
// Expose thread list revalidation to parent component
// Use refs to create a stable callback that always calls the latest mutate function
const onMutateReadyRef = useRef(onMutateReady);
const mutateRef = useRef(threads.mutate);
useEffect(() => {
onMutateReadyRef.current = onMutateReady;
}, [onMutateReady]);
useEffect(() => {
mutateRef.current = threads.mutate;
}, [threads.mutate]);
const mutateFn = useCallback(() => {
mutateRef.current();
}, []);
useEffect(() => {
onMutateReadyRef.current?.(mutateFn);
// Only run once on mount to avoid infinite loops
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Notify parent of interrupt count changes
useEffect(() => {
onInterruptCountChange?.(interruptedCount);
}, [interruptedCount, onInterruptCountChange]);
return (
<div className="absolute inset-0 flex flex-col">
{/* Header with title, filter, and close button */}
<div className="grid flex-shrink-0 grid-cols-[1fr_auto] items-center gap-3 border-b border-border p-4">
<h2 className="text-lg font-semibold tracking-tight">Threads</h2>
<div className="flex items-center gap-2">
<Select
value={statusFilter}
onValueChange={(v) => setStatusFilter(v as StatusFilter)}
>
<SelectTrigger className="w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="all">All statuses</SelectItem>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Active</SelectLabel>
<SelectItem value="idle">
<StatusFilterItem
status="idle"
label="Idle"
/>
</SelectItem>
<SelectItem value="busy">
<StatusFilterItem
status="busy"
label="Busy"
/>
</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Attention</SelectLabel>
<SelectItem value="interrupted">
<StatusFilterItem
status="interrupted"
label="Interrupted"
badge={interruptedCount}
/>
</SelectItem>
<SelectItem value="error">
<StatusFilterItem
status="error"
label="Error"
/>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{onClose && (
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8"
aria-label="Close threads sidebar"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
<ScrollArea className="h-0 flex-1">
{threads.error && <ErrorState message={threads.error.message} />}
{!threads.error && !threads.data && threads.isLoading && (
<LoadingState />
)}
{!threads.error && !threads.isLoading && isEmpty && <EmptyState />}
{!threads.error && !isEmpty && (
<div className="box-border w-full max-w-full overflow-hidden p-2">
{(
Object.keys(GROUP_LABELS) as Array<keyof typeof GROUP_LABELS>
).map((group) => {
const groupThreads = grouped[group];
if (groupThreads.length === 0) return null;
return (
<div
key={group}
className="mb-4"
>
<h4 className="m-0 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{GROUP_LABELS[group]}
</h4>
<div className="flex flex-col gap-1">
{groupThreads.map((thread) => (
<button
key={thread.id}
type="button"
onClick={() => onThreadSelect(thread.id)}
className={cn(
"grid w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors duration-200",
"hover:bg-accent",
currentThreadId === thread.id
? "border border-primary bg-accent hover:bg-accent"
: "border border-transparent bg-transparent"
)}
aria-current={currentThreadId === thread.id}
>
<div className="min-w-0 flex-1">
{/* Title + Timestamp Row */}
<div className="mb-1 flex items-center justify-between">
<h3 className="truncate text-sm font-semibold">
{thread.title}
</h3>
<span className="ml-2 flex-shrink-0 text-xs text-muted-foreground">
{formatTime(thread.updatedAt)}
</span>
</div>
{/* Description + Status Row */}
<div className="flex items-center justify-between">
<p className="flex-1 truncate text-sm text-muted-foreground">
{thread.description}
</p>
<div className="ml-2 flex-shrink-0">
<div
className={cn(
"h-2 w-2 rounded-full",
getThreadColor(thread.status)
)}
/>
</div>
</div>
</div>
</button>
))}
</div>
</div>
);
})}
{!isReachingEnd && (
<div className="flex justify-center py-4">
<Button
variant="outline"
size="sm"
onClick={() => threads.setSize(threads.size + 1)}
disabled={isLoadingMore}
>
{isLoadingMore ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
"Load More"
)}
</Button>
</div>
)}
</div>
)}
</ScrollArea>
</div>
);
}
@@ -0,0 +1,287 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { AlertCircle, Check, X, Pencil } from "lucide-react";
import type { ActionRequest, ReviewConfig } from "@/app/types/types";
import { cn } from "@/lib/utils";
interface ToolApprovalInterruptProps {
actionRequest: ActionRequest;
reviewConfig?: ReviewConfig;
onResume: (value: any) => void;
isLoading?: boolean;
}
export function ToolApprovalInterrupt({
actionRequest,
reviewConfig,
onResume,
isLoading,
}: ToolApprovalInterruptProps) {
const [rejectionMessage, setRejectionMessage] = useState("");
const [isEditing, setIsEditing] = useState(false);
const [editedArgs, setEditedArgs] = useState<Record<string, unknown>>({});
const [showRejectionInput, setShowRejectionInput] = useState(false);
const allowedDecisions = reviewConfig?.allowedDecisions ?? [
"approve",
"reject",
"edit",
];
const handleApprove = () => {
onResume({
decisions: [{ type: "approve" }],
});
};
const handleReject = () => {
if (showRejectionInput) {
onResume({
decisions: [
{
type: "reject",
message: rejectionMessage.trim(),
},
],
});
} else {
setShowRejectionInput(true);
}
};
const handleRejectConfirm = () => {
onResume({
decisions: [
{
type: "reject",
message: rejectionMessage.trim(),
},
],
});
};
const handleEdit = () => {
if (isEditing) {
onResume({
decisions: [
{
type: "edit",
edited_action: {
name: actionRequest.name,
args: editedArgs,
},
},
],
});
setIsEditing(false);
setEditedArgs({});
}
};
const startEditing = () => {
setIsEditing(true);
setEditedArgs(JSON.parse(JSON.stringify(actionRequest.args)));
setShowRejectionInput(false);
};
const cancelEditing = () => {
setIsEditing(false);
setEditedArgs({});
};
const updateEditedArg = (key: string, value: string) => {
try {
const parsedValue =
value.trim().startsWith("{") || value.trim().startsWith("[")
? JSON.parse(value)
: value;
setEditedArgs((prev) => ({ ...prev, [key]: parsedValue }));
} catch {
setEditedArgs((prev) => ({ ...prev, [key]: value }));
}
};
return (
<div className="w-full rounded-md border border-border bg-muted/30 p-4">
{/* Header */}
<div className="mb-3 flex items-center gap-2 text-foreground">
<AlertCircle
size={16}
className="text-yellow-600 dark:text-yellow-400"
/>
<span className="text-xs font-semibold uppercase tracking-wider">
Approval Required
</span>
</div>
{/* Description */}
{actionRequest.description && (
<p className="mb-3 text-sm text-muted-foreground">
{actionRequest.description}
</p>
)}
{/* Tool Info Card */}
<div className="mb-4 rounded-sm border border-border bg-background p-3">
<div className="mb-2">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Tool
</span>
<p className="mt-1 font-mono text-sm font-medium text-foreground">
{actionRequest.name}
</p>
</div>
{isEditing ? (
<div>
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Edit Arguments
</span>
<div className="mt-2 space-y-3">
{Object.entries(actionRequest.args).map(([key, value]) => (
<div key={key}>
<label className="mb-1 block text-xs font-medium text-foreground">
{key}
</label>
<Textarea
value={
editedArgs[key] !== undefined
? typeof editedArgs[key] === "string"
? (editedArgs[key] as string)
: JSON.stringify(editedArgs[key], null, 2)
: typeof value === "string"
? value
: JSON.stringify(value, null, 2)
}
onChange={(e) => updateEditedArg(key, e.target.value)}
className="font-mono text-xs"
rows={
typeof value === "string" && value.length < 100 ? 2 : 4
}
disabled={isLoading}
/>
</div>
))}
</div>
</div>
) : (
<div>
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Arguments
</span>
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-all rounded-sm border border-border bg-muted/40 p-2 font-mono text-xs text-foreground">
{JSON.stringify(actionRequest.args, null, 2)}
</pre>
</div>
)}
</div>
{/* Rejection Message Input */}
{showRejectionInput && !isEditing && (
<div className="mb-4">
<label className="mb-2 block text-xs font-medium text-foreground">
Rejection Message (optional)
</label>
<Textarea
value={rejectionMessage}
onChange={(e) => setRejectionMessage(e.target.value)}
placeholder="Explain why you're rejecting this action..."
className="text-sm"
rows={2}
disabled={isLoading}
/>
</div>
)}
{/* Actions */}
<div className="flex flex-wrap gap-2">
{isEditing ? (
<>
<Button
variant="outline"
size="sm"
onClick={cancelEditing}
disabled={isLoading}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleEdit}
disabled={isLoading}
className="bg-green-600 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700"
>
<Check size={14} />
{isLoading ? "Saving..." : "Save & Approve"}
</Button>
</>
) : showRejectionInput ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => {
setShowRejectionInput(false);
setRejectionMessage("");
}}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleRejectConfirm}
disabled={isLoading}
>
{isLoading ? "Rejecting..." : "Confirm Reject"}
</Button>
</>
) : (
<>
{allowedDecisions.includes("reject") && (
<Button
variant="outline"
size="sm"
onClick={handleReject}
disabled={isLoading}
className="text-destructive hover:bg-destructive/10"
>
<X size={14} />
Reject
</Button>
)}
{allowedDecisions.includes("edit") && (
<Button
variant="outline"
size="sm"
onClick={startEditing}
disabled={isLoading}
>
<Pencil size={14} />
Edit
</Button>
)}
{allowedDecisions.includes("approve") && (
<Button
size="sm"
onClick={handleApprove}
disabled={isLoading}
className={cn(
"bg-green-600 text-white hover:bg-green-700",
"dark:bg-green-600 dark:hover:bg-green-700"
)}
>
<Check size={14} />
{isLoading ? "Approving..." : "Approve"}
</Button>
)}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,230 @@
"use client";
import React, { useState, useMemo, useCallback } from "react";
import {
ChevronDown,
ChevronUp,
Terminal,
AlertCircle,
Loader2,
CircleCheckBigIcon,
StopCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ToolCall, ActionRequest, ReviewConfig } from "@/app/types/types";
import { cn } from "@/lib/utils";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
import { ToolApprovalInterrupt } from "@/app/components/ToolApprovalInterrupt";
interface ToolCallBoxProps {
toolCall: ToolCall;
uiComponent?: any;
stream?: any;
graphId?: string;
actionRequest?: ActionRequest;
reviewConfig?: ReviewConfig;
onResume?: (value: any) => void;
isLoading?: boolean;
}
export const ToolCallBox = React.memo<ToolCallBoxProps>(
({
toolCall,
uiComponent,
stream,
graphId,
actionRequest,
reviewConfig,
onResume,
isLoading,
}) => {
const [isExpanded, setIsExpanded] = useState(
() => !!uiComponent || !!actionRequest
);
const [expandedArgs, setExpandedArgs] = useState<Record<string, boolean>>(
{}
);
const { name, args, result, status } = useMemo(() => {
return {
name: toolCall.name || "Unknown Tool",
args: toolCall.args || {},
result: toolCall.result,
status: toolCall.status || "completed",
};
}, [toolCall]);
const statusIcon = useMemo(() => {
switch (status) {
case "completed":
return <CircleCheckBigIcon />;
case "error":
return (
<AlertCircle
size={14}
className="text-destructive"
/>
);
case "pending":
return (
<Loader2
size={14}
className="animate-spin"
/>
);
case "interrupted":
return (
<StopCircle
size={14}
className="text-orange-500"
/>
);
default:
return (
<Terminal
size={14}
className="text-muted-foreground"
/>
);
}
}, [status]);
const toggleExpanded = useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);
const toggleArgExpanded = useCallback((argKey: string) => {
setExpandedArgs((prev) => ({
...prev,
[argKey]: !prev[argKey],
}));
}, []);
const hasContent = result || Object.keys(args).length > 0;
return (
<div
className={cn(
"w-full overflow-hidden rounded-lg border-none shadow-none outline-none transition-colors duration-200 hover:bg-accent",
isExpanded && hasContent && "bg-accent"
)}
>
<Button
variant="ghost"
size="sm"
onClick={toggleExpanded}
className={cn(
"flex w-full items-center justify-between gap-2 border-none px-2 py-2 text-left shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-default"
)}
disabled={!hasContent}
>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center gap-2">
{statusIcon}
<span className="text-[15px] font-medium tracking-[-0.6px] text-foreground">
{name}
</span>
</div>
{hasContent &&
(isExpanded ? (
<ChevronUp
size={14}
className="shrink-0 text-muted-foreground"
/>
) : (
<ChevronDown
size={14}
className="shrink-0 text-muted-foreground"
/>
))}
</div>
</Button>
{isExpanded && hasContent && (
<div className="px-4 pb-4">
{uiComponent && stream && graphId ? (
<div className="mt-4">
<LoadExternalComponent
key={uiComponent.id}
stream={stream}
message={uiComponent}
namespace={graphId}
meta={{ status, args, result: result ?? "No Result Yet" }}
/>
</div>
) : actionRequest && onResume ? (
// Show tool approval UI when there's an action request but no GenUI
<div className="mt-4">
<ToolApprovalInterrupt
actionRequest={actionRequest}
reviewConfig={reviewConfig}
onResume={onResume}
isLoading={isLoading}
/>
</div>
) : (
<>
{Object.keys(args).length > 0 && (
<div className="mt-4">
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Arguments
</h4>
<div className="space-y-2">
{Object.entries(args).map(([key, value]) => (
<div
key={key}
className="rounded-sm border border-border"
>
<button
onClick={() => toggleArgExpanded(key)}
className="flex w-full items-center justify-between bg-muted/30 p-2 text-left text-xs font-medium transition-colors hover:bg-muted/50"
>
<span className="font-mono">{key}</span>
{expandedArgs[key] ? (
<ChevronUp
size={12}
className="text-muted-foreground"
/>
) : (
<ChevronDown
size={12}
className="text-muted-foreground"
/>
)}
</button>
{expandedArgs[key] && (
<div className="border-t border-border bg-muted/20 p-2">
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs leading-6 text-foreground">
{typeof value === "string"
? value
: JSON.stringify(value, null, 2)}
</pre>
</div>
)}
</div>
))}
</div>
</div>
)}
{result && (
<div className="mt-4">
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Result
</h4>
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-all rounded-sm border border-border bg-muted/40 p-2 font-mono text-xs leading-7 text-foreground">
{typeof result === "string"
? result
: JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</>
)}
</div>
)}
</div>
);
}
);
ToolCallBox.displayName = "ToolCallBox";
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+395
View File
@@ -0,0 +1,395 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
/* Remove default focus box-shadows */
input:focus,
textarea:focus,
select:focus {
box-shadow: none !important;
}
/* Set default outline color to match brand instead of browser blue */
* {
outline-color: hsl(var(--primary));
}
:root {
/* App-specific color variables */
--color-primary: #1c3c3c;
--color-user-message: #076699;
--color-user-message-bg: #e8f4f8;
--color-avatar-bg: #e8ebeb;
--color-secondary: #1c3c3c;
--color-success: #10b981;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-background: #f9f9f9;
--color-subagent-hover: #bbc4c4;
--color-surface: #f9fafb;
--color-border: #e5e7eb;
--color-border-light: #f3f4f6;
--color-text-primary: #111827;
--color-text-secondary: #6b7280;
--color-text-tertiary: #9ca3af;
--color-file-button: #ffffff;
--color-file-button-hover: #e5e7eb;
/* Dark theme colors */
--color-primary-dark: #2dd4bf;
--color-user-message-dark: #076699;
--color-avatar-bg-dark: #bcb2fd;
--color-secondary-dark: #2dd4bf;
--color-success-dark: #34d399;
--color-warning-dark: #fbbf24;
--color-error-dark: #f87171;
--color-background-dark: #0f0f0f;
--color-subagent-hover-dark: #d0c9fe;
--color-surface-dark: #1a1a1a;
--color-border-dark: #2d2d2d;
--color-border-light-dark: #232323;
--color-text-primary-dark: #f3f4f6;
--color-text-secondary-dark: #9ca3af;
--color-text-tertiary-dark: #6b7280;
/* Spacing variables */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Font family variables */
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
--font-family-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono",
Consolas, "Courier New", monospace;
/* Font size variables */
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
/* Font weight variables */
--font-weight-medium: 500;
--font-weight-semibold: 600;
/* Line height variables */
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.75;
/* Border radius variables */
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-full: 9999px;
/* Shadow variables */
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
/* Transition variables */
--transition-base: 200ms ease;
/* Layout variables */
--sidebar-width: 320px;
--sidebar-collapsed-width: 60px;
--header-height: 64px;
--panel-width: 40vw;
--chat-max-width: 900px;
/* Tailwind/Radix UI component variables */
--radius: 0.5rem;
--background: 0 0% 98%;
--foreground: 220 13% 13%;
--card: 0 0% 100%;
--card-foreground: 220 13% 13%;
--popover: 0 0% 100%;
--popover-foreground: 220 13% 13%;
--primary: 180 35% 17%;
--primary-foreground: 0 0% 100%;
--secondary: 220 13% 91%;
--secondary-foreground: 220 13% 13%;
--muted: 220 13% 95%;
--muted-foreground: 220 9% 46%;
--accent: 220 13% 95%;
--accent-foreground: 220 13% 13%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 180 35% 17%;
--sidebar: 220 13% 95%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
@media (prefers-color-scheme: dark) {
:root {
/* App-specific color variables */
--color-primary: #1c3c3c;
--color-user-message: #065a8a;
--color-user-message-bg: #2d2d2d;
--color-avatar-bg: #1c3c3c;
--color-secondary: #bbc4c4;
--color-success: #34d399;
--color-warning: #fbbf24;
--color-error: #f87171;
--color-background: #202020;
--color-subagent-hover: #1e3f3f;
--color-surface: #2a2a2a;
--color-border: #404040;
--color-border-light: #353535;
--color-text-primary: #f3f4f6;
--color-text-secondary: #9ca3af;
--color-text-tertiary: #6b7280;
--color-file-button: #2a2a2a;
--color-file-button-hover: #353535;
/* Tailwind/Radix UI component variables for dark mode */
--radius: 0.5rem;
--background: 0 0% 13%;
--foreground: 220 13% 95%;
--card: 0 0% 18%;
--card-foreground: 220 13% 95%;
--popover: 0 0% 18%;
--popover-foreground: 220 13% 95%;
--primary: 174 72% 56%;
--primary-foreground: 0 0% 13%;
--secondary: 0 0% 25%;
--secondary-foreground: 220 13% 95%;
--muted: 0 0% 22%;
--muted-foreground: 220 9% 70%;
--accent: 0 0% 22%;
--accent-foreground: 220 13% 95%;
--destructive: 0 63% 71%;
--destructive-foreground: 0 0% 13%;
--border: 0 0% 28%;
--input: 0 0% 28%;
--ring: 174 72% 56%;
--sidebar: 0 0% 18%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
font-size: 1rem;
line-height: 1.5;
color: var(--color-text-primary);
background-color: var(--color-background);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600;
line-height: 1.25;
color: var(--color-text-primary);
}
h1 {
font-size: 1.875rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
h4 {
font-size: 1.125rem;
}
h5 {
font-size: 1rem;
}
h6 {
font-size: 0.875rem;
}
p {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
a {
color: var(--color-primary);
text-decoration: none;
transition: opacity 200ms ease;
&:hover {
opacity: 0.8;
}
}
code {
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
"Courier New", monospace;
font-size: 0.9em;
padding: 0.125em 0.25em;
background-color: var(--color-surface);
border-radius: 0.25rem;
}
pre {
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
"Courier New", monospace;
font-size: 0.875rem;
line-height: 1.75;
padding: 1rem;
background-color: var(--color-surface);
border-radius: 0.375rem;
overflow-x: auto;
code {
padding: 0;
background-color: transparent;
}
}
ul,
ol {
padding-left: 1.5rem;
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
li {
margin-bottom: 0.25rem;
&:last-child {
margin-bottom: 0;
}
}
input,
textarea,
select {
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button {
font-family: inherit;
font-size: inherit;
line-height: inherit;
border: none;
background: none;
cursor: pointer;
&:disabled {
cursor: not-allowed;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Optimization Window animations */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* Custom scrollbar styles */
.scrollbar-pretty {
overflow-y: scroll;
}
.scrollbar-pretty::-webkit-scrollbar {
width: 6px;
}
.scrollbar-pretty::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-pretty::-webkit-scrollbar-thumb {
background-color: #d1d5db;
border-radius: 9999px;
}
.scrollbar-pretty::-webkit-scrollbar-thumb:hover {
background-color: #9ca3af;
}
/* Global diff highlighting styles */
.word-added {
background-color: rgba(46, 160, 67, 0.4);
color: #ffffff;
padding: 2px 4px;
border-radius: 3px;
font-weight: 600;
}
.word-removed {
background-color: rgba(248, 81, 73, 0.4);
color: #ffffff;
padding: 2px 4px;
border-radius: 3px;
font-weight: 600;
text-decoration: line-through;
text-decoration-color: #f85149;
}
+167
View File
@@ -0,0 +1,167 @@
"use client";
import { useCallback } from "react";
import { useStream } from "@langchain/langgraph-sdk/react";
import {
type Message,
type Assistant,
type Checkpoint,
} from "@langchain/langgraph-sdk";
import { v4 as uuidv4 } from "uuid";
import type { UseStreamThread } from "@langchain/langgraph-sdk/react";
import type { TodoItem } from "@/app/types/types";
import { useClient } from "@/providers/ClientProvider";
import { useQueryState } from "nuqs";
export type StateType = {
messages: Message[];
todos: TodoItem[];
files: Record<string, string>;
email?: {
id?: string;
subject?: string;
page_content?: string;
};
ui?: any;
};
export function useChat({
activeAssistant,
onHistoryRevalidate,
thread,
}: {
activeAssistant: Assistant | null;
onHistoryRevalidate?: () => void;
thread?: UseStreamThread<StateType>;
}) {
const [threadId, setThreadId] = useQueryState("threadId");
const client = useClient();
const stream = useStream<StateType>({
assistantId: activeAssistant?.assistant_id || "",
client: client ?? undefined,
reconnectOnMount: true,
threadId: threadId ?? null,
onThreadId: setThreadId,
defaultHeaders: { "x-auth-scheme": "langsmith" },
// Enable fetching state history when switching to existing threads
fetchStateHistory: true,
// Revalidate thread list when stream finishes, errors, or creates new thread
onFinish: onHistoryRevalidate,
onError: onHistoryRevalidate,
onCreated: onHistoryRevalidate,
experimental_thread: thread,
});
const sendMessage = useCallback(
(content: string) => {
const newMessage: Message = { id: uuidv4(), type: "human", content };
stream.submit(
{ messages: [newMessage] },
{
optimisticValues: (prev) => ({
messages: [...(prev.messages ?? []), newMessage],
}),
config: { ...(activeAssistant?.config ?? {}), recursion_limit: 100 },
}
);
// Update thread list immediately when sending a message
onHistoryRevalidate?.();
},
[stream, activeAssistant?.config, onHistoryRevalidate]
);
const runSingleStep = useCallback(
(
messages: Message[],
checkpoint?: Checkpoint,
isRerunningSubagent?: boolean,
optimisticMessages?: Message[]
) => {
if (checkpoint) {
stream.submit(undefined, {
...(optimisticMessages
? { optimisticValues: { messages: optimisticMessages } }
: {}),
config: activeAssistant?.config,
checkpoint: checkpoint,
...(isRerunningSubagent
? { interruptAfter: ["tools"] }
: { interruptBefore: ["tools"] }),
});
} else {
stream.submit(
{ messages },
{ config: activeAssistant?.config, interruptBefore: ["tools"] }
);
}
},
[stream, activeAssistant?.config]
);
const setFiles = useCallback(
async (files: Record<string, string>) => {
if (!threadId) return;
// TODO: missing a way how to revalidate the internal state
// I think we do want to have the ability to externally manage the state
await client.threads.updateState(threadId, { values: { files } });
},
[client, threadId]
);
const continueStream = useCallback(
(hasTaskToolCall?: boolean) => {
stream.submit(undefined, {
config: {
...(activeAssistant?.config || {}),
recursion_limit: 100,
},
...(hasTaskToolCall
? { interruptAfter: ["tools"] }
: { interruptBefore: ["tools"] }),
});
// Update thread list when continuing stream
onHistoryRevalidate?.();
},
[stream, activeAssistant?.config, onHistoryRevalidate]
);
const markCurrentThreadAsResolved = useCallback(() => {
stream.submit(null, { command: { goto: "__end__", update: null } });
// Update thread list when marking thread as resolved
onHistoryRevalidate?.();
}, [stream, onHistoryRevalidate]);
const resumeInterrupt = useCallback(
(value: any) => {
stream.submit(null, { command: { resume: value } });
// Update thread list when resuming from interrupt
onHistoryRevalidate?.();
},
[stream, onHistoryRevalidate]
);
const stopStream = useCallback(() => {
stream.stop();
}, [stream]);
return {
stream,
todos: stream.values.todos ?? [],
files: stream.values.files ?? {},
email: stream.values.email,
ui: stream.values.ui,
setFiles,
messages: stream.messages,
isLoading: stream.isLoading,
isThreadLoading: stream.isThreadLoading,
interrupt: stream.interrupt,
getMessagesMetadata: stream.getMessagesMetadata,
sendMessage,
runSingleStep,
continueStream,
stopStream,
markCurrentThreadAsResolved,
resumeInterrupt,
};
}
+136
View File
@@ -0,0 +1,136 @@
import useSWRInfinite from "swr/infinite";
import type { Thread } from "@langchain/langgraph-sdk";
import { Client } from "@langchain/langgraph-sdk";
import { getConfig } from "@/lib/config";
export interface ThreadItem {
id: string;
updatedAt: Date;
status: Thread["status"];
title: string;
description: string;
assistantId?: string;
}
const DEFAULT_PAGE_SIZE = 20;
export function useThreads(props: {
status?: Thread["status"];
limit?: number;
}) {
const pageSize = props.limit || DEFAULT_PAGE_SIZE;
return useSWRInfinite(
(pageIndex: number, previousPageData: ThreadItem[] | null) => {
const config = getConfig();
const apiKey =
config?.langsmithApiKey ||
process.env.NEXT_PUBLIC_LANGSMITH_API_KEY ||
"";
if (!config) {
return null;
}
// If the previous page returned no items, we've reached the end
if (previousPageData && previousPageData.length === 0) {
return null;
}
return {
kind: "threads" as const,
pageIndex,
pageSize,
deploymentUrl: config.deploymentUrl,
assistantId: config.assistantId,
apiKey,
status: props?.status,
};
},
async ({
deploymentUrl,
assistantId,
apiKey,
status,
pageIndex,
pageSize,
}: {
kind: "threads";
pageIndex: number;
pageSize: number;
deploymentUrl: string;
assistantId: string;
apiKey: string;
status?: Thread["status"];
}) => {
const client = new Client({
apiUrl: deploymentUrl,
defaultHeaders: apiKey ? { "X-Api-Key": apiKey } : {},
});
// Check if assistantId is a UUID (deployed) or graph name (local)
const isUUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
assistantId
);
const threads = await client.threads.search({
limit: pageSize,
offset: pageIndex * pageSize,
sortBy: "updated_at" as const,
sortOrder: "desc" as const,
status,
// Only filter by assistant_id metadata for deployed graphs (UUIDs)
// Local dev graphs don't set this metadata
...(isUUID ? { metadata: { assistant_id: assistantId } } : {}),
});
return threads.map((thread): ThreadItem => {
let title = "Untitled Thread";
let description = "";
try {
if (thread.values && typeof thread.values === "object") {
const values = thread.values as any;
const firstHumanMessage = values.messages.find(
(m: any) => m.type === "human"
);
if (firstHumanMessage?.content) {
const content =
typeof firstHumanMessage.content === "string"
? firstHumanMessage.content
: firstHumanMessage.content[0]?.text || "";
title = content.slice(0, 50) + (content.length > 50 ? "..." : "");
}
const firstAiMessage = values.messages.find(
(m: any) => m.type === "ai"
);
if (firstAiMessage?.content) {
const content =
typeof firstAiMessage.content === "string"
? firstAiMessage.content
: firstAiMessage.content[0]?.text || "";
description = content.slice(0, 100);
}
}
} catch {
// Fallback to thread ID
title = `Thread ${thread.thread_id.slice(0, 8)}`;
}
return {
id: thread.thread_id,
updatedAt: new Date(thread.updated_at),
status: thread.status,
title,
description,
assistantId,
};
});
},
{
revalidateFirstPage: true,
revalidateOnFocus: true,
}
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Inter } from "next/font/google";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { Toaster } from "sonner";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="en"
suppressHydrationWarning
>
<body
className={inter.className}
suppressHydrationWarning
>
<NuqsAdapter>{children}</NuqsAdapter>
<Toaster />
</body>
</html>
);
}
+293
View File
@@ -0,0 +1,293 @@
"use client";
import React, { useState, useEffect, useCallback, Suspense } from "react";
import { useQueryState } from "nuqs";
import { getConfig, saveConfig, StandaloneConfig } from "@/lib/config";
import { ConfigDialog } from "@/app/components/ConfigDialog";
import { Button } from "@/components/ui/button";
import { Assistant } from "@langchain/langgraph-sdk";
import { ClientProvider, useClient } from "@/providers/ClientProvider";
import { Settings, MessagesSquare, SquarePen } from "lucide-react";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { ThreadList } from "@/app/components/ThreadList";
import { ChatProvider } from "@/providers/ChatProvider";
import { ChatInterface } from "@/app/components/ChatInterface";
interface HomePageInnerProps {
config: StandaloneConfig;
configDialogOpen: boolean;
setConfigDialogOpen: (open: boolean) => void;
handleSaveConfig: (config: StandaloneConfig) => void;
}
function HomePageInner({
config,
configDialogOpen,
setConfigDialogOpen,
handleSaveConfig,
}: HomePageInnerProps) {
const client = useClient();
const [threadId, setThreadId] = useQueryState("threadId");
const [sidebar, setSidebar] = useQueryState("sidebar");
const [mutateThreads, setMutateThreads] = useState<(() => void) | null>(null);
const [interruptCount, setInterruptCount] = useState(0);
const [assistant, setAssistant] = useState<Assistant | null>(null);
const fetchAssistant = useCallback(async () => {
const isUUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
config.assistantId
);
if (isUUID) {
// We should try to fetch the assistant directly with this UUID
try {
const data = await client.assistants.get(config.assistantId);
setAssistant(data);
} catch (error) {
console.error("Failed to fetch assistant:", error);
setAssistant({
assistant_id: config.assistantId,
graph_id: config.assistantId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
config: {},
metadata: {},
version: 1,
name: "Assistant",
context: {},
});
}
} else {
try {
// We should try to list out the assistants for this graph, and then use the default one.
// TODO: Paginate this search, but 100 should be enough for graph name
const assistants = await client.assistants.search({
graphId: config.assistantId,
limit: 100,
});
const defaultAssistant = assistants.find(
(assistant) => assistant.metadata?.["created_by"] === "system"
);
if (defaultAssistant === undefined) {
throw new Error("No default assistant found");
}
setAssistant(defaultAssistant);
} catch (error) {
console.error(
"Failed to find default assistant from graph_id: try setting the assistant_id directly:",
error
);
setAssistant({
assistant_id: config.assistantId,
graph_id: config.assistantId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
config: {},
metadata: {},
version: 1,
name: config.assistantId,
context: {},
});
}
}
}, [client, config.assistantId]);
useEffect(() => {
fetchAssistant();
}, [fetchAssistant]);
return (
<>
<ConfigDialog
open={configDialogOpen}
onOpenChange={setConfigDialogOpen}
onSave={handleSaveConfig}
initialConfig={config}
/>
<div className="flex h-screen flex-col">
<header className="flex h-16 items-center justify-between border-b border-border px-6">
<div className="flex items-center gap-4">
<h1 className="text-xl font-semibold">BroJS Agent</h1>
{!sidebar && (
<Button
variant="ghost"
size="sm"
onClick={() => setSidebar("1")}
className="rounded-md border border-border bg-card p-3 text-foreground hover:bg-accent"
>
<MessagesSquare className="mr-2 h-4 w-4" />
Threads
{interruptCount > 0 && (
<span className="ml-2 inline-flex min-h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] text-destructive-foreground">
{interruptCount}
</span>
)}
</Button>
)}
</div>
<div className="flex items-center gap-2">
<div className="text-sm text-muted-foreground">
<span className="font-medium">Assistant:</span>{" "}
{config.assistantId}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setConfigDialogOpen(true)}
>
<Settings className="mr-2 h-4 w-4" />
Settings
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setThreadId(null)}
disabled={!threadId}
className="border-[#2F6868] bg-[#2F6868] text-white hover:bg-[#2F6868]/80"
>
<SquarePen className="mr-2 h-4 w-4" />
New Thread
</Button>
</div>
</header>
<div className="flex-1 overflow-hidden">
<ResizablePanelGroup
direction="horizontal"
autoSaveId="standalone-chat"
>
{sidebar && (
<>
<ResizablePanel
id="thread-history"
order={1}
defaultSize={25}
minSize={20}
className="relative min-w-[380px]"
>
<ThreadList
onThreadSelect={async (id) => {
await setThreadId(id);
}}
onMutateReady={(fn) => setMutateThreads(() => fn)}
onClose={() => setSidebar(null)}
onInterruptCountChange={setInterruptCount}
/>
</ResizablePanel>
<ResizableHandle />
</>
)}
<ResizablePanel
id="chat"
className="relative flex flex-col"
order={2}
>
<ChatProvider
activeAssistant={assistant}
onHistoryRevalidate={() => mutateThreads?.()}
>
<ChatInterface assistant={assistant} />
</ChatProvider>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
</>
);
}
function HomePageContent() {
const [config, setConfig] = useState<StandaloneConfig | null>(null);
const [configDialogOpen, setConfigDialogOpen] = useState(false);
const [assistantId, setAssistantId] = useQueryState("assistantId");
// On mount, check for saved config, otherwise show config dialog
useEffect(() => {
const savedConfig = getConfig();
if (savedConfig) {
setConfig(savedConfig);
if (!assistantId) {
setAssistantId(savedConfig.assistantId);
}
} else {
setConfigDialogOpen(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// If config changes, update the assistantId
useEffect(() => {
if (config && !assistantId) {
setAssistantId(config.assistantId);
}
}, [config, assistantId, setAssistantId]);
const handleSaveConfig = useCallback((newConfig: StandaloneConfig) => {
saveConfig(newConfig);
setConfig(newConfig);
}, []);
const langsmithApiKey =
config?.langsmithApiKey || process.env.NEXT_PUBLIC_LANGSMITH_API_KEY || "";
if (!config) {
return (
<>
<ConfigDialog
open={configDialogOpen}
onOpenChange={setConfigDialogOpen}
onSave={handleSaveConfig}
/>
<div className="flex h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold">Welcome to Standalone Chat</h1>
<p className="mt-2 text-muted-foreground">
Configure your deployment to get started
</p>
<Button
onClick={() => setConfigDialogOpen(true)}
className="mt-4"
>
Open Configuration
</Button>
</div>
</div>
</>
);
}
return (
<ClientProvider
deploymentUrl={config.deploymentUrl}
apiKey={langsmithApiKey}
>
<HomePageInner
config={config}
configDialogOpen={configDialogOpen}
setConfigDialogOpen={setConfigDialogOpen}
handleSaveConfig={handleSaveConfig}
/>
</ClientProvider>
);
}
export default function HomePage() {
return (
<Suspense
fallback={
<div className="flex h-screen items-center justify-center">
<p className="text-muted-foreground">Loading...</p>
</div>
}
>
<HomePageContent />
</Suspense>
);
}
+57
View File
@@ -0,0 +1,57 @@
export interface ToolCall {
id: string;
name: string;
args: Record<string, unknown>;
result?: string;
status: "pending" | "completed" | "error" | "interrupted";
}
export interface SubAgent {
id: string;
name: string;
subAgentName: string;
input: Record<string, unknown>;
output?: Record<string, unknown>;
status: "pending" | "active" | "completed" | "error";
}
export interface FileItem {
path: string;
content: string;
}
export interface TodoItem {
id: string;
content: string;
status: "pending" | "in_progress" | "completed";
updatedAt?: Date;
}
export interface Thread {
id: string;
title: string;
createdAt: Date;
updatedAt: Date;
}
export interface InterruptData {
value: any;
ns?: string[];
scope?: string;
}
export interface ActionRequest {
name: string;
args: Record<string, unknown>;
description?: string;
}
export interface ReviewConfig {
actionName: string;
allowedDecisions?: string[];
}
export interface ToolApprovalInterruptData {
action_requests: ActionRequest[];
review_configs?: ReviewConfig[];
}
+157
View File
@@ -0,0 +1,157 @@
import { Message } from "@langchain/langgraph-sdk";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function extractStringFromMessageContent(message: Message): string {
return typeof message.content === "string"
? message.content
: Array.isArray(message.content)
? message.content
.filter(
(c: unknown) =>
(typeof c === "object" &&
c !== null &&
"type" in c &&
(c as { type: string }).type === "text") ||
typeof c === "string"
)
.map((c: unknown) =>
typeof c === "string"
? c
: typeof c === "object" && c !== null && "text" in c
? (c as { text?: string }).text || ""
: ""
)
.join("")
: "";
}
export function extractSubAgentContent(data: unknown): string {
if (typeof data === "string") {
return data;
}
if (data && typeof data === "object") {
const dataObj = data as Record<string, unknown>;
// Try to extract description first
if (dataObj.description && typeof dataObj.description === "string") {
return dataObj.description;
}
// Then try prompt
if (dataObj.prompt && typeof dataObj.prompt === "string") {
return dataObj.prompt;
}
// For output objects, try result
if (dataObj.result && typeof dataObj.result === "string") {
return dataObj.result;
}
// Fallback to JSON stringification
return JSON.stringify(data, null, 2);
}
// Fallback for any other type
return JSON.stringify(data, null, 2);
}
export function isPreparingToCallTaskTool(messages: Message[]): boolean {
const lastMessage = messages[messages.length - 1];
return (
(lastMessage.type === "ai" &&
lastMessage.tool_calls?.some(
(call: { name?: string }) => call.name === "task"
)) ||
false
);
}
export function formatMessageForLLM(message: Message): string {
let role: string;
if (message.type === "human") {
role = "Human";
} else if (message.type === "ai") {
role = "Assistant";
} else if (message.type === "tool") {
role = `Tool Result`;
} else {
role = message.type || "Unknown";
}
const timestamp = message.id ? ` (${message.id.slice(0, 8)})` : "";
let contentText = "";
// Extract content text
if (typeof message.content === "string") {
contentText = message.content;
} else if (Array.isArray(message.content)) {
const textParts: string[] = [];
message.content.forEach((part: any) => {
if (typeof part === "string") {
textParts.push(part);
} else if (part && typeof part === "object" && part.type === "text") {
textParts.push(part.text || "");
}
// Ignore other types like tool_use in content - we handle tool calls separately
});
contentText = textParts.join("\n\n").trim();
}
// For tool messages, include additional tool metadata
if (message.type === "tool") {
const toolName = (message as any).name || "unknown_tool";
const toolCallId = (message as any).tool_call_id || "";
role = `Tool Result [${toolName}]`;
if (toolCallId) {
role += ` (call_id: ${toolCallId.slice(0, 8)})`;
}
}
// Handle tool calls from .tool_calls property (for AI messages)
const toolCallsText: string[] = [];
if (
message.type === "ai" &&
message.tool_calls &&
Array.isArray(message.tool_calls) &&
message.tool_calls.length > 0
) {
message.tool_calls.forEach((call: any) => {
const toolName = call.name || "unknown_tool";
const toolArgs = call.args ? JSON.stringify(call.args, null, 2) : "{}";
toolCallsText.push(`[Tool Call: ${toolName}]\nArguments: ${toolArgs}`);
});
}
// Combine content and tool calls
const parts: string[] = [];
if (contentText) {
parts.push(contentText);
}
if (toolCallsText.length > 0) {
parts.push(...toolCallsText);
}
if (parts.length === 0) {
return `${role}${timestamp}: [Empty message]`;
}
if (parts.length === 1) {
return `${role}${timestamp}: ${parts[0]}`;
}
return `${role}${timestamp}:\n${parts.join("\n\n")}`;
}
export function formatConversationForLLM(messages: Message[]): string {
const formattedMessages = messages.map(formatMessageForLLM);
return formattedMessages.join("\n\n---\n\n");
}
@@ -0,0 +1,59 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+163
View File
@@ -0,0 +1,163 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return (
<DialogPrimitive.Root
data-slot="dialog"
{...props}
/>
);
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return (
<DialogPrimitive.Trigger
data-slot="dialog-trigger"
{...props}
/>
);
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return (
<DialogPrimitive.Portal
data-slot="dialog-portal"
{...props}
/>
);
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return (
<DialogPrimitive.Close
data-slot="dialog-close"
{...props}
/>
);
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="focus:outline-hidden absolute right-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg font-semibold leading-none", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-blue-200 selection:text-gray-900 file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30 dark:selection:bg-blue-600 dark:selection:text-white md:text-sm",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
);
}
export { Input };
@@ -0,0 +1,26 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
@@ -0,0 +1,45 @@
"use client";
import { GripVertical } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
@@ -0,0 +1,58 @@
"use client";
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none select-none p-px transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };
+160
View File
@@ -0,0 +1,160 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] origin-[--radix-select-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };
@@ -0,0 +1,55 @@
"use client";
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
style={{
display: "inline-flex",
height: "20px",
width: "36px",
alignItems: "center",
borderRadius: "9999px",
border: "1px solid #d1d5db",
backgroundColor: "var(--color-border)",
cursor: "pointer",
transition: "background-color 0.2s",
}}
data-state-styles={{
checked: {
backgroundColor: "var(--color-primary)",
},
}}
className={cn(
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:!bg-[var(--color-primary)]",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
style={{
display: "block",
width: "16px",
height: "16px",
borderRadius: "9999px",
backgroundColor: "white",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.2)",
transition: "transform 0.2s",
transform: "translateX(1px)",
}}
className="data-[state=checked]:!translate-x-[17px]"
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
+66
View File
@@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground",
className
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium text-foreground transition-[color,box-shadow] focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:shadow-sm dark:text-muted-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };
@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = "Textarea";
export { Textarea };
@@ -0,0 +1,42 @@
import React from "react";
import { Button } from "./button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./tooltip";
interface TooltipIconButtonProps {
icon: React.ReactNode;
onClick: () => void;
tooltip: string;
disabled?: boolean;
}
export function TooltipIconButton({
icon,
onClick,
tooltip,
disabled,
}: TooltipIconButtonProps) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onClick}
disabled={disabled}
>
{icon}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -0,0 +1,69 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root
data-slot="tooltip"
{...props}
/>
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return (
<TooltipPrimitive.Trigger
data-slot="tooltip-trigger"
{...props}
/>
);
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"text-primary-foreground origin-(--radix-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md bg-primary px-3 py-1.5 text-xs animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-primary fill-primary" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+40
View File
@@ -0,0 +1,40 @@
export interface StandaloneConfig {
deploymentUrl: string;
assistantId: string;
langsmithApiKey?: string;
}
const CONFIG_KEY = "deep-agent-config";
export function getEnvConfig(): StandaloneConfig | null {
const deploymentUrl = process.env.NEXT_PUBLIC_DEPLOYMENT_URL;
const assistantId = process.env.NEXT_PUBLIC_ASSISTANT_ID;
if (!deploymentUrl || !assistantId) return null;
const langsmithApiKey = process.env.NEXT_PUBLIC_LANGSMITH_API_KEY;
return {
deploymentUrl,
assistantId,
langsmithApiKey: langsmithApiKey || undefined,
};
}
export function getConfig(): StandaloneConfig | null {
if (typeof window === "undefined") return null;
const stored = localStorage.getItem(CONFIG_KEY);
if (stored) {
try {
return JSON.parse(stored);
} catch {
return null;
}
}
return getEnvConfig();
}
export function saveConfig(config: StandaloneConfig): void {
if (typeof window === "undefined") return;
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -0,0 +1,37 @@
"use client";
import { ReactNode, createContext, useContext } from "react";
import { Assistant } from "@langchain/langgraph-sdk";
import { type StateType, useChat } from "@/app/hooks/useChat";
import type { UseStreamThread } from "@langchain/langgraph-sdk/react";
interface ChatProviderProps {
children: ReactNode;
activeAssistant: Assistant | null;
onHistoryRevalidate?: () => void;
thread?: UseStreamThread<StateType>;
}
export function ChatProvider({
children,
activeAssistant,
onHistoryRevalidate,
thread,
}: ChatProviderProps) {
const chat = useChat({ activeAssistant, onHistoryRevalidate, thread });
return <ChatContext.Provider value={chat}>{children}</ChatContext.Provider>;
}
export type ChatContextType = ReturnType<typeof useChat>;
export const ChatContext = createContext<ChatContextType | undefined>(
undefined
);
export function useChatContext() {
const context = useContext(ChatContext);
if (context === undefined) {
throw new Error("useChatContext must be used within a ChatProvider");
}
return context;
}
@@ -0,0 +1,47 @@
"use client";
import { createContext, useContext, useMemo, ReactNode } from "react";
import { Client } from "@langchain/langgraph-sdk";
interface ClientContextValue {
client: Client;
}
const ClientContext = createContext<ClientContextValue | null>(null);
interface ClientProviderProps {
children: ReactNode;
deploymentUrl: string;
apiKey: string;
}
export function ClientProvider({
children,
deploymentUrl,
apiKey,
}: ClientProviderProps) {
const client = useMemo(() => {
return new Client({
apiUrl: deploymentUrl,
defaultHeaders: {
"Content-Type": "application/json",
"X-Api-Key": apiKey,
},
});
}, [deploymentUrl, apiKey]);
const value = useMemo(() => ({ client }), [client]);
return (
<ClientContext.Provider value={value}>{children}</ClientContext.Provider>
);
}
export function useClient(): Client {
const context = useContext(ClientContext);
if (!context) {
throw new Error("useClient must be used within a ClientProvider");
}
return context.client;
}
+488
View File
@@ -0,0 +1,488 @@
import { blackA, green, mauve, slate, violet } from "@radix-ui/colors";
import plugin from "tailwindcss/plugin";
import containerQueries from "@tailwindcss/container-queries";
import typography from "@tailwindcss/typography";
import forms from "@tailwindcss/forms";
import tailwindcssAnimate from "tailwindcss-animate";
import headlessui from "@headlessui/tailwindcss";
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
darkMode: ["class", '[data-joy-color-scheme="dark"]'],
theme: {
extend: {
fontSize: {
xxs: [
"0.75rem", // 12px
{
lineHeight: "1.125rem", // 18px
},
],
xs: [
"0.8125rem", // 13px
{
lineHeight: "1.125rem", // 18px
},
],
sm: [
"0.875rem", // 14px
{
lineHeight: "1.25rem", // 20px
},
],
base: [
"1rem", // 16px
{
lineHeight: "1.5rem", // 24px
},
],
lg: [
"1.125rem", // 18px
{
lineHeight: "1.75rem", // 28px
letterSpacing: "-0.01em", // tracking-tight
},
],
xl: [
"1.25rem", // 20px
{
lineHeight: "1.875rem", // 30px
letterSpacing: "-0.01em", // tracking-tight
},
],
},
fontFamily: {
mono: [
`"Fira Code"`,
`ui-monospace`,
`SFMono-Regular`,
`Menlo`,
`Monaco`,
`Consolas`,
`"Liberation Mono"`,
`"Courier New"`,
`monospace`,
],
},
letterSpacing: {
tighter: "-0.04em",
tight: "-0.03em",
snug: "-0.02em",
normal: "0",
wide: "0.03em",
},
lineHeight: {
tight: "1.20",
},
backgroundImage: {
navMenu: "linear-gradient(132deg, #4499F7 0%, #3FCDD6 100%)",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
xs: "3px",
},
boxShadow: {
xs: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
},
backgroundColor: {
primary: "var(--bg-primary)",
"primary-hover": "var(--bg-primary_hover)",
secondary: "var(--bg-secondary)",
"secondary-hover": "var(--bg-secondary_hover)",
tertiary: "var(--bg-tertiary)",
quaternary: "var(--bg-quaternary)",
"brand-primary": "var(--bg-brand-primary)",
"brand-primary-hover": "var(--bg-brand-primary_hover)",
"brand-secondary": "var(--bg-brand-secondary)",
"brand-tertiary": "var(--bg-brand-tertiary)",
purple: "var(--bg-purple)",
"success-primary": "var(--bg-success-primary)",
"success-secondary": "var(--bg-success-secondary)",
"success-strong": "var(--bg-success-strong)",
"error-primary": "var(--bg-error-primary)",
"error-secondary": "var(--bg-error-secondary)",
"error-strong": "var(--bg-error-strong)",
"error-strong-hover": "var(--bg-error-strong-hover)",
"warning-primary": "var(--bg-warning-primary)",
"warning-secondary": "var(--bg-warning-secondary)",
"warning-strong": "var(--bg-warning-strong)",
},
borderColor: {
primary: "var(--border-primary)",
secondary: "var(--border-secondary)",
tertiary: "var(--border-tertiary)",
error: "var(--border-error)",
"error-strong": "var(--border-error-strong)",
brand: "var(--border-brand)",
"brand-strong": "var(--border-brand-strong)",
"brand-subtle": "var(--border-brand-subtle)",
strong: "var(--border-strong)",
warning: "var(--border-warning)",
success: "var(--border-success)",
purple: "var(--border-purple)",
"status-green": "var(--border-status-green)",
"status-orange": "var(--border-status-orange)",
"status-yellow": "var(--border-status-yellow)",
"status-red": "var(--border-status-red)",
},
textColor: {
primary: "var(--text-primary)",
secondary: "var(--text-secondary)",
tertiary: "var(--text-tertiary)",
quaternary: "var(--text-quaternary)",
disabled: "var(--text-disabled)",
error: "var(--text-error)",
warning: "var(--text-warning)",
success: "var(--text-success)",
placeholder: "var(--text-placeholder)",
purple: "var(--text-purple)",
"brand-primary": "var(--text-brand-primary)",
"brand-secondary": "var(--text-brand-secondary)",
"brand-tertiary": "var(--text-brand-tertiary)",
"brand-disabled": "var(--text-brand-disabled)",
"status-green": "var(--text-status-green)",
"status-orange": "var(--text-status-orange)",
"status-yellow": "var(--text-status-yellow)",
"status-red": "var(--text-status-red)",
"button-primary": "var(--text-button-primary)",
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar))",
},
chart: {
1: "hsl(var(--chart-1))",
2: "hsl(var(--chart-2))",
3: "hsl(var(--chart-3))",
4: "hsl(var(--chart-4))",
5: "hsl(var(--chart-5))",
},
ls: {
blue: "hsl(211.5, 91.8%, 61.8%)",
black: "hsl(var(--ls-black))",
green: {
600: "hsl(122, 63%, 38%)",
},
white: "var(--white)",
black: "var(--black)",
red: {
25: "var(--red-25)",
50: "var(--red-50)",
100: "var(--red-100)",
200: "var(--red-200)",
300: "var(--red-300)",
400: "var(--red-400)",
500: "var(--red-500)",
600: "var(--red-600)",
700: "var(--red-700)",
800: "var(--red-800)",
900: "var(--red-900)",
950: "var(--red-950)",
},
orange: {
25: "var(--orange-25)",
50: "var(--orange-50)",
100: "var(--orange-100)",
200: "var(--orange-200)",
300: "var(--orange-300)",
400: "var(--orange-400)",
500: "var(--orange-500)",
600: "var(--orange-600)",
700: "var(--orange-700)",
800: "var(--orange-800)",
900: "var(--orange-900)",
950: "var(--orange-950)",
},
gray: {
50: "var(--gray-50)",
100: "var(--gray-100)",
200: "var(--gray-200)",
300: "var(--gray-300)",
400: "var(--gray-400)",
500: "var(--gray-500)",
600: "var(--gray-600)",
700: "var(--gray-700)",
800: "var(--gray-800)",
900: "var(--gray-900)",
950: "var(--gray-950)",
},
green: {
25: "var(--green-25)",
50: "var(--green-50)",
100: "var(--green-100)",
200: "var(--green-200)",
300: "var(--green-300)",
400: "var(--green-400)",
500: "var(--green-500)",
600: "var(--green-600)",
700: "var(--green-700)",
800: "var(--green-800)",
900: "var(--green-900)",
950: "var(--green-950)",
},
},
brand: {
green: {
25: "var(--brand-25)",
50: "var(--brand-50)",
100: "var(--brand-100)",
200: "var(--brand-200)",
300: "var(--brand-300)",
400: "var(--brand-400)",
500: "var(--brand-500)",
600: "var(--brand-600)",
700: "var(--brand-700)",
800: "var(--brand-800)",
900: "var(--brand-900)",
950: "var(--brand-950)",
},
},
},
keyframes: {
hide: {
from: { opacity: 1 },
to: { opacity: 0 },
},
slideIn: {
from: {
transform: "translateX(calc(100% + var(--viewport-padding)))",
},
to: { transform: "translateX(0)" },
},
swipeOut: {
from: { transform: "translateX(var(--radix-toast-swipe-end-x))" },
to: { transform: "translateX(calc(100% + var(--viewport-padding)))" },
},
},
animation: {
hide: "hide 100ms ease-in",
slideIn: "slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1)",
swipeOut: "swipeOut 100ms ease-out",
},
},
typography: {
playground: {
css: {
"h1, h2, h3, h4, h5, h6": {
fontWeight: "bold",
},
h1: {
fontSize: "24px",
},
h2: {
fontSize: "20px",
},
h3: {
fontSize: "18px",
},
h4: {
fontSize: "16px",
},
h5: {
fontSize: "14px",
},
h6: {
fontSize: "12px",
},
ul: {
marginLeft: "20px !important",
listStyleType: "disc !important",
},
ol: {
marginLeft: "20px !important",
listStyleType: "decimal !important",
},
a: {
color: "#287977",
textDecoration: "underline",
"&:hover": {
textDecoration: "underline",
},
},
table: {
width: "100%",
borderCollapse: "collapse",
th: {
padding: "0.5rem",
border: "1px solid var(--gray-100)",
fontWeight: "bold",
textAlign: "left",
},
td: {
padding: "0.5rem",
border: "1px solid var(--gray-100)",
},
},
blockquote: {
borderLeft: "2px solid var(--gray-100)",
paddingLeft: "1rem",
marginLeft: "0",
fontStyle: "italic",
},
"s, strike, del": {
textDecoration: "line-through",
},
},
},
},
},
plugins: [
containerQueries,
typography,
forms,
tailwindcssAnimate,
headlessui,
plugin(({ addUtilities, addBase }) => {
addBase({
input: {
borderWidth: "0",
padding: "0",
},
// Global scrollbar styles for all scrollable elements
"html, body, *": {
"scrollbar-width": "thin",
"scrollbar-color": "var(--scrollbar-thumb) var(--bg-primary)",
},
"html::-webkit-scrollbar, body::-webkit-scrollbar, *::-webkit-scrollbar":
{
width: "8px",
background: "var(--bg-primary)",
},
"html::-webkit-scrollbar-track, body::-webkit-scrollbar-track, *::-webkit-scrollbar-track":
{
background: "var(--bg-primary)",
},
"html::-webkit-scrollbar-thumb, body::-webkit-scrollbar-track, *::-webkit-scrollbar-thumb":
{
background: "var(--scrollbar-thumb)",
"border-radius": "4px",
},
"html::-webkit-scrollbar-thumb:hover, body::-webkit-scrollbar-thumb:hover, *::-webkit-scrollbar-thumb:hover":
{
background: "var(--scrollbar-thumb-hover)",
},
});
addUtilities({
".no-scrollbar": {
"scrollbar-width": "none",
"&::-webkit-scrollbar": {
display: "none",
},
},
});
// https://github.com/tailwindlabs/tailwindcss/discussions/12127
addUtilities({
".break-anywhere": {
"@supports (overflow-wrap: anywhere)": {
"overflow-wrap": "anywhere",
},
"@supports not (overflow-wrap: anywhere)": {
"word-break": "break-word",
},
},
});
addUtilities({
".no-number-spinner": {
MozAppearance: "textfield",
"&::-webkit-outer-spin-button": {
WebkitAppearance: "none !important",
margin: 0,
},
"&::-webkit-inner-spin-button": {
WebkitAppearance: "none !important",
margin: 0,
},
},
});
addUtilities({
".text-security": {
textSecurity: "disc",
WebkitTextSecurity: "disc",
MozTextSecurity: "disc",
},
});
addUtilities({
".display-sm": {
fontSize: "1rem", // 16px
lineHeight: "1.5rem", // 24px
fontWeight: "600", // semibold
},
".display-base": {
fontSize: "1.5rem", // 24px
lineHeight: "2rem", // 32px
letterSpacing: "-0.01em", // tracking-tight
},
".display-lg": {
fontSize: "1.875rem", // 30px
lineHeight: "2.375rem", // 38px
letterSpacing: "-0.01em", // tracking-tight
},
".display-xl": {
fontSize: "2.25rem", // 36px
lineHeight: "2.75rem", // 44px
letterSpacing: "-0.01em", // tracking-tight
},
".display-2xl": {
fontSize: "3rem", // 48px
lineHeight: "3.75rem", // 60px
letterSpacing: "-0.01em", // tracking-tight
},
".caps-label-sm": {
fontSize: "0.875rem", // 14px
lineHeight: "1.25rem", // 20px
letterSpacing: "0.02625rem", // 0.42px
textTransform: "uppercase",
},
".caps-label-xs": {
fontSize: "0.75rem", // 14px
lineHeight: "1.125rem", // 20px
letterSpacing: "0.0225rem", // 0.42px
textTransform: "uppercase",
},
});
}),
],
};
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
File diff suppressed because it is too large Load Diff