99 lines
3.7 KiB
TypeScript
99 lines
3.7 KiB
TypeScript
|
|
"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>
|
||
|
|
);
|
||
|
|
}
|