feat: add Skills and Agents pages with sidebar navigation (#4)

- Add SkillsView and AgentsView stub pages matching goose2.0 layout
  (title left, action button right, search bar, empty state)
- Add MainPanelLayout shared component for page layout
- Add SearchBar shared component for page-level search
- Add page-transition CSS animation (matching goose2.0)
- Add storage path constants (~/.goose/)
- Wire up sidebar nav items (Agents, Skills) with view routing
- Add AppView type and view state management to AppShell
- Sidebar active state uses subtle bg-background-secondary treatment
This commit is contained in:
Wes
2026-03-29 20:34:23 -07:00
committed by GitHub
parent efec8897d2
commit ea7d712cf0
8 changed files with 265 additions and 7 deletions
+35 -4
View File
@@ -4,9 +4,13 @@ import { Sidebar } from "@/features/sidebar/ui/Sidebar";
import { StatusBar } from "@/features/status/ui/StatusBar";
import { HomeScreen } from "@/features/home/ui/HomeScreen";
import { ChatView } from "@/features/chat/ui/ChatView";
import { SkillsView } from "@/features/skills/ui/SkillsView";
import { AgentsView } from "@/features/agents/ui/AgentsView";
import { SettingsModal } from "@/features/settings/ui/SettingsModal";
import type { Tab } from "@/features/tabs/types";
export type AppView = "home" | "chat" | "skills" | "agents";
const SIDEBAR_WIDTH = 240;
const SIDEBAR_COLLAPSED_WIDTH = 48;
@@ -15,24 +19,35 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
const [settingsOpen, setSettingsOpen] = useState(false);
const [tabs, setTabs] = useState<Tab[]>([]);
const [activeTabId, setActiveTabId] = useState<string | null>(null);
const isHome = activeTabId === null;
const [activeView, setActiveView] = useState<AppView>("home");
const isHome = activeTabId === null && activeView === "home";
const handleNewTab = () => {
const id = String(Date.now());
setTabs((prev) => [...prev, { id, title: "New Chat" }]);
setActiveTabId(id);
setActiveView("chat");
};
const handleTabClose = (id: string) => {
setTabs((prev) => {
const next = prev.filter((t) => t.id !== id);
if (activeTabId === id) {
setActiveTabId(next[0]?.id ?? null);
const nextId = next[0]?.id ?? null;
setActiveTabId(nextId);
if (!nextId) setActiveView("home");
}
return next;
});
};
const handleNavigate = (view: AppView) => {
setActiveView(view);
if (view !== "chat") {
setActiveTabId(null);
}
};
const toggleSidebar = () => setSidebarCollapsed((prev) => !prev);
useEffect(() => {
@@ -52,6 +67,19 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
return () => window.removeEventListener("keydown", handler);
}, []);
const renderContent = () => {
switch (activeView) {
case "skills":
return <SkillsView />;
case "agents":
return <AgentsView />;
case "chat":
return <ChatView />;
case "home":
return activeTabId ? <ChatView /> : <HomeScreen />;
}
};
return (
<div className="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground">
{/* Tab bar — full width across the top */}
@@ -61,7 +89,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
onTabSelect={setActiveTabId}
onTabClose={handleTabClose}
onNewTab={handleNewTab}
onHomeClick={() => setActiveTabId(null)}
onHomeClick={() => handleNavigate("home")}
/>
{/* Main content area — sidebar + content as flex row */}
@@ -81,13 +109,16 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
width={SIDEBAR_WIDTH}
onCollapse={toggleSidebar}
onSettingsClick={() => setSettingsOpen(true)}
onNavigate={handleNavigate}
onNewChat={handleNewTab}
activeView={activeView}
className="h-full shadow-xl rounded-xl"
/>
</div>
{/* Content area */}
<main className="min-h-0 min-w-0 flex-1">
{children ?? (isHome ? <HomeScreen /> : <ChatView />)}
{children ?? renderContent()}
</main>
</div>
@@ -0,0 +1,50 @@
import { useState } from "react";
import { Bot, Plus } from "lucide-react";
import { SearchBar } from "@/shared/ui/SearchBar";
export function AgentsView() {
const [search, setSearch] = useState("");
return (
<div className="flex-1 overflow-y-auto">
<div className="max-w-5xl mx-auto w-full px-6 py-8 space-y-5 page-transition">
{/* Header */}
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">Agents</h1>
<p className="text-xs text-foreground-secondary">
Custom agent configurations for specific workflows
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-border hover:bg-background-tertiary transition-colors"
>
<Plus className="w-3.5 h-3.5" />
New Agent
</button>
</div>
</div>
{/* Search */}
<SearchBar
value={search}
onChange={setSearch}
placeholder="Search agents by name or description..."
/>
{/* Empty state */}
<div className="flex flex-col items-center justify-center gap-3 py-16 text-foreground-secondary">
<Bot className="h-10 w-10 opacity-30" />
<div className="text-center">
<p className="text-sm font-medium">No agents yet</p>
<p className="text-xs text-foreground-secondary/60 mt-1">
Create an agent or add a YAML file to your agents folder.
</p>
</div>
</div>
</div>
</div>
);
}
+14 -3
View File
@@ -10,6 +10,7 @@ import {
} from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { GooseIcon } from "@/shared/ui/icons/GooseIcon";
import type { AppView } from "@/app/AppShell";
interface SidebarProps {
collapsed: boolean;
@@ -18,13 +19,15 @@ interface SidebarProps {
onSettingsClick?: () => void;
onSearchClick?: () => void;
onNewChat?: () => void;
onNavigate?: (view: AppView) => void;
activeView?: AppView;
className?: string;
}
const NAV_ITEMS = [
const NAV_ITEMS: readonly { id: AppView; label: string; icon: typeof Bot }[] = [
{ id: "agents", label: "Agents", icon: Bot },
{ id: "skills", label: "Skills", icon: BookOpen },
] as const;
];
const RECENT_CHATS = [
{ id: "1", name: "Debug login flow", time: "2m" },
@@ -40,6 +43,8 @@ export function Sidebar({
onSettingsClick,
onSearchClick,
onNewChat,
onNavigate,
activeView,
className,
}: SidebarProps) {
const [expanded, setExpanded] = useState(!collapsed);
@@ -77,6 +82,7 @@ export function Sidebar({
>
<button
type="button"
onClick={() => onNavigate?.("home")}
className="hover:opacity-70 transition-opacity flex-shrink-0"
title="Home"
>
@@ -194,18 +200,23 @@ export function Sidebar({
{/* Nav items */}
{NAV_ITEMS.map((item, index) => {
const Icon = item.icon;
const isActive = activeView === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => onNavigate?.(item.id)}
title={collapsed ? item.label : undefined}
className={cn(
"flex items-center w-full rounded-md text-[13px] transition-all duration-200",
collapsed
? "justify-center px-0 py-1.5"
: "gap-2.5 px-3 py-1.5",
"text-foreground-secondary hover:text-foreground hover:bg-background-tertiary/50",
isActive
? "bg-background-secondary text-foreground"
: "text-foreground-secondary hover:text-foreground hover:bg-background-tertiary/50",
)}
aria-current={isActive ? "page" : undefined}
style={{
transitionDelay:
!collapsed && expanded ? `${index * 30}ms` : "0ms",
@@ -0,0 +1,50 @@
import { useState } from "react";
import { AtSign, Plus } from "lucide-react";
import { SearchBar } from "@/shared/ui/SearchBar";
export function SkillsView() {
const [search, setSearch] = useState("");
return (
<div className="flex-1 overflow-y-auto">
<div className="max-w-5xl mx-auto w-full px-6 py-8 space-y-5 page-transition">
{/* Header */}
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">Skills</h1>
<p className="text-xs text-foreground-secondary">
Reusable instructions for your AI agents
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-border hover:bg-background-tertiary transition-colors"
>
<Plus className="w-3.5 h-3.5" />
New Skill
</button>
</div>
</div>
{/* Search */}
<SearchBar
value={search}
onChange={setSearch}
placeholder="Search skills by name or description..."
/>
{/* Empty state */}
<div className="flex flex-col items-center justify-center gap-3 py-16 text-foreground-secondary">
<AtSign className="h-10 w-10 opacity-30" />
<div className="text-center">
<p className="text-sm font-medium">No skills yet</p>
<p className="text-xs text-foreground-secondary/60 mt-1">
Skills you add will appear here.
</p>
</div>
</div>
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Storage path constants.
*
* All persistent data lives under ~/.goose/ to stay consistent with the
* goose CLI and goose2.0 desktop app.
*/
/** Root directory for all Goose data. */
export const GOOSE_DIR = ".goose";
/** Subdirectory for saved recipes / skills. */
export const RECIPES_DIR = `${GOOSE_DIR}/recipes`;
/** Subdirectory for agent configurations. */
export const AGENTS_DIR = `${GOOSE_DIR}/agents`;
/** Subdirectory for session history. */
export const SESSIONS_DIR = `${GOOSE_DIR}/sessions`;
/** Subdirectory for extension state. */
export const EXTENSIONS_DIR = `${GOOSE_DIR}/extensions`;
+39
View File
@@ -187,6 +187,38 @@
padding-right: calc(0.5rem * var(--density-spacing));
}
/* ═══════════════════════════════════════════════════════════
Page-transition animation (matches goose2.0)
═══════════════════════════════════════════════════════════ */
@keyframes page-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.page-transition {
opacity: 0;
animation: page-fade-in 0.08s ease-out forwards;
pointer-events: auto;
}
/* Content fade-in (used for list areas after loading) */
@keyframes content-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.content-fade-in {
animation: content-fade-in 0.3s ease-out forwards;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
@@ -196,4 +228,11 @@
/* biome-ignore lint/complexity/noImportantStyles: required to override all transitions for reduced-motion accessibility */
transition-duration: 0.01ms !important;
}
.page-transition {
/* biome-ignore lint/complexity/noImportantStyles: required to override animation for reduced-motion accessibility */
animation: none !important;
/* biome-ignore lint/complexity/noImportantStyles: required to override opacity for reduced-motion accessibility */
opacity: 1 !important;
}
}
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
/**
* Layout wrapper for full-page views (Skills, Agents, etc.).
*
* Fills the parent container (typically `<main>` inside AppShell)
* and provides a flex-column context for header + scrollable content.
*/
export function MainPanelLayout({
children,
backgroundColor = "bg-background-primary",
}: {
children: ReactNode;
backgroundColor?: string;
}) {
return (
<div className={`flex flex-col ${backgroundColor} min-w-0 h-full min-h-0`}>
{children}
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { Search } from "lucide-react";
import { cn } from "@/shared/lib/cn";
interface SearchBarProps {
/** Current search value (controlled) */
value: string;
/** Called when the search term changes */
onChange: (term: string) => void;
/** Placeholder text */
placeholder?: string;
/** Optional className for the wrapper */
className?: string;
}
export function SearchBar({
value,
onChange,
placeholder,
className,
}: SearchBarProps) {
return (
<div className={cn("relative", className)}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-secondary/60" />
<input
type="search"
autoComplete="off"
spellCheck={false}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full pl-9 pr-3 py-2 text-sm bg-background-secondary border border-border-secondary/50 rounded-lg placeholder:text-foreground-secondary/40 focus:outline-none focus:ring-1 focus:ring-ring focus:border-border-primary transition-colors"
/>
</div>
);
}