mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user