feat: make personas optional (#51)

* feat: make personas optional — allow chatting without a persona

Adds a "Goose" / no-persona option to the persona picker so users can
chat directly with the agent without any persona system prompt injected.
HomeScreen and ChatView now default to no persona selected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: update HomeScreen test for optional persona default

The HomeScreen now defaults to no persona selected, so the placeholder
reads "Message Goose..." instead of "Message Solo...".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-01 10:19:53 -07:00
committed by GitHub
parent 188ca21ac0
commit c515bb48c2
7 changed files with 46 additions and 37 deletions
+4 -15
View File
@@ -38,7 +38,7 @@ interface ChatInputProps {
// Personas
personas?: Persona[];
selectedPersonaId?: string | null;
onPersonaChange?: (personaId: string) => void;
onPersonaChange?: (personaId: string | null) => void;
onCreatePersona?: () => void;
// Provider (secondary -- auto-set by persona but overridable)
providers?: AcpProvider[];
@@ -97,20 +97,11 @@ export function ChatInput({
const textareaRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const defaultPersonaId = useMemo(
() => personas.find((persona) => persona.id === "builtin-solo")?.id ?? null,
[personas],
);
const activePersona = useMemo(
() => personas.find((persona) => persona.id === selectedPersonaId) ?? null,
[personas, selectedPersonaId],
);
const stickyPersona =
activePersona &&
selectedPersonaId !== null &&
selectedPersonaId !== defaultPersonaId
? activePersona
: null;
const stickyPersona = activePersona;
const canSend = text.trim().length > 0 && !isStreaming && !disabled;
@@ -213,10 +204,8 @@ export function ChatInput({
: placeholder;
const handleClearStickyPersona = useCallback(() => {
if (defaultPersonaId) {
onPersonaChange?.(defaultPersonaId);
}
}, [defaultPersonaId, onPersonaChange]);
onPersonaChange?.(null);
}, [onPersonaChange]);
return (
<TooltipProvider delayDuration={300}>
@@ -52,7 +52,7 @@ interface ChatInputToolbarProps {
// Personas
personas: Persona[];
selectedPersonaId: string | null;
onPersonaChange?: (personaId: string) => void;
onPersonaChange?: (personaId: string | null) => void;
onCreatePersona?: () => void;
// Provider
providers: AcpProvider[];
+8 -13
View File
@@ -49,8 +49,8 @@ export function ChatView({
// Persona state
const personas = useAgentStore((s) => s.personas);
const [selectedPersonaId, setSelectedPersonaId] = useState<string>(
initialPersonaId ?? "builtin-solo",
const [selectedPersonaId, setSelectedPersonaId] = useState<string | null>(
initialPersonaId ?? null,
);
const session = useChatSessionStore((s) =>
s.sessions.find((candidate) => candidate.id === activeSessionId),
@@ -171,7 +171,7 @@ export function ChatView({
// When persona changes, update the provider to match persona's default
const handlePersonaChange = useCallback(
(personaId: string) => {
(personaId: string | null) => {
setSelectedPersonaId(personaId);
const persona = personas.find((p) => p.id === personaId);
if (persona?.provider) {
@@ -197,7 +197,7 @@ export function ChatView({
// Persist persona selection to session store
useChatSessionStore
.getState()
.updateSession(activeSessionId, { personaId });
.updateSession(activeSessionId, { personaId: personaId ?? undefined });
},
[personas, providers, activeSessionId, handleProviderChange],
);
@@ -205,19 +205,14 @@ export function ChatView({
// Validate persona still exists — fall back to default if deleted
useEffect(() => {
if (
selectedPersonaId !== null &&
personas.length > 0 &&
!personas.find((p) => p.id === selectedPersonaId)
) {
const fallback =
personas.find((p) => p.id === "builtin-solo") ?? personas[0];
if (fallback) {
setSelectedPersonaId(fallback.id);
useChatSessionStore
.getState()
.updateSession(activeSessionId, { personaId: fallback.id });
}
// Selected persona was deleted — reset to no persona
setSelectedPersonaId(null);
}
}, [personas, selectedPersonaId, activeSessionId]);
}, [personas, selectedPersonaId]);
const displayAgentName = selectedPersona?.displayName ?? agentName;
const personaAvatarSrc = useAvatarSrc(selectedPersona?.avatar);
@@ -16,7 +16,7 @@ import type { Persona } from "@/shared/types/agents";
interface PersonaPickerProps {
personas: Persona[];
selectedPersonaId: string | null;
onPersonaChange: (personaId: string) => void;
onPersonaChange: (personaId: string | null) => void;
onCreatePersona?: () => void;
compact?: boolean;
className?: string;
@@ -33,7 +33,10 @@ export function PersonaPicker({
triggerVariant = "default",
}: PersonaPickerProps) {
const selected = useMemo(
() => personas.find((p) => p.id === selectedPersonaId) ?? personas[0],
() =>
selectedPersonaId
? personas.find((p) => p.id === selectedPersonaId)
: undefined,
[personas, selectedPersonaId],
);
@@ -46,7 +49,7 @@ export function PersonaPicker({
[personas],
);
const label = selected?.displayName ?? "Select persona";
const label = selected?.displayName ?? "Goose";
return (
<DropdownMenu>
@@ -85,6 +88,24 @@ export function PersonaPicker({
align="start"
className="max-h-[min(70vh,32rem)] w-[22rem] overflow-y-auto"
>
<DropdownMenuItem
onSelect={() => onPersonaChange(null)}
className="flex items-start gap-2.5 py-2"
>
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-foreground/10 text-foreground">
<Sparkles className="h-3.5 w-3.5" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium">Goose</span>
<span className="text-[11px] leading-snug text-foreground-tertiary">
No persona — chat directly with the agent
</span>
</div>
{selectedPersonaId === null && (
<Check className="mt-0.5 h-4 w-4 shrink-0 text-foreground-secondary" />
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
{builtinPersonas.length > 0 && (
<>
<DropdownMenuLabel className="text-[11px] uppercase tracking-wider text-foreground-tertiary">
@@ -29,7 +29,9 @@ function StatefulChatInput({
}: {
onSend?: (text: string, personaId?: string) => void;
}) {
const [selectedPersonaId, setSelectedPersonaId] = useState("builtin-solo");
const [selectedPersonaId, setSelectedPersonaId] = useState<string | null>(
"builtin-solo",
);
return (
<ChatInput
@@ -57,10 +57,10 @@ describe("HomeScreen", () => {
expect(screen.getByText("Good afternoon")).toBeInTheDocument();
});
it("renders the chat input placeholder with persona name", () => {
it("renders the chat input placeholder with default agent name when no persona selected", () => {
render(<HomeScreen />);
expect(
screen.getByPlaceholderText("Message Solo... (type @ to mention)"),
screen.getByPlaceholderText("Message Goose... (type @ to mention)"),
).toBeInTheDocument();
});
@@ -61,7 +61,9 @@ export function HomeScreen({
const personas = useAgentStore((s) => s.personas);
const projects = useProjectStore((s) => s.projects);
const [selectedPersonaId, setSelectedPersonaId] = useState("builtin-solo");
const [selectedPersonaId, setSelectedPersonaId] = useState<string | null>(
null,
);
const [providers, setProviders] = useState<AcpProvider[]>([]);
const [selectedProvider, setSelectedProvider] = useState("goose");
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
@@ -96,7 +98,7 @@ export function HomeScreen({
const handleSend = useCallback(
(message: string, personaId?: string) => {
const effectivePersonaId = personaId ?? selectedPersonaId;
const effectivePersonaId = personaId ?? selectedPersonaId ?? undefined;
onStartChat?.(
message,