feat: Update chat UI to be Open SWE specific (#76) (#91)

* init repo-selector

* add branch selector UI, extend GH hook w branch info

* add   reconnectOnMount: true resumeStream, remove repo description from selector, fix styling

* styling repo + branch

* udpate getBranch calls to include targetRepo arg

* format

* add search to repo + branch selectors

* add settings icon link to /github for dev convenience

* add default branch selection

* format

* minor cleanup

* init task list UI demo

* individual task component

* tasks real data

* task visual improvement

* persisting tasks, navigate to thread onClick

* task UI

* TaskSidebar, Navigate to tasks

* org tasks by thread

* group by thread

* improve sidebar, task-list, rm github modal on refresh

* collapse sidebar works when viewing a thread

* remove micro-tasks expansion from threads component, change taskId to taskIndex for persistence + simplicity

* init config sidebar ui

* open and close config sidebar

* add ref for getAllTasks + add graph_id metadata

* format

* create threadItem component

* format

* add threadUtils

* centralize + refactor types

* format

* improve thread status, realtime updates

* fix scroll issue

* remove temperature from config-sidebar

* Change model via sidebar

* fix flashing on updates

* init plan UI

* centralize types

* self CR: refactoring, cleanup, remove comments, add todos

* CR: clean comments + logs, fix repo selector bug

* fix: Update example env file in web (#77)

* disable auto scroll on pageload

* reposelector: use default branch not main master

* remove archived tab temp, default branch listed first in branchSelector dropdown

* auto select first repo

* improve status indicator

* refactor status indicator

* simplify status implementation to use Langgraph

* util moved from provider to thread-utils

* linting, improve task completion logic

* feat: Support followup requests (#60)

* feat: Support followup requests

* cr

* cr

* fix

* account for pr already exists

* remove task provider

* fix: Better prompting and context provided for codebase structure awareness (#85)

* fix: Better prompting and context provided for codebase structure awareness

* cr

* linting + skeleton loader

* fix: branch selector, add github api pagination

* small branch update

* update task completion status realtime

* threadItem stream remove flashing/polling

* feat: Create a shared package (#89)

* feat: Create a shared package

* formatting

* fix: drop passing target repo to getBranchName

* drop docs

* use shared package for config fields in config sidebar

* fix: styling and structure for repo/branch selectors

* fix: use shared types/objects for config fields

* default key for configs

* fix: improved styling for threads

* fix renering num tasks

* disable repo branch selector when chat started

* cr

* cr

* bump deps

* fix: rendering plan in interrupt

* cr

* cr

---------

Co-authored-by: Dylan Boudro <121908331+starmorph@users.noreply.github.com>
This commit is contained in:
Brace Sproul 2025-06-08 17:29:40 -07:00 • committed by GitHub
parent d985ae4555
commit 8afe1e6803
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 3857 additions and 479 deletions

View file

@ -138,3 +138,13 @@ Once you've accepted the plan, it will begin the execution flow. When the agent
Open SWE will automatically create a branch whenever you create a new thread with a naming format of `open-swe/<threadId>`. Every time a file is created, modified, or deleted, the changes will be committed to this branch. You can access the changes in the repository by checking out this branch.
## Install Daytona CLI
```bash
# Mac os
brew install daytonaio/cli/daytona
# Windows
powershell -Command "irm https://get.daytona.io/windows | iex"
```

View file

@ -10,6 +10,7 @@
"type": "module",
"scripts": {
"dev": "langgraphjs dev --no-browser --config ../../langgraph.json",
"clean": "rm -rf ../../.langgraph_api || true && rm -rf .turbo || true && rm -rf ./dist || true",
"build": "tsc",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
@ -24,8 +25,8 @@
"@langchain/anthropic": "^0.3.20",
"@langchain/core": "^0.3.56",
"@langchain/google-genai": "^0.2.9",
"@langchain/langgraph": "^0.2.73",
"@langchain/langgraph-sdk": "^0.0.78",
"@langchain/langgraph": "^0.3.1",
"@langchain/langgraph-sdk": "^0.0.83",
"@langchain/openai": "^0.5.10",
"@octokit/rest": "^22.0.0",
"@open-swe/shared": "workspace:*",

View file

@ -10,10 +10,8 @@ import {
PlanItem,
} from "@open-swe/shared/open-swe/types";
import { formatPlanPromptWithSummaries } from "../utils/plan-prompt.js";
import {
getMessageContentString,
getMessageString,
} from "../utils/message/content.js";
import { getMessageString } from "../utils/message/content.js";
import { getMessageContentString } from "@open-swe/shared/messages";
import { loadModel, Task } from "../utils/load-model.js";
import { z } from "zod";
import { createLogger, LogLevel } from "../utils/logger.js";

View file

@ -5,10 +5,8 @@ import {
PlanItem,
} from "@open-swe/shared/open-swe/types";
import { loadModel, Task } from "../utils/load-model.js";
import {
getMessageContentString,
getMessageString,
} from "../utils/message/content.js";
import { getMessageContentString } from "@open-swe/shared/messages";
import { getMessageString } from "../utils/message/content.js";
import { createLogger, LogLevel } from "../utils/logger.js";
import { getUserRequest } from "../utils/user-request.js";
import {

View file

@ -14,7 +14,7 @@ import { formatPlanPrompt } from "../utils/plan-prompt.js";
import { stopSandbox } from "../utils/sandbox.js";
import { createLogger, LogLevel } from "../utils/logger.js";
import { getCurrentPlanItem } from "../utils/current-task.js";
import { getMessageContentString } from "../utils/message/content.js";
import { getMessageContentString } from "@open-swe/shared/messages";
import { getActivePlanItems } from "../utils/task-plan.js";
import { SANDBOX_ROOT_DIR } from "@open-swe/shared/constants";

View file

@ -8,6 +8,10 @@ import {
import { startSandbox } from "../utils/sandbox.js";
import { createNewTask } from "../utils/task-plan.js";
import { getUserRequest } from "../utils/user-request.js";
import {
PLAN_INTERRUPT_ACTION_TITLE,
PLAN_INTERRUPT_DELIMITER,
} from "@open-swe/shared/constants";
export async function interruptPlan(state: GraphState): Promise<Command> {
const { proposedPlan } = state;
@ -17,9 +21,9 @@ export async function interruptPlan(state: GraphState): Promise<Command> {
const interruptRes = interrupt<HumanInterrupt, HumanResponse[]>({
action_request: {
action: "Approve/Edit Plan",
action: PLAN_INTERRUPT_ACTION_TITLE,
args: {
plan: proposedPlan.join("\n:::\n"),
plan: proposedPlan.join(`\n${PLAN_INTERRUPT_DELIMITER}\n`),
},
},
config: {
@ -29,7 +33,7 @@ export async function interruptPlan(state: GraphState): Promise<Command> {
allow_ignore: true,
},
description: `A new plan has been generated for your request. Please review it and either approve it, edit it, respond to it, or ignore it. Responses will be passed to an LLM where it will rewrite then plan.
If editing the plan, ensure each step in the plan is separated by ":::".`,
If editing the plan, ensure each step in the plan is separated by "${PLAN_INTERRUPT_DELIMITER}".`,
})[0];
if (!state.sandboxSessionId) {
@ -66,7 +70,7 @@ export async function interruptPlan(state: GraphState): Promise<Command> {
// Plan was edited, route to the generate-action node to start taking actions.
const editedPlan = (interruptRes.args as ActionRequest).args.plan
.split(":::")
.split(PLAN_INTERRUPT_DELIMITER)
.map((step: string) => step.trim());
const planItems = editedPlan.map((p: string, index: number) => ({

View file

@ -9,10 +9,8 @@ import { loadModel, Task } from "../utils/load-model.js";
import { AIMessage, BaseMessage } from "@langchain/core/messages";
import { formatPlanPrompt } from "../utils/plan-prompt.js";
import { createLogger, LogLevel } from "../utils/logger.js";
import {
getMessageContentString,
getMessageString,
} from "../utils/message/content.js";
import { getMessageContentString } from "@open-swe/shared/messages";
import { getMessageString } from "../utils/message/content.js";
import { removeLastTaskMessages } from "../utils/message/modify-array.js";
import { Command } from "@langchain/langgraph";
import { ConfigurableModel } from "langchain/chat_models/universal";

View file

@ -3,7 +3,7 @@ import { shellTool } from "../../../tools/index.js";
import { PlannerGraphState, PlannerGraphUpdate } from "../types.js";
import { GraphConfig } from "@open-swe/shared/open-swe/types";
import { createLogger, LogLevel } from "../../../utils/logger.js";
import { getMessageContentString } from "../../../utils/message/content.js";
import { getMessageContentString } from "@open-swe/shared/messages";
import { getUserRequest } from "../../../utils/user-request.js";
import { isHumanMessage } from "@langchain/core/messages";
import { formatFollowupMessagePrompt } from "../utils/followup-prompt.js";

View file

@ -306,7 +306,7 @@ export async function configureGitUserInRepo(
}
// Set user email - use fetched email or fallback to a generic noreply address
const emailToUse = userEmail || "open-swe-bot@noreply.github.com";
const emailToUse = userEmail || `${repo}-bot@noreply.github.com`;
const configUserEmailOutput = await sandbox.process.executeCommand(
`git config user.email "${emailToUse}"`,
absoluteRepoDir,

View file

@ -6,20 +6,11 @@ import {
isHumanMessage,
isSystemMessage,
isToolMessage,
MessageContent,
SystemMessage,
ToolMessage,
} from "@langchain/core/messages";
import { ToolCall } from "@langchain/core/messages/tool";
export function getMessageContentString(content: MessageContent): string {
if (typeof content === "string") return content;
return content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join(" ");
}
import { getMessageContentString } from "@open-swe/shared/messages";
export function getToolCallsString(toolCalls: ToolCall[] | undefined): string {
if (!toolCalls?.length) return "";

View file

@ -3,7 +3,7 @@ import {
isHumanMessage,
HumanMessage,
} from "@langchain/core/messages";
import { getMessageContentString } from "./message/content.js";
import { getMessageContentString } from "@open-swe/shared/messages";
export function getUserRequest(
messages: BaseMessage[],

View file

@ -20,16 +20,22 @@
},
"dependencies": {
"@langchain/core": "^0.3.57",
"@langchain/langgraph": "^0.2.73",
"@langchain/langgraph-sdk": "^0.0.78",
"@langchain/langgraph": "^0.3.1",
"@langchain/langgraph-sdk": "^0.0.83",
"@open-swe/shared": "workspace:*",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.1.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -58,7 +64,8 @@
"tailwindcss-animate": "^1.0.7",
"use-stick-to-bottom": "^1.0.46",
"uuid": "^11.1.0",
"zod": "^3.24.2"
"zod": "^3.24.2",
"zustand": "^5.0.5"
},
"devDependencies": {
"@eslint/js": "^9.19.0",

BIN
apps/web/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -13,7 +13,6 @@ export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const installationId = searchParams.get("installation_id");
const setupAction = searchParams.get("setup_action");
// Get the return URL from cookies
const returnTo =

View file

@ -9,7 +9,6 @@ export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const installationId = searchParams.get("installation_id");
const setupAction = searchParams.get("setup_action");
if (!installationId) {
return NextResponse.redirect(

View file

@ -25,7 +25,7 @@ export default function GitHubPage() {
setError(errorData.error);
}
}
} catch (err) {
} catch {
setError("Failed to check installation status");
}
};
@ -50,7 +50,7 @@ export default function GitHubPage() {
const errorData = await response.json();
setError(errorData.error);
}
} catch (err) {
} catch {
setError("Failed to fetch repositories");
} finally {
setLoading(false);

View file

@ -13,6 +13,11 @@ const inter = Inter({
export const metadata: Metadata = {
title: "Open SWE",
description: "Open SWE UX by LangChain",
icons: {
icon: "/favicon.png",
shortcut: "/favicon.png",
apple: "/favicon.png",
},
};
export default function RootLayout({

View file

@ -0,0 +1,291 @@
"use client";
import { useState } from "react";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { useConfigStore } from "@/hooks/use-config-store";
import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import _ from "lodash";
import { cn } from "@/lib/utils";
interface Option {
label: string;
value: string;
}
interface ConfigFieldProps {
id: string;
label: string;
type:
| "text"
| "textarea"
| "number"
| "switch"
| "slider"
| "select"
| "json";
description?: string;
placeholder?: string;
options?: Option[];
min?: number;
max?: number;
step?: number;
className?: string;
// Optional props for external state management
value?: any;
setValue?: (value: any) => void;
}
export function ConfigField({
id,
label,
type,
description,
placeholder,
options = [],
min,
max,
step = 1,
className,
value: externalValue, // TODO: Rename to avoid conflict
setValue: externalSetValue, // TODO: Rename to avoid conflict
}: ConfigFieldProps) {
const store = useConfigStore();
const [jsonError, setJsonError] = useState<string | null>(null);
// Determine whether to use external state or Zustand store
const isExternallyManaged = externalSetValue !== undefined;
const currentValue = isExternallyManaged
? externalValue
: store.configs[id]?.[id];
const handleChange = (newValue: any) => {
setJsonError(null);
if (isExternallyManaged && externalSetValue) {
externalSetValue(newValue);
} else {
store.updateConfig(id, newValue);
}
};
const handleJsonChange = (jsonString: string) => {
try {
if (!jsonString.trim()) {
handleChange(undefined);
setJsonError(null);
return;
}
const parsedJson = JSON.parse(jsonString);
handleChange(parsedJson);
setJsonError(null);
} catch {
if (isExternallyManaged && externalSetValue) {
externalSetValue(jsonString);
} else {
store.updateConfig(id, jsonString);
}
setJsonError("Invalid JSON format");
}
};
const handleFormatJson = (jsonString: string) => {
try {
const parsed = JSON.parse(jsonString);
handleChange(parsed);
setJsonError(null);
} catch {
setJsonError("Invalid JSON format");
}
};
return (
<div className={cn("space-y-2", className)}>
<div className="flex items-center justify-between">
<Label
htmlFor={id}
className="text-sm font-medium"
>
{_.startCase(label)}
</Label>
{type === "switch" && (
<Switch
id={id}
checked={!!currentValue}
onCheckedChange={handleChange}
/>
)}
</div>
{description && (
<p className="text-xs whitespace-pre-line text-gray-500">
{description}
</p>
)}
{type === "text" && (
<Input
id={id}
value={currentValue || ""}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
/>
)}
{type === "textarea" && (
<Textarea
id={id}
value={currentValue || ""}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
className="min-h-[100px]"
/>
)}
{type === "number" && (
<Input
id={id}
type="number"
value={currentValue !== undefined ? currentValue : ""}
onChange={(e) => {
const val = e.target.value;
if (val === "") {
handleChange(undefined);
} else {
const num = Number(val);
if (!isNaN(num)) {
handleChange(num);
}
}
}}
min={min}
max={max}
step={step}
/>
)}
{type === "slider" && (
<div className="pt-2">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs text-gray-500">{min ?? ""}</span>
<span className="text-sm font-medium">
{currentValue !== undefined
? currentValue
: min !== undefined && max !== undefined
? (min + max) / 2
: ""}
</span>
<span className="text-xs text-gray-500">{max ?? ""}</span>
</div>
<Slider
id={id}
value={[
currentValue !== undefined
? currentValue
: min !== undefined && max !== undefined
? (min + max) / 2
: 0,
]}
min={min}
max={max}
step={step}
onValueChange={(vals: number[]) => handleChange(vals[0])}
disabled={min === undefined || max === undefined}
/>
</div>
)}
{type === "select" && (
<Select
value={currentValue ?? ""}
onValueChange={handleChange}
>
<SelectTrigger>
<SelectValue placeholder={placeholder || "Select an option"} />
</SelectTrigger>
<SelectContent>
{placeholder && (
<SelectItem
value=""
disabled
>
{placeholder}
</SelectItem>
)}
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{type === "json" && (
<>
<Textarea
id={id}
value={
typeof currentValue === "string"
? currentValue
: (JSON.stringify(currentValue, null, 2) ?? "")
}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder={placeholder || '{\n "key": "value"\n}'}
className={cn(
"min-h-[120px] font-mono text-sm",
jsonError &&
"border-red-500 focus:border-red-500 focus-visible:ring-red-500",
)}
/>
<div className="flex w-full items-start justify-between gap-2 pt-1">
{" "}
{/* Use items-start */}
<Button
variant="outline"
size="sm"
onClick={() => handleFormatJson(currentValue ?? "")}
disabled={
!currentValue || typeof currentValue !== "string" || !!jsonError
}
className="mt-1"
>
Format
</Button>
{jsonError && (
<Alert
variant="destructive"
className="flex-grow px-3 py-1"
>
<div className="flex items-center gap-2">
{" "}
<AlertCircle className="h-4 w-4 flex-shrink-0" />{" "}
<AlertDescription className="text-xs">
{jsonError}
</AlertDescription>
</div>
</Alert>
)}
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,28 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface ConfigSectionProps {
title: string;
children: ReactNode;
className?: string;
action?: ReactNode;
}
export function ConfigSection({
title,
children,
className,
action,
}: ConfigSectionProps) {
return (
<div className={cn("mb-6", className)}>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
{action}
</div>
<div className="space-y-4">{children}</div>
</div>
);
}

View file

@ -0,0 +1,153 @@
"use client";
import { forwardRef, ForwardedRef, useState, useEffect } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ConfigField } from "@/components/configuration-sidebar/config-field";
import { ConfigSection } from "@/components/configuration-sidebar/config-section";
import { useConfigStore } from "@/hooks/use-config-store";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import type {
ConfigurableFieldUIMetadata,
ConfigurableFieldUIType,
} from "@open-swe/shared/configurable-metadata";
import { Button } from "@/components/ui/button";
import { PanelRightOpen } from "lucide-react";
import { GraphConfigurationMetadata } from "@open-swe/shared/open-swe/types";
/**
* Extract configuration metadata from the GraphConfiguration Zod schema
*/
function extractConfigurationsFromSchema(): ConfigurableFieldUIMetadata[] {
const configurations: ConfigurableFieldUIMetadata[] = [];
for (const [label, { x_open_swe_ui_config: metadata }] of Object.entries(
GraphConfigurationMetadata,
)) {
if (metadata.type === "hidden") {
continue;
}
configurations.push({
label,
type: metadata.type,
default: metadata.default,
description: metadata.description,
placeholder: metadata.placeholder,
options: metadata.options,
min: metadata.min,
max: metadata.max,
step: metadata.step,
});
}
return configurations;
}
export interface AIConfigPanelProps {
className?: string;
open: boolean;
onClose?: () => void;
}
export const ConfigurationSidebar = forwardRef<
HTMLDivElement,
AIConfigPanelProps
>(({ className, open, onClose }, ref: ForwardedRef<HTMLDivElement>) => {
const { configs, updateConfig } = useConfigStore();
const [configurations, setConfigurations] = useState<
ConfigurableFieldUIMetadata[]
>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
const actualConfigs = extractConfigurationsFromSchema();
actualConfigs.forEach((config) => {
if (configs[config.label] === undefined && config.default !== undefined) {
updateConfig(config.label, config.default);
}
});
setConfigurations(actualConfigs);
setLoading(false);
}, [configs, updateConfig]);
return (
<div
ref={ref}
className={cn(
"fixed top-0 right-0 z-10 h-screen border-l border-gray-200 bg-white shadow-lg transition-all duration-300",
open ? "w-80 md:w-xl" : "w-0 overflow-hidden border-l-0",
className,
)}
>
{open && (
<div className="flex h-full flex-col">
<div className="flex flex-shrink-0 items-center justify-between border-b border-gray-200 p-4">
<h2 className="text-lg font-semibold">Agent Configuration</h2>
{onClose && (
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0 hover:bg-gray-100"
>
<PanelRightOpen className="h-4 w-4" />
</Button>
)}
</div>
<Tabs
defaultValue="general"
className="flex flex-1 flex-col overflow-y-auto"
>
<TabsList className="flex-shrink-0 justify-start bg-transparent px-4 pt-2">
<TabsTrigger value="general">General</TabsTrigger>
</TabsList>
<ScrollArea className="flex-1 overflow-y-auto">
<TabsContent
value="general"
className="m-0 p-4"
>
<ConfigSection title="Configuration">
{loading ? (
<div className="space-y-4">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
) : (
configurations.map(
(c: ConfigurableFieldUIMetadata, index: number) => (
<ConfigField
key={`${c.label}-${index}`}
id={c.label}
label={c.label}
type={
c.type === "boolean" ? "switch" : (c.type ?? "text")
}
description={c.description}
placeholder={c.placeholder}
options={c.options}
min={c.min}
max={c.max}
step={c.step}
/>
),
)
)}
</ConfigSection>
</TabsContent>
</ScrollArea>
</Tabs>
</div>
)}
</div>
);
});
ConfigurationSidebar.displayName = "ConfigurationSidebar";

View file

@ -3,7 +3,7 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { CopyIcon, CheckIcon, RefreshCwIcon } from "lucide-react";
import { InstallAppButton } from "./InstallAppButton";
import { InstallAppButton } from "./install-app-button";
interface AgentTokenProviderProps {
className?: string;
@ -41,7 +41,7 @@ export function AgentTokenProvider({
setToken(data.token);
setInstallationId(data.installation_id);
setIsLoading(false);
} catch (err) {
} catch {
setError("Network error when fetching token");
setIsLoading(false);
}

View file

@ -51,6 +51,8 @@ export function GitHubOAuthButton() {
<Button
variant="outline"
disabled
className="text-xs"
size="sm"
>
<GitHubSVG
width="16"
@ -67,11 +69,10 @@ export function GitHubOAuthButton() {
variant="outline"
onClick={handleLogout}
disabled={isLoading}
className="text-xs"
size="sm"
>
<GitHubSVG
width="16"
height="16"
/>
<GitHubSVG />
{isLoading ? "Disconnecting..." : "Disconnect GitHub"}
</Button>
);
@ -82,11 +83,10 @@ export function GitHubOAuthButton() {
variant="outline"
onClick={handleLogin}
disabled={isLoading}
className="text-xs"
size="sm"
>
<GitHubSVG
width="16"
height="16"
/>
<GitHubSVG />
{isLoading ? "Connecting..." : "Connect GitHub"}
</Button>
);

View file

@ -0,0 +1,240 @@
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { useState, useEffect } from "react";
import { useGitHubApp } from "@/hooks/useGitHubApp";
import { GitBranch, Shield } from "lucide-react";
interface BranchSelectorProps {
disabled?: boolean;
placeholder?: string;
buttonClassName?: string;
chatStarted?: boolean;
}
export function BranchSelector({
disabled = false,
placeholder = "Select a branch...",
buttonClassName,
chatStarted = false,
}: BranchSelectorProps) {
const [open, setOpen] = useState(false);
const {
branches,
branchesLoading,
branchesError,
selectedBranch,
setSelectedBranch,
selectedRepository,
defaultBranch,
} = useGitHubApp();
// Auto-select default branch when repository changes and branches are loaded
useEffect(() => {
if (
selectedRepository &&
!branchesLoading &&
!branchesError &&
branches.length > 0
) {
// Check if the current selected branch exists in the new repository
const currentBranchExists =
selectedBranch &&
branches.some((branch) => branch.name === selectedBranch);
// Auto-select default branch if no branch is selected OR if the selected branch doesn't exist in this repo
if (!selectedBranch || !currentBranchExists) {
// Try to find the repository's actual default branch first
const actualDefaultBranch = defaultBranch
? branches.find((branch) => branch.name === defaultBranch)
: null;
if (actualDefaultBranch) {
setSelectedBranch(actualDefaultBranch.name);
} else if (branches.length > 0) {
// If default branch doesn't exist in branches list, select the first available branch
setSelectedBranch(branches[0].name);
}
}
}
}, [
selectedRepository?.owner,
selectedRepository?.repo,
branchesLoading,
branchesError,
branches.length,
selectedBranch,
setSelectedBranch,
defaultBranch,
]);
const handleSelect = (branchName: string) => {
setSelectedBranch(branchName);
setOpen(false);
};
if (!selectedRepository) {
return (
<Button
variant="outline"
disabled
className={cn(buttonClassName)}
size="sm"
>
<GitBranch />
<span>Select a branch</span>
</Button>
);
}
if (branchesLoading) {
return (
<Button
variant="outline"
disabled
className={cn(buttonClassName)}
size="sm"
>
<GitBranch />
<span>Loading branches...</span>
</Button>
);
}
if (branchesError) {
return (
<Button
variant="outline"
disabled
className={cn(buttonClassName)}
size="sm"
>
<GitBranch />
<span>Error loading branches</span>
</Button>
);
}
if (branches.length === 0) {
return (
<Button
variant="outline"
disabled
className={cn(buttonClassName)}
size="sm"
>
<GitBranch />
<span>
No branches available {branchesError && `(${branchesError})`}
</span>
</Button>
);
}
if (chatStarted) {
return (
<Button
variant="outline"
className={cn(buttonClassName)}
size="sm"
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<GitBranch />
<span className="truncate text-left">
{selectedBranch || placeholder}
</span>
</div>
</Button>
);
}
return (
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(buttonClassName)}
size="sm"
disabled={disabled}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<GitBranch />
<span className="truncate text-left">
{selectedBranch || placeholder}
</span>
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[340px] p-0">
<Command>
<CommandInput placeholder="Search branches..." />
<CommandList>
<CommandEmpty>No branches found.</CommandEmpty>
<CommandGroup>
{branches
.slice()
.sort((a, b) => {
if (defaultBranch) {
if (a.name === defaultBranch) return -1;
if (b.name === defaultBranch) return 1;
}
return 0;
})
.map((branch) => {
const isSelected = selectedBranch === branch.name;
const isDefault = branch.name === defaultBranch;
return (
<CommandItem
key={branch.name}
value={branch.name}
onSelect={() => handleSelect(branch.name)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex items-center gap-2">
<GitBranch className="h-3 w-3" />
<span className="font-medium">{branch.name}</span>
{isDefault && (
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
default
</span>
)}
{branch.protected && (
<div title="Protected branch">
<Shield className="h-3 w-3 text-amber-500" />
</div>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View file

@ -0,0 +1,31 @@
import { BranchSelector } from "./branch-selector";
import { RepositorySelector } from "./repository-selector";
import { useQueryState } from "nuqs";
export function RepositoryBranchSelectors() {
const [threadId] = useQueryState("threadId");
const chatStarted = !!threadId;
const defaultButtonStyles =
"bg-inherit border-gray-300 rounded-full text-gray-500 hover:text-gray-700 text-xs";
const defaultStylesChatStarted =
"hover:bg-inherit cursor-default hover:cursor-default hover:text-gray-500 hover:border-gray-300 hover:ring-inherit";
return (
<div className="flex items-center gap-2">
<RepositorySelector
chatStarted={chatStarted}
buttonClassName={
defaultButtonStyles +
(chatStarted ? " " + defaultStylesChatStarted : "")
}
/>
<BranchSelector
chatStarted={chatStarted}
buttonClassName={
defaultButtonStyles +
(chatStarted ? " " + defaultStylesChatStarted : "")
}
/>
</div>
);
}

View file

@ -0,0 +1,218 @@
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { useState } from "react";
import type { TargetRepository } from "@open-swe/shared/open-swe/types";
import { useGitHubApp } from "@/hooks/useGitHubApp";
import type { Repository } from "@/utils/github";
import { GitHubSVG } from "@/components/icons/github";
interface RepositorySelectorProps {
disabled?: boolean;
placeholder?: string;
buttonClassName?: string;
chatStarted?: boolean;
}
// TODO: remove this, we should use the TargetRepository type from the open-swe package
// Convert GitHub Repository to TargetRepository format
const repositoryToTarget = (repo: Repository): TargetRepository => {
const [owner, repoName] = repo.full_name.split("/");
return { owner, repo: repoName };
};
// TODO: Add utility function for repoSelector while viewing a thread
// retrieve thread repo from nuqs params. set disabled = true.
export function RepositorySelector({
disabled = false,
placeholder = "Select a repository...",
buttonClassName,
chatStarted = false,
}: RepositorySelectorProps) {
const [open, setOpen] = useState(false);
const {
repositories,
selectedRepository,
setSelectedRepository,
isLoading,
error,
isInstalled,
} = useGitHubApp();
const handleSelect = (repositoryKey: string) => {
const repository = repositories.find(
(repo) => repo.full_name === repositoryKey,
);
if (repository) {
setSelectedRepository(repositoryToTarget(repository));
setOpen(false);
}
};
const selectedValue = selectedRepository
? `${selectedRepository.owner}/${selectedRepository.repo}`
: undefined;
if (isLoading) {
return (
<Button
variant="outline"
disabled
className={cn(buttonClassName)}
size="sm"
>
<div className="flex items-center gap-2">
<GitHubSVG
width="16"
height="16"
/>
<span>Loading repositories...</span>
</div>
</Button>
);
}
if (error) {
return (
<Button
variant="outline"
disabled
className={cn(buttonClassName)}
size="sm"
>
<div className="flex items-center gap-2">
<GitHubSVG
width="16"
height="16"
/>
<span>Error loading repositories</span>
</div>
</Button>
);
}
if (isInstalled === false) {
return (
<Button
variant="outline"
disabled
className={cn(buttonClassName)}
size="sm"
>
<div className="flex items-center gap-2">
<GitHubSVG
width="16"
height="16"
/>
<span>GitHub App not installed</span>
</div>
</Button>
);
}
if (repositories.length === 0) {
return (
<Button
variant="outline"
disabled
className={cn(buttonClassName)}
size="sm"
>
<div className="flex items-center gap-2">
<GitHubSVG
width="16"
height="16"
/>
<span>No repositories available</span>
</div>
</Button>
);
}
if (chatStarted) {
return (
<Button
variant="outline"
className={cn(buttonClassName)}
size="sm"
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<GitHubSVG />
<span className="truncate text-left">
{selectedValue || placeholder}
</span>
</div>
</Button>
);
}
return (
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(buttonClassName)}
disabled={disabled}
size="sm"
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<GitHubSVG />
<span className="truncate text-left">
{selectedValue || placeholder}
</span>
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[340px] p-0">
<Command>
<CommandInput placeholder="Search repositories..." />
<CommandList>
<CommandEmpty>No repositories found.</CommandEmpty>
<CommandGroup>
{repositories.map((repo) => {
const key = repo.full_name;
const isSelected = selectedValue === key;
return (
<CommandItem
key={repo.id}
value={key}
onSelect={() => handleSelect(key)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{repo.full_name}</span>
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import { useGitHubApp } from "@/hooks/useGitHubApp";
import { InstallAppButton } from "./InstallAppButton";
import { InstallAppButton } from "./install-app-button";
import { Button } from "@/components/ui/button";
import { RefreshCw } from "lucide-react";

View file

@ -1,4 +1,14 @@
export const GitHubSVG = ({ width = "100%", height = "100%" }) => (
interface GitHubSVGProps {
width?: string;
height?: string;
className?: string;
}
export const GitHubSVG = ({
width = "100%",
height = "100%",
className,
}: GitHubSVGProps) => (
<svg
role="img"
viewBox="0 0 24 24"
@ -6,6 +16,7 @@ export const GitHubSVG = ({ width = "100%", height = "100%" }) => (
height={height}
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
className={className}
>
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />

View file

@ -0,0 +1 @@
export { PlanViewer } from "./plan-viewer";

View file

@ -0,0 +1,128 @@
import { Check, Clock, Play } from "lucide-react";
import { cn } from "@/lib/utils";
import { PlanItem } from "@open-swe/shared/open-swe/types";
interface PlanViewerProps {
planItems: PlanItem[];
className?: string;
isProposedPlan?: boolean;
}
export function PlanViewer({
planItems,
className,
isProposedPlan,
}: PlanViewerProps) {
const currentTaskIndex = planItems
.filter((item) => !item.completed)
.reduce(
(minIndex, item) => (item.index < minIndex ? item.index : minIndex),
Infinity,
);
const getTaskStatus = (item: PlanItem) => {
if (isProposedPlan) return "proposed";
if (item.completed) return "completed";
if (item.index === currentTaskIndex) return "current";
return "remaining";
};
return (
<div className={cn("w-full space-y-3", className)}>
<div className="mb-4 flex items-center gap-2">
<h3 className="text-sm font-medium text-gray-900">
{isProposedPlan ? "Proposed" : "Execution"} Plan
</h3>
{!isProposedPlan && (
<span className="text-xs text-gray-500">
{planItems.filter((item) => item.completed).length} of{" "}
{planItems.length} completed
</span>
)}
</div>
<div className="space-y-2">
{planItems
.sort((a, b) => a.index - b.index)
.map((item) => {
const status = getTaskStatus(item);
return (
<div
key={`plan-item-${item.index}`}
className={cn(
"flex items-start gap-3 rounded-lg border p-3 transition-colors",
{
"border-green-200 bg-green-50": status === "completed",
"border-blue-200 bg-blue-50": status === "current",
"border-gray-200 bg-gray-50": [
"remaining",
"proposed",
].includes(status),
},
)}
>
{/* Status Icon */}
<div className="mt-0.5 flex-shrink-0">
{status === "completed" && (
<Check className="h-4 w-4 text-green-600" />
)}
{status === "current" && (
<Play className="h-4 w-4 text-blue-600" />
)}
{["remaining", "proposed"].includes(status) && (
<Clock className="h-4 w-4 text-gray-400" />
)}
</div>
{/* Task Content */}
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="text-xs font-medium text-gray-500">
Step {item.index + 1}
</span>
{status === "current" && (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
In Progress
</span>
)}
{status === "completed" && (
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
Done
</span>
)}
{status === "proposed" && (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-700">
Proposed
</span>
)}
</div>
<p
className={cn("text-sm leading-relaxed", {
"text-gray-900": status === "current",
"text-gray-600":
status === "completed" ||
["remaining", "proposed"].includes(status),
})}
>
{item.plan}
</p>
{/* Summary for completed tasks */}
{item.summary && status === "completed" && (
<div className="bg-green-25 mt-2 rounded border border-green-100 p-2">
<p className="mb-1 text-xs font-medium text-green-700">
Summary:
</p>
<p className="text-xs text-green-600">{item.summary}</p>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,114 @@
import { forwardRef, ForwardedRef } from "react";
import { motion } from "framer-motion";
import {
Settings,
History,
EllipsisVertical,
ChevronRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { TooltipIconButton } from "@/components/ui/tooltip-icon-button";
interface SidebarButtonsProps {
historyOpen: boolean;
setHistoryOpen: (open: boolean) => void;
configOpen: boolean;
setConfigOpen: (open: boolean) => void;
className?: string;
}
// TODO: Implement Using this component in the thread component instead of current implementation
export const SidebarButtons = forwardRef<HTMLDivElement, SidebarButtonsProps>(
(
{ historyOpen, setHistoryOpen, configOpen, setConfigOpen, className },
ref: ForwardedRef<HTMLDivElement>,
) => {
const handleConfigClick = () => {
setConfigOpen(true);
setHistoryOpen(false);
};
const handleHistoryClick = () => {
setHistoryOpen(true);
setConfigOpen(false);
};
const closeAll = () => {
setConfigOpen(false);
setHistoryOpen(false);
};
const isSidebarOpen = historyOpen || configOpen;
return (
<motion.div
ref={ref}
className={cn(
"fixed top-4 z-50 transition-all duration-300 ease-in-out",
isSidebarOpen
? "right-[theme(spacing.80)] md:right-[37rem]"
: "right-4",
className,
)}
>
<div className="relative flex items-center">
<Button
variant="outline"
size="icon"
className={cn(
"bg-background hover:bg-muted rounded-full transition-all",
isSidebarOpen ? "opacity-100 shadow-lg" : "opacity-0 shadow-none",
)}
onClick={() => {
if (!isSidebarOpen) return;
// Sidebar is open, clicking this will close any open sidebar
closeAll();
}}
>
{isSidebarOpen ? (
<ChevronRight className="size-5" />
) : (
<EllipsisVertical className="size-5" />
)}
</Button>
<div
className={cn(
"absolute top-0 right-0 flex items-center gap-2 transition-all",
isSidebarOpen && "right-full mr-2",
)}
>
<TooltipIconButton
tooltip="Agent Configuration"
variant="outline"
size="icon"
className={cn(
"bg-background hover:bg-muted size-9 rounded-full shadow-xs hover:cursor-pointer",
isSidebarOpen && "shadow-lg",
)}
onClick={handleConfigClick}
aria-label="Agent Configuration"
>
<Settings className="size-5" />
</TooltipIconButton>
<TooltipIconButton
tooltip="History"
variant="outline"
size="icon"
className={cn(
"bg-background hover:bg-muted size-9 rounded-full shadow-xs hover:cursor-pointer",
isSidebarOpen && "shadow-lg",
)}
onClick={handleHistoryClick}
aria-label="History"
>
<History className="size-5" />
</TooltipIconButton>
</div>
</div>
</motion.div>
);
},
);
SidebarButtons.displayName = "SidebarButtons";

View file

@ -0,0 +1,30 @@
"use client";
import { XCircle, LoaderCircle, Pause, Circle } from "lucide-react";
import { ThreadStatus } from "@langchain/langgraph-sdk";
import { cn } from "@/lib/utils";
export const StatusIndicator = ({
status,
size = "default",
}: {
status: ThreadStatus;
size?: "default" | "sm";
}) => {
const iconClass = size === "sm" ? "h-3 w-3" : "h-4 w-4";
switch (status) {
case "busy":
return (
<LoaderCircle className={cn("animate-spin text-blue-500", iconClass)} />
);
case "interrupted":
return <Pause className={cn("text-yellow-500", iconClass)} />;
case "idle":
return <Circle className={cn("text-gray-400", iconClass)} />;
case "error":
return <XCircle className={cn("text-red-500", iconClass)} />;
default:
return null;
}
};

View file

@ -0,0 +1,127 @@
"use client";
import {
Archive,
ChevronLeft,
ChevronRight,
PanelRightOpen,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useThreads, ThreadWithTasks } from "@/providers/Thread";
import { useQueryState, parseAsString } from "nuqs";
import { useState } from "react";
import { ThreadItem } from "./thread-item";
const THREADS_PER_PAGE = 10; // More threads per page in sidebar
// TODO: Clarify Language about Threads and Tasks in the TaskListSidebar component
interface TaskListSidebarProps {
onCollapse?: () => void;
}
export default function TaskListSidebar({ onCollapse }: TaskListSidebarProps) {
const [taskId, setTaskId] = useQueryState("taskId", parseAsString);
const [threadId, setThreadId] = useQueryState("threadId", parseAsString);
const [currentPage, setCurrentPage] = useState(0);
const { threads, threadsLoading } = useThreads();
// Handle thread navigation
const handleThreadClick = (thread: ThreadWithTasks) => {
setThreadId(thread.thread_id);
setTaskId(null);
};
// Sort threads by creation date (newest first) - already done in provider
const sortedThreads = threads;
const totalThreads = sortedThreads.length;
const totalPages = Math.ceil(totalThreads / THREADS_PER_PAGE);
const startIndex = currentPage * THREADS_PER_PAGE;
const endIndex = startIndex + THREADS_PER_PAGE;
const paginatedThreads = sortedThreads.slice(startIndex, endIndex);
return (
<div className="flex h-full w-full flex-col border-r bg-white">
<div className="border-b border-gray-200 p-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900">Threads</h2>
<p className="text-sm text-gray-500">
{totalThreads} total threads
</p>
</div>
{onCollapse && (
<Button
variant="ghost"
size="sm"
onClick={onCollapse}
className="h-8 w-8 p-0 hover:bg-gray-100"
>
<PanelRightOpen className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-hidden">
{threadsLoading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center text-gray-500">
<Archive className="mx-auto mb-2 h-5 w-5 animate-pulse opacity-50" />
<p className="text-sm">Loading threads...</p>
</div>
</div>
) : paginatedThreads.length > 0 ? (
<div className="h-full overflow-y-auto">
<div className="space-y-1 p-2">
{paginatedThreads.map((thread) => (
<ThreadItem
key={thread.thread_id}
thread={thread}
onClick={handleThreadClick}
variant="sidebar"
/>
))}
</div>
</div>
) : (
<div className="flex h-full items-center justify-center">
<div className="text-center text-gray-500">
<Archive className="mx-auto mb-2 h-5 w-5 opacity-50" />
<p className="text-sm">No threads found</p>
</div>
</div>
)}
</div>
{totalPages > 1 && (
<div className="border-t border-gray-200 p-3">
<div className="flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(Math.max(0, currentPage - 1))}
disabled={currentPage === 0}
className="h-7"
>
<ChevronLeft className="h-3 w-3" />
</Button>
<span className="text-xs text-gray-500">
Page {currentPage + 1} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
setCurrentPage(Math.min(totalPages - 1, currentPage + 1))
}
disabled={currentPage === totalPages - 1}
className="h-7"
>
<ChevronRight className="h-3 w-3" />
</Button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,102 @@
"use client";
import { Archive, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useThreads, ThreadWithTasks } from "@/providers/Thread";
import { useQueryState, parseAsString } from "nuqs";
import { useState } from "react";
import { ThreadItem } from "./thread-item";
const THREADS_PER_PAGE = 5;
export default function TaskList() {
const [taskId, setTaskId] = useQueryState("taskId", parseAsString);
const [_threadId, setThreadId] = useQueryState("threadId", parseAsString);
const [currentPage, setCurrentPage] = useState(0);
const { threads, threadsLoading } = useThreads();
const isDashboardMode = !taskId;
const handleThreadClick = (thread: ThreadWithTasks) => {
setThreadId(thread.thread_id);
setTaskId(null);
};
if (!isDashboardMode) {
return null;
}
// Threads are already sorted by creation date in ThreadProvider
const sortedThreads = threads;
const totalThreads = sortedThreads.length;
const totalPages = Math.ceil(totalThreads / THREADS_PER_PAGE);
const startIndex = currentPage * THREADS_PER_PAGE;
const endIndex = startIndex + THREADS_PER_PAGE;
const paginatedThreads = sortedThreads.slice(startIndex, endIndex);
return (
<div className="flex h-full w-full max-w-3xl flex-col gap-2">
<p className="text-sm text-gray-500">Threads ({totalThreads})</p>
<div>
{threadsLoading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center text-gray-500">
<Archive className="mx-auto mb-2 h-6 w-6 animate-pulse opacity-50" />
<p className="text-sm">Loading threads...</p>
</div>
</div>
) : paginatedThreads.length > 0 ? (
<div className="space-y-2">
{paginatedThreads.map((thread) => (
<ThreadItem
key={thread.thread_id}
thread={thread}
onClick={handleThreadClick}
variant="dashboard"
/>
))}
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex items-center justify-between border-t pt-4">
<div className="text-sm text-gray-500">
Showing {startIndex + 1}-{Math.min(endIndex, totalThreads)} of{" "}
{totalThreads} threads
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(Math.max(0, currentPage - 1))}
disabled={currentPage === 0}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
setCurrentPage(Math.min(totalPages - 1, currentPage + 1))
}
disabled={currentPage === totalPages - 1}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
) : (
<div className="flex h-full items-center justify-center">
<div className="text-center text-gray-500">
<Archive className="mx-auto mb-2 h-6 w-6 opacity-50" />
<p className="text-sm">No threads found</p>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,134 @@
"use client";
import { GitBranch, ArrowRight, ListTodo } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { ThreadWithTasks } from "@/providers/Thread";
import { cn } from "@/lib/utils";
import { StatusIndicator } from "@/components/status-indicator";
import { Skeleton } from "@/components/ui/skeleton";
import { GitHubSVG } from "./icons/github";
interface ThreadItemProps {
thread: ThreadWithTasks;
onClick: (thread: ThreadWithTasks) => void;
variant?: "sidebar" | "dashboard";
className?: string;
}
export function ThreadItem({
thread,
onClick,
variant = "dashboard",
className,
}: ThreadItemProps) {
const isSidebar = variant === "sidebar";
const displayDate = new Date(thread.created_at).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
// Check if thread data is still loading/incomplete
const isLoading =
!thread.threadTitle ||
thread.threadTitle.includes("undefined") ||
!thread.repository ||
thread.repository === "Unknown Repository" ||
thread.repository.includes("undefined");
if (isLoading) {
return (
<div
className={cn(
"rounded-md border border-gray-200 bg-inherit p-1.5 shadow-sm",
className,
)}
>
<div className="flex items-start gap-1.5">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-3 w-24" />
</div>
<div className="mt-1 flex items-center gap-1 text-xs">
<Skeleton className="h-3 w-3" /> {/* GitHub icon placeholder */}
<Skeleton className="h-3 w-16" /> {/* Repo */}
<Skeleton className="ml-0.5 h-2.5 w-2.5" />{" "}
<Skeleton className="ml-0.5 h-3 w-12" /> {/* Branch */}
<Skeleton className="ml-auto h-3 w-10" /> {/* Date */}
</div>
<div className="mt-1">
<Skeleton className="h-4 w-20 rounded" />
</div>
</div>
</div>
</div>
);
}
return (
<div
className={cn(
"group cursor-pointer rounded-md border border-gray-200 bg-inherit p-2 shadow-sm transition-colors hover:bg-gray-50 hover:shadow-md",
className,
)}
onClick={() => onClick(thread)}
>
<div className="flex items-start gap-1.5">
<div className="flex w-full min-w-0 flex-col gap-1">
<div className="flex w-full items-center gap-1.5">
<StatusIndicator status={thread.status} />
<h4 className="w-full truncate text-xs leading-tight font-medium text-gray-900">
{thread.threadTitle}
</h4>
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-1 text-xs text-gray-500">
<div className="mr-1 flex flex-row items-center justify-start gap-1">
<GitHubSVG
width="16"
height="16"
className="flex-shrink-0"
/>
<span className="max-w-[90px] truncate">{thread.repository}</span>
<span>/</span>
<GitBranch className="size-2.5 flex-shrink-0" />
<span className="max-w-[70px] truncate">{thread.branch}</span>
</div>
<span>•</span>
<span className="mx-1 whitespace-nowrap">{displayDate}</span>
{!isSidebar && (
<>
<span>•</span>
<div className="ml-1 flex items-center gap-1">
<ListTodo className="size-4 flex-shrink-0" />
<span>
{thread.completedTasksCount}/{thread.totalTasksCount} tasks
</span>
</div>
</>
)}
</div>
{isSidebar && (
<div className="flex items-center gap-1 text-xs text-gray-500">
<ListTodo className="size-4 flex-shrink-0" />
<span>
{thread.completedTasksCount}/{thread.totalTasksCount} tasks
</span>
</div>
)}
</div>
<ArrowRight
className={cn(
"mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-gray-400 opacity-0 transition-opacity group-hover:opacity-100",
)}
/>
</div>
</div>
);
}

View file

@ -1,5 +1,5 @@
import React from "react";
import { File, Image as ImageIcon, X as XIcon } from "lucide-react";
import { File, X as XIcon } from "lucide-react";
import type { Base64ContentBlock } from "@langchain/core/messages";
import { cn } from "@/lib/utils";
import Image from "next/image";

View file

@ -8,6 +8,34 @@ import { MarkdownText } from "../../markdown-text";
import { ActionRequest, HumanInterrupt } from "@langchain/langgraph/prebuilt";
import { toast } from "sonner";
import { Separator } from "@/components/ui/separator";
import { PlanViewer } from "@/components/plan";
import { isPlanData, parsePlanData, getPlanKey } from "@/lib/plan-utils";
interface InboxItemInputProps {
interruptValue: HumanInterrupt;
humanResponse: HumanResponseWithEdits[];
supportsMultipleMethods: boolean;
acceptAllowed: boolean;
hasEdited: boolean;
hasAddedResponse: boolean;
initialValues: Record<string, string>;
streaming: boolean;
streamFinished: boolean;
setHumanResponse: React.Dispatch<
React.SetStateAction<HumanResponseWithEdits[]>
>;
setSelectedSubmitType: React.Dispatch<
React.SetStateAction<SubmitType | undefined>
>;
setHasAddedResponse: React.Dispatch<React.SetStateAction<boolean>>;
setHasEdited: React.Dispatch<React.SetStateAction<boolean>>;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void>;
}
function ResetButton({ handleReset }: { handleReset: () => void }) {
return (
@ -22,7 +50,36 @@ function ResetButton({ handleReset }: { handleReset: () => void }) {
);
}
function ArgsRenderer({ args }: { args: Record<string, any> }) {
function ArgsRenderer({
args,
actionName,
}: {
args: Record<string, any>;
actionName: string;
}) {
if (isPlanData(args, actionName)) {
const planItems = parsePlanData(args);
const planKey = getPlanKey(args);
if (planItems.length > 0) {
return (
<div className="flex w-full flex-col items-start gap-6">
<div className="flex w-full flex-col items-start gap-1">
<p className="text-sm leading-[18px] text-wrap text-gray-600">
{prettifyText(planKey || "plan")}:
</p>
<div className="w-full max-w-full rounded-xl border border-gray-200 bg-white p-4">
<PlanViewer
planItems={planItems}
isProposedPlan
/>
</div>
</div>
</div>
);
}
}
return (
<div className="flex w-full flex-col items-start gap-6">
{Object.entries(args).map(([k, v]) => {
@ -51,32 +108,6 @@ function ArgsRenderer({ args }: { args: Record<string, any> }) {
);
}
interface InboxItemInputProps {
interruptValue: HumanInterrupt;
humanResponse: HumanResponseWithEdits[];
supportsMultipleMethods: boolean;
acceptAllowed: boolean;
hasEdited: boolean;
hasAddedResponse: boolean;
initialValues: Record<string, string>;
streaming: boolean;
streamFinished: boolean;
setHumanResponse: React.Dispatch<
React.SetStateAction<HumanResponseWithEdits[]>
>;
setSelectedSubmitType: React.Dispatch<
React.SetStateAction<SubmitType | undefined>
>;
setHasAddedResponse: React.Dispatch<React.SetStateAction<boolean>>;
setHasEdited: React.Dispatch<React.SetStateAction<boolean>>;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void>;
}
function ResponseComponent({
humanResponse,
streaming,
@ -120,7 +151,10 @@ function ResponseComponent({
</div>
{showArgsInResponse && (
<ArgsRenderer args={interruptValue.action_request.args} />
<ArgsRenderer
args={interruptValue.action_request.args}
actionName={interruptValue.action_request.action}
/>
)}
<div className="flex w-full flex-col items-start gap-[6px]">
@ -152,10 +186,12 @@ const Response = React.memo(ResponseComponent);
function AcceptComponent({
streaming,
actionRequestArgs,
actionName,
handleSubmit,
}: {
streaming: boolean;
actionRequestArgs: Record<string, any>;
actionName: string;
handleSubmit: (
e: React.MouseEvent<HTMLButtonElement, MouseEvent> | React.KeyboardEvent,
) => Promise<void>;
@ -163,7 +199,10 @@ function AcceptComponent({
return (
<div className="flex w-full flex-col items-start gap-4 rounded-lg border-[1px] border-gray-300 p-6">
{actionRequestArgs && Object.keys(actionRequestArgs).length > 0 && (
<ArgsRenderer args={actionRequestArgs} />
<ArgsRenderer
args={actionRequestArgs}
actionName={actionName}
/>
)}
<Button
variant="brand"
@ -199,8 +238,13 @@ function EditAndOrAcceptComponent({
) => Promise<void>;
}) {
const defaultRows = React.useRef<Record<string, number>>({});
const [editingField, setEditingField] = React.useState<string | null>(null);
const editResponse = humanResponse.find((r) => r.type === "edit");
const acceptResponse = humanResponse.find((r) => r.type === "accept");
const actionName = interruptValue.action_request.action;
if (
!editResponse ||
typeof editResponse.args !== "object" ||
@ -209,6 +253,7 @@ function EditAndOrAcceptComponent({
if (acceptResponse) {
return (
<AcceptComponent
actionName={actionName}
actionRequestArgs={interruptValue.action_request.args}
streaming={streaming}
handleSubmit={handleSubmit}
@ -217,6 +262,7 @@ function EditAndOrAcceptComponent({
}
return null;
}
const header = editResponse.acceptAllowed ? "Edit/Accept" : "Edit";
let buttonText = "Submit";
if (editResponse.acceptAllowed && !editResponse.editsMade) {
@ -268,6 +314,12 @@ function EditAndOrAcceptComponent({
const value = ["string", "number"].includes(typeof v)
? v
: JSON.stringify(v, null);
// Check if this field contains plan data and user isn't actively editing it
const isFieldBeingEdited = editingField === k;
const fieldArgs = { [k]: v };
const isFieldPlanData = isPlanData(fieldArgs, actionName);
// Calculate the default number of rows by the total length of the initial value divided by 30
// or 8, whichever is greater. Stored in a ref to prevent re-rendering.
if (
@ -287,15 +339,49 @@ function EditAndOrAcceptComponent({
key={`allow-edit-args--${k}-${idx}`}
>
<div className="flex w-full flex-col items-start gap-[6px]">
<p className="min-w-fit text-sm font-medium">{prettifyText(k)}</p>
<Textarea
disabled={streaming}
className="h-full"
value={value}
onChange={(e) => onEditChange(e.target.value, editResponse, k)}
onKeyDown={handleKeyDown}
rows={numRows}
/>
<div className="flex w-full items-center justify-between">
<p className="min-w-fit text-sm font-medium">
{prettifyText(k)}
</p>
{isFieldPlanData && !isFieldBeingEdited && (
<button
onClick={() => setEditingField(k)}
className="rounded border border-blue-200 px-2 py-1 text-xs text-blue-600 hover:bg-blue-50 hover:text-blue-800"
>
Edit as Text
</button>
)}
{isFieldBeingEdited && (
<button
onClick={() => setEditingField(null)}
className="rounded border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50 hover:text-gray-800"
>
Show Plan View
</button>
)}
</div>
{isFieldPlanData && !isFieldBeingEdited ? (
// Show plan viewer for plan data when not editing
<div className="w-full rounded-lg border border-gray-200 bg-white p-3">
<PlanViewer
planItems={parsePlanData(fieldArgs)}
isProposedPlan
/>
</div>
) : (
// Show textarea for editing or non-plan data
<Textarea
disabled={streaming}
className="h-full"
value={value}
onChange={(e) =>
onEditChange(e.target.value, editResponse, k)
}
onKeyDown={handleKeyDown}
rows={numRows}
/>
)}
</div>
</div>
);
@ -338,6 +424,7 @@ export function InboxItemInput({
hasArgs && !isEditAllowed && !acceptAllowed && isResponseAllowed;
const showArgsOutsideActionCards =
hasArgs && !showArgsInResponse && !isEditAllowed && !acceptAllowed;
const actionName = interruptValue.action_request.action;
const onEditChange = (
change: string | string[],
@ -493,7 +580,10 @@ export function InboxItemInput({
return (
<div className="flex w-full flex-col items-start justify-start gap-2">
{showArgsOutsideActionCards && (
<ArgsRenderer args={interruptValue.action_request.args} />
<ArgsRenderer
args={interruptValue.action_request.args}
actionName={actionName}
/>
)}
<div className="flex w-full flex-col items-start gap-2">

View file

@ -7,7 +7,7 @@ import {
} from "@/components/ui/tooltip";
import React from "react";
import { motion, AnimatePresence } from "framer-motion";
import { TooltipIconButton } from "../../tooltip-icon-button";
import { TooltipIconButton } from "../../../ui/tooltip-icon-button";
export function ThreadIdTooltip({ threadId }: { threadId: string }) {
const firstThreeChars = threadId.slice(0, 3);

View file

@ -29,7 +29,7 @@ export function ToolCallTable({ toolCall }: { toolCall: ToolCall }) {
try {
valueStr = valueStr || JSON.stringify(value, null);
} catch (_) {
} catch {
// failed to stringify, just assign an empty string
valueStr = "";
}

View file

@ -13,6 +13,7 @@ import { toast } from "sonner";
import { HumanInterrupt, HumanResponse } from "@langchain/langgraph/prebuilt";
import { END } from "@langchain/langgraph/web";
import { useStreamContext } from "@/providers/Stream";
import { useThreads } from "@/providers/Thread";
interface UseInterruptedActionsInput {
interrupt: HumanInterrupt;
@ -54,6 +55,7 @@ export default function useInterruptedActions({
interrupt,
}: UseInterruptedActionsInput): UseInterruptedActionsValue {
const thread = useStreamContext();
const { refreshThreads } = useThreads();
const [humanResponse, setHumanResponse] = useState<HumanResponseWithEdits[]>(
[],
);
@ -93,6 +95,12 @@ export default function useInterruptedActions({
},
},
);
// Trigger immediate status refresh when execution resumes
setTimeout(() => {
refreshThreads().catch(console.error);
}, 1000); // 1 second delay to allow thread status to update
return true;
} catch (e: any) {
console.error("Error sending human response", e);

View file

@ -75,8 +75,8 @@ export function unknownToPrettyDate(input: unknown): string | undefined {
) {
return format(new Date(input as string), "MM/dd/yyyy hh:mm a");
}
} catch (_) {
// failed to parse date. no-op
} catch {
console.error("Failed to parse date", input);
}
return undefined;
}

View file

@ -82,17 +82,12 @@ export default function ThreadHistory() {
parseAsBoolean.withDefault(false),
);
const { getThreads, threads, setThreads, threadsLoading, setThreadsLoading } =
useThreads();
const { refreshThreads, threads, threadsLoading } = useThreads();
useEffect(() => {
if (typeof window === "undefined") return;
setThreadsLoading(true);
getThreads()
.then(setThreads)
.catch(console.error)
.finally(() => setThreadsLoading(false));
}, []);
refreshThreads().catch(console.error);
}, [refreshThreads]);
return (
<>

View file

@ -13,7 +13,7 @@ import {
ensureToolCallsHaveResponses,
} from "@/lib/ensure-tool-responses";
import { LangGraphLogoSVG } from "../icons/langgraph";
import { TooltipIconButton } from "./tooltip-icon-button";
import { TooltipIconButton } from "../ui/tooltip-icon-button";
import {
ArrowDown,
LoaderCircle,
@ -22,14 +22,14 @@ import {
SquarePen,
XIcon,
Plus,
Settings,
} from "lucide-react";
import { useQueryState, parseAsBoolean } from "nuqs";
import { useQueryState, parseAsBoolean, parseAsString } from "nuqs";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
import ThreadHistory from "./history";
import TaskListSidebar from "../task-list-sidebar";
import { toast } from "sonner";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import { useFileUpload } from "@/hooks/use-file-upload";
import { ContentBlocksPreview } from "./ContentBlocksPreview";
import {
@ -38,7 +38,20 @@ import {
ArtifactTitle,
useArtifactContext,
} from "./artifact";
import { GitHubOAuthButton } from "../github-oauth-button";
import { GitHubOAuthButton } from "../github/github-oauth-button";
import { useGitHubApp } from "@/hooks/useGitHubApp";
import TaskList from "../task-list";
import { ConfigurationSidebar } from "../configuration-sidebar";
import { useConfigStore } from "@/hooks/use-config-store";
import { RepositoryBranchSelectors } from "../github/repo-branch-selectors";
import { useRouter } from "next/navigation";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
import { BaseMessage } from "@langchain/core/messages";
function StickyToBottomContent(props: {
content: ReactNode;
@ -82,18 +95,48 @@ function ScrollToBottom(props: { className?: string }) {
}
export function Thread() {
const { push } = useRouter();
const [artifactContext, setArtifactContext] = useArtifactContext();
const [artifactOpen, closeArtifact] = useArtifactOpen();
const { selectedRepository } = useGitHubApp();
const { getConfigs } = useConfigStore();
const [threadId, _setThreadId] = useQueryState("threadId");
const [taskId, setTaskId] = useQueryState("taskId", parseAsString);
const [chatHistoryOpen, setChatHistoryOpen] = useQueryState(
"chatHistoryOpen",
parseAsBoolean.withDefault(false),
);
const [hideToolCalls, setHideToolCalls] = useQueryState(
"hideToolCalls",
parseAsBoolean.withDefault(false),
);
const [configSidebarOpen, setConfigSidebarOpen] = useState(false);
const isTaskView = !!taskId;
const isThreadView = !!threadId;
// Track previous states to detect navigation changes
const prevTaskId = useRef(taskId);
const prevThreadId = useRef(threadId);
useEffect(() => {
const isNavigatingToTask = !prevTaskId.current && taskId;
const isNavigatingToThread = !prevThreadId.current && threadId;
if ((isNavigatingToTask || isNavigatingToThread) && !chatHistoryOpen) {
setChatHistoryOpen(true);
}
prevTaskId.current = taskId;
prevThreadId.current = threadId;
}, [taskId, threadId, chatHistoryOpen, setChatHistoryOpen]);
useEffect(() => {
if (taskId && typeof window !== "undefined") {
// TaskId format is "${threadId}-${taskIndex}", so we can extract the threadId directly
const taskThreadId = taskId.split("-").slice(0, -1).join("-");
if (taskThreadId && taskThreadId !== threadId) {
_setThreadId(taskThreadId);
}
}
}, [taskId, threadId, _setThreadId]);
const [input, setInput] = useState("");
const {
contentBlocks,
@ -116,9 +159,11 @@ export function Thread() {
const setThreadId = (id: string | null) => {
_setThreadId(id);
// close artifact and reset artifact context
closeArtifact();
setArtifactContext({});
if (id === null) {
setTaskId(null);
closeArtifact();
setArtifactContext({});
}
};
useEffect(() => {
@ -129,11 +174,9 @@ export function Thread() {
try {
const message = (stream.error as any).message;
if (!message || lastError.current === message) {
// Message has already been logged. do not modify ref, return early.
return;
}
// Message is defined, and it has not been logged yet. Save it, and send the error
lastError.current = message;
toast.error("An error occurred. Please try again.", {
description: (
@ -145,11 +188,10 @@ export function Thread() {
closeButton: true,
});
} catch {
// no-op
console.error("Error in stream", stream.error);
}
}, [stream.error]);
// TODO: this should be part of the useStream hook
const prevMessageLength = useRef(0);
useEffect(() => {
if (
@ -167,6 +209,17 @@ export function Thread() {
e.preventDefault();
if ((input.trim().length === 0 && contentBlocks.length === 0) || isLoading)
return;
if (!selectedRepository) {
toast.error("Please select a repository first", {
description:
"You need to select a repository before sending a message.",
richColors: true,
closeButton: true,
});
return;
}
setFirstTokenReceived(false);
const newHumanMessage: Message = {
@ -183,15 +236,14 @@ export function Thread() {
const context =
Object.keys(artifactContext).length > 0 ? artifactContext : undefined;
const targetRepository = {
owner: "langchain-ai",
repo: "open-swe",
};
stream.submit(
{
messages: [...toolMessages, newHumanMessage],
messages: [
...toolMessages,
newHumanMessage,
] as unknown as BaseMessage[],
context,
targetRepository,
targetRepository: selectedRepository,
},
{
streamMode: ["values"],
@ -202,10 +254,16 @@ export function Thread() {
...(prev.messages ?? []),
...toolMessages,
newHumanMessage,
],
] as unknown as BaseMessage[],
}),
config: {
recursion_limit: 400,
configurable: {
...getConfigs(),
},
},
metadata: {
graph_id: process.env.NEXT_PUBLIC_ASSISTANT_ID ?? "open-swe",
},
},
);
@ -225,6 +283,12 @@ export function Thread() {
streamMode: ["values"],
config: {
recursion_limit: 400,
configurable: {
...getConfigs(),
},
},
metadata: {
graph_id: process.env.NEXT_PUBLIC_ASSISTANT_ID ?? "open-swe",
},
});
};
@ -257,7 +321,7 @@ export function Thread() {
className="relative h-full"
style={{ width: 300 }}
>
<ThreadHistory />
<TaskListSidebar onCollapse={() => setChatHistoryOpen(false)} />
</div>
</motion.div>
</div>
@ -305,7 +369,16 @@ export function Thread() {
</Button>
)}
</div>
<div className="absolute top-2 right-4 flex items-center">
<div className="absolute top-2 right-4 flex items-center gap-2 text-gray-700">
<TooltipIconButton
tooltip="Configuration"
variant="ghost"
onClick={() => {
setConfigSidebarOpen(true);
}}
>
<Settings className="size-4" />
</TooltipIconButton>
<GitHubOAuthButton />
</div>
</div>
@ -330,7 +403,7 @@ export function Thread() {
</div>
<motion.button
className="flex cursor-pointer items-center gap-2"
onClick={() => setThreadId(null)}
onClick={() => push("/")}
animate={{
marginLeft: !chatHistoryOpen ? 48 : 0,
}}
@ -350,18 +423,23 @@ export function Thread() {
</motion.button>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center">
<GitHubOAuthButton />
</div>
<div className="flex items-center gap-2 text-gray-700">
<GitHubOAuthButton />
<TooltipIconButton
tooltip="Configuration"
variant="ghost"
onClick={() => {
setConfigSidebarOpen(true);
}}
>
<Settings className="size-4" />
</TooltipIconButton>
<TooltipIconButton
size="lg"
className="p-4"
tooltip="New thread"
variant="ghost"
onClick={() => setThreadId(null)}
>
<SquarePen className="size-5" />
<SquarePen className="size-4" />
</TooltipIconButton>
</div>
@ -369,11 +447,14 @@ export function Thread() {
</div>
)}
<StickToBottom className="relative flex-1 overflow-hidden">
<StickToBottom
className="relative flex-1 overflow-hidden"
initial={false}
>
<StickyToBottomContent
className={cn(
"absolute inset-0 overflow-y-scroll px-4 [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent",
!chatStarted && "mt-[25vh] flex flex-col items-stretch",
!chatStarted && "mt-[10vh] flex flex-col items-stretch",
chatStarted && "grid grid-rows-[1fr_auto]",
)}
contentClassName="pt-8 pb-16 max-w-3xl mx-auto flex flex-col gap-4 w-full"
@ -415,7 +496,14 @@ export function Thread() {
</>
}
footer={
<div className="sticky bottom-0 flex flex-col items-center gap-8 bg-white">
<div
className={cn(
"flex flex-col items-center gap-8 bg-white",
!isTaskView && !isThreadView
? "mb-32 pb-32"
: "sticky bottom-0",
)}
>
{!chatStarted && (
<div className="flex items-center gap-3">
<LangGraphLogoSVG className="h-8 flex-shrink-0" />
@ -465,31 +553,21 @@ export function Thread() {
className="field-sizing-content resize-none border-none bg-transparent p-3.5 pb-0 shadow-none ring-0 outline-none focus:ring-0 focus:outline-none"
/>
<div className="flex items-center gap-6 p-2 pt-4">
<div>
<div className="flex items-center space-x-2">
<Switch
id="render-tool-calls"
checked={hideToolCalls ?? false}
onCheckedChange={setHideToolCalls}
/>
<Label
htmlFor="render-tool-calls"
className="text-sm text-gray-600"
>
Hide Tool Calls
</Label>
</div>
</div>
<Label
htmlFor="file-input"
className="flex cursor-pointer items-center gap-2"
>
<Plus className="size-5 text-gray-600" />
<span className="text-sm text-gray-600">
Upload PDF or Image
</span>
</Label>
<div className="flex items-center gap-1 p-2 pt-4">
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Label
htmlFor="file-input"
className="mr-1 ml-2 flex cursor-pointer items-center gap-2"
>
<Plus className="size-4 text-gray-600" />
</Label>
</TooltipTrigger>
<TooltipContent>Attach files</TooltipContent>
</Tooltip>
</TooltipProvider>
<input
id="file-input"
type="file"
@ -498,6 +576,8 @@ export function Thread() {
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf"
className="hidden"
/>
<RepositoryBranchSelectors />
{stream.isLoading ? (
<Button
key="stop"
@ -522,6 +602,8 @@ export function Thread() {
</div>
</form>
</div>
{!isTaskView && !isThreadView && <TaskList />}
</div>
}
/>
@ -542,6 +624,11 @@ export function Thread() {
</div>
</div>
</div>
<ConfigurationSidebar
open={configSidebarOpen}
onClose={() => setConfigSidebarOpen(false)}
/>
</div>
);
}

View file

@ -10,7 +10,7 @@ import { FC, memo, useState } from "react";
import { CheckIcon, CopyIcon } from "lucide-react";
import { SyntaxHighlighter } from "@/components/thread/syntax-highlighter";
import { TooltipIconButton } from "@/components/thread/tooltip-icon-button";
import { TooltipIconButton } from "@/components/ui/tooltip-icon-button";
import { cn } from "@/lib/utils";
import "katex/dist/katex.min.css";

View file

@ -7,6 +7,7 @@ import { Textarea } from "@/components/ui/textarea";
import { BranchSwitcher, CommandBar } from "./shared";
import { MultimodalPreview } from "@/components/thread/MultimodalPreview";
import { isBase64ContentBlock } from "@/lib/multimodal-utils";
import { BaseMessage } from "@langchain/core/messages";
function EditableContent({
value,
@ -54,7 +55,7 @@ export function HumanMessage({
const newMessage: Message = { type: "human", content: value };
thread.submit(
{ messages: [newMessage] },
{ messages: [newMessage] as unknown as BaseMessage[] },
{
checkpoint: parentCheckpoint,
streamMode: ["values"],
@ -64,7 +65,10 @@ export function HumanMessage({
return {
...values,
messages: [...(values.messages ?? []), newMessage],
messages: [
...(values.messages ?? []),
newMessage,
] as unknown as BaseMessage[],
};
},
config: {

View file

@ -8,7 +8,7 @@ import {
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { TooltipIconButton } from "../tooltip-icon-button";
import { TooltipIconButton } from "@/components/ui/tooltip-icon-button";
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
import { Button } from "@/components/ui/button";

View file

@ -1,44 +0,0 @@
"use client";
import { forwardRef } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button, ButtonProps } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type TooltipIconButtonProps = ButtonProps & {
tooltip: string;
side?: "top" | "bottom" | "left" | "right";
};
export const TooltipIconButton = forwardRef<
HTMLButtonElement,
TooltipIconButtonProps
>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
{...rest}
className={cn("size-6 p-1", className)}
ref={ref}
>
{children}
<span className="sr-only">{tooltip}</span>
</Button>
</TooltipTrigger>
<TooltipContent side={side}>{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
});
TooltipIconButton.displayName = "TooltipIconButton";

View file

@ -0,0 +1,166 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return (
<AlertDialogPrimitive.Root
data-slot="alert-dialog"
{...props}
/>
);
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger
data-slot="alert-dialog-trigger"
{...props}
/>
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal
data-slot="alert-dialog-portal"
{...props}
/>
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View file

@ -0,0 +1,66 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View file

@ -0,0 +1,46 @@
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 badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-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 transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View file

@ -0,0 +1,36 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return (
<CollapsiblePrimitive.Root
data-slot="collapsible"
{...props}
/>
);
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View file

@ -0,0 +1,56 @@
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="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{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 p-px transition-colors select-none",
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="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View file

@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

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(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className,
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
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 };

View file

@ -0,0 +1,74 @@
"use client";
import { forwardRef } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button, ButtonProps } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type TooltipIconButtonProps = ButtonProps & {
tooltip: string;
/**
* @default "bottom"
*/
side?: "top" | "bottom" | "left" | "right";
/**
* @default 200
*/
delayDuration?: number;
/**
* @default "ghost"
*/
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
};
export const TooltipIconButton = forwardRef<
HTMLButtonElement,
TooltipIconButtonProps
>(
(
{
children,
tooltip,
side = "bottom",
className,
delayDuration = 200,
variant = "ghost",
...rest
},
ref,
) => {
return (
<TooltipProvider>
<Tooltip delayDuration={delayDuration}>
<TooltipTrigger asChild>
<Button
variant={variant}
size="icon"
{...rest}
className={cn("size-6 p-1", className)}
ref={ref}
>
{children}
<span className="sr-only">{tooltip}</span>
</Button>
</TooltipTrigger>
<TooltipContent side={side}>{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
);
TooltipIconButton.displayName = "TooltipIconButton";

View file

@ -0,0 +1,106 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { ConfigurableFieldUIMetadata } from "@open-swe/shared/configurable-metadata";
export const DEFAULT_CONFIG_KEY = "open_swe_default_config";
interface ConfigState {
configs: Record<string, any>;
getConfig: (key: string) => Record<string, any>;
getConfigs: () => Record<string, any>;
updateConfig: (key: string, value: any) => void;
resetConfig: (key: string) => void;
setDefaultConfig: (
key: string,
configurations: ConfigurableFieldUIMetadata[],
) => void;
resetStore: (key: string) => void;
}
export const useConfigStore = create<ConfigState>()(
persist(
(set, get) => ({
configs: {},
getConfig: (key: string) => {
const state = get();
const baseConfig = state.configs[key];
const configObj = {
...baseConfig,
};
delete configObj.__defaultValues;
return configObj;
},
getConfigs: () => {
const state = get();
const flatConfigs: Record<string, any> = {};
Object.entries(state.configs).forEach(([key, configObj]) => {
if (
configObj &&
typeof configObj === "object" &&
configObj[key] !== undefined
) {
flatConfigs[key] = configObj[key];
}
});
return flatConfigs;
},
updateConfig: (key, value) =>
set((state) => ({
configs: {
...state.configs,
[key]: {
...(state.configs[key] || {}),
[key]: value,
},
},
})),
resetConfig: (key: string) => {
set((state) => {
const config = state.configs[key];
if (!config || !config.__defaultValues) {
return state;
}
const defaultsToUse = { ...config.__defaultValues };
return {
configs: {
...state.configs,
[key]: defaultsToUse,
},
};
});
},
setDefaultConfig: (
key: string,
configurations: ConfigurableFieldUIMetadata[],
) => {
const defaultConfig: Record<string, any> = {};
configurations.forEach((config: ConfigurableFieldUIMetadata) => {
if (config.default !== undefined) {
defaultConfig[config.label] = config.default;
}
});
defaultConfig.__defaultValues = { ...defaultConfig };
set((currentState) => ({
configs: {
...currentState.configs,
[key]: defaultConfig,
},
}));
},
resetStore: () => set({ configs: {} }),
}),
{
name: "open-swe-config-storage",
},
),
);

View file

@ -2,14 +2,7 @@ import { useState, useRef, useEffect, ChangeEvent } from "react";
import { toast } from "sonner";
import type { Base64ContentBlock } from "@langchain/core/messages";
import { fileToContentBlock } from "@/lib/multimodal-utils";
export const SUPPORTED_FILE_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"application/pdf",
];
import { SUPPORTED_FILE_TYPES } from "@/types/file-upload";
interface UseFileUploadOptions {
initialBlocks?: Base64ContentBlock[];

View file

@ -1,5 +1,7 @@
import { useState, useEffect } from "react";
import { Repository } from "@/utils/github";
import { useState, useEffect, useRef, useCallback } from "react";
import { useQueryState } from "nuqs";
import { Repository, getRepositoryBranches, Branch } from "@/utils/github";
import type { TargetRepository } from "@open-swe/shared/open-swe/types";
interface UseGitHubAppReturn {
isInstalled: boolean | null;
@ -7,6 +9,26 @@ interface UseGitHubAppReturn {
error: string | null;
repositories: Repository[];
refreshRepositories: () => Promise<void>;
selectedRepository: TargetRepository | null;
setSelectedRepository: (repo: TargetRepository | null) => void;
branches: Branch[];
branchesLoading: boolean;
branchesError: string | null;
selectedBranch: string | null;
setSelectedBranch: (branch: string | null) => void;
refreshBranches: () => Promise<void>;
defaultBranch: string | null;
}
// Helper function to get GitHub OAuth access token from cookies
function getGitHubAccessToken(): string | null {
if (typeof document === "undefined") return null;
const cookies = document.cookie.split("; ");
const tokenCookie = cookies.find((row) =>
row.startsWith("x-github_access_token="),
);
return tokenCookie ? tokenCookie.split("=")[1] : null;
}
export function useGitHubApp(): UseGitHubAppReturn {
@ -14,6 +36,51 @@ export function useGitHubApp(): UseGitHubAppReturn {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [repositories, setRepositories] = useState<Repository[]>([]);
const [branches, setBranches] = useState<Branch[]>([]);
const [branchesLoading, setBranchesLoading] = useState(false);
const [branchesError, setBranchesError] = useState<string | null>(null);
const [selectedRepositoryParam, setSelectedRepositoryParam] =
useQueryState("repo");
const [selectedBranchParam, setSelectedBranchParam] = useQueryState("branch");
// Track if auto-selection has been attempted to prevent re-triggering
const hasAutoSelectedRef = useRef(false);
const selectedRepository = selectedRepositoryParam
? (() => {
try {
// Parse "owner/repo" format instead of JSON
const parts = selectedRepositoryParam.split("/");
if (parts.length === 2) {
return {
owner: parts[0],
repo: parts[1],
branch: selectedBranchParam || undefined,
} as TargetRepository;
}
return null;
} catch {
return null;
}
})()
: null;
const selectedBranch = selectedBranchParam;
const setSelectedRepository = useCallback(
(repo: TargetRepository | null) => {
setSelectedRepositoryParam(repo ? `${repo.owner}/${repo.repo}` : null);
if (!repo) {
setSelectedBranchParam(null);
setBranches([]);
}
},
[setSelectedRepositoryParam, setSelectedBranchParam],
);
const setSelectedBranch = (branch: string | null) => {
setSelectedBranchParam(branch);
};
const checkInstallation = async () => {
setIsLoading(true);
@ -35,7 +102,7 @@ export function useGitHubApp(): UseGitHubAppReturn {
setIsInstalled(false);
}
}
} catch (err) {
} catch {
setError("Failed to check GitHub App installation status");
setIsInstalled(false);
} finally {
@ -43,19 +110,117 @@ export function useGitHubApp(): UseGitHubAppReturn {
}
};
const fetchBranches = useCallback(async () => {
if (!selectedRepository) {
setBranches([]);
return;
}
const accessToken = getGitHubAccessToken();
if (!accessToken) {
setBranchesError("GitHub access token not found");
return;
}
setBranchesLoading(true);
setBranchesError(null);
try {
const branchData = await getRepositoryBranches(
selectedRepository.owner,
selectedRepository.repo,
accessToken,
);
setBranches(branchData || []);
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "Failed to fetch branches";
console.error(
`Error fetching branches for ${selectedRepository.owner}/${selectedRepository.repo}:`,
err,
);
setBranchesError(errorMessage);
} finally {
setBranchesLoading(false);
}
}, [selectedRepository?.owner, selectedRepository?.repo]);
useEffect(() => {
checkInstallation();
}, []);
useEffect(() => {
if (selectedRepository) {
fetchBranches();
} else {
setBranches([]);
setSelectedBranchParam(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
selectedRepository?.owner,
selectedRepository?.repo,
setSelectedBranchParam,
]);
// Auto-select first repository on initial page load
useEffect(() => {
if (
!hasAutoSelectedRef.current && // Haven't auto-selected yet
!selectedRepository && // No repo currently selected
!isLoading && // Not loading repositories
!error && // No error occurred
isInstalled === true && // GitHub App is installed
repositories.length > 0 // Repositories are available
) {
const firstRepo = repositories[0];
const targetRepo = {
owner: firstRepo.full_name.split("/")[0],
repo: firstRepo.full_name.split("/")[1],
};
setSelectedRepository(targetRepo);
hasAutoSelectedRef.current = true;
}
}, [
repositories,
selectedRepository,
isLoading,
error,
isInstalled,
setSelectedRepository,
]);
const refreshRepositories = async () => {
await checkInstallation();
};
const refreshBranches = async () => {
await fetchBranches();
};
// Get the default branch for the currently selected repository
const defaultBranch = selectedRepository
? repositories.find(
(repo) =>
repo.full_name ===
`${selectedRepository.owner}/${selectedRepository.repo}`,
)?.default_branch || null
: null;
return {
isInstalled,
isLoading,
error,
repositories,
refreshRepositories,
selectedRepository,
setSelectedRepository,
branches,
branchesLoading,
branchesError,
selectedBranch,
setSelectedBranch,
refreshBranches,
defaultBranch,
};
}

View file

@ -1,10 +0,0 @@
export function getApiKey(): string | null {
try {
if (typeof window === "undefined") return null;
return window.localStorage.getItem("lg:chat:apiKey") ?? null;
} catch {
// no-op
}
return null;
}

View file

@ -0,0 +1,56 @@
import { PlanItem } from "@open-swe/shared/open-swe/types";
import {
PLAN_INTERRUPT_ACTION_TITLE,
PLAN_INTERRUPT_DELIMITER,
} from "@open-swe/shared/constants";
/**
* Checks if the interrupt args contain plan data
*/
export function isPlanData(
args: Record<string, any>,
actionName: string,
): boolean {
return !!(
actionName === PLAN_INTERRUPT_ACTION_TITLE &&
args.plan &&
typeof args.plan === "string" &&
args.plan?.length > 0
);
}
/**
* Parses plan data from interrupt args into PlanItem array
*/
export function parsePlanData(args: Record<string, any>): PlanItem[] {
if (!args.plan) return [];
if (typeof args.plan !== "string") return [];
if (!args.plan.includes(PLAN_INTERRUPT_DELIMITER)) {
// if args.plan is defined, but does not include the delimiter, it is a single plan item
return [
{
index: 0,
plan: args.plan,
completed: false,
summary: undefined,
},
];
}
const planItems = args.plan
.split(PLAN_INTERRUPT_DELIMITER)
.map((item: string, index: number) => ({
index,
plan: item,
completed: false,
summary: undefined,
}));
return planItems;
}
/**
* Gets the key name that contains plan data
*/
export function getPlanKey(args: Record<string, any>): string | null {
if (args.plan && typeof args.plan === "string") return "plan";
return null;
}

View file

@ -4,6 +4,7 @@ import React, {
ReactNode,
useState,
useEffect,
useRef,
} from "react";
import { useStream } from "@langchain/langgraph-sdk/react";
import { type Message } from "@langchain/langgraph-sdk";
@ -17,30 +18,18 @@ import {
import { useQueryState } from "nuqs";
import { LangGraphLogoSVG } from "@/components/icons/langgraph";
import { useThreads } from "./Thread";
import { TooltipIconButton } from "@/components/thread/tooltip-icon-button";
import { TooltipIconButton } from "@/components/ui/tooltip-icon-button";
import { Copy, CopyCheck, ArrowRight } from "lucide-react";
import { motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { GitHubSVG } from "@/components/icons/github";
import { useGitHubToken } from "@/hooks/useGitHubToken";
type TargetRepository = { owner: string; repo: string };
export type StateType = {
messages: Message[];
ui?: UIMessage[];
targetRepository?: TargetRepository;
};
import { GraphState, GraphUpdate } from "@open-swe/shared/open-swe/types";
const useTypedStream = useStream<
StateType,
GraphState,
{
UpdateType: {
messages?: Message[] | Message | string;
ui?: (UIMessage | RemoveUIMessage)[] | UIMessage | RemoveUIMessage;
context?: Record<string, unknown>;
targetRepository?: TargetRepository;
};
UpdateType: GraphUpdate;
CustomEventType: UIMessage | RemoveUIMessage;
}
>;
@ -64,7 +53,8 @@ const StreamSession = ({
githubToken: string;
}) => {
const [threadId, setThreadId] = useQueryState("threadId");
const { getThreads, setThreads } = useThreads();
const { refreshThreads, setThreads, updateThreadFromStream } = useThreads();
const githubAccessToken =
document.cookie
.split("; ")
@ -73,6 +63,7 @@ const StreamSession = ({
const streamValue = useTypedStream({
apiUrl,
assistantId,
reconnectOnMount: true,
threadId: threadId ?? null,
defaultHeaders: {
"x-github-installation-token": githubToken,
@ -90,10 +81,44 @@ const StreamSession = ({
setThreadId(id);
// Refetch threads list when thread ID changes.
// Wait for some seconds before fetching so we're able to get the new thread that was created.
sleep().then(() => getThreads().then(setThreads).catch(console.error));
sleep().then(() => {
refreshThreads().catch(console.error);
});
},
});
// Listen for stream updates to update all thread properties in real-time
// Use a ref to track the last update to prevent excessive calls
const lastUpdateRef = useRef<{ threadId: string; valuesHash: string } | null>(
null,
);
useEffect(() => {
if (threadId && streamValue.values) {
// Create a simple hash of the values to detect actual changes
const valuesHash = JSON.stringify({
plan: (streamValue.values as any).plan,
proposedPlan: (streamValue.values as any).proposedPlan,
targetRepository: (streamValue.values as any).targetRepository,
messages: (
streamValue.values as any
).messages?.[0]?.content?.[0]?.text?.substring(0, 50),
});
// Only update if thread or values actually changed
if (
!lastUpdateRef.current ||
lastUpdateRef.current.threadId !== threadId ||
lastUpdateRef.current.valuesHash !== valuesHash
) {
lastUpdateRef.current = { threadId, valuesHash };
updateThreadFromStream(threadId, streamValue.values);
}
}
}, [threadId, streamValue.values, updateThreadFromStream]);
// Real-time updates via stream - no polling needed
return (
<StreamContext.Provider value={streamValue}>
{children}
@ -113,12 +138,23 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
const [isAuth, setIsAuth] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Initialize from localStorage to prevent modal flash
const [hasGitHubAppInstalled, setHasGitHubAppInstalled] = useState<
boolean | null
>(null);
>(() => {
if (typeof window === "undefined") return null;
const cached = localStorage.getItem("github_app_installed");
return cached === "true" ? true : cached === "false" ? false : null;
});
const [isCheckingAppInstallation, setIsCheckingAppInstallation] =
useState(false);
const { token: githubToken, fetchToken: fetchGitHubToken } = useGitHubToken();
const {
token: githubToken,
fetchToken: fetchGitHubToken,
isLoading: isTokenLoading,
} = useGitHubToken();
useEffect(() => {
checkAuthStatus();
@ -126,9 +162,24 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
useEffect(() => {
if (isAuth) {
checkGitHubAppInstallation();
// Check if we already have cached installation status
const cachedInstallationStatus = localStorage.getItem(
"github_app_installed",
);
if (cachedInstallationStatus === "true") {
setHasGitHubAppInstalled(true);
// Fetch token if we don't have one yet
if (!githubToken && !isTokenLoading) {
fetchGitHubToken();
}
} else if (cachedInstallationStatus === "false") {
setHasGitHubAppInstalled(false);
} else {
// Only check installation if we don't have cached status
checkGitHubAppInstallation();
}
}
}, [isAuth]);
}, [isAuth, githubToken, isTokenLoading]);
const checkAuthStatus = async () => {
try {
@ -147,20 +198,24 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
const response = await fetch("/api/github/repositories");
if (response.ok) {
setHasGitHubAppInstalled(true);
localStorage.setItem("github_app_installed", "true");
// If the app is installed, fetch a token
await fetchGitHubToken();
} else {
const errorData = await response.json();
if (errorData.error.includes("installation")) {
setHasGitHubAppInstalled(false);
localStorage.setItem("github_app_installed", "false");
} else {
// If there's a different error, we'll assume the app is not installed
setHasGitHubAppInstalled(false);
localStorage.setItem("github_app_installed", "false");
}
}
} catch (error) {
console.error("Error checking GitHub App installation:", error);
setHasGitHubAppInstalled(false);
localStorage.setItem("github_app_installed", "false");
} finally {
setIsCheckingAppInstallation(false);
}
@ -173,6 +228,8 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
const handleInstallGitHubApp = () => {
setIsLoading(true);
// Clear cached status so we check again after installation
localStorage.removeItem("github_app_installed");
window.location.href = "/api/github/installation";
};
@ -271,7 +328,9 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
}
// Step 2: GitHub App Installation (only show if authenticated but app not installed)
if ((isAuth && hasGitHubAppInstalled === false) || !githubToken) {
// Only show modal if we've definitively determined the app is not installed
// Don't show while we're still loading or have cached positive status
if (isAuth && hasGitHubAppInstalled === false && !isTokenLoading) {
return (
<div className="flex min-h-screen w-full items-center justify-center p-4">
<div className="animate-in fade-in-0 zoom-in-95 flex w-full max-w-3xl flex-col rounded-lg border shadow-lg">
@ -320,6 +379,27 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
);
}
// Only render StreamSession when we have a valid token
if (!githubToken) {
return (
<div className="flex min-h-screen w-full items-center justify-center p-4">
<div className="animate-in fade-in-0 zoom-in-95 flex w-full max-w-3xl flex-col rounded-lg border shadow-lg">
<div className="flex flex-col gap-4 border-b p-6">
<div className="flex flex-col items-start gap-2">
<LangGraphLogoSVG className="h-7" />
<h1 className="text-xl font-semibold tracking-tight">
Loading...
</h1>
</div>
<p className="text-muted-foreground">
Setting up your GitHub integration...
</p>
</div>
</div>
</div>
);
}
return (
<StreamSession
apiUrl={apiUrl}

View file

@ -1,7 +1,5 @@
import { validate } from "uuid";
import { getApiKey } from "@/lib/api-key";
import { Thread } from "@langchain/langgraph-sdk";
import { useQueryState } from "nuqs";
import {
createContext,
useContext,
@ -10,15 +8,30 @@ import {
useState,
Dispatch,
SetStateAction,
useEffect,
} from "react";
import { createClient } from "./client";
import { getMessageContentString } from "@open-swe/shared/messages";
import { TaskPlan } from "@open-swe/shared/open-swe/types";
export interface ThreadWithTasks extends Thread {
threadTitle: string;
repository: string;
branch: string;
completedTasksCount: number;
totalTasksCount: number;
tasks: TaskPlan | undefined;
proposedPlan: string[];
}
interface ThreadContextType {
getThreads: () => Promise<Thread[]>;
threads: Thread[];
setThreads: Dispatch<SetStateAction<Thread[]>>;
threads: ThreadWithTasks[];
setThreads: Dispatch<SetStateAction<ThreadWithTasks[]>>;
threadsLoading: boolean;
setThreadsLoading: Dispatch<SetStateAction<boolean>>;
refreshThreads: () => Promise<void>;
getThread: (threadId: string) => Promise<ThreadWithTasks | null>;
updateThreadFromStream: (threadId: string, streamValues: any) => void;
}
const ThreadContext = createContext<ThreadContextType | undefined>(undefined);
@ -33,34 +46,223 @@ function getThreadSearchMetadata(
}
}
const getTaskCounts = (
tasks?: TaskPlan,
proposedPlan?: string[],
): { totalTasksCount: number; completedTasksCount: number } => {
if (proposedPlan && !tasks) {
return {
totalTasksCount: proposedPlan.length,
completedTasksCount: 0,
};
}
if (!tasks) {
// No tasks passed, return 0s
return { totalTasksCount: 0, completedTasksCount: 0 };
}
const activeTaskList = tasks.tasks.find(
(t) => t.taskIndex === tasks.activeTaskIndex,
);
if (!activeTaskList) {
// Something is wrong here. Return 0
return { totalTasksCount: 0, completedTasksCount: 0 };
}
const activeTaskPlans = activeTaskList.planRevisions.find(
(p) => p.revisionIndex === activeTaskList.activeRevisionIndex,
);
if (!activeTaskPlans) {
// Something is wrong here. Return 0
return { totalTasksCount: 0, completedTasksCount: 0 };
}
return {
totalTasksCount: activeTaskPlans.plans.length,
completedTasksCount: activeTaskPlans.plans.filter((p) => p.completed)
.length,
};
};
export function ThreadProvider({ children }: { children: ReactNode }) {
const apiUrl: string | undefined = process.env.NEXT_PUBLIC_API_URL ?? "";
const assistantId: string | undefined =
process.env.NEXT_PUBLIC_ASSISTANT_ID ?? "";
const [threads, setThreads] = useState<Thread[]>([]);
const [threads, setThreads] = useState<ThreadWithTasks[]>([]);
const [threadsLoading, setThreadsLoading] = useState(false);
const getThreads = useCallback(async (): Promise<Thread[]> => {
if (!apiUrl || !assistantId) return [];
const client = createClient(apiUrl, getApiKey() ?? undefined);
// Real-time thread updater for all properties (replaces polling)
const updateThreadFromStream = useCallback(
(threadId: string, streamValues: any) => {
if (!threadId || !streamValues) return;
const threads = await client.threads.search({
metadata: {
...getThreadSearchMetadata(assistantId),
},
limit: 100,
});
setThreads((currentThreads) => {
const targetThread = currentThreads.find(
(t) => t.thread_id === threadId,
);
if (!targetThread) return currentThreads; // Thread not found, no update needed
return threads;
const plan: TaskPlan | undefined = streamValues?.plan;
const proposedPlan: string[] = streamValues?.proposedPlan || [];
const targetRepository = streamValues?.targetRepository;
const messages = streamValues?.messages;
const { totalTasksCount, completedTasksCount } = getTaskCounts(
plan,
proposedPlan,
);
// Extract thread title from messages if available
const firstMessageContent = messages?.[0]?.content;
const threadTitle = firstMessageContent
? getMessageContentString(firstMessageContent)
: targetThread.threadTitle;
const newRepository =
targetRepository?.repo ||
targetRepository?.name ||
targetThread.repository ||
"Unknown Repository";
const newBranch =
targetRepository?.branch || targetThread.branch || "main";
return currentThreads.map((thread) => {
if (thread.thread_id === threadId) {
return {
...thread,
threadTitle,
repository: newRepository,
branch: newBranch,
completedTasksCount,
totalTasksCount,
tasks: plan,
proposedPlan,
};
}
return thread;
});
});
},
[],
);
const getThread = useCallback(
async (threadId: string): Promise<ThreadWithTasks | null> => {
if (!apiUrl || !assistantId) return null;
const client = createClient(apiUrl);
try {
const thread = await client.threads.get(threadId);
return enhanceThreadWithTasks(thread);
} catch (error) {
console.error("Failed to fetch thread:", threadId, error);
return null;
}
},
[apiUrl, assistantId],
);
const enhanceThreadWithTasks = (thread: Thread): ThreadWithTasks => {
const threadValues = thread.values as any;
const plan: TaskPlan | undefined = threadValues?.plan;
const proposedPlan: string[] = threadValues?.proposedPlan || [];
const targetRepository = threadValues?.targetRepository;
const messages = (threadValues as any)?.messages;
const firstMessageContent = messages?.[0]?.content;
const threadTitle = firstMessageContent
? getMessageContentString(firstMessageContent)
: `Thread ${thread.thread_id.substring(0, 8)}`;
const { totalTasksCount, completedTasksCount } = getTaskCounts(
plan,
proposedPlan,
);
return {
...thread,
threadTitle,
repository:
targetRepository?.repo ||
targetRepository?.name ||
"Unknown Repository",
branch: targetRepository?.branch || "main",
completedTasksCount,
totalTasksCount,
tasks: plan,
proposedPlan,
};
};
const refreshThreads = useCallback(async (): Promise<void> => {
if (!apiUrl || !assistantId) return;
setThreadsLoading(true);
const client = createClient(apiUrl);
try {
// Simple thread search - try both metadata approaches
const searchParams = {
limit: 100,
metadata: getThreadSearchMetadata(assistantId),
};
let threadsResponse = await client.threads.search(searchParams);
// If no threads found, try alternative metadata
if (threadsResponse.length === 0) {
const altMetadata = assistantId.includes("-")
? { assistant_id: assistantId }
: { graph_id: assistantId };
threadsResponse = await client.threads.search({
limit: 100,
metadata: altMetadata,
});
}
// Enhance threads with task data
const enhancedThreads: ThreadWithTasks[] = [];
for (const thread of threadsResponse) {
try {
const fullThread = await client.threads.get(thread.thread_id);
enhancedThreads.push(enhanceThreadWithTasks(fullThread));
} catch (error) {
console.error(`Failed to enhance thread ${thread.thread_id}:`, error);
}
}
// Sort by creation date (newest first)
enhancedThreads.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
setThreads(enhancedThreads);
} catch (error) {
console.error("Failed to fetch threads:", error);
} finally {
setThreadsLoading(false);
}
}, [apiUrl, assistantId]);
// Removed polling - now using real-time stream updates via updateThreadFromStream
// Initial load
useEffect(() => {
refreshThreads();
}, [refreshThreads]);
const value = {
getThreads,
threads,
setThreads,
threadsLoading,
setThreadsLoading,
refreshThreads,
getThread,
updateThreadFromStream,
};
return (

View file

@ -1,8 +1,7 @@
import { Client } from "@langchain/langgraph-sdk";
export function createClient(apiUrl: string, apiKey: string | undefined) {
export function createClient(apiUrl: string) {
return new Client({
apiKey,
apiUrl,
});
}

View file

@ -0,0 +1,7 @@
export const SUPPORTED_FILE_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"application/pdf",
];

View file

@ -0,0 +1,50 @@
import { Dispatch, SetStateAction } from "react";
import { ThreadStatus } from "@langchain/langgraph-sdk";
import { TaskPlan } from "@open-swe/shared/open-swe/types";
// Task with status extended from the agent
export interface TaskWithStatus extends TaskPlan {
status: ThreadStatus;
repository?: string;
date?: string;
}
// Enhanced task type with thread context
export interface TaskWithContext extends TaskWithStatus {
taskId: string; // Globally unique UUID
threadId: string; // Internal reference (not exposed to user)
threadTitle?: string;
branch?: string;
createdAt: string; // For chronological sorting
// status, repository, and date are inherited from TaskWithStatus
}
export interface TaskContextType {
getTasks: (threadId: string) => Promise<TaskWithStatus[]>;
getAllTasks: () => Promise<TaskWithContext[]>;
refreshStatus: () => Promise<void>;
tasks: TaskWithStatus[];
setTasks: Dispatch<SetStateAction<TaskWithStatus[]>>;
allTasks: TaskWithContext[];
setAllTasks: Dispatch<SetStateAction<TaskWithContext[]>>;
tasksLoading: boolean;
setTasksLoading: Dispatch<SetStateAction<boolean>>;
// Active thread management for real-time status updates
addActiveThread: (threadId: string) => void;
removeActiveThread: (threadId: string) => void;
activeThreads: Set<string>;
}
// Thread summary for grouping tasks
export interface ThreadSummary {
threadId: string;
threadTitle: string;
repository: string;
branch: string;
date: string;
createdAt: string;
tasks: TaskWithContext[];
completedTasksCount: number;
totalTasksCount: number;
status: ThreadStatus;
}

View file

@ -76,6 +76,85 @@ export async function getInstallationRepositories(
return data.repositories;
}
/**
* Fetches branches for a specific repository using OAuth access token
*/
export async function getRepositoryBranches(
owner: string,
repo: string,
accessToken: string,
): Promise<Branch[]> {
const allBranches: Branch[] = [];
let page = 1;
const perPage = 100; // Maximum allowed by GitHub API
// First, get repository info to ensure we have the default branch
const repoResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": "OpenSWE-Agent",
},
},
);
let defaultBranch: string | null = null;
if (repoResponse.ok) {
const repoData = await repoResponse.json();
defaultBranch = repoData.default_branch;
}
// Fetch all branches with pagination
while (true) {
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/branches?per_page=${perPage}&page=${page}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": "OpenSWE-Agent",
},
},
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(`Failed to fetch branches: ${JSON.stringify(errorData)}`);
}
const data = await response.json();
if (!Array.isArray(data) || data.length === 0) {
break;
}
allBranches.push(...data);
// If we got less than the requested amount, we've reached the end
if (data.length < perPage) {
break;
}
page++;
}
// Ensure default branch is at the beginning if it exists
if (defaultBranch) {
const defaultBranchIndex = allBranches.findIndex(
(branch) => branch.name === defaultBranch,
);
if (defaultBranchIndex > 0) {
const defaultBranchData = allBranches[defaultBranchIndex];
allBranches.splice(defaultBranchIndex, 1);
allBranches.unshift(defaultBranchData);
}
}
return allBranches;
}
/**
* Repository interface representing GitHub repository data
*/
@ -95,3 +174,15 @@ export interface Repository {
pull: boolean;
};
}
/**
* Branch interface representing GitHub branch data
*/
export interface Branch {
name: string;
commit: {
sha: string;
url: string;
};
protected: boolean;
}

View file

@ -1,7 +1,7 @@
{
"node_version": "20",
"graphs": {
"open-swe": "./apps/open-swe/src/index.ts:graph"
"open_swe": "./apps/open-swe/src/index.ts:graph"
},
"env": "./apps/open-swe/.env",
"dependencies": ["./apps/open-swe"]

View file

@ -19,5 +19,8 @@
"turbo": "^2.5.0",
"typescript": "^5"
},
"resolutions": {
"@langchain/langgraph-sdk": "^0.0.83"
},
"packageManager": "yarn@3.5.1"
}

View file

@ -18,7 +18,8 @@
},
"dependencies": {
"@langchain/core": "^0.3.56",
"@langchain/langgraph": "^0.2.73",
"@langchain/langgraph": "^0.3.1",
"@langchain/langgraph-sdk": "^0.0.83",
"zod": "^3.23.8"
},
"devDependencies": {

View file

@ -0,0 +1,69 @@
// The type interface for configuration fields
export type ConfigurableFieldUIType =
| "text"
| "textarea"
| "number"
| "boolean"
| "slider"
| "select"
| "json";
/**
* The type interface for options in a select field.
*/
export interface ConfigurableFieldOption {
label: string;
value: string;
}
/**
* The UI configuration for a field in the configurable object.
*/
export type ConfigurableFieldUIMetadata = {
/**
* The label of the field. This will be what is rendered in the UI.
*/
label: string;
/**
* The default value to render in the UI component.
*
* @default undefined
*/
default?: unknown;
/**
* The type of the field.
* @default "text"
*/
type?: ConfigurableFieldUIType;
/**
* The description of the field. This will be rendered below the UI component.
*/
description?: string;
/**
* The placeholder of the field. This will be rendered inside the UI component.
* This is only applicable for text, textarea, number, json, and select fields.
*/
placeholder?: string;
/**
* The options of the field. These will be the options rendered in the select UI component.
* This is only applicable for select fields.
*/
options?: ConfigurableFieldOption[];
/**
* The minimum value of the field.
* This is only applicable for number fields.
*/
min?: number;
/**
* The maximum value of the field.
* This is only applicable for number fields.
*/
max?: number;
/**
* The step value of the field. E.g if using a slider, where you want
* people to be able to increment by 0.1, you would set this field to 0.1
* This is only applicable for number fields.
*/
step?: number;
};

View file

@ -1,3 +1,5 @@
export const TIMEOUT_SEC = 60; // 1 minute
export const SANDBOX_ROOT_DIR = "/home/daytona";
export const SNAPSHOT_NAME = "daytonaio/langchain-open-swe:0.1.0";
export const PLAN_INTERRUPT_DELIMITER = ":::";
export const PLAN_INTERRUPT_ACTION_TITLE = "Approve/Edit Plan";

View file

@ -0,0 +1,10 @@
import { MessageContent } from "@langchain/core/messages";
export function getMessageContentString(content: MessageContent): string {
if (typeof content === "string") return content;
return content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join(" ");
}

View file

@ -0,0 +1,63 @@
export const MODEL_OPTIONS = [
{
label: "Claude Sonnet 4 (Extended Thinking)",
value: "anthropic:extended-thinking:claude-sonnet-4-0",
},
{
label: "Claude Opus 4 (Extended Thinking)",
value: "anthropic:extended-thinking:claude-opus-4-0",
},
{
label: "Claude Sonnet 4",
value: "anthropic:claude-sonnet-4-0",
},
{
label: "Claude Opus 4",
value: "anthropic:claude-opus-4-0",
},
{
label: "Claude 3.7 Sonnet",
value: "anthropic:claude-3-7-sonnet-latest",
},
{
label: "Claude 3.5 Sonnet",
value: "anthropic:claude-3-5-sonnet-latest",
},
{
label: "o4",
value: "openai:o4",
},
{
label: "o4 mini",
value: "openai:o4-mini",
},
{
label: "o3",
value: "openai:o3",
},
{
label: "o3 mini",
value: "openai:o3-mini",
},
{
label: "GPT 4o",
value: "openai:gpt-4o",
},
{
label: "GPT 4.1",
value: "openai:gpt-4.1",
},
{
label: "Gemini 2.5 Pro Preview",
value: "google-genai:gemini-2.5-pro-preview-05-06",
},
{
label: "Gemini 2.5 Flash Preview",
value: "google-genai:gemini-2.5-flash-preview-05-20",
},
];
export const MODEL_OPTIONS_NO_THINKING = MODEL_OPTIONS.filter(
({ value }) =>
!value.includes("extended-thinking") || !value.startsWith("openai:o"),
);

View file

@ -1,11 +1,16 @@
import "@langchain/langgraph/zod";
import { z } from "zod";
import {
addMessages,
LangGraphRunnableConfig,
Messages,
} from "@langchain/langgraph";
import { BaseMessage } from "@langchain/core/messages";
MessagesZodState,
} from "@langchain/langgraph/web";
import { MODEL_OPTIONS, MODEL_OPTIONS_NO_THINKING } from "./models.js";
import { ConfigurableFieldUIMetadata } from "../configurable-metadata.js";
import {
uiMessageReducer,
type UIMessage,
type RemoveUIMessage,
} from "@langchain/langgraph-sdk/react-ui";
export type PlanItem = {
/**
@ -108,11 +113,7 @@ export type TargetRepository = {
branch?: string;
};
export const GraphAnnotation = z.object({
messages: z
.custom<BaseMessage[]>()
.default(() => [])
.langgraph.reducer<Messages>((state, update) => addMessages(state, update)),
export const GraphAnnotation = MessagesZodState.extend({
proposedPlan: z
.array(z.string())
.default(() => [])
@ -153,74 +154,147 @@ export const GraphAnnotation = z.object({
.string()
.default(() => "")
.langgraph.reducer((_state, update) => update),
ui: z
.custom<UIMessage[]>()
.default(() => [])
.langgraph.reducer<(UIMessage | RemoveUIMessage)[]>((state, update) =>
uiMessageReducer(state, update),
),
// TODO: Not used, but can be used in the future for Gen UI artifacts
context: z.record(z.string(), z.unknown()),
});
export type GraphState = z.infer<typeof GraphAnnotation>;
export type GraphUpdate = Partial<GraphState>;
const MODEL_OPTIONS = [
{
label: "Claude Sonnet 4 (Extended Thinking)",
value: "anthropic:extended-thinking:claude-sonnet-4-0",
export const GraphConfigurationMetadata: {
[key: string]: {
x_open_swe_ui_config:
| Omit<ConfigurableFieldUIMetadata, "label">
| { type: "hidden" };
};
} = {
plannerModelName: {
x_open_swe_ui_config: {
type: "select",
default: "anthropic:claude-sonnet-4-0",
description: "The model to use for planning",
options: MODEL_OPTIONS_NO_THINKING,
},
},
{
label: "Claude Opus 4 (Extended Thinking)",
value: "anthropic:extended-thinking:claude-opus-4-0",
plannerTemperature: {
x_open_swe_ui_config: {
type: "slider",
default: 0,
min: 0,
max: 2,
step: 0.1,
description: "Controls randomness (0 = deterministic, 2 = creative)",
},
},
{
label: "Claude Sonnet 4",
value: "anthropic:claude-sonnet-4-0",
plannerContextModelName: {
x_open_swe_ui_config: {
type: "select",
default: "anthropic:claude-sonnet-4-0",
description: "The model to use for planning",
options: MODEL_OPTIONS,
},
},
{
label: "Claude Opus 4",
value: "anthropic:claude-opus-4-0",
plannerContextTemperature: {
x_open_swe_ui_config: {
type: "slider",
default: 0,
min: 0,
max: 2,
step: 0.1,
description: "Controls randomness (0 = deterministic, 2 = creative)",
},
},
{
label: "Claude 3.7 Sonnet",
value: "anthropic:claude-3-7-sonnet-latest",
actionGeneratorModelName: {
x_open_swe_ui_config: {
type: "select",
default: "anthropic:claude-sonnet-4-0",
description: "The model to use for action generation",
options: MODEL_OPTIONS,
},
},
{
label: "Claude 3.5 Sonnet",
value: "anthropic:claude-3-5-sonnet-latest",
actionGeneratorTemperature: {
x_open_swe_ui_config: {
type: "slider",
default: 0,
min: 0,
max: 2,
step: 0.1,
description: "Controls randomness (0 = deterministic, 2 = creative)",
},
},
{
label: "o4",
value: "openai:o4",
progressPlanCheckerModelName: {
x_open_swe_ui_config: {
type: "select",
default: "anthropic:claude-sonnet-4-0",
description: "The model to use for progress plan checking",
options: MODEL_OPTIONS_NO_THINKING,
},
},
{
label: "o4 mini",
value: "openai:o4-mini",
progressPlanCheckerTemperature: {
x_open_swe_ui_config: {
type: "slider",
default: 0,
min: 0,
max: 2,
step: 0.1,
description: "Controls randomness (0 = deterministic, 2 = creative)",
},
},
{
label: "o3",
value: "openai:o3",
summarizerModelName: {
x_open_swe_ui_config: {
type: "select",
default: "anthropic:claude-sonnet-4-0",
description: "The model to use for summarizing the conversation history",
options: MODEL_OPTIONS_NO_THINKING,
},
},
{
label: "o3 mini",
value: "openai:o3-mini",
summarizerTemperature: {
x_open_swe_ui_config: {
type: "slider",
default: 0,
min: 0,
max: 2,
step: 0.1,
description: "Controls randomness (0 = deterministic, 2 = creative)",
},
},
{
label: "GPT 4o",
value: "openai:gpt-4o",
maxContextActions: {
x_open_swe_ui_config: {
type: "number",
default: 6,
min: 1,
max: 20,
description:
"Maximum number of context gathering actions during planning",
},
},
{
label: "GPT 4.1",
value: "openai:gpt-4.1",
maxTokens: {
x_open_swe_ui_config: {
type: "number",
default: 10_000,
min: 1,
max: 64_000,
description:
"The maximum number of tokens to generate in an individual generation",
},
},
{
label: "Gemini 2.5 Pro Preview",
value: "google-genai:gemini-2.5-pro-preview-05-06",
"x-github-installation-token": {
x_open_swe_ui_config: {
type: "hidden",
},
},
{
label: "Gemini 2.5 Flash Preview",
value: "google-genai:gemini-2.5-flash-preview-05-20",
"x-github-access-token": {
x_open_swe_ui_config: {
type: "hidden",
},
},
];
const MODEL_OPTIONS_NO_THINKING = MODEL_OPTIONS.filter(
({ value }) =>
!value.includes("extended-thinking") || !value.startsWith("openai:o"),
);
};
export const GraphConfiguration = z.object({
/**
@ -231,15 +305,7 @@ export const GraphConfiguration = z.object({
plannerModelName: z
.string()
.optional()
.langgraph.metadata({
x_oap_ui_config: {
type: "select",
default: "anthropic:claude-sonnet-4-0",
description: "The model to use for planning",
// Do not show extended thinking models
options: MODEL_OPTIONS_NO_THINKING,
},
}),
.langgraph.metadata(GraphConfigurationMetadata.plannerModelName),
/**
* The temperature to use for the planning step.
* This includes initial planning, and rewriting.
@ -249,16 +315,7 @@ export const GraphConfiguration = z.object({
plannerTemperature: z
.number()
.optional()
.langgraph.metadata({
x_oap_ui_config: {
type: "slider",
default: 0,
min: 0,
max: 2,
step: 0.1,
description: "Controls randomness (0 = deterministic, 2 = creative)",
},
}),
.langgraph.metadata(GraphConfigurationMetadata.plannerTemperature),
/**
* The model ID to use for the planning step.
* This includes initial planning, and rewriting.
@ -267,14 +324,7 @@ export const GraphConfiguration = z.object({
plannerContextModelName: z
.string()
.optional()
.langgraph.metadata({
x_oap_ui_config: {
type: "select",
default: "anthropic:claude-sonnet-4-0",
description: "The model to use for planning",
options: MODEL_OPTIONS,
},
}),
.langgraph.metadata(GraphConfigurationMetadata.plannerContextModelName),
/**
* The temperature to use for the planning step.
* This includes initial planning, and rewriting.
@ -284,16 +334,7 @@ export const GraphConfiguration = z.object({
plannerContextTemperature: z
.number()
.optional()
.langgraph.metadata({
x_oap_ui_config: {
type: "slider",
default: 0,
min: 0,
max: 2,
step: 0.1,
description: "Controls randomness (0 = deterministic, 2 = creative)",
},
}),
.langgraph.metadata(GraphConfigurationMetadata.plannerContextTemperature),
/**
* The model ID to use for action generation.
@ -302,14 +343,7 @@ export const GraphConfiguration = z.object({
actionGeneratorModelName: z
.string()
.optional()
.langgraph.metadata({
x_oap_ui_config: {
type: "select",
default: "anthropic:claude-sonnet-4-0",
description: "The model to use for action generation",
options: MODEL_OPTIONS,
},
}),
.langgraph.metadata(GraphConfigurationMetadata.actionGeneratorModelName),
/**
* The temperature to use for action generation.
* If selecting a reasoning model, this will be ignored.
@ -318,16 +352,7 @@ export const GraphConfiguration = z.object({
actionGeneratorTemperature: z
.number()
.optional()
.langgraph.metadata({
x_oap_ui_config: {
type: "slider",
default: 0,
min: 0,
max: 2,
step: 0.1,
description: "Controls randomness (0 = deterministic, 2 = creative)",
},
}),
.langgraph.metadata(GraphConfigurationMetadata.actionGeneratorTemperature),
/**
* The model ID to use for progress plan checking.
@ -336,14 +361,9 @@ export const GraphConfiguration = z.object({
progressPlanCheckerModelName: z
.string()
.optional()
.langgraph.metadata({
x_oap_ui_config: {
type: "select",
default: "anthropic:claude-sonnet-4-0",
description: "The model to use for progress plan checking",
options: MODEL_OPTIONS_NO_THINKING,
},
}),
.langgraph.metadata(
GraphConfigurationMetadata.progressPlanCheckerModelName,
),
/**
* The temperature to use for progress plan checking.
* If selecting a reasoning model, this will be ignored.
@ -352,16 +372,9 @@ export const GraphConfiguration = z.object({
progressPlanCheckerTemperature: z
.number()
.optional()
.langgraph.metadata({
x_oap_ui_config: {
type: "slider",
default: 0,
min: 0,
max: 2,
step: 0.1,
description: "Controls randomness (0 = deterministic, 2 = creative)",
},
}),
.langgraph.metadata(
GraphConfigurationMetadata.progressPlanCheckerTemperature,
),
/**
* The model ID to use for summarizing the conversation history.
@ -370,15 +383,7 @@ export const GraphConfiguration = z.object({
summarizerModelName: z
.string()
.optional()
.langgraph.metadata({
x_oap_ui_config: {
type: "select",
default: "anthropic:claude-sonnet-4-0",
description:
"The model to use for summarizing the conversation history",
options: MODEL_OPTIONS_NO_THINKING,
},
}),
.langgraph.metadata(GraphConfigurationMetadata.summarizerModelName),
/**
* The temperature to use for summarizing the conversation history.
* If selecting a reasoning model, this will be ignored.
@ -387,16 +392,7 @@ export const GraphConfiguration = z.object({
summarizerTemperature: z
.number()
.optional()
.langgraph.metadata({
x_oap_ui_config: {
type: "slider",
default: 0,
min: 0,
max: 2,
step: 0.1,
description: "Controls randomness (0 = deterministic, 2 = creative)",
},
}),
.langgraph.metadata(GraphConfigurationMetadata.summarizerTemperature),
/**
* The maximum number of context gathering actions to take during planning.
@ -407,16 +403,7 @@ export const GraphConfiguration = z.object({
maxContextActions: z
.number()
.optional()
.langgraph.metadata({
x_oap_ui_config: {
type: "number",
default: 6,
min: 1,
max: 20,
description:
"Maximum number of context gathering actions during planning",
},
}),
.langgraph.metadata(GraphConfigurationMetadata.maxContextActions),
/**
* The maximum number of tokens to generate in an individual generation.
* @default 10_000
@ -425,38 +412,23 @@ export const GraphConfiguration = z.object({
.number()
.optional()
.default(() => 10_000)
.langgraph.metadata({
x_oap_ui_config: {
type: "number",
default: 10_000,
min: 1,
max: 64_000,
description:
"The maximum number of tokens to generate in an individual generation",
},
}),
.langgraph.metadata(GraphConfigurationMetadata.maxTokens),
/**
* The user's GitHub installation token. To be used to take actions on behalf of the user.
*/
"x-github-installation-token": z
.string()
.optional()
.langgraph.metadata({
x_oap_ui_config: {
type: "hidden",
},
}),
.langgraph.metadata(
GraphConfigurationMetadata["x-github-installation-token"],
),
/**
* The user's GitHub access token. To be used in requests to get information about the user.
*/
"x-github-access-token": z
.string()
.optional()
.langgraph.metadata({
x_oap_ui_config: {
type: "hidden",
},
}),
.langgraph.metadata(GraphConfigurationMetadata["x-github-access-token"]),
});
export type GraphConfig = LangGraphRunnableConfig<

230
yarn.lock
View file

@ -1488,14 +1488,14 @@ __metadata:
languageName: node
linkType: hard
"@langchain/langgraph-checkpoint@npm:~0.0.17":
version: 0.0.17
resolution: "@langchain/langgraph-checkpoint@npm:0.0.17"
"@langchain/langgraph-checkpoint@npm:~0.0.18":
version: 0.0.18
resolution: "@langchain/langgraph-checkpoint@npm:0.0.18"
dependencies:
uuid: ^10.0.0
peerDependencies:
"@langchain/core": ">=0.2.31 <0.4.0"
checksum: c8dc429e66ff5b0f31037c493c4f8687196f1800d52b48e8ff04829742cd7200af66ce46dee93ff3c0f6b6e6c2af36336f09626a5ce296ec610383a3bd7131ef
checksum: bd7ba56696bbfde0be8f2cef2f0dadf30826732cbb1d9bf92439f314a01219053c50ff6b624ef58a9d6723d6e1a8b872e725abc4d95e4ea9610a41c5f1e41047
languageName: node
linkType: hard
@ -1527,9 +1527,9 @@ __metadata:
languageName: node
linkType: hard
"@langchain/langgraph-sdk@npm:^0.0.78, @langchain/langgraph-sdk@npm:~0.0.32":
version: 0.0.78
resolution: "@langchain/langgraph-sdk@npm:0.0.78"
"@langchain/langgraph-sdk@npm:^0.0.83":
version: 0.0.83
resolution: "@langchain/langgraph-sdk@npm:0.0.83"
dependencies:
"@types/json-schema": ^7.0.15
p-queue: ^6.6.2
@ -1543,7 +1543,7 @@ __metadata:
optional: true
react:
optional: true
checksum: 3f391c79cf32aebe506e56122f7422f7075a0ba8d1331cde6d65de241a14b9f4ec6187baec69ea9372fa41426eb97067067c07566483b7bf2445689420238da5
checksum: c6d037b8b9486456a8180d892e8b05ad3c2332456d7d237597720ebe42dd6b126f1aed963a0d2a681405724b7b8e0eac0607994b8e527d2f0e756e4c9e30c19e
languageName: node
linkType: hard
@ -1562,11 +1562,11 @@ __metadata:
languageName: node
linkType: hard
"@langchain/langgraph@npm:^0.2.73":
version: 0.2.73
resolution: "@langchain/langgraph@npm:0.2.73"
"@langchain/langgraph@npm:^0.3.1":
version: 0.3.1
resolution: "@langchain/langgraph@npm:0.3.1"
dependencies:
"@langchain/langgraph-checkpoint": ~0.0.17
"@langchain/langgraph-checkpoint": ~0.0.18
"@langchain/langgraph-sdk": ~0.0.32
uuid: ^10.0.0
zod: ^3.23.8
@ -1576,7 +1576,7 @@ __metadata:
peerDependenciesMeta:
zod-to-json-schema:
optional: true
checksum: dd9f2cb38fe6702264657ab35c60fdc7cd4e88574e3ab5a21cf18f39a4df069fef3aa9503ec33780657b8ca17c359f50e2f7247c57350306f58ee14adde400d2
checksum: 25f4a338635c00a0bb56be483438eef29a133b3900cec91b0a32e880ba0b6ee719ea0365f957cc7a5c25218f50f10b83975f2198565aafc27d17127fabf188cc
languageName: node
linkType: hard
@ -1913,9 +1913,9 @@ __metadata:
"@langchain/anthropic": ^0.3.20
"@langchain/core": ^0.3.56
"@langchain/google-genai": ^0.2.9
"@langchain/langgraph": ^0.2.73
"@langchain/langgraph": ^0.3.1
"@langchain/langgraph-cli": latest
"@langchain/langgraph-sdk": ^0.0.78
"@langchain/langgraph-sdk": ^0.0.83
"@langchain/openai": ^0.5.10
"@octokit/rest": ^22.0.0
"@open-swe/shared": "workspace:*"
@ -1948,7 +1948,8 @@ __metadata:
"@eslint/eslintrc": ^3.1.0
"@eslint/js": ^9.19.0
"@langchain/core": ^0.3.56
"@langchain/langgraph": ^0.2.73
"@langchain/langgraph": ^0.3.1
"@langchain/langgraph-sdk": ^0.0.83
"@tsconfig/recommended": ^1.0.8
"@types/node": ^22.13.5
dotenv: ^16.4.7
@ -1970,16 +1971,22 @@ __metadata:
dependencies:
"@eslint/js": ^9.19.0
"@langchain/core": ^0.3.57
"@langchain/langgraph": ^0.2.73
"@langchain/langgraph-sdk": ^0.0.78
"@langchain/langgraph": ^0.3.1
"@langchain/langgraph-sdk": ^0.0.83
"@open-swe/shared": "workspace:*"
"@radix-ui/react-alert-dialog": ^1.1.14
"@radix-ui/react-avatar": ^1.1.3
"@radix-ui/react-collapsible": ^1.1.11
"@radix-ui/react-dialog": ^1.1.14
"@radix-ui/react-label": ^2.1.2
"@radix-ui/react-popover": ^1.1.14
"@radix-ui/react-scroll-area": ^1.2.9
"@radix-ui/react-select": ^2.2.5
"@radix-ui/react-separator": ^1.1.2
"@radix-ui/react-slot": ^1.1.2
"@radix-ui/react-slider": ^1.3.5
"@radix-ui/react-slot": ^1.2.3
"@radix-ui/react-switch": ^1.1.3
"@radix-ui/react-tabs": ^1.1.12
"@radix-ui/react-tooltip": ^1.1.8
"@tailwindcss/postcss": ^4.0.13
"@types/jsonwebtoken": ^9.0.9
@ -2032,6 +2039,7 @@ __metadata:
use-stick-to-bottom: ^1.0.46
uuid: ^11.1.0
zod: ^3.24.2
zustand: ^5.0.5
languageName: unknown
linkType: soft
@ -2056,6 +2064,30 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-alert-dialog@npm:^1.1.14":
version: 1.1.14
resolution: "@radix-ui/react-alert-dialog@npm:1.1.14"
dependencies:
"@radix-ui/primitive": 1.1.2
"@radix-ui/react-compose-refs": 1.1.2
"@radix-ui/react-context": 1.1.2
"@radix-ui/react-dialog": 1.1.14
"@radix-ui/react-primitive": 2.1.3
"@radix-ui/react-slot": 1.2.3
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: 5b3fec8fc11267c6c5bcf6afcbcb5ca7e8050401c692019097d08502a43d9fd555a18beb5b74806f301074150b6212dc7523722311e8dad9baa50a502d8b3828
languageName: node
linkType: hard
"@radix-ui/react-arrow@npm:1.1.7":
version: 1.1.7
resolution: "@radix-ui/react-arrow@npm:1.1.7"
@ -2098,6 +2130,32 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-collapsible@npm:^1.1.11":
version: 1.1.11
resolution: "@radix-ui/react-collapsible@npm:1.1.11"
dependencies:
"@radix-ui/primitive": 1.1.2
"@radix-ui/react-compose-refs": 1.1.2
"@radix-ui/react-context": 1.1.2
"@radix-ui/react-id": 1.1.1
"@radix-ui/react-presence": 1.1.4
"@radix-ui/react-primitive": 2.1.3
"@radix-ui/react-use-controllable-state": 1.2.2
"@radix-ui/react-use-layout-effect": 1.1.1
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: a7ef7992caa3c02952dd8210493c1807739a8ac2ffb6ecf9b07974de65ed49a93b743e325e2dcd23318ea658328a01f750ecd3a965db68059a84b14988892dc4
languageName: node
linkType: hard
"@radix-ui/react-collection@npm:1.1.7":
version: 1.1.7
resolution: "@radix-ui/react-collection@npm:1.1.7"
@ -2146,7 +2204,7 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-dialog@npm:^1.1.14, @radix-ui/react-dialog@npm:^1.1.6":
"@radix-ui/react-dialog@npm:1.1.14, @radix-ui/react-dialog@npm:^1.1.14, @radix-ui/react-dialog@npm:^1.1.6":
version: 1.1.14
resolution: "@radix-ui/react-dialog@npm:1.1.14"
dependencies:
@ -2402,6 +2460,60 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-roving-focus@npm:1.1.10":
version: 1.1.10
resolution: "@radix-ui/react-roving-focus@npm:1.1.10"
dependencies:
"@radix-ui/primitive": 1.1.2
"@radix-ui/react-collection": 1.1.7
"@radix-ui/react-compose-refs": 1.1.2
"@radix-ui/react-context": 1.1.2
"@radix-ui/react-direction": 1.1.1
"@radix-ui/react-id": 1.1.1
"@radix-ui/react-primitive": 2.1.3
"@radix-ui/react-use-callback-ref": 1.1.1
"@radix-ui/react-use-controllable-state": 1.2.2
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: 9f6afabc82e3a018cbb48f4fc94ce046f776f894df2d66c96f6b78bf864d59d71f749406e30c4600f6415a6f703c66fdbb87b2841cfa919f10d1f6602df2a5b6
languageName: node
linkType: hard
"@radix-ui/react-scroll-area@npm:^1.2.9":
version: 1.2.9
resolution: "@radix-ui/react-scroll-area@npm:1.2.9"
dependencies:
"@radix-ui/number": 1.1.1
"@radix-ui/primitive": 1.1.2
"@radix-ui/react-compose-refs": 1.1.2
"@radix-ui/react-context": 1.1.2
"@radix-ui/react-direction": 1.1.1
"@radix-ui/react-presence": 1.1.4
"@radix-ui/react-primitive": 2.1.3
"@radix-ui/react-use-callback-ref": 1.1.1
"@radix-ui/react-use-layout-effect": 1.1.1
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: dd5e097b58bf39c51d748043e7524414da860fc116ef9265daaed7d08d5faf5f0a7b3c2acd00463f9d72d7da0eb28ce00c0b886402b87535c0c0983aad2e69b7
languageName: node
linkType: hard
"@radix-ui/react-select@npm:^2.2.5":
version: 2.2.5
resolution: "@radix-ui/react-select@npm:2.2.5"
@ -2460,7 +2572,36 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-slot@npm:1.2.3, @radix-ui/react-slot@npm:^1.1.2":
"@radix-ui/react-slider@npm:^1.3.5":
version: 1.3.5
resolution: "@radix-ui/react-slider@npm:1.3.5"
dependencies:
"@radix-ui/number": 1.1.1
"@radix-ui/primitive": 1.1.2
"@radix-ui/react-collection": 1.1.7
"@radix-ui/react-compose-refs": 1.1.2
"@radix-ui/react-context": 1.1.2
"@radix-ui/react-direction": 1.1.1
"@radix-ui/react-primitive": 2.1.3
"@radix-ui/react-use-controllable-state": 1.2.2
"@radix-ui/react-use-layout-effect": 1.1.1
"@radix-ui/react-use-previous": 1.1.1
"@radix-ui/react-use-size": 1.1.1
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: c09071d637309ae0a4438849cca98049d9b2220f10afe53f85c8c33fe41ba8ca813854a659ecef868e48e89ac389fb821726a766fc0c6c10aa6d7d408f4485f5
languageName: node
linkType: hard
"@radix-ui/react-slot@npm:1.2.3, @radix-ui/react-slot@npm:^1.2.3":
version: 1.2.3
resolution: "@radix-ui/react-slot@npm:1.2.3"
dependencies:
@ -2500,6 +2641,32 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-tabs@npm:^1.1.12":
version: 1.1.12
resolution: "@radix-ui/react-tabs@npm:1.1.12"
dependencies:
"@radix-ui/primitive": 1.1.2
"@radix-ui/react-context": 1.1.2
"@radix-ui/react-direction": 1.1.1
"@radix-ui/react-id": 1.1.1
"@radix-ui/react-presence": 1.1.4
"@radix-ui/react-primitive": 2.1.3
"@radix-ui/react-roving-focus": 1.1.10
"@radix-ui/react-use-controllable-state": 1.2.2
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: eb5eecb4813b784c6f324a947b1f391b1b890bc3f5bd0eb2d08e73f84a10b25bbf482a8dd9785de8a6fe49ff99860c07cf0a6953c9f9631202e5d9c8ebc06758
languageName: node
linkType: hard
"@radix-ui/react-tooltip@npm:^1.1.8":
version: 1.2.7
resolution: "@radix-ui/react-tooltip@npm:1.2.7"
@ -12910,6 +13077,27 @@ __metadata:
languageName: node
linkType: hard
"zustand@npm:^5.0.5":
version: 5.0.5
resolution: "zustand@npm:5.0.5"
peerDependencies:
"@types/react": ">=18.0.0"
immer: ">=9.0.6"
react: ">=18.0.0"
use-sync-external-store: ">=1.2.0"
peerDependenciesMeta:
"@types/react":
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
checksum: 9f4fa5d7152c6b5a9cbcccb08ae34500c7495f8707549e7a8de3cea2844a75a81b4acc69a85601dbe539ee91f69c2d25d4e5286eb973bc76292a2012256061cb
languageName: node
linkType: hard
"zwitch@npm:^2.0.0":
version: 2.0.4
resolution: "zwitch@npm:2.0.4"