Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions dashboard/src/components/ChatPicker/SearchablePickerPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface SearchablePickerPanelProps<T> {
emptyMessage: string;
width?: PickerPanelWidth;
renderItem: (item: T) => ReactNode;
listFooter?: ReactNode;
footerIcon: ReactNode;
footerLabel: string;
onFooterClick: () => void;
Expand All @@ -24,6 +25,7 @@ export default function SearchablePickerPanel<T>({
emptyMessage,
width = "wide",
renderItem,
listFooter,
footerIcon,
footerLabel,
onFooterClick,
Expand Down Expand Up @@ -57,6 +59,7 @@ export default function SearchablePickerPanel<T>({
)}
</div>

{listFooter}
<button type="button" className={styles.footer} onClick={onFooterClick}>
{footerIcon}
<span>{footerLabel}</span>
Expand Down
112 changes: 99 additions & 13 deletions dashboard/src/layouts/PageShell.module.less
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,118 @@
}
}

/** Desktop path-tab label: icon + text. */
.pathTabLabel {
.titleRow {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px 16px;
flex-shrink: 0;
}

.titleCopy {
flex: 1 1 16rem;
min-width: min(100%, 12rem);
writing-mode: horizontal-tb;
text-orientation: mixed;
}

.titleText {
margin: 0 !important;
line-height: 28px !important;
font-size: 20px !important;
font-weight: 600 !important;
writing-mode: horizontal-tb;
text-orientation: mixed;
white-space: normal;
overflow-wrap: break-word;
}

.subtitleText {
display: block;
margin-top: 4px;
font-size: 13px;
writing-mode: horizontal-tb;
text-orientation: mixed;
white-space: normal;
overflow-wrap: break-word;
}

.titleActions {
flex: 1 1 18rem;
min-width: 0;
max-width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: flex-start;
gap: 8px;
padding-top: 2px;
}

.pathTabs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 3px;
box-sizing: border-box;
background: var(--fn-fill-tertiary, rgba(0, 0, 0, 0.06));
border-radius: 8px;
}

.pathTabsCompact {
width: 100%;
}

.pathTab {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
border: none;
background: transparent;
padding: 4px 11px;
border-radius: 6px;
font-size: 14px;
line-height: 22px;
color: var(--fn-text-secondary);
cursor: pointer;
white-space: nowrap;
flex: 0 0 auto;

&:hover {
color: var(--fn-text-primary);
}

&:focus-visible {
outline: 2px solid var(--fn-color-brand, #1677ff);
outline-offset: 1px;
}
}

.pathTabActive {
background: var(--fn-bg-elevated, #fff);
color: var(--fn-text-primary);
font-weight: 500;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}

.pathTabText {
writing-mode: horizontal-tb;
white-space: nowrap;
}

/**
* Mobile path tabs (Workbench / Personalization): full-width Segmented
* Mobile path tabs (Workbench / Personalization): full-width wrapping bar
* under the title, not squeezed into the title row.
*/
.pathTabsMobile {
flex-shrink: 0;
margin: -4px 0 12px;
}

.pathTabsMobileSegmented {
width: 100%;

:global(.ant-segmented-item-label),
:global(.octop-segmented-item-label) {
font-size: 13px;
white-space: nowrap;
}
}

/**
* Pin Ant Design Tabs nav; only the active pane scrolls.
* Use inside PageShell with `fill` (desktop).
Expand Down
40 changes: 40 additions & 0 deletions dashboard/src/layouts/PageShell.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import PageShell from "./PageShell";

vi.mock("../hooks/useIsMobile", () => ({
useIsMobile: () => false,
}));

describe("PageShell path tabs", () => {
it("renders wrapping tabs and keeps title copy horizontal", () => {
const onChange = vi.fn();
const { container } = render(
<PageShell
title="个性化 / 子智能体"
subtitle="技能、通道、连接器、技能包"
pathTabs={{
value: "subagents",
onChange,
options: [
{ value: "skills", label: "技能", icon: null },
{ value: "channels", label: "通道", icon: null },
{ value: "subagents", label: "子智能体", icon: null },
],
}}
>
<div>body</div>
</PageShell>,
);

expect(container.querySelector("div[class*='titleRow']")).toBeTruthy();
expect(container.querySelector("div[class*='pathTabs']")).toBeTruthy();
expect(screen.getByText("个性化 / 子智能体")).toBeInTheDocument();
fireEvent.click(screen.getByRole("tab", { name: "技能" }));
expect(onChange).toHaveBeenCalledWith("skills");
expect(screen.getByRole("tab", { name: "子智能体" })).toHaveAttribute(
"aria-selected",
"true",
);
});
});
88 changes: 39 additions & 49 deletions dashboard/src/layouts/PageShell.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { Segmented, Typography } from "antd";
import { Typography } from "antd";
import AgentSelector from "../components/AgentSelector";
import { useIsMobile } from "../hooks/useIsMobile";
import {
Expand Down Expand Up @@ -31,7 +31,7 @@ interface PageShellProps {
actions?: React.ReactNode;
/**
* Path tabs shared by Workbench / Personalization:
* desktop → title-row actions; mobile → full-width bar above content.
* desktop → title-row actions (wraps); mobile → full-width bar above content.
*/
pathTabs?: PathTabsConfig;
/** Render agent picker below the title row, outside the scrollable content card. */
Expand All @@ -41,32 +41,37 @@ interface PageShellProps {
children: React.ReactNode;
}

function PathTabsSegmented({
function PathTabsBar({
pathTabs,
isMobile,
compact,
}: {
pathTabs: PathTabsConfig;
isMobile: boolean;
compact: boolean;
}) {
return (
<Segmented
size={isMobile ? "small" : "middle"}
value={pathTabs.value}
block={isMobile}
className={isMobile ? styles.pathTabsMobileSegmented : undefined}
onChange={pathTabs.onChange}
options={pathTabs.options.map((opt) => ({
value: opt.value,
label: isMobile ? (
opt.label
) : (
<span className={styles.pathTabLabel}>
{opt.icon}
{opt.label}
</span>
),
}))}
/>
<div
className={`${styles.pathTabs} ${compact ? styles.pathTabsCompact : ""}`}
role="tablist"
>
{pathTabs.options.map((opt) => {
const active = opt.value === pathTabs.value;
return (
<button
key={opt.value}
type="button"
role="tab"
aria-selected={active}
className={`${styles.pathTab} ${
active ? styles.pathTabActive : ""
}`}
onClick={() => pathTabs.onChange(opt.value)}
>
{compact ? null : opt.icon}
<span className={styles.pathTabText}>{opt.label}</span>
</button>
);
})}
</div>
);
}

Expand All @@ -81,7 +86,7 @@ function PathTabsSegmented({
* - Content: colorBgContainer background, 24px padding, 8px radius
* - Only the content area scrolls internally
* - `actions` slot: right-aligned in the title row
* - `pathTabs`: desktop in title row; mobile full-width bar in content
* - `pathTabs`: desktop in title row (flex-wrap); mobile full-width wrapping bar
*
* Tabbed helpers: `PageShell.FillTabs` (Ant Tabs) and `PageShell.Tabbed`
* (custom tab bar) pin the tab chrome and scroll only the body on desktop.
Expand All @@ -105,7 +110,7 @@ function PageShell({
const titleActions =
!isMobile && pathTabs ? (
<>
<PathTabsSegmented pathTabs={pathTabs} isMobile={false} />
<PathTabsBar pathTabs={pathTabs} compact={false} />
{actions}
</>
) : (
Expand All @@ -124,42 +129,27 @@ function PageShell({
overflow: "hidden",
}}
>
{/* Title row — fixed, never scrolls */}
{/* Title row — wraps so a long tab strip cannot squeeze CJK titles. */}
<div
className={styles.titleRow}
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 12,
flexShrink: 0,
marginBottom: agentScoped ? 12 : 24,
paddingRight: titleRowEndPadding(outerPad),
}}
>
<div>
<Title
level={4}
style={{
margin: 0,
lineHeight: "28px",
fontSize: 20,
fontWeight: 600,
}}
>
<div className={styles.titleCopy}>
<Title level={4} className={styles.titleText}>
{title}
</Title>
{subtitle && (
<Text
type="secondary"
style={{ fontSize: 13, marginTop: 4, display: "block" }}
>
<Text type="secondary" className={styles.subtitleText}>
{subtitle}
</Text>
)}
</div>
{titleActions && (
<div style={{ flexShrink: 0, paddingTop: 2 }}>{titleActions}</div>
)}
{titleActions ? (
<div className={styles.titleActions}>{titleActions}</div>
) : null}
</div>

{agentScoped && (
Expand Down Expand Up @@ -190,7 +180,7 @@ function PageShell({
>
{isMobile && pathTabs && (
<div className={styles.pathTabsMobile}>
<PathTabsSegmented pathTabs={pathTabs} isMobile />
<PathTabsBar pathTabs={pathTabs} compact />
</div>
)}
{children}
Expand Down
28 changes: 0 additions & 28 deletions dashboard/src/layouts/Sidebar.module.less
Original file line number Diff line number Diff line change
Expand Up @@ -198,34 +198,6 @@
}
}

.composeRow {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 12px 8px;
}

.composeBtn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
border: none;
border-radius: var(--fn-radius-md);
background: color-mix(in srgb, var(--fn-accent, #0033ff) 8%, transparent);
color: var(--fn-text-secondary);
font-size: 12px;
line-height: 1.3;
text-align: left;
padding: 7px 10px;
cursor: pointer;
}

.composeBtn:hover {
background: color-mix(in srgb, var(--fn-accent, #0033ff) 14%, transparent);
color: var(--fn-text-primary);
}

.minimalPaneToggleCollapsed {
display: flex;
flex-direction: column;
Expand Down
Loading
Loading