diff --git a/apps/app/public/ext-context7.svg b/apps/app/public/ext-context7.svg index 0cb20b61b..56e81005c 100644 --- a/apps/app/public/ext-context7.svg +++ b/apps/app/public/ext-context7.svg @@ -1 +1,5 @@ -Semantic Scholar \ No newline at end of file + + Context7 + + + diff --git a/apps/app/public/ext-figma.svg b/apps/app/public/ext-figma.svg new file mode 100644 index 000000000..7c1c78ac3 --- /dev/null +++ b/apps/app/public/ext-figma.svg @@ -0,0 +1,8 @@ + + Figma + + + + + + diff --git a/apps/app/public/ext-github.svg b/apps/app/public/ext-github.svg new file mode 100644 index 000000000..f71a51584 --- /dev/null +++ b/apps/app/public/ext-github.svg @@ -0,0 +1,4 @@ + + GitHub + + diff --git a/apps/app/public/ext-linear.svg b/apps/app/public/ext-linear.svg index ce8503795..08615dab3 100644 --- a/apps/app/public/ext-linear.svg +++ b/apps/app/public/ext-linear.svg @@ -1 +1 @@ -Linear \ No newline at end of file +Linear diff --git a/apps/app/public/ext-sentry.svg b/apps/app/public/ext-sentry.svg index e28a62931..92529d349 100644 --- a/apps/app/public/ext-sentry.svg +++ b/apps/app/public/ext-sentry.svg @@ -1 +1 @@ -Sentry \ No newline at end of file +Sentry diff --git a/apps/app/public/ext-stripe.svg b/apps/app/public/ext-stripe.svg index a3eed0b0b..e9fa84122 100644 --- a/apps/app/public/ext-stripe.svg +++ b/apps/app/public/ext-stripe.svg @@ -1 +1 @@ -Stripe \ No newline at end of file +Stripe diff --git a/apps/app/public/ipollowork-mark.svg b/apps/app/public/ipollowork-mark.svg index e5ba53f65..0b9f8efe0 100644 --- a/apps/app/public/ipollowork-mark.svg +++ b/apps/app/public/ipollowork-mark.svg @@ -1,9 +1,5 @@ - - iPollo logo - - - - - - + + + + diff --git a/apps/app/public/sidebar-icon/figma-folder-closed.svg b/apps/app/public/sidebar-icon/figma-folder-closed.svg index 7895499d0..6a162cc6d 100644 --- a/apps/app/public/sidebar-icon/figma-folder-closed.svg +++ b/apps/app/public/sidebar-icon/figma-folder-closed.svg @@ -1,3 +1,3 @@ - + diff --git a/apps/app/public/sidebar-icon/figma-layout-panel-top.svg b/apps/app/public/sidebar-icon/figma-layout-panel-top.svg index df13f0c2b..71b914164 100644 --- a/apps/app/public/sidebar-icon/figma-layout-panel-top.svg +++ b/apps/app/public/sidebar-icon/figma-layout-panel-top.svg @@ -1,7 +1,7 @@ - - - + + + diff --git a/apps/app/public/sidebar-icon/figma-square-pen.svg b/apps/app/public/sidebar-icon/figma-square-pen.svg index 27ad2c815..223a36cb0 100644 --- a/apps/app/public/sidebar-icon/figma-square-pen.svg +++ b/apps/app/public/sidebar-icon/figma-square-pen.svg @@ -1,3 +1,3 @@ - + diff --git a/apps/app/public/sidebar-icon/toy-brick.svg b/apps/app/public/sidebar-icon/toy-brick.svg new file mode 100644 index 000000000..a9dfb64cd --- /dev/null +++ b/apps/app/public/sidebar-icon/toy-brick.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/app/src/app/index.css b/apps/app/src/app/index.css index 24a934e8b..ec7b080b0 100644 --- a/apps/app/src/app/index.css +++ b/apps/app/src/app/index.css @@ -190,6 +190,10 @@ --dls-radius-lg: 24px; --dls-shell-shadow: 0 10px 30px rgba(15, 23, 42, 0.06); --dls-card-shadow: 0 8px 24px rgba(15, 23, 42, 0.05); + --project-dialog-accent: #1fbac0; + --project-dialog-accent-strong: #087b82; + --project-dialog-notice: #f4f4ee; + --project-dialog-option-border: #e7e7e8; --background: var(--slate-1); --foreground: var(--slate-12); --card: var(--slate-1); @@ -283,6 +287,22 @@ body { -moz-osx-font-smoothing: auto; } +html:lang(zh) .settings-description { + font-weight: 500; +} + +[data-settings-shell] :where( + button:not([role="switch"]):not([role="checkbox"]):not([role="radio"]), + [role="button"]:not([role="switch"]):not([role="checkbox"]):not([role="radio"]), + input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="color"]), + textarea, + select, + [role="combobox"], + [data-slot="table-container"] +) { + border-radius: 8px !important; +} + *::-webkit-scrollbar { width: 10px; height: 10px; @@ -435,10 +455,9 @@ body { .ow-input { appearance: none; width: 100%; - border: 0; + border: 1px solid var(--slate-6); border-radius: 0.9rem; background: var(--slate-1); - box-shadow: inset 0 0 0 1px var(--slate-6); color: var(--dls-text-primary); } @@ -447,8 +466,8 @@ body { } .ow-input:focus { - outline: none; - box-shadow: inset 0 0 0 1px rgba(var(--dls-accent-rgb), 0.28), 0 0 0 3px rgba(var(--dls-accent-rgb), 0.08); + border-color: rgba(var(--dls-accent-rgb), 0.28); + outline: 3px solid rgba(var(--dls-accent-rgb), 0.08); } /* Global clickable elements pointer */ @@ -557,6 +576,10 @@ select:disabled { .dark, [data-theme="dark"] { + --project-dialog-accent: #1fbac0; + --project-dialog-accent-strong: #a9e7ea; + --project-dialog-notice: var(--slate-3); + --project-dialog-option-border: var(--slate-6); --background: var(--slate-1); --foreground: var(--slate-12); --card: var(--slate-2); diff --git a/apps/app/src/components/ui/button.tsx b/apps/app/src/components/ui/button.tsx index bb87b114c..4bb754e2d 100644 --- a/apps/app/src/components/ui/button.tsx +++ b/apps/app/src/components/ui/button.tsx @@ -1,5 +1,6 @@ import { Button as ButtonPrimitive } from "@base-ui/react/button" import { cva, type VariantProps } from "class-variance-authority" +import { createContext, useContext, type ReactNode } from "react" import { cn } from "@/lib/utils" @@ -38,19 +39,71 @@ const buttonVariants = cva( } ) +const settingsCompactButtonSize = "h-7 px-2 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2 [&_svg:not([class*='size-'])]:size-3.5" +const settingsCompactIconButtonSize = "size-7 [&_svg:not([class*='size-'])]:size-3.5" + +const settingsButtonVariants = cva( + "gap-[6px] rounded-[8px] text-[13px] font-medium before:rounded-[7px] active:not-aria-[haspopup]:translate-y-0", + { + variants: { + variant: { + default: "", + outline: + "bg-transparent shadow-none before:shadow-none hover:bg-foreground/[0.06] active:bg-foreground/[0.12] aria-expanded:bg-foreground/[0.08] aria-pressed:bg-foreground/[0.08] aria-selected:bg-foreground/[0.08] data-[state=on]:bg-foreground/[0.08] data-[state=open]:bg-foreground/[0.08] dark:bg-transparent dark:before:shadow-none dark:hover:bg-foreground/[0.06] dark:active:bg-foreground/[0.12]", + secondary: + "bg-foreground/[0.08] hover:bg-foreground/[0.10] active:bg-foreground/[0.12]", + ghost: + "bg-transparent hover:bg-foreground/[0.06] active:bg-foreground/[0.12] aria-expanded:bg-foreground/[0.08] aria-pressed:bg-foreground/[0.08] aria-selected:bg-foreground/[0.08] data-[state=on]:bg-foreground/[0.08] data-[state=open]:bg-foreground/[0.08]", + destructive: "", + link: "", + }, + size: { + default: settingsCompactButtonSize, + xs: settingsCompactButtonSize, + sm: settingsCompactButtonSize, + lg: settingsCompactButtonSize, + icon: settingsCompactIconButtonSize, + "icon-xs": settingsCompactIconButtonSize, + "icon-sm": settingsCompactIconButtonSize, + "icon-lg": settingsCompactIconButtonSize, + }, + }, + }, +) + +type ButtonStyleScope = "default" | "settings" + +const ButtonStyleScopeContext = createContext("default") + +function ButtonStyleScopeProvider({ + value, + children, +}: { + value: ButtonStyleScope + children: ReactNode +}) { + return {children} +} + function Button({ className, variant = "default", size = "default", ...props }: ButtonPrimitive.Props & VariantProps) { + const styleScope = useContext(ButtonStyleScopeContext) + return ( ) } -export { Button, buttonVariants } +export { Button, ButtonStyleScopeProvider, buttonVariants } diff --git a/apps/app/src/components/ui/input.tsx b/apps/app/src/components/ui/input.tsx index 350131079..819a0ba2d 100644 --- a/apps/app/src/components/ui/input.tsx +++ b/apps/app/src/components/ui/input.tsx @@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { type={type} data-slot="input" className={cn( - "h-9 w-full min-w-0 rounded-lg border border-border px-3 py-1 text-ui-control transition-[color,box-shadow,background-color] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-ui-control file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 relative inline-flex bg-background not-dark:bg-clip-padding text-foreground shadow-xs/5 ring-ring/24 before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-has-disabled:not-has-focus-visible:not-has-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] has-focus-visible:has-aria-invalid:border-destructive/64 has-focus-visible:has-aria-invalid:ring-destructive/16 has-aria-invalid:border-destructive/36 has-focus-visible:border-ring has-autofill:bg-foreground/4 has-disabled:opacity-64 has-[:disabled,:focus-visible,[aria-invalid]]:shadow-none has-focus-visible:ring-[3px] dark:bg-background/40 dark:has-autofill:bg-foreground/8 dark:has-aria-invalid:ring-destructive/24 dark:not-has-disabled:not-has-focus-visible:not-has-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)]", + "h-9 w-full min-w-0 rounded-lg border border-border px-3 py-1 text-ui-control transition-[color,box-shadow,background-color] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-ui-control file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 relative inline-flex bg-background not-dark:bg-clip-padding text-foreground ring-ring/24 has-focus-visible:has-aria-invalid:border-destructive/64 has-focus-visible:has-aria-invalid:ring-destructive/16 has-aria-invalid:border-destructive/36 has-focus-visible:border-ring has-autofill:bg-foreground/4 has-disabled:opacity-64 has-focus-visible:ring-[3px] dark:bg-background/40 dark:has-autofill:bg-foreground/8 dark:has-aria-invalid:ring-destructive/24", className )} {...props} diff --git a/apps/app/src/components/ui/select.tsx b/apps/app/src/components/ui/select.tsx index 1b28a8d19..88adab41a 100644 --- a/apps/app/src/components/ui/select.tsx +++ b/apps/app/src/components/ui/select.tsx @@ -6,11 +6,26 @@ import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" const Select = SelectPrimitive.Root +type SelectStyleScope = "default" | "settings" + +const SelectStyleScopeContext = React.createContext("default") + +function SelectStyleScopeProvider({ + value, + children, +}: { + value: SelectStyleScope + children: React.ReactNode +}) { + return {children} +} + function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) { + const styleScope = React.useContext(SelectStyleScopeContext) return ( ) @@ -34,12 +49,15 @@ function SelectTrigger({ }: SelectPrimitive.Trigger.Props & { size?: "sm" | "default" }) { + const styleScope = React.useContext(SelectStyleScopeContext) return ( ) { + const styleScope = React.useContext(SelectStyleScopeContext) return ( @@ -100,10 +121,14 @@ function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) { + const styleScope = React.useContext(SelectStyleScopeContext) return ( ) @@ -114,11 +139,14 @@ function SelectItem({ children, ...props }: SelectPrimitive.Item.Props) { + const styleScope = React.useContext(SelectStyleScopeContext) return ( } > - + ) @@ -141,11 +169,14 @@ function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) { + const styleScope = React.useContext(SelectStyleScopeContext) return ( ) { + const styleScope = React.useContext(SelectStyleScopeContext) return ( ) { + const styleScope = React.useContext(SelectStyleScopeContext) return ( ) { {displayLabel} { props.onChange(opt.value); @@ -109,8 +109,8 @@ export function SelectMenu(props: SelectMenuProps) { {opt.label} {opt.value === props.value ? ( ) : null} diff --git a/apps/app/src/react-app/design-system/text-input.tsx b/apps/app/src/react-app/design-system/text-input.tsx index 7df37e647..acd655b7d 100644 --- a/apps/app/src/react-app/design-system/text-input.tsx +++ b/apps/app/src/react-app/design-system/text-input.tsx @@ -16,7 +16,7 @@ export function TextInput({ label, hint, className, ref, ...rest }: TextInputPro ) : null} + + diff --git a/apps/app/src/react-app/domains/session/chat/assets/project-engine-unselected.svg b/apps/app/src/react-app/domains/session/chat/assets/project-engine-unselected.svg new file mode 100644 index 000000000..941ded04c --- /dev/null +++ b/apps/app/src/react-app/domains/session/chat/assets/project-engine-unselected.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index 6c44b3e72..7196cc11a 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -3,7 +3,7 @@ import type { CSSProperties } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { UIMessage } from "ai"; import { useNavigate } from "react-router-dom"; -import { Code2, Ellipsis, Eye, FileText, Film, FolderOpen, Globe, Image, LoaderCircle, Mic2, Palette, PanelRightClose, PanelRightOpen, Pencil, Presentation, Search, Settings2, Trash2, Upload, X, Zap } from "lucide-react"; +import { Code2, Ellipsis, Eye, FileText, Film, Folder, FolderPlus, Globe, Image, LoaderCircle, Lock, Mic2, Palette, PanelRightClose, PanelRightOpen, Pencil, Presentation, Search, Settings2, Trash2, Upload, X, Zap } from "lucide-react"; import { MAX_TEMPLATE_PACKAGE_BYTES, TEMPLATE_PACKAGE_FILE_ACCEPT, isPptxCompatibleTemplate, type PptxCompatibility, type TemplateCatalogItem, type TemplateCategory, type TemplateManifestV1, type TemplateSessionSnapshot, type TemplateSessionState, type TemplateValidationReport } from "@ipollowork/types/templates"; import { DEEPSEEK_HARNESS_ENGINE_ID, @@ -59,8 +59,9 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { toast } from "@/components/ui/sonner"; +import { Input } from "@/components/ui/input"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { toast } from "@/components/ui/sonner"; import { ConfirmModal } from "../../../design-system/modals/confirm-modal"; import { useDenAuth } from "../../cloud/den-auth-provider"; import { CloudSignInComingSoonDialog } from "../../cloud/cloud-signin-coming-soon-dialog"; @@ -137,6 +138,8 @@ import { cn } from "@/lib/utils"; import { useActiveEnterpriseConnection } from "@/react-app/domains/enterprise/use-active-enterprise-connection"; import { useInstalledPluginContributions } from "@/react-app/plugin-ui/plugin-ui-contributions"; import type { WorkspaceAppModelContext } from "@/react-app/plugin-ui/workspace-app-frame"; +import projectEngineSelectedIcon from "./assets/project-engine-selected.svg"; +import projectEngineUnselectedIcon from "./assets/project-engine-unselected.svg"; const STARTUP_SKELETON_ROWS = [ { id: "intro", titleWidth: "42%", bodyWidth: "88%" }, @@ -2516,7 +2519,7 @@ export function SessionPage(props: SessionPageProps) { const submitCreateProject = async () => { const name = createProjectName.trim(); const folderPath = createProjectFolder.trim(); - if (!name || !folderPath) return; + if (!name) return; setCreateProjectBusy(true); setCreateProjectError(null); try { @@ -2621,8 +2624,8 @@ export function SessionPage(props: SessionPageProps) { />
-
- {mainHeaderHidden ? ( +
+ {mainHeaderHidden && mainWorkspaceView !== "extensions" ? (
{shellConfig.sidebar && sidebarVisuallyCollapsed ? ( @@ -3237,33 +3240,79 @@ export function SessionPage(props: SessionPageProps) { ) : null} { if (!open && !createProjectBusy) setCreateProjectOpen(false); }}> - - - {t("projects.create")} - {t("projects.create_description")} + + + {t("projects.create")} + + {t("projects.create_description")} + -
- setCreateProjectName(event.currentTarget.value)} - placeholder={t("projects.name_placeholder")} - disabled={createProjectBusy} - /> - + +
+
+ +
+ + setCreateProjectName(event.currentTarget.value)} + onKeyDown={(event) => { if (event.key === "Enter") void submitCreateProject(); }} + placeholder={t("projects.name_example")} + disabled={createProjectBusy} + className="h-10 rounded-lg px-4 ps-12 text-sm leading-[22px] placeholder-shown:text-[13px] placeholder-shown:leading-5 placeholder:text-slate-9 focus-visible:ring-0! has-focus-visible:ring-0! dark:placeholder:text-slate-11" + /> +
+
+ +
+

{t("projects.source_folder")}

+ +
+
- {t("projects.engine")} -
- {([ + {t("projects.default_engine")} + { + if (engineId === DEFAULT_ENGINE_ID || engineId === DEEPSEEK_HARNESS_ENGINE_ID) { + setCreateProjectEngineId(engineId); + } + }} + disabled={createProjectBusy} + aria-label={t("projects.default_engine")} + className="grid grid-cols-2 gap-4" + > + {[ { id: DEFAULT_ENGINE_ID, name: t("projects.engine_opencode"), @@ -3274,35 +3323,72 @@ export function SessionPage(props: SessionPageProps) { name: t("projects.engine_dsh"), description: t("projects.engine_dsh_description"), }, - ] as const).map((engine) => { + ].map((engine) => { const selected = createProjectEngineId === engine.id; return ( - + + + + {engine.name} + + + + {engine.description} + ); })} + +
+ + {t("projects.engine_locked_notice")}
+ {createProjectError ?

{createProjectError}

: null}
- - }>{t("common.cancel")} - diff --git a/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx b/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx index 6e8d3f5d6..0ce851312 100644 --- a/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx +++ b/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx @@ -489,7 +489,7 @@ function SidebarSectionHeader({ ); } -const primarySidebarActionClassName = "h-8 gap-1 rounded-[8px] px-1 py-0 text-sm font-normal leading-4 text-sidebar-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground focus-visible:ring-1"; +const primarySidebarActionClassName = "h-8 gap-2 rounded-[8px] px-2 py-0 text-sm font-normal leading-4 text-sidebar-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground focus-visible:ring-1"; function useSessionTree( sessions: ProjectSessionList["sessions"], @@ -654,7 +654,7 @@ export function AppSidebar(props: AppSidebarProps) { ) : null}
- +
props.onCreateTaskInWorkspace(props.selectedWorkspaceId)} > {t("session.new_task")} @@ -678,7 +678,7 @@ export function AppSidebar(props: AppSidebarProps) { className={primarySidebarActionClass} > {t("template_market.title")} @@ -690,7 +690,7 @@ export function AppSidebar(props: AppSidebarProps) { className={primarySidebarActionClass} > {t("settings.tab_extensions")} @@ -878,6 +878,10 @@ function ProjectSidebarContent({ const [projectExpanded, setProjectExpanded] = React.useState(true); const tree = useSessionTree(project.sessions, ctx.sessionStatusById); + React.useEffect(() => { + if (isSelectedProject) setProjectExpanded(true); + }, [isSelectedProject]); + const forcedExpandedSessionIds = React.useMemo( () => new Set( ctx.selectedSessionId @@ -943,24 +947,27 @@ function ProjectSidebarContent({ {!projectExpanded ? ( @@ -1031,7 +1038,7 @@ function ProjectSidebarContent({ : null} - + {showRemoteConnectionIssue ? ( 0 ? "ps-10" : "ps-1"; + const rowPadding = depth > 0 ? "ps-[68px]" : "ps-8"; const trailingActionPosition = "right-1"; const nestedActionPosition = "right-8"; const sessionFontWeight = ctx.language === "zh" ? "font-medium" : "font-normal"; @@ -1196,7 +1203,7 @@ function SessionMenuItem({ {activeWorkMode.label} @@ -1787,7 +1787,7 @@ export function ReactSessionComposer(props: ComposerProps) { className={`relative inline-flex h-8 max-h-8 w-8 shrink-0 items-center justify-center rounded-full transition-colors ${ canSend ? "bg-[var(--dls-accent)] text-[var(--dls-accent-fg)] hover:bg-[var(--dls-accent-hover)]" - : "bg-gray-4 text-gray-10" + : "bg-gray-2 text-gray-10" }`} title={t("composer.queue_hint")} > @@ -1806,7 +1806,7 @@ export function ReactSessionComposer(props: ComposerProps) { disabled={props.disabled || !canSend} className={`inline-flex h-8 max-h-8 w-8 items-center justify-center rounded-full transition-colors ${ !canSend || props.disabled - ? `bg-gray-4 text-gray-10 ${props.layout === "inline" ? "dark:bg-white/15 dark:text-[#999]" : ""}` + ? "bg-gray-2 text-gray-10" : props.layout === "inline" ? "bg-[var(--dls-accent)] text-[var(--dls-accent-fg)] hover:bg-[var(--dls-accent-hover)] dark:bg-white dark:text-black dark:hover:bg-white/90" : "bg-[var(--dls-accent)] text-[var(--dls-accent-fg)] hover:bg-[var(--dls-accent-hover)]" diff --git a/apps/app/src/react-app/domains/session/surface/composer/model-behavior-menu.tsx b/apps/app/src/react-app/domains/session/surface/composer/model-behavior-menu.tsx index 00eb82c8c..cf9876c3d 100644 --- a/apps/app/src/react-app/domains/session/surface/composer/model-behavior-menu.tsx +++ b/apps/app/src/react-app/domains/session/surface/composer/model-behavior-menu.tsx @@ -76,7 +76,7 @@ export function ModelBehaviorMenu({ type="button" disabled={disabled} aria-label={`${t("model_picker.change_model")} ${hasBehavior ? `· ${t("composer.behavior_label")}` : ""}`} - className="inline-flex max-w-72 items-center gap-1.5 rounded-full bg-gray-3 px-3 py-1.5 text-sm text-gray-11 transition-colors hover:bg-gray-4 hover:text-gray-12 disabled:pointer-events-none disabled:opacity-60" + className="inline-flex max-w-72 items-center gap-1.5 rounded-full bg-gray-2 px-3 py-1.5 text-sm text-gray-11 transition-colors hover:bg-gray-3 hover:text-gray-12 disabled:pointer-events-none disabled:opacity-60" > {summary} diff --git a/apps/app/src/react-app/domains/settings/pages/cloud-marketplaces-view.tsx b/apps/app/src/react-app/domains/settings/pages/cloud-marketplaces-view.tsx index 6bd86955d..40f348e7c 100644 --- a/apps/app/src/react-app/domains/settings/pages/cloud-marketplaces-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/cloud-marketplaces-view.tsx @@ -19,6 +19,7 @@ import { formatPluginPlatformError } from "@/react-app/domains/settings/plugin-p import { SettingsListEmptyState, SettingsListSearchInput } from "@/react-app/domains/settings/settings-list"; import { SettingsNotice, SettingsPill } from "@/react-app/domains/settings/settings-section"; import { notifyPluginUiContributionsChanged } from "@/react-app/plugin-ui/plugin-ui-contributions"; +import { settingsPageTitleClass } from "@/react-app/domains/settings/shell/panel"; export const MARKETPLACE_CATEGORY_IDS = [ "ai-agents", @@ -31,6 +32,8 @@ export const MARKETPLACE_CATEGORY_IDS = [ ] as const; export type MarketplaceCategoryId = typeof MARKETPLACE_CATEGORY_IDS[number]; +export type MarketplaceCategoryFilter = "all" | MarketplaceCategoryId; +export type MarketplaceStatusFilter = "all" | "available" | "installed" | "update"; const categoryKeywords: Record, string[]> = { "ai-agents": ["ai agent", "agent", "automation", "智能体", "代理", "自动化"], @@ -59,6 +62,8 @@ export type CloudMarketplacesViewProps = { onOpenInstalled?: (pluginId: string) => void; embedded?: boolean; search?: string; + categoryFilter?: MarketplaceCategoryFilter; + statusFilter?: MarketplaceStatusFilter; }; export function shouldShowMarketplaceRows(isSignedIn: boolean): boolean { @@ -119,6 +124,8 @@ export function CloudMarketplacesView({ onOpenInstalled, embedded = false, search: controlledSearch, + categoryFilter = "all", + statusFilter = "all", }: CloudMarketplacesViewProps) { const cloud = useCloudSession(); const [items, setItems] = React.useState([]); @@ -184,10 +191,22 @@ export function CloudMarketplacesView({ const filteredItems = React.useMemo(() => { const query = search.trim().toLocaleLowerCase(); - if (!query) return items; - return items.filter((item) => [item.name, item.description, item.enterpriseCategory, item.category] - .some((value) => value.toLocaleLowerCase().includes(query))); - }, [items, search]); + return items.filter((item) => { + if (query && [item.name, item.description, item.enterpriseCategory, item.category] + .every((value) => !value.toLocaleLowerCase().includes(query))) return false; + if (categoryFilter !== "all" && resolveMarketplaceCategory({ + pluginId: resourcePluginId(item), + category: item.category, + manifest: {}, + }) !== categoryFilter) return false; + const localPackage = installed[resourcePluginId(item)]; + const version = item.latestVersion?.version; + if (statusFilter === "installed" && !localPackage) return false; + if (statusFilter === "update" && (!localPackage || !version || localPackage.version === version)) return false; + if (statusFilter === "available" && localPackage) return false; + return true; + }); + }, [categoryFilter, installed, items, search, statusFilter]); const selected = items.find((item) => item.id === selectedId) ?? null; const featuredItems = filteredItems.filter((item) => item.featured); const categorySections = MARKETPLACE_CATEGORY_IDS.map((categoryId) => ({ @@ -251,8 +270,8 @@ export function CloudMarketplacesView({ <>
-

{t("extensions.marketplace_title")}

-

{t("extensions.marketplace_description")}

+

{t("extensions.marketplace_title")}

+

{t("extensions.marketplace_description")}

- - -
+ {props.showActions === false ? null : ( +
+ + + +
+ )} +
+ +
+ + setSearchQuery(event.currentTarget.value)} + placeholder={t("skills.catalog_search_placeholder")} + className="h-[34px] w-full rounded-[8px] border border-transparent bg-[#f5f6f9] pl-[42px] pr-4 text-[13px] text-dls-text shadow-none outline-none transition-colors placeholder:text-[#a2a6af] focus:border-ring focus:ring-3 focus:ring-ring/30 dark:bg-dls-hover dark:placeholder:text-dls-secondary" + />
-
-
- - setSearchQuery(event.currentTarget.value)} - placeholder={t("skills.catalog_search_placeholder")} - className="w-full rounded-xl border border-dls-border bg-dls-surface py-3 pl-11 pr-4 text-[14px] text-dls-text focus:outline-none focus:ring-2 focus:ring-[rgba(var(--dls-accent-rgb),0.12)]" +
+
+
-
- {(["all", "installed", "cloud", "hub"] as SkillsFilter[]).map((filter) => ( - - ))} - -
+
@@ -776,126 +826,86 @@ export function SkillsView(props: SkillsViewProps) { ) : null} {showInstalledSection ? ( -
-
-
-

{t("skills.installed")}

-

{t("skills.installed_desc")}

+
+
+
+

{t("skills.installed")}

+

{t("skills.installed_desc")}

-
{t("skills.shown_count", undefined, { count: filteredSkills.length })}
+ {t("skills.shown_count", undefined, { count: filteredSkills.length })}
{filteredSkills.length === 0 ? ( -
+
{t("skills.no_skills")}
) : ( -
-
- {filteredSkills.map((skill) => ( -
+ {filteredSkills.map((skill) => ( +
+ -
- {t("skills.installed_status")} -
- - - -
+
+ {t("skills.installed_status")} +
+ +
- ))} -
+
+ ))}
)}
) : null} {showCloudSection ? ( -
-
-
-

{cloudOrgLabel}

-

{t("skills.cloud_section_title")}

-

- {t("skills.cloud_section_subtitle")} -

-
-
- +
+
+
+

{t("skills.cloud_section_title")}

+

{t("skills.cloud_section_subtitle")}

+ {cloudOrgLabel}
{!cloudSessionReady ? ( @@ -917,19 +927,23 @@ export function SkillsView(props: SkillsViewProps) { ) : ( <> {cloudOrgSkillsStatus ? ( -
+
{cloudOrgSkillsStatus}
) : null} - {filteredCloudOrgSkills.length === 0 ? ( + {visibleCloudOrgSkills.length === 0 ? (
{cloudOrgSkills.length === 0 ? t("skills.cloud_org_empty") : t("skills.cloud_no_search_matches")}
) : (
- {filteredCloudOrgSkills.map((skill) => { + {visibleCloudOrgSkills.map((skill) => { const state = cloudSkillInstallState(skill); const installedName = importedCloudSkills[skill.id]?.installedName ?? null; return ( @@ -988,13 +1002,13 @@ export function SkillsView(props: SkillsViewProps) { ) : null} {showHubSection ? ( -
-
-
-

{t("skills.available_from_hub")}

-

{t("skills.hub_desc")}

+
+
+
+

{t("skills.available_from_hub")}

+

{t("skills.hub_desc")}

-
+
-
@@ -1064,19 +1069,19 @@ export function SkillsView(props: SkillsViewProps) {
{hubSkillsStatus ? ( -
+
{hubSkillsStatus}
) : null} - {filteredHubSkills.length === 0 ? ( + {visibleHubSkills.length === 0 ? (
{hubRepo ? t("skills.no_hub_skills") : t("skills.no_hub_repo_selected")}
) : (
- {filteredHubSkills.map((skill) => ( + {visibleHubSkills.map((skill) => (
@@ -1137,22 +1142,11 @@ export function SkillsView(props: SkillsViewProps) { > -
- {selectedSkill?.name} -
- -
-
+ {selectedSkill?.name}
- {selectedError ?
{selectedError}
: null} + {selectedError ?
{selectedError}
: null} {selectedLoading ? (
{t("skills.loading")}
) : ( @@ -1167,6 +1161,39 @@ export function SkillsView(props: SkillsViewProps) { /> )}
+ + + + +
@@ -1210,7 +1237,7 @@ export function SkillsView(props: SkillsViewProps) {
{shareTeamOrgLabel}
- {shareTeamError?.trim() ?
{shareTeamError}
: null} + {shareTeamError?.trim() ?
{shareTeamError}
: null} {shareTeamSuccess?.trim() ?
{shareTeamSuccess}
: null} {shareCloudSignedIn && shareTeamDisabledReason?.trim() ? (
{shareTeamDisabledReason}
@@ -1309,7 +1336,7 @@ export function SkillsView(props: SkillsViewProps) { /> - {customRepoError ?
{customRepoError}
: null} + {customRepoError ?
{customRepoError}
: null}
}> @@ -1323,6 +1350,6 @@ export function SkillsView(props: SkillsViewProps) { ); -} +}); export default SkillsView; diff --git a/apps/app/src/react-app/domains/settings/plugin-package-detail.tsx b/apps/app/src/react-app/domains/settings/plugin-package-detail.tsx index 64fc1d551..407960605 100644 --- a/apps/app/src/react-app/domains/settings/plugin-package-detail.tsx +++ b/apps/app/src/react-app/domains/settings/plugin-package-detail.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import { ChevronRight, Package } from "lucide-react"; import { t } from "@/i18n"; +import { settingsPageTitleClass, settingsStandardContentClass } from "@/react-app/domains/settings/shell/panel"; type PluginPackageDetailProps = { name: string; @@ -22,9 +23,9 @@ export function PluginPackageDetail({ onBack, }: PluginPackageDetailProps) { return ( -
+
-
@@ -314,6 +372,7 @@ export function PluginPackagesPanel(props: PluginPackagesPanelProps) { && contribution.ref?.startsWith("https://") )?.ref; const iconUrl = resolveExtensionIconUrl({ + pluginId: item.pluginId, iconSrc: item.manifest.icon?.src, iconSlug: item.manifest.icon?.simpleIconSlug, }); @@ -723,147 +782,220 @@ export function PluginPackagesPanel(props: PluginPackagesPanelProps) { } return ( -
-
-
-

{t("plugin_library.title")}

-

{t("plugin_library.description")}

-
-
- - +
+
+
+

{t("plugin_library.title")}

+

{t("plugin_library.description")}

+ setSearch(event.currentTarget.value)} + placeholder={t("plugin_library.search")} + aria-label={t("plugin_library.search")} + containerClassName="h-[34px] rounded-lg border-transparent bg-[#f5f6f9] shadow-none hover:bg-[#f1f2f5] dark:bg-white/[0.06] dark:hover:bg-white/[0.09]" + />
- setSearch(event.currentTarget.value)} - placeholder={t("plugin_library.search")} - aria-label={t("plugin_library.search")} - /> - -
+
-

{t("plugin_library.installed")}

+
+

{t("plugin_library.installed")}

+

{t("plugin_library.installed_description")}

+
+ {localizedItems.length > 0 ? ( -
- {localizedItems.map((item) => { - const iconUrl = resolveExtensionIconUrl({ - iconSrc: item.manifest.icon?.src, - iconSlug: item.manifest.icon?.simpleIconSlug, - }); - return ( - - ); - })} +
+ +
+ {installedPreviewItems.map((item) => { + const iconUrl = resolveExtensionIconUrl({ + pluginId: item.pluginId, + iconSrc: item.manifest.icon?.src, + iconSlug: item.manifest.icon?.simpleIconSlug, + }); + const authorization = packageAuthorization(item, authorizations[item.pluginId], props.mcpStatuses); + const status = statusText( + authorizations[item.pluginId], + authorization.required, + authorization.connected, + ); + return ( + + props.onSelectPlugin(item.pluginId)} + /> + )} + > + {iconUrl ? : } + + {item.name} · {status} + + ); + })} +
+
+ {installedExpanded || remainingInstalledCount > 0 ? ( + + ) : null}
) : ( -

{t("plugin_platform.empty_title")}

+

{t("plugin_platform.empty_title")}

)} -
+
-
+
- +
+ + + {source === "marketplace" ? ( +
+ + +
+ ) : null} +
+

+ {t(source === "marketplace" ? "plugin_library.marketplace_description" : "plugin_library.personal_description")} +

{source === "marketplace" ? ( -
{props.marketplaceView(search)}
+ props.marketplaceView(search, { category: marketplaceCategory, status: marketplaceStatus }) + ) : (filteredCatalogItems.length > 0 || filteredItems.length > 0) ? ( +
+ {filteredCatalogItems.map((item) => ( + {t("plugin_platform.official_bundle")}} + actionBusy={busyKey !== null} + actionLabel={<>{busyKey === `catalog:${item.pluginId}` ? : null}{item.updateAvailable ? t("plugin_platform.action.update") : t("plugin_platform.action.install")}} + onAction={() => void installBundledPackage(item)} + /> + ))} + {filteredItems.map((item) => { + const auth = authorizations[item.pluginId]; + const authorization = packageAuthorization(item, auth, props.mcpStatuses); + const connected = authorization.connected; + const primaryAction = derivePluginPrimaryAction({ + installed: true, + authorizationRequired: authorization.required, + connected, + updateAvailable: false, + broken: !item.enabled, + }); + return ( + {t("plugin_platform.status.disabled")} : null} + status={{connected || !authorization.required ? : }{statusText(auth, authorization.required, connected)}} + actionBusy={busyKey !== null} + actionLabel={t(primaryAction.labelKey)} + onOpen={() => props.onSelectPlugin(item.pluginId)} + onAction={() => { + if (primaryAction.kind === "repair") { + void run(`${item.pluginId}:enable`, async () => { + await props.client?.setPluginPackageEnabled(props.workspaceId ?? "", item.pluginId, true); + await refresh(); + }); + return; + } + props.onSelectPlugin(item.pluginId); + }} + /> + ); + })} +
) : ( -
-
-

{t("plugin_library.personal_title")}

-

{t("plugin_library.personal_description")}

-
- {(filteredCatalogItems.length > 0 || filteredItems.length > 0) ? ( -
- {filteredCatalogItems.map((item) => ( - {t("plugin_platform.official_bundle")}} - actionBusy={busyKey !== null} - actionLabel={<>{busyKey === `catalog:${item.pluginId}` ? : null}{item.updateAvailable ? t("plugin_platform.action.update") : t("plugin_platform.action.install")}} - onAction={() => void installBundledPackage(item)} - /> - ))} - {filteredItems.map((item) => { - const auth = authorizations[item.pluginId]; - const authorization = packageAuthorization(item, auth, props.mcpStatuses); - const connected = authorization.connected; - const primaryAction = derivePluginPrimaryAction({ - installed: true, - authorizationRequired: authorization.required, - connected, - updateAvailable: false, - broken: !item.enabled, - }); - return ( - {t("plugin_platform.status.disabled")} : null} - status={{connected || !authorization.required ? : }{statusText(auth, authorization.required, connected)}} - actionBusy={busyKey !== null} - actionLabel={t(primaryAction.labelKey)} - onOpen={() => props.onSelectPlugin(item.pluginId)} - onAction={() => { - if (primaryAction.kind === "repair") { - void run(`${item.pluginId}:enable`, async () => { - await props.client?.setPluginPackageEnabled(props.workspaceId ?? "", item.pluginId, true); - await refresh(); - }); - return; - } - props.onSelectPlugin(item.pluginId); - }} - /> - ); - })} -
- ) : ( -
- {search ? t("settings.marketplace.no_match") : t("plugin_library.personal_empty")} -
- )} +
+ {search ? t("settings.marketplace.no_match") : t("plugin_library.personal_empty")}
)} -
+
{error ?
{error}
: null}
); -} +}); diff --git a/apps/app/src/react-app/domains/settings/settings-layout.tsx b/apps/app/src/react-app/domains/settings/settings-layout.tsx index 9876faf1e..33826ed35 100644 --- a/apps/app/src/react-app/domains/settings/settings-layout.tsx +++ b/apps/app/src/react-app/domains/settings/settings-layout.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { cn } from "@/lib/utils"; +import { settingsStandardContentClass } from "@/react-app/domains/settings/shell/panel"; export interface LayoutStackProps { children: React.ReactNode; @@ -7,7 +8,7 @@ export interface LayoutStackProps { } export function LayoutStack({ children, className }: LayoutStackProps) { - return
{children}
; + return
{children}
; } interface LayoutSectionProps { @@ -41,7 +42,7 @@ interface LayoutSectionTitleProps { export function LayoutSectionTitle({ children, className }: LayoutSectionTitleProps) { return ( -

+

{children}

); @@ -54,7 +55,7 @@ interface LayoutSectionDescriptionProps { export function LayoutSectionDescription({ children, className }: LayoutSectionDescriptionProps) { return ( -

+

{children}

); @@ -106,7 +107,7 @@ interface LayoutSectionItemTitleProps { export function LayoutSectionItemTitle({ children, className }: LayoutSectionItemTitleProps) { return ( -

+

{children}

); @@ -119,7 +120,7 @@ interface LayoutSectionItemDescriptionProps { export function LayoutSectionItemDescription({ children, className }: LayoutSectionItemDescriptionProps) { return ( -

+

{children}

); @@ -158,8 +159,8 @@ interface LayoutSectionItemFootnoteProps { export function LayoutSectionItemFootnote({ children, className }: LayoutSectionItemFootnoteProps) { return ( -

+

{children}

); -} \ No newline at end of file +} diff --git a/apps/app/src/react-app/domains/settings/settings-list.tsx b/apps/app/src/react-app/domains/settings/settings-list.tsx index ee93ae24b..9ca3a2f51 100644 --- a/apps/app/src/react-app/domains/settings/settings-list.tsx +++ b/apps/app/src/react-app/domains/settings/settings-list.tsx @@ -31,7 +31,7 @@ interface SettingsListEmptyStateProps { export function SettingsListEmptyState({ children, className }: SettingsListEmptyStateProps) { return ( - + {children} ); @@ -63,7 +63,7 @@ export function SettingsListItem({ children, className }: SettingsListItemProps) return (
@@ -96,15 +96,16 @@ interface SettingsListItemDescriptionProps { } export function SettingsListItemDescription({ children, className }: SettingsListItemDescriptionProps) { - return
{children}
; + return
{children}
; } export function SettingsListSearchInput({ + containerClassName, placeholder = "Search...", ...props -}: React.ComponentProps<"input">) { +}: React.ComponentProps<"input"> & { containerClassName?: string }) { return ( - + diff --git a/apps/app/src/react-app/domains/settings/settings-section.tsx b/apps/app/src/react-app/domains/settings/settings-section.tsx index 398e8fefd..a7c760baf 100644 --- a/apps/app/src/react-app/domains/settings/settings-section.tsx +++ b/apps/app/src/react-app/domains/settings/settings-section.tsx @@ -5,6 +5,7 @@ import { cva } from "class-variance-authority"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; +import { settingsStandardContentClass } from "@/react-app/domains/settings/shell/panel"; type SettingsTone = "ready" | "warning" | "neutral" | "error"; @@ -61,7 +62,7 @@ export interface SettingsLayoutProps { } export function SettingsStack({ children, className }: SettingsLayoutProps) { - return
{children}
; + return
{children}
; } interface SettingsSectionProps { @@ -187,7 +188,7 @@ interface SettingsItemHeaderTitleProps { export function SettingsSectionHeaderTitle({ children, className }: SettingsItemHeaderTitleProps) { return ( -
+
{children}
); @@ -199,7 +200,7 @@ interface SectionItemHeaderDescriptionProps { } export function SettingsSectionHeaderDescription({ children, className }: SectionItemHeaderDescriptionProps) { - return
{children}
; + return
{children}
; } @@ -209,7 +210,7 @@ interface SectionItemHintProps { } export function SettingsSectionHint({ children, className }: SectionItemHintProps) { - return
{children}
; + return
{children}
; } interface SectionItemHeaderActionsProps { diff --git a/apps/app/src/react-app/domains/settings/settings-segmented-tabs.tsx b/apps/app/src/react-app/domains/settings/settings-segmented-tabs.tsx index 7b0d9b940..41d60e215 100644 --- a/apps/app/src/react-app/domains/settings/settings-segmented-tabs.tsx +++ b/apps/app/src/react-app/domains/settings/settings-segmented-tabs.tsx @@ -1,8 +1,11 @@ /** @jsxImportSource react */ +import { cn } from "@/lib/utils"; + type SettingsSegmentedTabItem = { value: Value; label: string; + disabled?: boolean; }; type SettingsSegmentedTabsProps = { @@ -19,14 +22,20 @@ export function SettingsSegmentedTabs({ onValueChange, }: SettingsSegmentedTabsProps) { return ( -
+
{items.map((item) => ( -
- - -
-
- {props.children} - - {props.modalSlot} -
- - {props.footer} -
-
- ); - } - - return ( -
- - - -
- {!props.hideShellHeader ? ( -
-
- - {props.headerTitle ??

{title}

} - {props.developerMode && props.headerStatus ? ( - - {props.headerStatus} - - ) : null} - {props.busyHint ? ( - - {props.busyHint} - - ) : null} -
-
- - -
-
- ) : null} + +
+
+
+ + {props.headerTitle ?? ( + + )} +
+
+ {props.headerActions} + {props.hideCloseButton ? null : ( + + )} +
+
+
{props.children} @@ -148,9 +125,74 @@ export function SettingsShell(props: SettingsShellProps) { {props.footer}
- - -
+
+ + ); + } + + return ( + +
+ + + +
+ {!props.hideShellHeader ? ( +
+
+
+ + {props.headerTitle ??

{title}

} + {props.developerMode && props.headerStatus ? ( + + {props.headerStatus} + + ) : null} + {props.busyHint ? ( + + {props.busyHint} + + ) : null} +
+
+ {props.headerActions} + {props.showNotifications === false ? null : } + +
+
+
+ ) : null} + +
+ {props.children} + + {props.modalSlot} +
+ + {props.footer} +
+
+
+
+
); } @@ -162,14 +204,16 @@ function SettingsSectionMenu(props: Pick page.id === props.activePluginPageId); - const ActiveIcon = activePluginPage ? PanelsTopLeft : getSettingsTabIcon(props.activeTab); - return ( - + {activePluginPage ? ( + activePluginPage.iconSrc + ? + : + ) : } {activePluginPage?.label ?? getSettingsTabLabel(props.activeTab)} {!activePluginPage && isSettingsTabBeta(props.activeTab) ? : null} @@ -182,14 +226,13 @@ function SettingsSectionMenu(props: Pick 0 ? : null} {section.label ? {section.label} : null} {section.tabs.map((tab) => { - const Icon = getSettingsTabIcon(tab); return ( props.onSelectTab(tab)} - className={props.activeTab === tab ? "bg-foreground/10 text-accent-foreground" : undefined} + className={!props.activePluginPageId && props.activeTab === tab ? "bg-foreground/10 text-accent-foreground" : undefined} > - + {getSettingsTabLabel(tab)} {isSettingsTabBeta(tab) ? : null} diff --git a/apps/app/src/react-app/domains/settings/state/extensions-store.ts b/apps/app/src/react-app/domains/settings/state/extensions-store.ts index 765d9612f..f1ad193c6 100644 --- a/apps/app/src/react-app/domains/settings/state/extensions-store.ts +++ b/apps/app/src/react-app/domains/settings/state/extensions-store.ts @@ -1389,7 +1389,7 @@ export function createExtensionsStore(options: { mutateState((current) => ({ ...current, hubSkills: [], - hubSkillsStatus: "No hub repo selected. Add a GitHub repo to browse skills.", + hubSkillsStatus: null, })); hubSkillsLoaded = true; hubSkillsLoadKey = loadKey; @@ -1416,7 +1416,7 @@ export function createExtensionsStore(options: { mutateState((current) => ({ ...current, hubSkills: next, - hubSkillsStatus: next.length ? null : "No hub skills found.", + hubSkillsStatus: null, hubSkillsContextKey: getWorkspaceContextKey(), })); hubSkillsLoaded = true; @@ -1450,17 +1450,17 @@ export function createExtensionsStore(options: { mutateState((current) => ({ ...current, hubSkills: sorted, - hubSkillsStatus: sorted.length ? null : "No hub skills found.", + hubSkillsStatus: null, hubSkillsContextKey: getWorkspaceContextKey(), })); hubSkillsLoaded = true; hubSkillsLoadKey = loadKey; - } catch (error) { + } catch { if (refreshHubSkillsAborted) return; mutateState((current) => ({ ...current, hubSkills: [], - hubSkillsStatus: error instanceof Error ? error.message : "Failed to load hub skills.", + hubSkillsStatus: t("skills.hub_load_failed"), })); } finally { refreshHubSkillsInFlight = false; @@ -1529,13 +1529,12 @@ export function createExtensionsStore(options: { cloudOrgSkillsLoaded = true; cloudOrgSkillsLoadKey = loadKey; await refreshImportedCloudSkills(); - } catch (error) { + } catch { if (refreshCloudOrgSkillsAborted || getCurrentCloudOrgLoadKey() !== loadKey) return; mutateState((current) => ({ ...current, cloudOrgSkills: [], - cloudOrgSkillsStatus: - error instanceof Error ? error.message : t("skills.cloud_org_load_failed"), + cloudOrgSkillsStatus: t("skills.cloud_org_load_failed"), })); } finally { if (refreshCloudOrgSkillsInFlightKey === loadKey) { @@ -2830,7 +2829,7 @@ export function createExtensionsStore(options: { ...current, hubRepo: nextRepos[0] ?? null, hubSkills: nextRepos.length ? current.hubSkills : [], - hubSkillsStatus: nextRepos.length ? current.hubSkillsStatus : "No hub repo selected. Add a GitHub repo to browse skills.", + hubSkillsStatus: nextRepos.length ? current.hubSkillsStatus : null, })); hubSkillsLoaded = false; if (!nextRepos.length) { diff --git a/apps/app/src/react-app/domains/workspace/modal-styles.ts b/apps/app/src/react-app/domains/workspace/modal-styles.ts index aa2b12f9c..dfdb0cfdf 100644 --- a/apps/app/src/react-app/domains/workspace/modal-styles.ts +++ b/apps/app/src/react-app/domains/workspace/modal-styles.ts @@ -58,4 +58,4 @@ export const modalNoticeSuccessClass = "rounded-xl border border-dls-border bg-emerald-2/25 px-3 py-2.5 text-[13px] leading-relaxed text-dls-text"; export const modalNoticeErrorClass = - "rounded-xl border border-dls-border bg-red-2/20 px-3 py-2.5 text-[13px] leading-relaxed text-dls-text"; + "flex min-h-9 items-center rounded-[8px] bg-red-2/60 px-3 py-2 text-[13px] leading-5 text-[#E5484D] dark:bg-red-2/20"; diff --git a/apps/app/src/react-app/shell/session-route.tsx b/apps/app/src/react-app/shell/session-route.tsx index 1576d68c2..7e9f99669 100644 --- a/apps/app/src/react-app/shell/session-route.tsx +++ b/apps/app/src/react-app/shell/session-route.tsx @@ -530,14 +530,16 @@ export function SessionRoute() { }) => { const name = input.name.trim(); const requestedFolderPath = input.folderPath.trim(); - if (!name || !requestedFolderPath) throw new Error(t("projects.name_and_folder_required")); - const requestedFolderKey = normalizeDirectoryPath(requestedFolderPath); - const existingProject = workspaces.find((workspace) => - workspace.workspaceType !== "remote" - && normalizeDirectoryPath(workspace.path) === requestedFolderKey - ); - if (existingProject) { - throw new Error(t("projects.folder_already_in_use")); + if (!name) throw new Error(t("projects.name_required")); + if (requestedFolderPath) { + const requestedFolderKey = normalizeDirectoryPath(requestedFolderPath); + const existingProject = workspaces.find((workspace) => + workspace.workspaceType !== "remote" + && normalizeDirectoryPath(workspace.path) === requestedFolderKey + ); + if (existingProject) { + throw new Error(t("projects.folder_already_in_use")); + } } if (!client) throw new Error(t("projects.server_unavailable")); const workContextId = activeWorkContextId === PERSONAL_WORK_CONTEXT_ID ? null : activeWorkContextId; @@ -546,7 +548,7 @@ export function SessionRoute() { if (isDesktopRuntime()) { const desktopState = await workspaceCreate({ - folderPath, + folderPath: folderPath || undefined, name, preset: "starter", workContextId, @@ -560,6 +562,8 @@ export function SessionRoute() { folderPath = desktopProject.path; } + if (!folderPath) throw new Error(t("projects.create_failed")); + const result = await client.createLocalWorkspace({ folderPath, name, diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index 828170503..49721dccf 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -1,7 +1,9 @@ /** @jsxImportSource react */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Navigate, useLocation, useNavigate, useParams } from "react-router-dom"; +import { Download, Plus, RefreshCw, Sparkles } from "lucide-react"; import { toast } from "@/components/ui/sonner"; +import { Button } from "@/components/ui/button"; import { getMcpServerName, @@ -87,11 +89,11 @@ import { DebugView } from "@/react-app/domains/settings/pages/debug-view"; import { EnvironmentView } from "@/react-app/domains/settings/pages/environment-view"; import { AuthorizationCenterView } from "@/react-app/domains/settings/pages/authorization-center-view"; import { ExtensionsView, type ExtensionsSection } from "@/react-app/domains/settings/pages/extensions-view"; -import { PluginPackagesPanel } from "@/react-app/domains/settings/plugin-packages-panel"; +import { PluginPackagesPanel, type PluginPackagesPanelHandle } from "@/react-app/domains/settings/plugin-packages-panel"; import type { PluginPackageRelationships } from "@/react-app/domains/settings/plugin-platform-state"; import { SettingsSegmentedTabs } from "@/react-app/domains/settings/settings-segmented-tabs"; import { RecoveryView } from "@/react-app/domains/settings/pages/recovery-view"; -import { SkillsView } from "@/react-app/domains/settings/pages/skills-view"; +import { SkillsView, type SkillsViewHandle } from "@/react-app/domains/settings/pages/skills-view"; import { UpdatesView } from "@/react-app/domains/settings/pages/updates-view"; import { useDebugViewModel } from "@/react-app/domains/settings/state/debug-view-model"; import { useElectronUpdaterState } from "@/react-app/domains/settings/state/electron-updater-state"; @@ -413,6 +415,8 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { const workContextRef = useRef(activeWorkContextId); const reconnectAttemptedWorkspaceIdRef = useRef(""); const refreshMcpServersRef = useRef<(() => void | Promise) | null>(null); + const pluginPackagesPanelRef = useRef(null); + const skillsViewRef = useRef(null); const notifyMcpReloadingRef = useRef<(() => void) | null>(null); const pollMcpServersAfterReloadRef = useRef<(() => void | Promise) | null>(null); const [providers, setProviders] = useState([]); @@ -1946,9 +1950,10 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { case "extensions": { const skillsView = ( ( + marketplaceView={(search, filters) => ( navigateSettingsPath(value === "skills" ? "extensions/skills" : "extensions")} /> ) : undefined} + headerActions={route.tab === "extensions" && !route.pluginPackageId && !selectedPluginSettingsPage ? ( + route.extensionsSection === "skills" ? ( +
+ + + +
+ ) : ( +
+ + +
+ ) + ) : undefined} + showNotifications={route.tab !== "extensions"} hidePageHeader={route.tab === "extensions" || Boolean(route.pluginPackageId) || Boolean(selectedPluginSettingsPage)} fullBleed={Boolean(selectedPluginSettingsPage)} hideShellHeader={Boolean(route.pluginPackageId)} + hideCloseButton={props.embedded && route.tab === "extensions"} > {settingsView} diff --git a/apps/app/tests/cloud-marketplaces-view.test.ts b/apps/app/tests/cloud-marketplaces-view.test.ts index 78f28787e..1a9114a0b 100644 --- a/apps/app/tests/cloud-marketplaces-view.test.ts +++ b/apps/app/tests/cloud-marketplaces-view.test.ts @@ -5,6 +5,7 @@ import { resolveMarketplaceCategory, shouldShowMarketplaceRows, } from "../src/react-app/domains/settings/pages/cloud-marketplaces-view"; +import { resolveExtensionIconUrl } from "../src/react-app/design-system/extension-icon-src"; describe("Cloud marketplace row visibility", () => { test("requires a Cloud account but not an organization", () => { @@ -56,6 +57,83 @@ describe("Cloud marketplace row visibility", () => { expect(resolveMarketplaceCategory({ pluginId: "unknown", category: "Uncategorized", manifest: {} })).toBe("other"); }); + test("uses curated brand assets for featured plugins while preserving iPollo branding", () => { + expect(resolveExtensionIconUrl({ pluginId: "figma", iconSlug: "figma" })).toBe("/ext-figma.svg"); + expect(resolveExtensionIconUrl({ pluginId: "context7", iconSlug: "semanticscholar" })).toBe("/ext-context7.svg"); + expect(resolveExtensionIconUrl({ pluginId: "design-agent", iconSrc: "ipollowork-mark.svg" })).toBe("ipollowork-mark.svg"); + expect(resolveExtensionIconUrl({ pluginId: "video-agent", iconSrc: "ipollowork-mark.svg" })).toBe("ipollowork-mark.svg"); + expect(resolveExtensionIconUrl({ pluginId: "custom-plugin", iconSlug: "example" })).toBe("https://cdn.simpleicons.org/example"); + }); + + test("matches the Figma plugin library layout with functional source and marketplace filters", async () => { + const panelSource = await Bun.file(new URL("../src/react-app/domains/settings/plugin-packages-panel.tsx", import.meta.url)).text(); + const marketplaceSource = await Bun.file(new URL("../src/react-app/domains/settings/pages/cloud-marketplaces-view.tsx", import.meta.url)).text(); + const listItemSource = await Bun.file(new URL("../src/react-app/domains/settings/plugin-package-list-item.tsx", import.meta.url)).text(); + const segmentedTabsSource = await Bun.file(new URL("../src/react-app/domains/settings/settings-segmented-tabs.tsx", import.meta.url)).text(); + const routeSource = await Bun.file(new URL("../src/react-app/shell/settings-route.tsx", import.meta.url)).text(); + const ipolloLogo = await Bun.file(new URL("../public/ipollowork-mark.svg", import.meta.url)).text(); + const englishLocale = await Bun.file(new URL("../src/i18n/locales/en.ts", import.meta.url)).text(); + const chineseLocale = await Bun.file(new URL("../src/i18n/locales/zh.ts", import.meta.url)).text(); + + expect(panelSource).toContain('data-testid="plugin-library-heading" className={settingsPageTitleClass}'); + expect(panelSource).toContain('settingsSectionTitleClass,'); + expect(panelSource).toContain('data-testid="plugin-library-description" className={settingsPageDescriptionClass}'); + expect(panelSource).toContain('

'); + expect(panelSource).toContain('containerClassName="h-[34px] rounded-lg'); + expect(panelSource).toContain('t("plugin_library.installed_description")'); + expect(panelSource).not.toContain('t("plugin_library.manage")'); + expect(panelSource).toContain('data-testid="plugin-installed-expand"'); + expect(panelSource).toContain('data-testid="plugin-installed-overflow-thumbnails"'); + expect(panelSource).toContain('remainingInstalledItems.slice(0, 3)'); + expect(panelSource).toContain('new ResizeObserver(updatePreviewLimit)'); + expect(panelSource).toContain('onClick={() => setInstalledExpanded((current) => !current)}'); + expect(panelSource).toContain('aria-expanded={installedExpanded}'); + expect(panelSource).toContain('const INSTALLED_TILE_GAP = 8;'); + expect(panelSource).toContain('const INSTALLED_TILE_WIDTH = 48;'); + expect(panelSource).toContain('className={`flex w-full items-center gap-2'); + expect(panelSource).toContain('className="flex size-12 shrink-0 items-center justify-center'); + expect(panelSource).toContain('hover:border-[#1FBAC0]'); + expect(panelSource).toContain('border-2 border-transparent'); + expect(panelSource).toContain(''); + expect(panelSource).not.toContain('hover:w-[202px]'); + expect(panelSource).not.toContain('data-testid="plugin-installed-details"'); + expect(panelSource).toContain('pluginId: item.pluginId'); + expect(panelSource).not.toContain('data-testid="plugin-installed-status-card"'); + expect(segmentedTabsSource).toContain('inline-flex h-7 items-center gap-0.5'); + expect(segmentedTabsSource).toContain('h-7 items-center justify-center rounded-[8px] px-3'); + expect(segmentedTabsSource).toContain('bg-[#f3f3f4] text-[#161e24]'); + expect(segmentedTabsSource).toContain('hover:bg-[#f6f7fb] active:bg-[#e7e7e9] active:text-[#161e24]'); + expect(segmentedTabsSource).toContain('disabled:pointer-events-none disabled:opacity-50'); + expect(segmentedTabsSource).not.toContain('variant?:'); + expect(panelSource).not.toContain('variant="standalone"'); + expect(routeSource).toContain('data-testid="plugin-library-navigation-actions"'); + expect(routeSource).toContain('showNotifications={route.tab !== "extensions"}'); + expect(routeSource).toContain('hideShellHeader={Boolean(route.pluginPackageId)}'); + expect(panelSource).toContain('MARKETPLACE_CATEGORY_IDS.map'); + expect(panelSource).toContain('props.marketplaceView(search, { category: marketplaceCategory, status: marketplaceStatus })'); + expect(marketplaceSource).toContain('statusFilter === "installed"'); + expect(marketplaceSource).toContain('statusFilter === "update"'); + expect(marketplaceSource).toContain('categoryFilter !== "all"'); + expect(listItemSource).toContain('h-[74px]'); + expect(listItemSource).toContain('bg-transparent px-4 py-2 transition-colors hover:bg-[#f6f7fb]'); + expect(listItemSource).not.toContain('rounded-lg border border-dls-border bg-dls-surface px-4 py-2'); + expect(listItemSource).toContain('size-12 shrink-0'); + expect(listItemSource).toContain('rounded-[8px] bg-[#f3f3f4] text-dls-secondary'); + expect(listItemSource).toContain('data-testid="plugin-package-card-copy"'); + expect(listItemSource).toContain('flex min-w-0 flex-1 flex-col gap-1'); + expect(listItemSource).toContain('className="line-clamp-2 text-ui-caption'); + expect(listItemSource).not.toContain('{skills} Skills'); + expect(listItemSource).not.toContain('{mcps} MCP'); + expect(panelSource).toContain('grid gap-x-8 gap-y-2 lg:grid-cols-2'); + expect(marketplaceSource).toContain('mt-3 grid gap-x-8 gap-y-2 lg:grid-cols-2'); + expect(listItemSource).not.toContain('dark:bg-white/[0.06]'); + expect(listItemSource).toContain('text-ui-caption text-dls-secondary'); + expect(ipolloLogo).toContain('viewBox="0 0 281 298"'); + expect(ipolloLogo.match(/ { const extensionsSource = await Bun.file(new URL("../src/react-app/domains/settings/pages/extensions-view.tsx", import.meta.url)).text(); const routeSource = await Bun.file(new URL("../src/react-app/shell/settings-route.tsx", import.meta.url)).text(); diff --git a/apps/app/tests/composer-model-behavior-menu.test.ts b/apps/app/tests/composer-model-behavior-menu.test.ts index 2662fe81d..a614c0531 100644 --- a/apps/app/tests/composer-model-behavior-menu.test.ts +++ b/apps/app/tests/composer-model-behavior-menu.test.ts @@ -104,6 +104,8 @@ describe("Composer model and reasoning menu", () => { expect(menu).toContain('type MenuView = "root" | "model" | "behavior"'); expect(menu).toContain("modelVariantLabel"); expect(menu).toContain("onModelVariantChange"); + expect(menu).toContain("rounded-full bg-gray-2 px-3 py-1.5 text-sm"); + expect(menu).toContain("hover:bg-gray-3"); expect(model).toContain('kind: "tokenstar-connect"'); expect(model).toContain("Connect TokenStar"); expect(model).toContain('grouped.push({ value: "TokenStar", items: [tokenStarEntry] })'); @@ -126,7 +128,8 @@ describe("Composer model and reasoning menu", () => { expect(modelIndex).toBeGreaterThan(-1); expect(modeIndex).toBeGreaterThan(modelIndex); expect(composer).toContain(" { expect(composer).toContain("if (props.busy || props.modeSelectionDisabled) return;"); expect(composer).toContain("if (props.busy || props.modeSelectionDisabled) setWorkModeOpen(false);"); expect(composer).toContain("disabled={props.busy || props.modeSelectionDisabled}"); + expect(composer.match(/"bg-gray-2 text-gray-10"/g)).toHaveLength(2); + expect(composer).not.toContain("dark:bg-white/15"); expect(composer).toContain(" { test("redirects the settings root to preferences while keeping the overview route available", () => { @@ -64,4 +140,176 @@ describe("settings route parsing", () => { test("returns to the task that opened settings", () => { expect(settingsRouteSource).toContain("workspaceSessionRoute(selectedWorkspaceId, navigationSessionId)"); }); + + test("uses a white settings workspace in light mode and preserves the dark theme", () => { + expect(settingsShellSource.match(/bg-white dark:bg-background/g)).toHaveLength(2); + expect(settingsShellSource).not.toContain("mac:bg-background/80"); + }); + + test("keeps settings navigation boundaries divider-free", () => { + expect(settingsShellSource).not.toMatch(/]*border-b border-dls-border/); + expect(settingsPageSource).toContain(' { + expect(settingsPageSource).toContain("export function SettingsTabIcon"); + expect(settingsPageSource).toContain('className="size-4! [&_*]:[vector-effect:non-scaling-stroke]" strokeWidth={1}'); + expect(settingsPageSource).toContain('alt="" className="size-[15px] dark:invert"'); + expect(settingsPageSource).toContain('publicAssetUrl("sidebar-icon/toy-brick.svg")'); + expect(settingsPageSource.match(//g)).toHaveLength(3); + expect(settingsShellSource).toContain(''); + expect(settingsShellSource).toContain(''); + }); + + test("hides the redundant close action from embedded Extensions only", () => { + expect(settingsRouteSource).toContain('hideCloseButton={props.embedded && route.tab === "extensions"}'); + expect(settingsShellSource).toContain('{props.hideCloseButton ? null : ('); + expect(settingsShellSource).toContain('data-testid="embedded-sidebar-restore"'); + expect(settingsShellSource).toContain('state !== "collapsed"'); + }); + + test("uses the shared settings typography hierarchy", () => { + expect(settingsPanelSource).toContain('export const settingsPageTitleClass = "text-2xl font-semibold leading-8 text-dls-text";'); + expect(settingsPanelSource).toContain('export const settingsPageDescriptionClass = "settings-description mt-1.5 text-ui-control leading-5 text-dls-secondary";'); + expect(settingsPanelSource).toContain('export const settingsSectionTitleClass = "text-ui-body font-semibold text-dls-text";'); + expect(settingsPanelSource).toContain("cn(settingsPageTitleClass, props.className)"); + expect(pluginPanelSource).toContain('className={settingsPageTitleClass}'); + expect(pluginPanelSource).toContain('className={settingsPageDescriptionClass}'); + expect(skillsViewSource).toContain('className={`${settingsPageDescriptionClass} max-w-[714px]`}'); + expect(settingsPanelSource).toContain('"settings-description text-ui-control leading-5 text-muted-foreground"'); + expect(settingsLayoutSource).toContain('"flex items-center gap-2 text-ui-body font-semibold text-foreground"'); + expect(settingsLayoutSource).toContain('"settings-description text-ui-control leading-5 text-muted-foreground"'); + expect(settingsShellSource).toContain('"truncate text-ui-body font-semibold text-dls-text"'); + expect(appStylesSource).toContain('html:lang(zh) .settings-description'); + expect(appStylesSource).toContain('font-weight: 500;'); + expect(`${settingsPanelSource}\n${settingsShellSource}\n${pluginPanelSource}\n${pluginListItemSource}`.replace('text-2xl font-semibold leading-8 text-dls-text', '')).not.toMatch(/text-(?:xl|2xl|3xl)|text-\[(?:15|2\d)px\]/); + }); + + test("uses a centered responsive settings safe area", () => { + expect(settingsPanelSource).toContain("px-8 py-4 md:py-6 lg:py-8 min-[1600px]:px-12 min-[1920px]:px-16"); + expect(settingsPanelSource).toContain('data-settings-safe-area className="mx-auto flex w-full max-w-[1280px] flex-col items-center gap-6 md:gap-8 [&>*]:w-full"'); + expect(settingsShellSource).toContain('data-settings-header-safe-area className="mx-auto flex h-full w-full max-w-[1280px] items-center justify-between"'); + expect(settingsPanelSource).toContain('export const settingsStandardContentClass = "mx-auto w-full max-w-[960px]";'); + expect(settingsLayoutSource).toContain('cn(settingsStandardContentClass, "@container/settings flex flex-col gap-y-6"'); + expect(pluginPanelSource).toContain('
'); + expect(settingsShellSource).toContain("px-8 min-[1600px]:px-12 min-[1920px]:px-16 mac:titlebar-drag"); + expect(settingsPanelSource).not.toMatch(/\bp-4\b|md:p-6|lg:p-8/); + }); + + test("uses the compact button size throughout settings", () => { + expect(settingsRouteSource.match(/