Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ app/api/
sessions/[id]/state/route.ts GET live wrapper state while running
sessions/[id]/auto-name/route.ts POST generate a session title
sessions/search/route.ts GET session search
agent/new/route.ts POST { cwd, type: prompt|ensure_session (start only), message?, toolNames?, provider?, modelId?, thinkingLevel? }
agent/new/route.ts POST { cwd, type: prompt|ensure_session (start only), message?, toolNames?, provider?, modelId?, thinkingLevel?, agentProfile? }
agent/[id]/route.ts GET state | POST any command
agent/[id]/events/route.ts GET SSE stream
agent/running/route.ts GET running session ids
Expand Down
8 changes: 6 additions & 2 deletions app/api/agent/new/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ export async function POST(req: Request) {
}

// Use a one-time key so startRpcSession's lock doesn't conflict with real session ids
const { provider, modelId, toolNames, thinkingLevel, ...promptCommand } = command as { provider?: string; modelId?: string; toolNames?: string[]; thinkingLevel?: unknown; [key: string]: unknown };
const { provider, modelId, toolNames, thinkingLevel, agentProfile, ...promptCommand } = command as { provider?: string; modelId?: string; toolNames?: string[]; thinkingLevel?: unknown; agentProfile?: unknown; [key: string]: unknown };
if (agentProfile !== undefined && (typeof agentProfile !== "string" || !agentProfile.trim())) {
throw new Error("agentProfile must be a non-empty string");
}
if ((provider && !modelId) || (!provider && modelId)) {
throw new Error("provider and modelId must be provided together");
}
Expand All @@ -56,7 +59,8 @@ export async function POST(req: Request) {
// requests in the same millisecond, merging two new sessions into one.
const tempKey = `__new__${randomUUID()}`;
const { session, realSessionId } = await startRpcSession(tempKey, "", cwd, {
...(toolNames ? { toolNames } : {}),
// A profile fixes its own tools, so a tool preset sent alongside it is ignored.
...(agentProfile ? { agentProfile } : toolNames ? { toolNames } : {}),
...(provider && modelId ? { initialModel: { provider, modelId } } : {}),
...(explicitThinkingLevel ? { thinkingLevel: explicitThinkingLevel } : {}),
});
Expand Down
5 changes: 4 additions & 1 deletion app/api/sessions/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { computeSessionStats } from "@/lib/session-stats";
import { startServerPerf } from "@/lib/perf";
import { computeSessionRevision } from "@/lib/session-revision";
import type { SessionEntry } from "@/lib/types";
import { readSubagentRun, readSubagentSessionResources, SUBAGENT_META_TYPE } from "@/lib/subagents";
import { readSessionAgentProfile, readSubagentRun, readSubagentSessionResources, SUBAGENT_META_TYPE } from "@/lib/subagents";
import { readSessionToolSelection } from "@/lib/session-tool-selection";
import { jsonResponse } from "@/lib/json-response";

Expand Down Expand Up @@ -108,6 +108,7 @@ export async function GET(
: null;
const toolNames = readSubagentSessionResources(entries as never)?.tools
?? readSessionToolSelection(entries as never);
const agentProfile = readSessionAgentProfile(entries as never);
const info = header ? (await attachSessionProjectInfo([{
path: filePath,
id: header.id,
Expand Down Expand Up @@ -145,6 +146,7 @@ export async function GET(
stats,
totalActiveMs,
...(toolNames !== undefined ? { toolNames } : {}),
...(agentProfile ? { agentProfile } : {}),
...(wrapperRebuilt ? { wrapperRebuilt: true } : {}),
},
)) ?? jsonResponse(
Expand All @@ -161,6 +163,7 @@ export async function GET(
stats,
totalActiveMs,
...(toolNames !== undefined ? { toolNames } : {}),
...(agentProfile ? { agentProfile } : {}),
...(wrapperRebuilt ? { wrapperRebuilt: true } : {}),
},
);
Expand Down
168 changes: 168 additions & 0 deletions components/AgentProfileSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "@/hooks/useI18n";

interface ProfileOption {
name: string;
displayName: string;
description: string;
}

interface Props {
cwd?: string;
agentProfile: string | null;
/** Present only while the session can still pick its agent (a new, unsent session). */
onChange?: (profile: string | null) => void;
disabled?: boolean;
isMobile?: boolean;
showLabel?: boolean;
}

/** Enabled profiles, one per name: the API lists shadowed sources too, highest precedence last. */
function uniqueEnabledProfiles(profiles: Array<ProfileOption & { enabled?: boolean }>): ProfileOption[] {
const byName = new Map<string, ProfileOption & { enabled?: boolean }>();
for (const profile of profiles) byName.set(profile.name.toLowerCase(), profile);
return [...byName.values()]
.filter((profile) => profile.enabled !== false)
.sort((a, b) => a.displayName.localeCompare(b.displayName));
}

export function AgentProfileSelector({ cwd, agentProfile, onChange, disabled, isMobile, showLabel = true }: Props) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [profiles, setProfiles] = useState<ProfileOption[] | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const editable = Boolean(onChange) && !disabled;

useEffect(() => {
if (!open || !cwd) return;
const controller = new AbortController();
void fetch(`/api/subagents/profiles?cwd=${encodeURIComponent(cwd)}`, { cache: "no-store", signal: controller.signal })
.then((response) => response.ok ? response.json() : { profiles: [] })
.then((body: { profiles?: Array<ProfileOption & { enabled?: boolean }> }) => setProfiles(uniqueEnabledProfiles(body.profiles ?? [])))
.catch(() => { if (!controller.signal.aborted) setProfiles([]); });
return () => controller.abort();
}, [open, cwd]);

useEffect(() => {
if (!open) return;
const close = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, [open]);

const filtered = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!profiles || !needle) return profiles ?? [];
return profiles.filter((profile) =>
profile.name.toLowerCase().includes(needle)
|| profile.displayName.toLowerCase().includes(needle)
|| profile.description.toLowerCase().includes(needle));
}, [profiles, query]);

// A session without a profile that can no longer pick one has nothing to show.
if (!agentProfile && !editable) return null;

const label = agentProfile ?? t("chat.defaultAgent");
const select = (profile: string | null) => {
setOpen(false);
setQuery("");
if (profile !== agentProfile) onChange?.(profile);
};
const rowStyle = (active: boolean) => ({
display: "flex", flexDirection: "column" as const, alignItems: "flex-start", gap: 2,
width: "100%", padding: "7px 12px",
background: active ? "var(--bg-selected)" : "none",
border: "none",
color: active ? "var(--text)" : "var(--text-muted)",
cursor: "pointer", fontSize: 12, textAlign: "left" as const,
fontWeight: active ? 600 : 400,
});

return (
<div ref={rootRef} style={{ position: "relative" }}>
<button
onClick={() => editable && setOpen((value) => !value)}
disabled={!editable}
title={editable ? `${t("chat.changeAgentProfile")}: ${label}` : t("chat.agentProfileFixed")}
aria-label={t("chat.agentProfile")}
style={{
display: "flex", alignItems: "center", justifyContent: "center", gap: 5,
padding: isMobile ? "0 6px" : "8px 12px",
height: 32,
background: open ? "var(--bg-hover)" : "none",
border: "none",
borderRadius: 9,
color: agentProfile ? "var(--accent)" : "var(--text-muted)",
cursor: editable ? "pointer" : "default",
fontSize: 12,
}}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="8" r="4" />
<path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1" />
</svg>
{showLabel && <span style={{ whiteSpace: "nowrap", maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis" }}>{label}</span>}
</button>
{open && (
<div style={{
position: "absolute",
bottom: "calc(100% + 6px)",
right: isMobile ? undefined : 0,
left: isMobile ? 0 : undefined,
zIndex: 100, background: "var(--bg)", border: "1px solid var(--border)",
borderRadius: 8, boxShadow: "0 -4px 16px rgba(0,0,0,0.10)",
width: isMobile ? "min(320px, calc(100vw - 32px))" : 320,
display: "flex", flexDirection: "column",
}}>
<input
autoFocus
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") { event.stopPropagation(); setOpen(false); }
if (event.key === "Enter" && filtered.length > 0) select(filtered[0].name);
}}
placeholder={t("chat.searchAgents")}
aria-label={t("chat.searchAgents")}
style={{
margin: 8, padding: "6px 8px", fontSize: 12,
background: "var(--bg-panel)", color: "var(--text)",
border: "1px solid var(--border)", borderRadius: 6, outline: "none",
}}
/>
<div style={{ maxHeight: 320, overflowY: "auto" }}>
{!query.trim() && (
<button onClick={() => select(null)} style={rowStyle(agentProfile === null)}>
{t("chat.defaultAgent")}
</button>
)}
{filtered.map((profile) => (
<button
key={profile.name}
onClick={() => select(profile.name)}
title={profile.description}
style={rowStyle(agentProfile === profile.name)}
>
<span>{profile.displayName}</span>
<span style={{
fontSize: 11, color: "var(--text-dim)", fontWeight: 400,
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", width: "100%",
}}>
{profile.description}
</span>
</button>
))}
{profiles && filtered.length === 0 && (
<div style={{ padding: "7px 12px", fontSize: 12, color: "var(--text-dim)" }}>{t("chat.noAgentsFound")}</div>
)}
</div>
</div>
)}
</div>
);
}
16 changes: 15 additions & 1 deletion components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { useEnterSendMode } from "@/hooks/useEnterSendMode";
import { useI18n } from "@/hooks/useI18n";
import { useChatAppearance } from "@/hooks/useChatAppearance";
import type { ToolPreset } from "@/lib/tool-presets";
import { AgentProfileSelector } from "./AgentProfileSelector";
import { SelectorRow } from "./SelectorRow";
import { ModelSelector, type ModelSelectorOption } from "./ModelSelector";

Expand Down Expand Up @@ -71,6 +72,10 @@ interface Props {
compactResult?: CompactResultInfo | null;
toolPreset?: ToolPreset;
onToolPresetChange?: (preset: ToolPreset) => void;
/** The agent profile the session runs as; null is the default agent. */
agentProfile?: string | null;
/** Present only while a new session can still choose its agent profile. */
onAgentProfileChange?: (profile: string | null) => void;
thinkingLevel?: "auto" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
/** New session has not committed a thinking level; the button still shows the resolved default. */
isAutoThinkingSelection?: boolean;
Expand Down Expand Up @@ -587,7 +592,7 @@ export function ModelScopeWarningBanner({ warnings }: { warnings?: string[] }) {
export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
onSend, onAbort, onSteer, onFollowUp, isStreaming, model, isAutoModelSelection, modelNames, modelList, modelError, modelScopeWarnings, onModelChange, modelSwitching,
defaultModel, onSetDefaultModel,
onCompact, onAbortCompaction, isCompacting, compactError, compactResult, toolPreset, onToolPresetChange,
onCompact, onAbortCompaction, isCompacting, compactError, compactResult, toolPreset, onToolPresetChange, agentProfile = null, onAgentProfileChange,
thinkingLevel, isAutoThinkingSelection = false, onThinkingLevelChange, availableThinkingLevels, thinkingLevelMap,
savedDefaultThinkingLevel, onSetDefaultThinkingLevel,
retryInfo, queuedMessages, inputHistory = [], onRecallQueue,
Expand Down Expand Up @@ -2568,6 +2573,15 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
)}
</div>
)}
{!isStreaming && (
<AgentProfileSelector
cwd={cwd ?? undefined}
agentProfile={agentProfile}
onChange={onAgentProfileChange}
isMobile={isMobile}
showLabel={!isMobile || controlsMenuOpen}
/>
)}
{!isStreaming && onToolPresetChange && (
<div ref={toolDropdownRef} style={{ position: "relative" }}>
<button
Expand Down
9 changes: 6 additions & 3 deletions components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi

const {
loading, error, messages, activeToolResults, entryIds, historyCursor, hasEarlierMessages, streamState,
agentRunning, bashRunning, pendingBash, modelNames, modelList, modelError, modelScopeWarnings, modelThinkingLevels, modelThinkingLevelMaps, toolPreset, thinkingLevel,
agentRunning, bashRunning, pendingBash, modelNames, modelList, modelError, modelScopeWarnings, modelThinkingLevels, modelThinkingLevelMaps, toolPreset, agentProfile, thinkingLevel,
retryInfo, contextUsage, forkingEntryId,
isCompacting, compactError, compactResult, displayModel: displayModelValue, modelSwitching, sessionStats,
slashCommands, slashCommandsLoading, queuedMessages,
Expand All @@ -276,7 +276,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi
handleCompact, handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction,
handleRecallQueue,
handleBuiltinSlashCommand,
handleToolPresetChange, handleThinkingLevelChange, handleSetDefaultModel, handleSetDefaultThinkingLevel, loadSlashCommands, scrollUserMsgToTop,
handleToolPresetChange, handleAgentProfileChange, handleThinkingLevelChange, handleSetDefaultModel, handleSetDefaultThinkingLevel, loadSlashCommands, scrollUserMsgToTop,
loadContext, activeLeafId, scrollToBottom, scrollToMessage,
} = useAgentSession({
session, sessionRunning, newSessionCwd, newSessionDraftKey, onAgentEnd: wrappedOnAgentEnd, onAttentionNeeded, onSessionCreated, onSessionForked,
Expand Down Expand Up @@ -870,7 +870,10 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi
compactError={compactError}
compactResult={compactResult}
toolPreset={toolPreset}
onToolPresetChange={session || isNew ? handleToolPresetChange : undefined}
// An agent profile fixes the session's tools.
onToolPresetChange={(session || isNew) && !agentProfile ? handleToolPresetChange : undefined}
agentProfile={agentProfile}
onAgentProfileChange={isNew && messages.length === 0 ? handleAgentProfileChange : undefined}
thinkingLevel={thinkingLevel}
isAutoThinkingSelection={isAutoThinkingSelection}
onThinkingLevelChange={session || isNew ? handleThinkingLevelChange : undefined}
Expand Down
Loading