Submatter/apps/web/components/ai/ai-workspace.tsx

99 lines
3.7 KiB
TypeScript
Raw Permalink Normal View History

"use client";
import * as Tabs from "@radix-ui/react-tabs";
import { useLayoutEffect, useRef } from "react";
import { useSearchParams } from "next/navigation";
import { Sparkles } from "lucide-react";
import { AgentsMode } from "./agents-mode";
import { ChatMode } from "./chat-mode";
import { changeAIMode, parseAIMode } from "./workspace-mode";
import { createComposerFocusMemory } from "./workspace-interaction";
export function AIWorkspace({ workspaceSlug }: { workspaceSlug: string }) {
const searchParams = useSearchParams();
const mode = parseAIMode(searchParams.get("tab"));
const rootRef = useRef<HTMLDivElement>(null);
const focusMemory = useRef(createComposerFocusMemory());
useLayoutEffect(() => {
const root = rootRef.current;
const tabHasFocus = !!root
?.querySelector('[role="tablist"]')
?.contains(document.activeElement);
if (focusMemory.current.activate(mode, tabHasFocus)) {
root
?.querySelector<HTMLTextAreaElement>(`[data-composer-mode="${mode}"]`)
?.focus({ preventScroll: true });
}
}, [mode]);
return (
<Tabs.Root
key={workspaceSlug}
value={mode}
activationMode="manual"
ref={rootRef}
onFocusCapture={(event) => {
const target = event.target;
// Tabs retain keyboard focus; only focus within a panel changes its memory.
if (
!(target instanceof HTMLElement) ||
!target.closest('[role="tabpanel"]')
)
return;
focusMemory.current.record(mode, target.dataset.composerMode === mode);
}}
onValueChange={(value) => {
// Next integrates native history with useSearchParams, including popstate.
changeAIMode(window.location, parseAIMode(value), (href) => {
window.history.pushState(null, "", href);
});
}}
className="flex h-full min-h-0 flex-col"
>
<header className="shrink-0 border-b px-4 py-4 sm:px-6">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<Sparkles className="size-5 text-primary" aria-hidden="true" />
</div>
<div>
<h1 className="text-lg font-semibold">AI Assistant</h1>
<p className="text-xs text-muted-foreground">
Chat about your workspace or draft reusable agents
</p>
</div>
</div>
<Tabs.List aria-label="AI workspace mode" className="mt-4 flex gap-2">
{(["chat", "agents"] as const).map((value) => (
<Tabs.Trigger
key={value}
value={value}
className="rounded-md px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-[state=active]:bg-primary/10 data-[state=active]:text-primary"
>
{value === "chat" ? "Chat" : "Agents"}
</Tabs.Trigger>
))}
</Tabs.List>
</header>
{/* Keeping both trees mounted retains drafts, Chat transport, and scroll containers. */}
<Tabs.Content
value="chat"
forceMount
hidden={mode !== "chat"}
className="min-h-0 flex-1 data-[state=inactive]:hidden"
>
<ChatMode workspaceSlug={workspaceSlug} active={mode === "chat"} />
</Tabs.Content>
<Tabs.Content
value="agents"
forceMount
hidden={mode !== "agents"}
className="min-h-0 flex-1 data-[state=inactive]:hidden"
>
<AgentsMode workspaceSlug={workspaceSlug} active={mode === "agents"} />
</Tabs.Content>
</Tabs.Root>
);
}