feat(sessions): merge the activity feed into a tab on /sessions
Sessions and Activity Feed are two views of the same event pipeline, so they now share one page: /sessions renders a tab bar (Sessions | Activity Feed) with the active view in the ?view= query param, and /activity redirects to /sessions?view=events. The sidebar entry is dropped. The tab bar itself moves out of SettingsHub into components/PageTabs so both hubs share it, and DocumentTitle loses the two branches for paths that now only redirect.
This commit is contained in:
+5
-4
@@ -444,9 +444,9 @@ useEffect(() => {
|
|||||||
graph TB
|
graph TB
|
||||||
Root["/"]
|
Root["/"]
|
||||||
Dashboard["/ (Dashboard)"]
|
Dashboard["/ (Dashboard)"]
|
||||||
Sessions["/sessions"]
|
Sessions["/sessions (tabs: sessions | events)"]
|
||||||
Detail["/sessions/:id"]
|
Detail["/sessions/:id"]
|
||||||
Feed["/activity"]
|
Feed["/activity (redirect)"]
|
||||||
Workflows["/workflows"]
|
Workflows["/workflows"]
|
||||||
CcConfig["/cc-config (redirect)"]
|
CcConfig["/cc-config (redirect)"]
|
||||||
Run["/run"]
|
Run["/run"]
|
||||||
@@ -472,9 +472,10 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Layout />}>
|
<Route path="/" element={<Layout />}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="sessions" element={<Sessions />} />
|
<Route path="sessions" element={<History />} />
|
||||||
<Route path="sessions/:id" element={<SessionDetail />} />
|
<Route path="sessions/:id" element={<SessionDetail />} />
|
||||||
<Route path="activity" element={<ActivityFeed />} />
|
{/* legacy path - the activity feed is now a tab on /sessions */}
|
||||||
|
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
||||||
<Route path="workflows" element={<Workflows />} />
|
<Route path="workflows" element={<Workflows />} />
|
||||||
{/* legacy path - Claude config now lives in a Settings tab */}
|
{/* legacy path - Claude config now lives in a Settings tab */}
|
||||||
<Route path="cc-config" element={<Navigate to="/settings?tool=claude-code" replace />} />
|
<Route path="cc-config" element={<Navigate to="/settings?tool=claude-code" replace />} />
|
||||||
|
|||||||
+4
-6
@@ -39,9 +39,8 @@
|
|||||||
* - `./components/Layout`
|
* - `./components/Layout`
|
||||||
* - `./components/SplashScreen`
|
* - `./components/SplashScreen`
|
||||||
* - `./pages/Dashboard`
|
* - `./pages/Dashboard`
|
||||||
* - `./pages/Sessions`
|
* - `./pages/History`
|
||||||
* - `./pages/SessionDetail`
|
* - `./pages/SessionDetail`
|
||||||
* - `./pages/ActivityFeed`
|
|
||||||
* - `./pages/Workflows`
|
* - `./pages/Workflows`
|
||||||
* - `./pages/SettingsHub`
|
* - `./pages/SettingsHub`
|
||||||
* - `./pages/Workspace`
|
* - `./pages/Workspace`
|
||||||
@@ -63,9 +62,8 @@ import { Layout } from "./components/Layout";
|
|||||||
import { DocumentTitle } from "./components/DocumentTitle";
|
import { DocumentTitle } from "./components/DocumentTitle";
|
||||||
import { SplashScreen } from "./components/SplashScreen";
|
import { SplashScreen } from "./components/SplashScreen";
|
||||||
import { Dashboard } from "./pages/Dashboard";
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
import { Sessions } from "./pages/Sessions";
|
import { History } from "./pages/History";
|
||||||
import { SessionDetail } from "./pages/SessionDetail";
|
import { SessionDetail } from "./pages/SessionDetail";
|
||||||
import { ActivityFeed } from "./pages/ActivityFeed";
|
|
||||||
import { Workflows } from "./pages/Workflows";
|
import { Workflows } from "./pages/Workflows";
|
||||||
import { SettingsHub } from "./pages/SettingsHub";
|
import { SettingsHub } from "./pages/SettingsHub";
|
||||||
import { Workspace } from "./pages/Workspace";
|
import { Workspace } from "./pages/Workspace";
|
||||||
@@ -95,9 +93,9 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout wsConnected={connected} />}>
|
<Route element={<Layout wsConnected={connected} />}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="sessions" element={<Sessions />} />
|
<Route path="sessions" element={<History />} />
|
||||||
<Route path="sessions/:id" element={<SessionDetail />} />
|
<Route path="sessions/:id" element={<SessionDetail />} />
|
||||||
<Route path="activity" element={<ActivityFeed />} />
|
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
||||||
<Route path="workflows" element={<Workflows />} />
|
<Route path="workflows" element={<Workflows />} />
|
||||||
<Route path="lanes" element={<Navigate to="/run" replace />} />
|
<Route path="lanes" element={<Navigate to="/run" replace />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -24,12 +24,8 @@ export function DocumentTitle() {
|
|||||||
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
|
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
|
||||||
} else if (path.startsWith("/sessions")) {
|
} else if (path.startsWith("/sessions")) {
|
||||||
title = t("sessions");
|
title = t("sessions");
|
||||||
} else if (path.startsWith("/activity")) {
|
|
||||||
title = t("activityFeed");
|
|
||||||
} else if (path.startsWith("/workflows")) {
|
} else if (path.startsWith("/workflows")) {
|
||||||
title = t("workflows");
|
title = t("workflows");
|
||||||
} else if (path.startsWith("/cc-config")) {
|
|
||||||
title = t("ccConfig");
|
|
||||||
} else if (path.startsWith("/run")) {
|
} else if (path.startsWith("/run")) {
|
||||||
title = t("run");
|
title = t("run");
|
||||||
} else if (path.startsWith("/settings")) {
|
} else if (path.startsWith("/settings")) {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* @file PageTabs.tsx
|
||||||
|
* @description Shared top-of-page tab bar used by the pages that merged several
|
||||||
|
* former routes into one (Settings hub, Sessions/Events history). Rendering only -
|
||||||
|
* the owning page keeps the active tab in its own query param.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import type { ComponentType } from "react";
|
||||||
|
|
||||||
|
export interface PageTab {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
Icon: ComponentType<{ className?: string }>;
|
||||||
|
/** Small muted suffix, e.g. "soon" for a tool that is not integrated yet. */
|
||||||
|
badge?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageTabsProps {
|
||||||
|
tabs: PageTab[];
|
||||||
|
activeId: string;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontal tab strip for a page that hosts several panels.
|
||||||
|
* @param props - Tabs to render, the active tab id, and the selection callback.
|
||||||
|
* @returns The tab bar element.
|
||||||
|
*/
|
||||||
|
export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="flex items-center gap-1.5 overflow-x-auto no-scrollbar border-b border-border/60 pb-2"
|
||||||
|
>
|
||||||
|
{tabs.map(({ id, label, Icon, badge }) => {
|
||||||
|
const isActive = id === activeId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
onClick={() => onSelect(id)}
|
||||||
|
className={`inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-3 py-1.5 rounded-lg border transition-colors flex-shrink-0 ${
|
||||||
|
isActive
|
||||||
|
? "bg-accent/15 border-accent/30 text-accent"
|
||||||
|
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{label}
|
||||||
|
{badge && <span className="text-[10px] text-fg-muted">{badge}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -88,7 +88,6 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
|||||||
const NAV_KEYS = [
|
const NAV_KEYS = [
|
||||||
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
|
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
|
||||||
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
||||||
{ to: "/activity", icon: Activity, key: "nav:activityFeed" },
|
|
||||||
{ to: "/workflows", icon: Workflow, key: "nav:workflows" },
|
{ to: "/workflows", icon: Workflow, key: "nav:workflows" },
|
||||||
{ to: "/run", icon: Play, key: "nav:workspace" },
|
{ to: "/run", icon: Play, key: "nav:workspace" },
|
||||||
{ to: "/settings", icon: Settings, key: "nav:settings" },
|
{ to: "/settings", icon: Settings, key: "nav:settings" },
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ describe("Sidebar", () => {
|
|||||||
renderSidebar(true);
|
renderSidebar(true);
|
||||||
expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
expect(screen.getByText("Dashboard")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Sessions")).toBeInTheDocument();
|
expect(screen.getByText("Sessions")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Activity Feed")).toBeInTheDocument();
|
// Activity Feed and Claude Config are no longer top-level entries: they
|
||||||
|
// moved into tabs on /sessions and /settings respectively.
|
||||||
|
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Activity Feed")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show "Live" when WebSocket is connected', () => {
|
it('should show "Live" when WebSocket is connected', () => {
|
||||||
@@ -60,7 +63,8 @@ describe("Sidebar", () => {
|
|||||||
const hrefs = links.map((link) => link.getAttribute("href"));
|
const hrefs = links.map((link) => link.getAttribute("href"));
|
||||||
expect(hrefs).toContain("/");
|
expect(hrefs).toContain("/");
|
||||||
expect(hrefs).toContain("/sessions");
|
expect(hrefs).toContain("/sessions");
|
||||||
expect(hrefs).toContain("/activity");
|
expect(hrefs).toContain("/settings");
|
||||||
|
expect(hrefs).not.toContain("/activity");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render both language options in expanded mode", () => {
|
it("should render both language options in expanded mode", () => {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* @file History.tsx
|
||||||
|
* @description Tabbed shell for /sessions that hosts the two views over the same
|
||||||
|
* event pipeline: the per-session list and the raw activity feed. The active tab
|
||||||
|
* lives in the `?view=` query param, so `/activity` can redirect into it.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import type { ComponentType } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Activity, FolderOpen } from "lucide-react";
|
||||||
|
import { PageTabs } from "../components/PageTabs";
|
||||||
|
import { Sessions } from "./Sessions";
|
||||||
|
import { ActivityFeed } from "./ActivityFeed";
|
||||||
|
|
||||||
|
interface HistoryView {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
Icon: ComponentType<{ className?: string }>;
|
||||||
|
Panel: ComponentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sessions page shell: grouped sessions and the raw event stream as sibling tabs.
|
||||||
|
* @returns The tab bar plus the active view.
|
||||||
|
*/
|
||||||
|
export function History() {
|
||||||
|
const { t } = useTranslation("nav");
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
|
||||||
|
const views: HistoryView[] = [
|
||||||
|
{ id: "sessions", label: t("sessions"), Icon: FolderOpen, Panel: Sessions },
|
||||||
|
{ id: "events", label: t("activityFeed"), Icon: Activity, Panel: ActivityFeed },
|
||||||
|
];
|
||||||
|
const defaultView = views[0]!;
|
||||||
|
const active = views.find((v) => v.id === params.get("view")) ?? defaultView;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageTabs
|
||||||
|
ariaLabel={t("sessions")}
|
||||||
|
activeId={active.id}
|
||||||
|
onSelect={(id) => setParams(id === defaultView.id ? {} : { view: id }, { replace: true })}
|
||||||
|
tabs={views.map(({ id, label, Icon }) => ({ id, label, Icon }))}
|
||||||
|
/>
|
||||||
|
<active.Panel />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type { ComponentType } from "react";
|
|||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Boxes, Settings as SettingsIcon, SquareTerminal, MousePointerClick } from "lucide-react";
|
import { Boxes, Settings as SettingsIcon, SquareTerminal, MousePointerClick } from "lucide-react";
|
||||||
|
import { PageTabs } from "../components/PageTabs";
|
||||||
import { Settings } from "./Settings";
|
import { Settings } from "./Settings";
|
||||||
import { CcConfig } from "./CcConfig";
|
import { CcConfig } from "./CcConfig";
|
||||||
|
|
||||||
@@ -45,37 +46,17 @@ export function SettingsHub() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div
|
<PageTabs
|
||||||
role="tablist"
|
ariaLabel={t("tools.label", "Tools")}
|
||||||
aria-label={t("tools.label", "Tools")}
|
activeId={active.id}
|
||||||
className="flex items-center gap-1.5 overflow-x-auto no-scrollbar border-b border-border/60 pb-2"
|
onSelect={(id) => setParams(id === DASHBOARD_TAB.id ? {} : { tool: id }, { replace: true })}
|
||||||
>
|
tabs={TOOL_TABS.map(({ id, labelKey, Icon, Panel }) => ({
|
||||||
{TOOL_TABS.map(({ id, labelKey, Icon, Panel }) => {
|
id,
|
||||||
const isActive = id === active.id;
|
Icon,
|
||||||
return (
|
label: t(labelKey),
|
||||||
<button
|
...(Panel ? {} : { badge: t("tools.soonBadge", "soon") }),
|
||||||
key={id}
|
}))}
|
||||||
type="button"
|
/>
|
||||||
role="tab"
|
|
||||||
aria-selected={isActive}
|
|
||||||
onClick={() =>
|
|
||||||
setParams(id === DASHBOARD_TAB.id ? {} : { tool: id }, { replace: true })
|
|
||||||
}
|
|
||||||
className={`inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-3 py-1.5 rounded-lg border transition-colors flex-shrink-0 ${
|
|
||||||
isActive
|
|
||||||
? "bg-accent/15 border-accent/30 text-accent"
|
|
||||||
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="w-3.5 h-3.5" />
|
|
||||||
{t(labelKey)}
|
|
||||||
{!Panel && (
|
|
||||||
<span className="text-[10px] text-fg-muted">{t("tools.soonBadge", "soon")}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{active.Panel ? (
|
{active.Panel ? (
|
||||||
<active.Panel />
|
<active.Panel />
|
||||||
|
|||||||
Reference in New Issue
Block a user