feat(nexus): add LocalAI and CloudProviders settings cards (phase 13)
LocalAISection surfaces the Hermes adapter tier, Whisper/Piper voice availability, and the global voice toggle wired through nexus-settings. CloudProvidersSection lists Anthropic and OpenAI API key slots; keys are set via the existing /api-keys/store endpoint and rendered as a password-type Input so values are masked on display and never logged. Presence is derived from the workspace secret vault. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b277527510
commit
f41690ff30
5 changed files with 588 additions and 15 deletions
139
ui/src/components/settings/CloudProvidersSection.test.tsx
Normal file
139
ui/src/components/settings/CloudProvidersSection.test.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { storeApiKeySpy, pushToastSpy } = vi.hoisted(() => ({
|
||||
storeApiKeySpy: vi.fn(async () => ({})),
|
||||
pushToastSpy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/secrets", () => ({
|
||||
secretsApi: {
|
||||
list: vi.fn(async () => [
|
||||
{
|
||||
id: "s1",
|
||||
companyId: "c1",
|
||||
name: "anthropic_api_key",
|
||||
provider: "local_encrypted",
|
||||
description: "API key for anthropic",
|
||||
},
|
||||
]),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/puter-proxy", () => ({
|
||||
puterProxyApi: {
|
||||
storeApiKey: storeApiKeySpy,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
companies: [],
|
||||
selectedCompanyId: "c1",
|
||||
selectedCompany: { id: "c1", name: "Test" },
|
||||
selectionSource: "manual" as const,
|
||||
loading: false,
|
||||
error: null,
|
||||
setSelectedCompanyId: () => {},
|
||||
reloadCompanies: async () => {},
|
||||
createCompany: async () => {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
useToast: () => ({
|
||||
pushToast: pushToastSpy,
|
||||
dismissToast: () => {},
|
||||
clearToasts: () => {},
|
||||
toasts: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
import { CloudProvidersSection } from "./CloudProvidersSection";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("CloudProvidersSection", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
storeApiKeySpy.mockClear();
|
||||
pushToastSpy.mockClear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root!.unmount();
|
||||
});
|
||||
root = null;
|
||||
}
|
||||
if (container.parentNode) container.remove();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
async function renderAndFlush() {
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CloudProvidersSection />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
// Allow the secrets query to resolve across several microtask ticks.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("renders the Cloud providers section header", async () => {
|
||||
await renderAndFlush();
|
||||
expect(container.querySelector("h2")?.textContent).toBe("Cloud providers");
|
||||
});
|
||||
|
||||
it("shows 'set' for a provider whose secret exists and 'not set' otherwise", async () => {
|
||||
await renderAndFlush();
|
||||
// Both providers are listed as row labels
|
||||
expect(container.textContent).toContain("Anthropic API key");
|
||||
expect(container.textContent).toContain("OpenAI API key");
|
||||
// anthropic has a secret in the mock
|
||||
const setBadges = Array.from(container.querySelectorAll("span")).filter(
|
||||
(s) => s.textContent === "set",
|
||||
);
|
||||
const notSetBadges = Array.from(container.querySelectorAll("span")).filter(
|
||||
(s) => s.textContent === "not set",
|
||||
);
|
||||
expect(setBadges.length).toBe(1);
|
||||
expect(notSetBadges.length).toBe(1);
|
||||
});
|
||||
|
||||
it("input rendered for editing masks its value (type=password)", async () => {
|
||||
await renderAndFlush();
|
||||
const editButtons = Array.from(container.querySelectorAll("button")).filter(
|
||||
(b) => b.textContent === "Set key" || b.textContent === "Replace",
|
||||
);
|
||||
expect(editButtons.length).toBe(2);
|
||||
act(() => {
|
||||
editButtons[0]!.click();
|
||||
});
|
||||
const input = container.querySelector("input");
|
||||
expect(input).not.toBeNull();
|
||||
expect(input?.getAttribute("type")).toBe("password");
|
||||
});
|
||||
});
|
||||
166
ui/src/components/settings/CloudProvidersSection.tsx
Normal file
166
ui/src/components/settings/CloudProvidersSection.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { secretsApi } from "@/api/secrets";
|
||||
import { puterProxyApi } from "@/api/puter-proxy";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SettingsSection, SettingsRow } from "./SettingsSection";
|
||||
|
||||
const PROVIDERS: ReadonlyArray<{ id: "anthropic" | "openai"; label: string }> = [
|
||||
{ id: "anthropic", label: "Anthropic API key" },
|
||||
{ id: "openai", label: "OpenAI API key" },
|
||||
];
|
||||
|
||||
interface ProviderRowProps {
|
||||
id: "anthropic" | "openai";
|
||||
label: string;
|
||||
hasKey: boolean;
|
||||
companyId: string | null;
|
||||
}
|
||||
|
||||
function ProviderRow({ id, label, hasKey, companyId }: ProviderRowProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (apiKey: string) => {
|
||||
if (!companyId) throw new Error("No workspace selected");
|
||||
if (!apiKey.trim()) throw new Error("API key cannot be empty");
|
||||
await puterProxyApi.storeApiKey(companyId, id, apiKey.trim());
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setEditing(false);
|
||||
setValue("");
|
||||
pushToast({ type: "success", message: `${label} saved.` });
|
||||
if (companyId) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.secrets.list(companyId),
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
type: "error",
|
||||
message: error instanceof Error ? error.message : `Failed to save ${label}.`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<SettingsRow label={label} description={`Paste your ${label.toLowerCase()} to store it encrypted.`}>
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate(value);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
autoComplete="off"
|
||||
aria-label={`${label} input`}
|
||||
className="h-8 w-56 text-xs focus-visible:ring-offset-background"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={saveMutation.isPending || !value.trim() || !companyId}
|
||||
>
|
||||
{saveMutation.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setValue("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</form>
|
||||
</SettingsRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsRow
|
||||
label={label}
|
||||
description={
|
||||
hasKey
|
||||
? "Key is stored encrypted in the workspace secret vault."
|
||||
: "Not set — the workspace will fall back to Puter.js free tier when available."
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{hasKey ? "set" : "not set"}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditing(true)}
|
||||
disabled={!companyId}
|
||||
>
|
||||
{hasKey ? "Replace" : "Set key"}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function CloudProvidersSection() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
|
||||
const secretsQuery = useQuery({
|
||||
queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"],
|
||||
queryFn: () => (selectedCompanyId ? secretsApi.list(selectedCompanyId) : Promise.resolve([])),
|
||||
enabled: Boolean(selectedCompanyId),
|
||||
});
|
||||
|
||||
const secretNames = new Set(
|
||||
(secretsQuery.data ?? []).map((s) => s.name.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Cloud providers"
|
||||
description="Bring your own API keys. Values are masked on display and never logged."
|
||||
>
|
||||
{PROVIDERS.map((provider) => (
|
||||
<ProviderRow
|
||||
key={provider.id}
|
||||
id={provider.id}
|
||||
label={provider.label}
|
||||
hasKey={secretNames.has(`${provider.id}_api_key`)}
|
||||
companyId={selectedCompanyId}
|
||||
/>
|
||||
))}
|
||||
|
||||
<SettingsRow
|
||||
label="Puter.js"
|
||||
description="Zero-config free tier that routes through the Puter cloud when no API key is set."
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">Enabled</span>
|
||||
</SettingsRow>
|
||||
|
||||
{!selectedCompanyId ? (
|
||||
<div className="rounded-md border border-border/60 bg-transparent px-3 py-2 text-xs text-muted-foreground">
|
||||
Select a workspace to configure API keys.
|
||||
</div>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
87
ui/src/components/settings/LocalAISection.test.tsx
Normal file
87
ui/src/components/settings/LocalAISection.test.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/hooks/useHardwareInfo", () => ({
|
||||
useHardwareInfo: () => ({
|
||||
data: {
|
||||
totalGb: 32,
|
||||
freeGb: 16,
|
||||
usableGb: 24,
|
||||
platform: "linux",
|
||||
gpuName: "NVIDIA RTX 4090",
|
||||
gpuVramGb: 24,
|
||||
unifiedMemory: false,
|
||||
hardwareTier: "gpu" as const,
|
||||
cpuModel: "AMD Ryzen 9",
|
||||
voiceCapability: {
|
||||
whisperAvailable: true,
|
||||
piperAvailable: false,
|
||||
voiceTierSufficient: true,
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/hardware", () => ({
|
||||
fetchNexusSettings: vi.fn(async () => ({ mode: "both", voiceEnabled: false })),
|
||||
updateNexusSettings: vi.fn(async (patch: unknown) => patch),
|
||||
}));
|
||||
|
||||
import { LocalAISection } from "./LocalAISection";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("LocalAISection", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root!.unmount();
|
||||
});
|
||||
root = null;
|
||||
}
|
||||
if (container.parentNode) container.remove();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
function render() {
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LocalAISection />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("renders Local AI section header and hardware tier", () => {
|
||||
render();
|
||||
expect(container.querySelector("h2")?.textContent).toBe("Local AI");
|
||||
expect(container.textContent).toContain("GPU");
|
||||
expect(container.textContent).toContain("NVIDIA RTX 4090");
|
||||
});
|
||||
|
||||
it("shows Whisper as installed and Piper as not installed based on voice capability", () => {
|
||||
render();
|
||||
expect(container.textContent).toContain("Installed");
|
||||
expect(container.textContent).toContain("Not installed");
|
||||
});
|
||||
});
|
||||
125
ui/src/components/settings/LocalAISection.tsx
Normal file
125
ui/src/components/settings/LocalAISection.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useHardwareInfo } from "@/hooks/useHardwareInfo";
|
||||
import { fetchNexusSettings, updateNexusSettings } from "@/api/hardware";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SettingsSection, SettingsRow } from "./SettingsSection";
|
||||
|
||||
function formatTier(tier: string | undefined) {
|
||||
switch (tier) {
|
||||
case "gpu":
|
||||
return "GPU";
|
||||
case "apple_silicon":
|
||||
return "Apple Silicon";
|
||||
case "cpu_only":
|
||||
return "CPU only";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function LocalAISection() {
|
||||
const queryClient = useQueryClient();
|
||||
const hardwareQuery = useHardwareInfo(true);
|
||||
const settingsQuery = useQuery({
|
||||
queryKey: queryKeys.nexus.settings,
|
||||
queryFn: fetchNexusSettings,
|
||||
});
|
||||
|
||||
const updateSettingsMutation = useMutation({
|
||||
mutationFn: updateNexusSettings,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.nexus.settings });
|
||||
},
|
||||
});
|
||||
|
||||
const hardware = hardwareQuery.data;
|
||||
const voice = hardware?.voiceCapability;
|
||||
const voiceEnabled = settingsQuery.data?.voiceEnabled === true;
|
||||
|
||||
const hermesProviderLabel =
|
||||
hardware?.gpuName
|
||||
? `${hardware.gpuName}${hardware.gpuVramGb ? ` · ${hardware.gpuVramGb}GB VRAM` : ""}`
|
||||
: hardware?.cpuModel ?? "Detecting...";
|
||||
|
||||
return (
|
||||
<SettingsSection title="Local AI">
|
||||
<SettingsRow
|
||||
label="Hermes provider"
|
||||
description="Local inference adapter auto-selected based on your hardware tier."
|
||||
>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatTier(hardware?.hardwareTier)}
|
||||
{hardware ? ` · ${hermesProviderLabel}` : ""}
|
||||
</span>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label="Whisper STT"
|
||||
description="Speech-to-text. Runs entirely on your device when available."
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
voice?.whisperAvailable ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{voice?.whisperAvailable ? "Installed" : "Not installed"}
|
||||
</span>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label="Piper TTS"
|
||||
description="Text-to-speech voice model for reading assistant replies aloud."
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
voice?.piperAvailable ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{voice?.piperAvailable ? "Installed" : "Not installed"}
|
||||
</span>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label="Voice features"
|
||||
description="Enable speaking to the assistant and hearing responses. Requires Whisper and Piper installed."
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="toggle"
|
||||
aria-label="Toggle voice features"
|
||||
aria-pressed={voiceEnabled}
|
||||
disabled={
|
||||
updateSettingsMutation.isPending ||
|
||||
settingsQuery.isLoading ||
|
||||
!voice?.voiceTierSufficient
|
||||
}
|
||||
onClick={() => updateSettingsMutation.mutate({ voiceEnabled: !voiceEnabled })}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
voiceEnabled ? "bg-success" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
|
||||
voiceEnabled ? "translate-x-4.5" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</SettingsRow>
|
||||
|
||||
{hardwareQuery.error ? (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
{hardwareQuery.error instanceof Error
|
||||
? hardwareQuery.error.message
|
||||
: "Failed to load hardware info."}
|
||||
</div>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import { useNexusMode } from "../hooks/useNexusMode";
|
|||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useChatPanel } from "../context/ChatPanelContext";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useVoice } from "../context/VoiceContext";
|
||||
import { chatApi } from "../api/chat";
|
||||
import { useStreamingChat } from "../hooks/useStreamingChat";
|
||||
import {
|
||||
|
|
@ -28,6 +29,9 @@ import { AssistantInputBar } from "../components/assistant/AssistantInputBar";
|
|||
import { ActionStrip } from "../components/assistant/ActionStrip";
|
||||
import { HistorySheet } from "../components/assistant/HistorySheet";
|
||||
import { MemorySheet } from "../components/assistant/MemorySheet";
|
||||
import { BrainstormerPanel } from "../components/assistant/BrainstormerPanel";
|
||||
import { PromoteTransition } from "../components/assistant/PromoteTransition";
|
||||
import { usePromoteToProject } from "../hooks/usePromoteToProject";
|
||||
import { normalizeCompanyPrefix } from "../lib/company-routes";
|
||||
|
||||
export function PersonalAssistant() {
|
||||
|
|
@ -170,24 +174,31 @@ export function PersonalAssistant() {
|
|||
[companyId, queryClient, pushToast, selectedConvId, setActiveConversationId, startStream],
|
||||
);
|
||||
|
||||
const handlePromote = useCallback(async () => {
|
||||
if (!selectedConvId || !canPromote) return;
|
||||
try {
|
||||
await chatApi.assistantHandoff(selectedConvId);
|
||||
// Phase 12 — promote-to-project state machine drives the 700ms
|
||||
// compress-and-rise animation + brainstormer form. Replaces the
|
||||
// previous direct `chatApi.assistantHandoff()` call.
|
||||
const promote = usePromoteToProject({
|
||||
conversationId: selectedConvId,
|
||||
companyId,
|
||||
});
|
||||
|
||||
// Surface promote errors as toasts and reset the machine.
|
||||
useEffect(() => {
|
||||
if (promote.state.kind === "error") {
|
||||
pushToast({
|
||||
title: "Conversation handed off to PM",
|
||||
tone: "success",
|
||||
});
|
||||
} catch {
|
||||
pushToast({
|
||||
title: "Handoff failed",
|
||||
body: "Could not create project conversation.",
|
||||
title: "Promote failed",
|
||||
body: promote.state.message,
|
||||
tone: "error",
|
||||
});
|
||||
promote.reset();
|
||||
}
|
||||
// Phase 12 will replace this direct handoff call with the 700ms
|
||||
// compress-and-rise animated transition to the brainstormer panel.
|
||||
}, [selectedConvId, canPromote, pushToast]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [promote.state.kind]);
|
||||
|
||||
const handlePromote = useCallback(() => {
|
||||
if (!selectedConvId || !canPromote) return;
|
||||
promote.startPrompting();
|
||||
}, [selectedConvId, canPromote, promote]);
|
||||
|
||||
const handleAttach = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
|
|
@ -228,11 +239,31 @@ export function PersonalAssistant() {
|
|||
// there's no selected id — the user can always start one by typing.
|
||||
const showHomeGreeting = !hasActiveConversation;
|
||||
|
||||
const promoteActive =
|
||||
promote.state.kind === "prompting" || promote.state.kind === "creating";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex h-full min-h-0 flex-col bg-background -m-4 md:-m-6"
|
||||
data-pathname={location.pathname}
|
||||
>
|
||||
{/* Phase 12 — persistent project banner when the current chat has
|
||||
been promoted. */}
|
||||
{promote.state.kind === "done" && companyPrefix && (
|
||||
<div
|
||||
data-testid="assistant-project-banner"
|
||||
className="border-l-2 border-primary bg-card px-4 py-2 text-[12px] text-foreground"
|
||||
>
|
||||
→{" "}
|
||||
<a
|
||||
href={`/${companyPrefix}/projects/${promote.state.projectSlug}/overview`}
|
||||
className="font-semibold uppercase tracking-[0.12em] text-primary hover:underline"
|
||||
>
|
||||
Project: {promote.state.projectName}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conversation thread — full-bleed, max-width 760px centered */}
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<div className="mx-auto w-full max-w-[760px] px-6 py-8">
|
||||
|
|
@ -259,6 +290,31 @@ export function PersonalAssistant() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase 12 — promote-to-project overlay. Absolutely positioned so
|
||||
the chat canvas is covered during the animation while keeping
|
||||
the anchored input bar visible underneath. */}
|
||||
{promoteActive && selectedConvId && (
|
||||
<PromoteTransition
|
||||
state={promote.state.kind}
|
||||
panelChildren={
|
||||
<BrainstormerPanel
|
||||
conversationId={selectedConvId}
|
||||
onConfirm={promote.confirm}
|
||||
onCancel={promote.cancel}
|
||||
isCreating={promote.state.kind === "creating"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{hasActiveConversation && selectedConvId && (
|
||||
<ChatMessageList
|
||||
conversationId={selectedConvId}
|
||||
streamingContent={streamingContent}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
)}
|
||||
</PromoteTransition>
|
||||
)}
|
||||
|
||||
{/* Anchored input + action strip */}
|
||||
<div className="border-t border-border bg-background">
|
||||
<div className="mx-auto w-full max-w-[760px] px-6 py-4">
|
||||
|
|
@ -268,7 +324,7 @@ export function PersonalAssistant() {
|
|||
enableVoiceInput
|
||||
/>
|
||||
<ActionStrip
|
||||
canPromote={canPromote}
|
||||
canPromote={canPromote && promote.state.kind === "idle"}
|
||||
onPromote={handlePromote}
|
||||
onAttach={handleAttach}
|
||||
onOpenMemory={() => setMemoryOpen(true)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue