feat: embed chat ui in the task sidebar (#18216)

**Demo:**

<img width="1512" alt="Screenshot 2025-06-03 at 14 36 25"
src="https://github.com/user-attachments/assets/e4a61bd3-2182-4593-991d-5db9573a5b7f"
/>

- Extract components to be reused and easier to reasoning about 
- When having cloude-code-web, embed the chat in the sidebar 
- The sidebar will be wider when having the chat to better fit that 

**Does not include:**
- Sidebar width drag and drop control. The width is static but would be
nice to have a control to customize it.
This commit is contained in:
Bruno Quaresma
2025-06-03 17:00:59 -03:00
committed by GitHub
parent 63adfa57bd
commit 13449b9e78
5 changed files with 445 additions and 391 deletions
+40
View File
@@ -0,0 +1,40 @@
import type { WorkspaceApp } from "api/typesGenerated";
import { useAppLink } from "modules/apps/useAppLink";
import type { Task } from "modules/tasks/tasks";
import type { FC } from "react";
import { cn } from "utils/cn";
type TaskAppIFrameProps = {
task: Task;
app: WorkspaceApp;
active: boolean;
};
export const TaskAppIFrame: FC<TaskAppIFrameProps> = ({
task,
app,
active,
}) => {
const agent = task.workspace.latest_build.resources
.flatMap((r) => r.agents)
.filter((a) => !!a)
.find((a) => a.apps.some((a) => a.id === app.id));
if (!agent) {
throw new Error(`Agent for app ${app.id} not found in task workspace`);
}
const link = useAppLink(app, {
agent,
workspace: task.workspace,
});
return (
<iframe
src={link.href}
title={link.label}
loading="eager"
className={cn([active ? "block" : "hidden", "w-full h-full border-0"])}
/>
);
};
+169
View File
@@ -0,0 +1,169 @@
import type { WorkspaceApp } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { ChevronDownIcon, LayoutGridIcon } from "lucide-react";
import { useAppLink } from "modules/apps/useAppLink";
import type { Task } from "modules/tasks/tasks";
import type React from "react";
import { type FC, useState } from "react";
import { Link as RouterLink } from "react-router-dom";
import { cn } from "utils/cn";
import { TaskAppIFrame } from "./TaskAppIframe";
import { AI_APP_CHAT_SLUG } from "./constants";
type TaskAppsProps = {
task: Task;
};
export const TaskApps: FC<TaskAppsProps> = ({ task }) => {
const agents = task.workspace.latest_build.resources
.flatMap((r) => r.agents)
.filter((a) => !!a);
// The Chat UI app will be displayed in the sidebar, so we don't want to show
// it here
const apps = agents
.flatMap((a) => a?.apps)
.filter((a) => !!a && a.slug !== AI_APP_CHAT_SLUG);
const [activeAppId, setActiveAppId] = useState<string>(() => {
const appId = task.workspace.latest_app_status?.app_id;
if (!appId) {
throw new Error("No active app found in task");
}
return appId;
});
const activeApp = apps.find((app) => app.id === activeAppId);
if (!activeApp) {
throw new Error(`Active app with ID ${activeAppId} not found in task`);
}
const agent = agents.find((a) =>
a.apps.some((app) => app.id === activeAppId),
);
if (!agent) {
throw new Error(`Agent for app ${activeAppId} not found in task workspace`);
}
const embeddedApps = apps.filter((app) => !app.external);
const externalApps = apps.filter((app) => app.external);
return (
<main className="flex-1 flex flex-col">
<div className="border-0 border-b border-border border-solid w-full p-1 flex gap-2">
{embeddedApps.map((app) => (
<TaskAppButton
key={app.id}
task={task}
app={app}
active={app.id === activeAppId}
onClick={(e) => {
e.preventDefault();
setActiveAppId(app.id);
}}
/>
))}
{externalApps.length > 0 && (
<div className="ml-auto">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="subtle">
Open locally
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{externalApps.map((app) => {
const link = useAppLink(app, {
agent,
workspace: task.workspace,
});
return (
<DropdownMenuItem key={app.id} asChild>
<RouterLink to={link.href}>
{app.icon ? (
<ExternalImage src={app.icon} />
) : (
<LayoutGridIcon />
)}
{link.label}
</RouterLink>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
<div className="flex-1">
{embeddedApps.map((app) => {
return (
<TaskAppIFrame
key={app.id}
active={activeAppId === app.id}
app={app}
task={task}
/>
);
})}
</div>
</main>
);
};
type TaskAppButtonProps = {
task: Task;
app: WorkspaceApp;
active: boolean;
onClick: (e: React.MouseEvent<HTMLAnchorElement>) => void;
};
const TaskAppButton: FC<TaskAppButtonProps> = ({
task,
app,
active,
onClick,
}) => {
const agent = task.workspace.latest_build.resources
.flatMap((r) => r.agents)
.filter((a) => !!a)
.find((a) => a.apps.some((a) => a.id === app.id));
if (!agent) {
throw new Error(`Agent for app ${app.id} not found in task workspace`);
}
const link = useAppLink(app, {
agent,
workspace: task.workspace,
});
return (
<Button
size="sm"
variant="subtle"
key={app.id}
asChild
className={cn([
{ "text-content-primary": active },
{ "opacity-75 hover:opacity-100": !active },
])}
>
<RouterLink to={link.href} onClick={onClick}>
{app.icon ? <ExternalImage src={app.icon} /> : <LayoutGridIcon />}
{link.label}
</RouterLink>
</Button>
);
};
+5 -391
View File
@@ -1,48 +1,20 @@
import GitHub from "@mui/icons-material/GitHub";
import { API } from "api/api";
import { getErrorDetail, getErrorMessage } from "api/errors";
import type { WorkspaceApp, WorkspaceStatus } from "api/typesGenerated";
import type { WorkspaceStatus } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { Loader } from "components/Loader/Loader";
import { Margins } from "components/Margins/Margins";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import { Spinner } from "components/Spinner/Spinner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import {
ArrowLeftIcon,
BugIcon,
ChevronDownIcon,
EllipsisVerticalIcon,
ExternalLinkIcon,
GitPullRequestArrowIcon,
LayoutGridIcon,
RotateCcwIcon,
} from "lucide-react";
import { AppStatusStateIcon } from "modules/apps/AppStatusStateIcon";
import { useAppLink } from "modules/apps/useAppLink";
import { ArrowLeftIcon, RotateCcwIcon } from "lucide-react";
import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks";
import type React from "react";
import { type FC, type ReactNode, useState } from "react";
import type { ReactNode } from "react";
import { Helmet } from "react-helmet-async";
import { useQuery } from "react-query";
import { useParams } from "react-router-dom";
import { Link as RouterLink } from "react-router-dom";
import { cn } from "utils/cn";
import { pageTitle } from "utils/page";
import { timeFrom } from "utils/time";
import { truncateURI } from "utils/uri";
import { TaskApps } from "./TaskApps";
import { TaskSidebar } from "./TaskSidebar";
const TaskPage = () => {
const { workspace: workspaceName, username } = useParams() as {
@@ -204,364 +176,6 @@ const TaskPage = () => {
export default TaskPage;
type TaskSidebarProps = {
task: Task;
};
const TaskSidebar: FC<TaskSidebarProps> = ({ task }) => {
let statuses = task.workspace.latest_build.resources
.flatMap((r) => r.agents)
.flatMap((a) => a?.apps)
.flatMap((a) => a?.statuses)
.filter((s) => !!s)
.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
// This happens when the workspace is not running so it has no resources to
// get the statuses so we can fallback to the latest status received from the
// workspace.
if (statuses.length === 0 && task.workspace.latest_app_status) {
statuses = [task.workspace.latest_app_status];
}
return (
<aside className="flex flex-col h-full border-0 border-r border-solid border-border w-[320px] shrink-0">
<header className="border-0 border-b border-solid border-border p-4 pt-0">
<div className="flex items-center justify-between py-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="subtle" asChild className="-ml-2">
<RouterLink to="/tasks">
<ArrowLeftIcon />
<span className="sr-only">Back to tasks</span>
</RouterLink>
</Button>
</TooltipTrigger>
<TooltipContent>Back to tasks</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenu>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="subtle" className="-mr-2">
<EllipsisVerticalIcon />
<span className="sr-only">Settings</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent>
<DropdownMenuItem asChild>
<RouterLink
to={`/@${task.workspace.owner_name}/${task.workspace.name}`}
>
View workspace
</RouterLink>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<h1 className="m-0 mt-1 text-base font-medium">{task.prompt}</h1>
{task.workspace.latest_app_status?.uri && (
<div className="flex items-center gap-2 mt-2 flex-wrap">
<TaskStatusLink uri={task.workspace.latest_app_status.uri} />
</div>
)}
</header>
{statuses ? (
<ScrollArea className="h-full">
{statuses.length === 0 && (
<article className="px-4 py-2 flex gap-2 first-of-type:pt-4 last-of-type:pb-4">
<div className="flex flex-col gap-1 flex-1">
<h3 className="m-0 font-medium text-sm leading-normal">
Running your task
</h3>
<time
dateTime={task.workspace.latest_build.created_at}
className="font-medium text-xs text-content-secondary first-letter:uppercase"
>
{timeFrom(new Date(task.workspace.latest_build.created_at))}
</time>
</div>
<AppStatusStateIcon state="working" latest className="size-5" />
</article>
)}
{statuses.map((status, index) => {
return (
<article
className={cn(
["px-4 py-2 flex gap-2 first-of-type:pt-4 last-of-type:pb-4"],
{
"opacity-50 hover:opacity-100": index !== 0,
},
)}
key={status.id}
>
<div className="flex flex-col gap-1 flex-1">
<h3 className="m-0 font-medium text-sm leading-normal">
{status.message}
</h3>
<time
dateTime={status.created_at}
className="font-medium text-xs text-content-secondary first-letter:uppercase"
>
{timeFrom(new Date(status.created_at))}
</time>
</div>
<AppStatusStateIcon
state={status.state}
latest={index === 0}
disabled={task.workspace.latest_build.status !== "running"}
className={cn(["size-5", { "opacity-0": index !== 0 }])}
/>
</article>
);
})}
</ScrollArea>
) : (
<Spinner loading />
)}
</aside>
);
};
type TaskAppsProps = {
task: Task;
};
const TaskApps: FC<TaskAppsProps> = ({ task }) => {
const agents = task.workspace.latest_build.resources
.flatMap((r) => r.agents)
.filter((a) => !!a);
const apps = agents.flatMap((a) => a?.apps).filter((a) => !!a);
const [activeAppId, setActiveAppId] = useState<string>(() => {
const appId = task.workspace.latest_app_status?.app_id;
if (!appId) {
throw new Error("No active app found in task");
}
return appId;
});
const activeApp = apps.find((app) => app.id === activeAppId);
if (!activeApp) {
throw new Error(`Active app with ID ${activeAppId} not found in task`);
}
const agent = agents.find((a) =>
a.apps.some((app) => app.id === activeAppId),
);
if (!agent) {
throw new Error(`Agent for app ${activeAppId} not found in task workspace`);
}
const embeddedApps = apps.filter((app) => !app.external);
const externalApps = apps.filter((app) => app.external);
return (
<main className="flex-1 flex flex-col">
<div className="border-0 border-b border-border border-solid w-full p-1 flex gap-2">
{embeddedApps
.filter((app) => !app.external)
.map((app) => (
<TaskAppButton
key={app.id}
task={task}
app={app}
active={app.id === activeAppId}
onClick={(e) => {
if (app.external) {
return;
}
e.preventDefault();
setActiveAppId(app.id);
}}
/>
))}
{externalApps.length > 0 && (
<div className="ml-auto">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="subtle">
Open locally
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{externalApps
.filter((app) => app.external)
.map((app) => {
const link = useAppLink(app, {
agent,
workspace: task.workspace,
});
return (
<DropdownMenuItem key={app.id} asChild>
<RouterLink to={link.href}>
{app.icon ? (
<ExternalImage src={app.icon} />
) : (
<LayoutGridIcon />
)}
{link.label}
</RouterLink>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
<div className="flex-1">
{embeddedApps.map((app) => {
return (
<TaskAppIFrame
key={app.id}
active={activeAppId === app.id}
app={app}
task={task}
/>
);
})}
</div>
</main>
);
};
type TaskAppButtonProps = {
task: Task;
app: WorkspaceApp;
active: boolean;
onClick: (e: React.MouseEvent<HTMLAnchorElement>) => void;
};
const TaskAppButton: FC<TaskAppButtonProps> = ({
task,
app,
active,
onClick,
}) => {
const agent = task.workspace.latest_build.resources
.flatMap((r) => r.agents)
.filter((a) => !!a)
.find((a) => a.apps.some((a) => a.id === app.id));
if (!agent) {
throw new Error(`Agent for app ${app.id} not found in task workspace`);
}
const link = useAppLink(app, {
agent,
workspace: task.workspace,
});
return (
<Button
size="sm"
variant="subtle"
key={app.id}
asChild
className={cn([
{ "text-content-primary": active },
{ "opacity-75 hover:opacity-100": !active },
])}
>
<RouterLink to={link.href} onClick={onClick}>
{app.icon ? <ExternalImage src={app.icon} /> : <LayoutGridIcon />}
{link.label}
</RouterLink>
</Button>
);
};
type TaskAppIFrameProps = {
task: Task;
app: WorkspaceApp;
active: boolean;
};
const TaskAppIFrame: FC<TaskAppIFrameProps> = ({ task, app, active }) => {
const agent = task.workspace.latest_build.resources
.flatMap((r) => r.agents)
.filter((a) => !!a)
.find((a) => a.apps.some((a) => a.id === app.id));
if (!agent) {
throw new Error(`Agent for app ${app.id} not found in task workspace`);
}
const link = useAppLink(app, {
agent,
workspace: task.workspace,
});
return (
<iframe
src={link.href}
title={link.label}
loading="eager"
className={cn([active ? "block" : "hidden", "w-full h-full border-0"])}
/>
);
};
type TaskStatusLinkProps = {
uri: string;
};
const TaskStatusLink: FC<TaskStatusLinkProps> = ({ uri }) => {
let icon = <ExternalLinkIcon />;
let label = truncateURI(uri);
if (uri.startsWith("https://github.com")) {
const issueNumber = uri.split("/").pop();
const [org, repo] = uri.split("/").slice(3, 5);
const prefix = `${org}/${repo}`;
if (uri.includes("pull/")) {
icon = <GitPullRequestArrowIcon />;
label = issueNumber
? `${prefix}#${issueNumber}`
: `${prefix} Pull Request`;
} else if (uri.includes("issues/")) {
icon = <BugIcon />;
label = issueNumber ? `${prefix}#${issueNumber}` : `${prefix} Issue`;
} else {
icon = <GitHub />;
label = `${org}/${repo}`;
}
}
return (
<Button asChild variant="outline" size="sm" className="min-w-0">
<a href={uri} target="_blank" rel="noreferrer">
{icon}
{label}
</a>
</Button>
);
};
export const data = {
fetchTask: async (workspaceOwnerUsername: string, workspaceName: string) => {
const workspace = await API.getWorkspaceByOwnerAndName(
+230
View File
@@ -0,0 +1,230 @@
import GitHub from "@mui/icons-material/GitHub";
import { Button } from "components/Button/Button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/DropdownMenu/DropdownMenu";
import { ScrollArea } from "components/ScrollArea/ScrollArea";
import { Spinner } from "components/Spinner/Spinner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import {
ArrowLeftIcon,
BugIcon,
EllipsisVerticalIcon,
ExternalLinkIcon,
GitPullRequestArrowIcon,
} from "lucide-react";
import { AppStatusStateIcon } from "modules/apps/AppStatusStateIcon";
import type { Task } from "modules/tasks/tasks";
import type { FC } from "react";
import { Link as RouterLink } from "react-router-dom";
import { cn } from "utils/cn";
import { timeFrom } from "utils/time";
import { truncateURI } from "utils/uri";
import { TaskAppIFrame } from "./TaskAppIframe";
import { AI_APP_CHAT_SLUG } from "./constants";
type TaskSidebarProps = {
task: Task;
};
export const TaskSidebar: FC<TaskSidebarProps> = ({ task }) => {
const chatApp = task.workspace.latest_build.resources
.flatMap((r) => r.agents)
.flatMap((a) => a?.apps)
.find((a) => a?.slug === AI_APP_CHAT_SLUG);
return (
<aside
className={cn([
[
"flex flex-col h-full shrink-0",
"border-0 border-r border-solid border-border",
],
// We want to make the sidebar wider for chat apps
chatApp ? "w-[440px]" : "w-[320px]",
])}
>
<header className="border-0 border-b border-solid border-border p-4 pt-0">
<div className="flex items-center justify-between py-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="subtle" asChild className="-ml-2">
<RouterLink to="/tasks">
<ArrowLeftIcon />
<span className="sr-only">Back to tasks</span>
</RouterLink>
</Button>
</TooltipTrigger>
<TooltipContent>Back to tasks</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenu>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="subtle" className="-mr-2">
<EllipsisVerticalIcon />
<span className="sr-only">Settings</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent>
<DropdownMenuItem asChild>
<RouterLink
to={`/@${task.workspace.owner_name}/${task.workspace.name}`}
>
View workspace
</RouterLink>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<h1 className="m-0 mt-1 text-base font-medium">{task.prompt}</h1>
{task.workspace.latest_app_status?.uri && (
<div className="flex items-center gap-2 mt-2 flex-wrap">
<TaskStatusLink uri={task.workspace.latest_app_status.uri} />
</div>
)}
</header>
{chatApp ? (
<TaskAppIFrame active key={chatApp.id} app={chatApp} task={task} />
) : (
<TaskStatuses task={task} />
)}
</aside>
);
};
type TaskStatusesProps = {
task: Task;
};
const TaskStatuses: FC<TaskStatusesProps> = ({ task }) => {
let statuses = task.workspace.latest_build.resources
.flatMap((r) => r.agents)
.flatMap((a) => a?.apps)
.flatMap((a) => a?.statuses)
.filter((s) => !!s)
.sort(
(a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
// This happens when the workspace is not running so it has no resources to
// get the statuses so we can fallback to the latest status received from the
// workspace.
if (statuses.length === 0 && task.workspace.latest_app_status) {
statuses = [task.workspace.latest_app_status];
}
return statuses ? (
<ScrollArea className="h-full">
{statuses.length === 0 && (
<article className="px-4 py-2 flex gap-2 first-of-type:pt-4 last-of-type:pb-4">
<div className="flex flex-col gap-1 flex-1">
<h3 className="m-0 font-medium text-sm leading-normal">
Running your task
</h3>
<time
dateTime={task.workspace.latest_build.created_at}
className="font-medium text-xs text-content-secondary first-letter:uppercase"
>
{timeFrom(new Date(task.workspace.latest_build.created_at))}
</time>
</div>
<AppStatusStateIcon state="working" latest className="size-5" />
</article>
)}
{statuses.map((status, index) => {
return (
<article
className={cn(
["px-4 py-2 flex gap-2 first-of-type:pt-4 last-of-type:pb-4"],
{
"opacity-50 hover:opacity-100": index !== 0,
},
)}
key={status.id}
>
<div className="flex flex-col gap-1 flex-1">
<h3 className="m-0 font-medium text-sm leading-normal">
{status.message}
</h3>
<time
dateTime={status.created_at}
className="font-medium text-xs text-content-secondary first-letter:uppercase"
>
{timeFrom(new Date(status.created_at))}
</time>
</div>
<AppStatusStateIcon
state={status.state}
latest={index === 0}
disabled={task.workspace.latest_build.status !== "running"}
className={cn(["size-5", { "opacity-0": index !== 0 }])}
/>
</article>
);
})}
</ScrollArea>
) : (
<Spinner loading />
);
};
type TaskStatusLinkProps = {
uri: string;
};
const TaskStatusLink: FC<TaskStatusLinkProps> = ({ uri }) => {
let icon = <ExternalLinkIcon />;
let label = truncateURI(uri);
if (uri.startsWith("https://github.com")) {
const issueNumber = uri.split("/").pop();
const [org, repo] = uri.split("/").slice(3, 5);
const prefix = `${org}/${repo}`;
if (uri.includes("pull/")) {
icon = <GitPullRequestArrowIcon />;
label = issueNumber
? `${prefix}#${issueNumber}`
: `${prefix} Pull Request`;
} else if (uri.includes("issues/")) {
icon = <BugIcon />;
label = issueNumber ? `${prefix}#${issueNumber}` : `${prefix} Issue`;
} else {
icon = <GitHub />;
label = `${org}/${repo}`;
}
}
return (
<Button asChild variant="outline" size="sm" className="min-w-0">
<a href={uri} target="_blank" rel="noreferrer">
{icon}
{label}
</a>
</Button>
);
};
+1
View File
@@ -0,0 +1 @@
export const AI_APP_CHAT_SLUG = "claude-code-web";