feat(webui): add project workspaces and access controls (#4007)

* feat(webui): add project workspaces and access controls

* feat(webui): add project workspaces and access controls

* refactor(tools): centralize workspace access resolution

* refactor(webui): remove unused workspace host state

* fix(webui): hide estimated file edit label

* fix(webui): clarify file edit deletion feedback

* fix(webui): label deleted file activity

* fix(webui): flatten file edit activity rows

* fix(core): remove path-only patch deletion

* fix(core): keep apply patch non-destructive

* refactor(webui): trim workspace host plumbing

* fix(tools): register exec with tools config
This commit is contained in:
Xubin Ren
2026-05-29 03:42:53 +08:00
committed by GitHub
parent 84428136e6
commit 3a420136bb
111 changed files with 9972 additions and 1822 deletions
+20 -120
View File
@@ -1,7 +1,6 @@
import { useState, type ReactNode } from "react";
import {
Archive,
ListFilter,
Menu,
Search,
Settings,
@@ -13,20 +12,9 @@ import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import type {
ChatSummary,
SidebarSortMode,
SidebarViewState,
} from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -41,12 +29,14 @@ interface SidebarProps {
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
onToggleGroup: (groupId: string) => void;
onRequestRenameProject: (projectKey: string, label: string) => void;
onNewChatInProject: (projectPath: string, projectName: string) => void;
onOpenSettings: () => void;
onOpenApps: () => void;
onOpenSearch: () => void;
activeUtility?: "apps" | null;
onToggleArchived: () => void;
onUpdateView: (view: Partial<SidebarViewState>) => void;
onCollapse: () => void;
onExpand?: () => void;
containActionMenus?: boolean;
@@ -54,11 +44,15 @@ interface SidebarProps {
pinnedKeys?: string[];
archivedKeys?: string[];
titleOverrides?: Record<string, string>;
projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>;
runningChatIds?: string[];
completedChatIds?: string[];
viewState?: SidebarViewState;
showArchived?: boolean;
archivedCount?: number;
defaultWorkspacePath?: string | null;
hostChromeInset?: boolean;
}
export function Sidebar(props: SidebarProps) {
@@ -72,11 +66,15 @@ export function Sidebar(props: SidebarProps) {
<nav
ref={props.containActionMenus ? setMenuPortalContainer : undefined}
aria-label={t("sidebar.navigation")}
className="flex h-full w-full min-w-0 flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground"
className={cn(
"flex h-full w-full min-w-0 flex-col bg-sidebar text-sidebar-foreground",
!props.hostChromeInset && "border-r border-sidebar-border/60",
)}
>
<div
className={cn(
"flex items-center px-3 pb-2.5 pt-3",
"flex items-center px-3 pb-2.5",
props.hostChromeInset ? "pt-[2.85rem]" : "pt-3",
collapsed ? "w-14 justify-start" : "justify-between",
)}
>
@@ -101,7 +99,7 @@ export function Sidebar(props: SidebarProps) {
draggable={false}
/>
</button>
{!collapsed && (
{!collapsed && !props.hostChromeInset && (
<Button
variant="ghost"
size="icon"
@@ -139,11 +137,6 @@ export function Sidebar(props: SidebarProps) {
active={props.activeUtility === "apps"}
icon={<Blocks className="h-4 w-4" />}
/>
<SidebarViewMenu
compact={collapsed}
view={props.viewState}
onUpdateView={props.onUpdateView}
/>
{props.archivedCount ? (
<SidebarActionButton
collapsed={collapsed}
@@ -170,9 +163,14 @@ export function Sidebar(props: SidebarProps) {
onTogglePin={props.onTogglePin}
onRequestRename={props.onRequestRename}
onToggleArchive={props.onToggleArchive}
onToggleGroup={props.onToggleGroup}
onRequestRenameProject={props.onRequestRenameProject}
onNewChatInProject={props.onNewChatInProject}
pinnedKeys={props.pinnedKeys}
archivedKeys={props.archivedKeys}
titleOverrides={props.titleOverrides}
projectNameOverrides={props.projectNameOverrides}
collapsedGroups={props.collapsedGroups}
runningChatIds={props.runningChatIds}
completedChatIds={props.completedChatIds}
density={props.viewState?.density}
@@ -180,6 +178,7 @@ export function Sidebar(props: SidebarProps) {
showTimestamps={props.viewState?.show_timestamps}
sort={props.viewState?.sort}
showArchived={props.showArchived}
defaultWorkspacePath={props.defaultWorkspacePath}
actionMenuPortalContainer={
props.containActionMenus ? menuPortalContainer : undefined
}
@@ -261,102 +260,3 @@ function SidebarActionButton({
</Button>
);
}
function SidebarViewMenu({
compact = false,
view,
onUpdateView,
}: {
compact?: boolean;
view?: SidebarViewState;
onUpdateView: (view: Partial<SidebarViewState>) => void;
}) {
const { t } = useTranslation();
const sort = view?.sort ?? "updated_desc";
const setSort = (value: string) => {
if (isSidebarSortMode(value)) onUpdateView({ sort: value });
};
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
aria-label={t("sidebar.viewOptions")}
title={compact ? t("sidebar.viewOptions") : undefined}
className={cn(
"h-8 min-w-0 overflow-hidden font-medium text-sidebar-foreground/75 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
compact
? "w-9 justify-center gap-0 rounded-xl px-0"
: "w-full justify-start gap-2 rounded-full px-3 text-[12.5px]",
)}
variant="ghost"
>
<ListFilter className="h-4 w-4 shrink-0" aria-hidden />
<span
className={cn(
"min-w-0 overflow-hidden truncate whitespace-nowrap transition-[max-width,opacity,transform] duration-200 ease-out",
compact
? "max-w-0 -translate-x-1 opacity-0"
: "max-w-[12rem] translate-x-0 opacity-100",
)}
>
{t("sidebar.viewOptions")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t("sidebar.viewOptions")}
</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={view?.density === "compact"}
onCheckedChange={(checked) =>
onUpdateView({ density: checked ? "compact" : "comfortable" })
}
onSelect={(event) => event.preventDefault()}
>
{t("sidebar.compactList")}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={Boolean(view?.show_previews)}
onCheckedChange={(checked) =>
onUpdateView({ show_previews: Boolean(checked) })
}
onSelect={(event) => event.preventDefault()}
>
{t("sidebar.showPreviews")}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={Boolean(view?.show_timestamps)}
onCheckedChange={(checked) =>
onUpdateView({ show_timestamps: Boolean(checked) })
}
onSelect={(event) => event.preventDefault()}
>
{t("sidebar.showTimestamps")}
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
{t("sidebar.sortLabel")}
</DropdownMenuLabel>
<DropdownMenuRadioGroup value={sort} onValueChange={setSort}>
<DropdownMenuRadioItem value="updated_desc">
{t("sidebar.sortUpdated")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="created_desc">
{t("sidebar.sortCreated")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="title_asc">
{t("sidebar.sortTitle")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
function isSidebarSortMode(value: string): value is SidebarSortMode {
return value === "updated_desc" || value === "created_desc" || value === "title_asc";
}