Skip to content
Open
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
21 changes: 15 additions & 6 deletions docs/content/docs/(configuration)/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -463,18 +463,27 @@ At least one provider (legacy key or custom provider) must be configured.
| `compactor` | string | `anthropic/claude-haiku-4.5-20250514` | Model for summarization |
| `cortex` | string | `anthropic/claude-haiku-4.5-20250514` | Model for system observation |
| `rate_limit_cooldown_secs` | integer | 60 | How long to deprioritize a rate-limited model |
| `channel_thinking_effort` | string | `"auto"` | Internal reasoning effort hint for channel model (`"auto"`, `"max"`, `"high"`, `"medium"`, `"low"`) |
| `branch_thinking_effort` | string | `"auto"` | Internal reasoning effort hint for branch model (`"auto"`, `"max"`, `"high"`, `"medium"`, `"low"`) |
| `worker_thinking_effort` | string | `"auto"` | Internal reasoning effort hint for worker model (`"auto"`, `"max"`, `"high"`, `"medium"`, `"low"`) |
| `compactor_thinking_effort` | string | `"auto"` | Internal reasoning effort hint for compactor model (`"auto"`, `"max"`, `"high"`, `"medium"`, `"low"`) |
| `cortex_thinking_effort` | string | `"auto"` | Internal reasoning effort hint for cortex model (`"auto"`, `"max"`, `"high"`, `"medium"`, `"low"`) |

Routing selects providers by the prefix before the first `/` in the model name.

`*_thinking_effort` is provider-specific:
- Anthropic adaptive-thinking models use the value directly.
- `openai-chatgpt/*` models map this to Responses API `reasoning.effort` (`max|high -> high`, `medium -> medium`, `low -> low`, `auto -> omitted`).

Comment on lines +466 to +477

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Align the documented mapping with the runtime contract.

This section now documents max -> high for all openai-chatgpt/* models and omits minimal entirely. That will mislead users: the runtime already treats minimal as an alias, and GPT-5.4-family models need model-specific normalization rather than the generic max -> high rule. Please either document those exceptions here or tighten the parser to match the docs exactly.

Based on learnings: In src/llm/model.rs, GPT-5.4-family models intentionally support xhigh, and gpt-5.4-pro requires model-specific normalization rather than the generic low/medium/high mapping.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/content/docs/`(configuration)/config.mdx around lines 466 - 477, Update
the docs to match the runtime behavior: explicitly list "minimal" as an accepted
alias for `*_thinking_effort`, document that `openai-chatgpt/*` models map
values to Responses API `reasoning.effort` with the noted normalization
(max|high -> high, medium -> medium, low -> low, auto -> omitted) but add
exceptions for GPT-5.4-family (which supports `xhigh`) and call out that
`gpt-5.4-pro` uses model-specific normalization rather than the generic mapping;
reference the runtime normalization logic in src/llm/model.rs so readers can see
the exact behavior or, if you prefer code-first, tighten the parser in
src/llm/model.rs to enforce the documented mapping instead of allowing the extra
aliases.

```toml
[defaults.routing]
channel = "my_openai/gpt-4o-mini"
worker = "custom_anthropic/claude-3-5-sonnet"
channel = "openai-chatgpt/gpt-5.3-codex"
worker = "openai-chatgpt/gpt-5.3-codex"
channel_thinking_effort = "high"
worker_thinking_effort = "medium"

[llm.provider.my_openai]
api_type = "openai_completions"
base_url = "https://api.openai.com"
api_key = "env:OPENAI_API_KEY"
[llm]
openai_key = "env:OPENAI_API_KEY"

[llm.provider.custom_anthropic]
api_type = "anthropic"
Expand Down
74 changes: 43 additions & 31 deletions interface/src/components/AgentTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Link, useMatchRoute } from "@tanstack/react-router";
import { Link, useMatchRoute, useRouterState } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { useEffect, useRef } from "react";

const tabs = [
{ label: "Overview", to: "/agents/$agentId" as const, exact: true },
Expand All @@ -18,38 +19,49 @@ const tabs = [

export function AgentTabs({ agentId }: { agentId: string }) {
const matchRoute = useMatchRoute();
const pathname = useRouterState({ select: (state) => state.location.pathname });
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const container = containerRef.current;
if (!container) return;
const active = container.querySelector<HTMLElement>("[data-active='true']");
if (!active) return;
active.scrollIntoView({ block: "nearest", inline: "center", behavior: "smooth" });
}, [pathname, agentId]);

return (
<div className="relative flex h-12 items-stretch border-b border-app-line bg-app-darkBox/30 px-6">
{tabs.map((tab) => {
const isActive = matchRoute({
to: tab.to,
params: { agentId },
fuzzy: !tab.exact,
});
<div className="relative h-12 overflow-x-auto border-b border-app-line bg-app-darkBox/30 px-3 sm:px-6">
<div ref={containerRef} className="flex h-full min-w-max items-stretch">
{tabs.map((tab) => {
const isActive = matchRoute({
to: tab.to,
params: { agentId },
fuzzy: !tab.exact,
});

return (
<Link
key={tab.to}
to={tab.to}
params={{ agentId }}
className={`relative flex items-center px-3 text-sm transition-colors ${
isActive
? "text-ink"
: "text-ink-faint hover:text-ink-dull"
}`}
>
{tab.label}
{isActive && (
<motion.div
layoutId={`agent-tab-indicator-${agentId}`}
className="absolute bottom-0 left-0 right-0 h-px bg-accent"
transition={{ type: "spring", stiffness: 500, damping: 35 }}
/>
)}
</Link>
);
})}
return (
<Link
key={tab.to}
to={tab.to}
params={{ agentId }}
data-active={isActive ? "true" : "false"}
className={`relative flex items-center whitespace-nowrap px-3 text-sm transition-colors ${
isActive ? "text-ink" : "text-ink-faint hover:text-ink-dull"
}`}
>
{tab.label}
{isActive && (
<motion.div
layoutId={`agent-tab-indicator-${agentId}`}
className="absolute bottom-0 left-0 right-0 h-px bg-accent"
transition={{ type: "spring", stiffness: 500, damping: 35 }}
/>
)}
</Link>
);
})}
</div>
</div>
);
}
}
7 changes: 1 addition & 6 deletions interface/src/components/CortexChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,6 @@ function CortexChatInput({
isStreaming: boolean;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);

useEffect(() => {
textareaRef.current?.focus();
}, []);

useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
Expand Down Expand Up @@ -218,7 +213,7 @@ function CortexChatInput({
}
disabled={isStreaming}
rows={1}
className="flex-1 resize-none bg-transparent px-1 py-1 text-sm text-ink placeholder:text-ink-faint/60 focus:outline-none disabled:opacity-40"
className="flex-1 resize-none bg-transparent px-1 py-1 text-base md:text-sm text-ink placeholder:text-ink-faint/60 focus:outline-none disabled:opacity-40"
style={{maxHeight: "160px"}}
/>
<button
Expand Down
59 changes: 59 additions & 0 deletions interface/src/components/ResponsiveSplitPane.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { ReactNode } from "react";
import { useIsMobile } from "@/hooks/useViewport";
import { Button } from "@/ui";

interface ResponsiveSplitPaneProps {
primary: ReactNode;
secondary: ReactNode;
showSecondary: boolean;
onCloseSecondary?: () => void;
secondaryTitle?: string;
secondaryWidthClassName?: string;
}

export function ResponsiveSplitPane({
primary,
secondary,
showSecondary,
onCloseSecondary,
secondaryTitle = "Details",
secondaryWidthClassName = "w-[400px]",
}: ResponsiveSplitPaneProps) {
const isMobile = useIsMobile();

if (!isMobile) {
return (
<div className="flex h-full">
<div className="min-w-0 flex-1">{primary}</div>
{showSecondary && (
<div className={`shrink-0 overflow-hidden border-l border-app-line/50 ${secondaryWidthClassName}`}>
{secondary}
</div>
)}
</div>
);
}

if (showSecondary) {
return (
<div className="flex h-full flex-col">
<div className="flex h-11 items-center gap-2 border-b border-app-line/50 bg-app-darkBox/30 px-3">
{onCloseSecondary && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onCloseSecondary}
>
Back
</Button>
)}
<span className="truncate text-sm text-ink-dull">{secondaryTitle}</span>
</div>
<div className="min-h-0 flex-1">{secondary}</div>
</div>
);
}

return <div className="flex h-full min-h-0 flex-col">{primary}</div>;
}
103 changes: 94 additions & 9 deletions interface/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@ import { CSS } from "@dnd-kit/utilities";
import { api } from "@/api/client";
import type { ChannelLiveState } from "@/hooks/useChannelLiveState";
import { useAgentOrder } from "@/hooks/useAgentOrder";
import { DashboardSquare01Icon, Settings01Icon } from "@hugeicons/core-free-icons";
import { DashboardSquare01Icon, Settings01Icon, Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { CreateAgentDialog } from "@/components/CreateAgentDialog";
import { ProfileAvatar } from "@/components/ProfileAvatar";

interface SidebarProps {
liveStates: Record<string, ChannelLiveState>;
isMobile?: boolean;
mobileOpen?: boolean;
onCloseMobile?: () => void;
}

interface SortableAgentItemProps {
Expand All @@ -52,7 +55,7 @@ function SortableAgentItem({ agentId, displayName, gradientStart, gradientEnd, i
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
cursor: isDragging ? 'grabbing' : 'grab',
cursor: isDragging ? "grabbing" : "grab",
};

return (
Expand All @@ -61,7 +64,7 @@ function SortableAgentItem({ agentId, displayName, gradientStart, gradientEnd, i
to="/agents/$agentId"
params={{ agentId }}
className="flex h-8 w-8 items-center justify-center"
style={{ pointerEvents: isDragging ? 'none' : 'auto' }}
style={{ pointerEvents: isDragging ? "none" : "auto" }}
title={displayName ?? agentId}
>
<ProfileAvatar
Expand All @@ -77,25 +80,27 @@ function SortableAgentItem({ agentId, displayName, gradientStart, gradientEnd, i
);
}

export function Sidebar({ liveStates: _liveStates }: SidebarProps) {
export function Sidebar({ liveStates: _liveStates, isMobile = false, mobileOpen = false, onCloseMobile }: SidebarProps) {
const [createOpen, setCreateOpen] = useState(false);

const isDrawerHidden = isMobile && !mobileOpen;

const { data: agentsData } = useQuery({
queryKey: ["agents"],
queryFn: api.agents,
refetchInterval: 30_000,
enabled: !isDrawerHidden,
refetchInterval: isDrawerHidden ? false : 30_000,
});

const { data: providersData } = useQuery({
queryKey: ["providers"],
queryFn: api.providers,
enabled: !isDrawerHidden,
staleTime: 10_000,
});

const hasProvider = providersData?.has_any ?? false;

const agents = agentsData?.agents ?? [];

const agentIds = useMemo(() => agents.map((a) => a.id), [agents]);
const agentDisplayNames = useMemo(() => {
const map: Record<string, string | undefined> = {};
Expand All @@ -122,7 +127,7 @@ export function Sidebar({ liveStates: _liveStates }: SidebarProps) {
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
}),
);

const handleDragEnd = (event: DragEndEvent) => {
Expand All @@ -134,9 +139,89 @@ export function Sidebar({ liveStates: _liveStates }: SidebarProps) {
}
};

if (isMobile) {
if (!mobileOpen) return null;
return (
<>
<div className="fixed inset-0 z-40 bg-black/40" onClick={onCloseMobile} />
<nav className="fixed inset-y-0 left-0 z-50 flex w-72 flex-col border-r border-sidebar-line bg-sidebar">
<div className="flex h-12 items-center justify-between border-b border-sidebar-line px-3">
<span className="font-plex text-sm font-medium text-sidebar-ink">Navigation</span>
<button
type="button"
onClick={onCloseMobile}
aria-label="Close navigation"
className="flex h-8 w-8 items-center justify-center rounded-md text-sidebar-inkDull hover:bg-sidebar-selected/50 hover:text-sidebar-ink"
>
<HugeiconsIcon icon={Cancel01Icon} className="h-4 w-4" />
</button>
</div>
<div className="flex flex-col gap-1 px-2 py-2">
<Link
to="/"
onClick={onCloseMobile}
className={`flex items-center gap-2 rounded-md px-3 py-2 text-sm ${isOverview ? "bg-sidebar-selected text-sidebar-ink" : "text-sidebar-inkDull hover:bg-sidebar-selected/50"}`}
>
<HugeiconsIcon icon={DashboardSquare01Icon} className="h-4 w-4" />
Overview
</Link>
<Link
to="/settings"
onClick={onCloseMobile}
className={`flex items-center gap-2 rounded-md px-3 py-2 text-sm ${isSettings ? "bg-sidebar-selected text-sidebar-ink" : "text-sidebar-inkDull hover:bg-sidebar-selected/50"}`}
>
<HugeiconsIcon icon={Settings01Icon} className="h-4 w-4" />
Settings
</Link>
</div>
<div className="mx-3 my-1 h-px bg-sidebar-line" />
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
<div className="mb-2 px-1 text-tiny uppercase tracking-wider text-sidebar-inkFaint">Agents</div>
<div className="flex flex-col gap-1">
{agentOrder.map((agentId) => {
const isActive = !!matchRoute({ to: "/agents/$agentId", params: { agentId }, fuzzy: true });
return (
<Link
key={agentId}
to="/agents/$agentId"
params={{ agentId }}
onClick={onCloseMobile}
className={`flex items-center gap-2 rounded-md px-2 py-1.5 ${isActive ? "bg-sidebar-selected text-sidebar-ink" : "text-sidebar-inkDull hover:bg-sidebar-selected/50"}`}
>
<ProfileAvatar
seed={agentId}
name={agentDisplayNames[agentId] ?? agentId}
size={22}
className="rounded-full"
gradientStart={agentGradients[agentId]?.start}
gradientEnd={agentGradients[agentId]?.end}
/>
<span className="min-w-0 flex-1 truncate text-sm">{agentDisplayNames[agentId] ?? agentId}</span>
</Link>
);
})}
</div>
</div>
{hasProvider && agents[0] && (
<div className="border-t border-sidebar-line p-2">
<button
onClick={() => setCreateOpen(true)}
className="w-full rounded-md bg-sidebar-selected px-3 py-2 text-sm text-sidebar-ink hover:bg-sidebar-selected/80"
>
New Agent
</button>
</div>
)}
</nav>
{agents[0] && (
<CreateAgentDialog open={createOpen} onOpenChange={setCreateOpen} agentId={agents[0].id} />
)}
</>
);
Comment on lines +142 to +220

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The mobile drawer needs real modal focus handling.

This path is visually modal, but it never moves focus into the drawer or traps it there. Since interface/src/router.tsx:50-57 mounts the drawer after the main content, keyboard users can keep tabbing through the obscured page instead of the navigation. Please switch this to the app's dialog/sheet primitive or add focus trapping and inert-background semantics for the mobile path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@interface/src/components/Sidebar.tsx` around lines 142 - 220, The mobile
drawer path in Sidebar.tsx does not manage focus or trap it; update the mobile
branch that renders when isMobile && mobileOpen to use the app's dialog/sheet
primitive (or wrap the existing nav in a focus-trap and inert-background
implementation): mount the drawer via the Dialog/Sheet component (instead of raw
JSX), move focus into the first interactive element (e.g., the Close button)
when opening, restore focus to the opener on close, add keyboard handling for
Escape to call onCloseMobile, and mark the rest of the app as inert/aria-hidden
while open; ensure CreateAgentDialog usage and setCreateOpen remain unchanged
but are compatible with the dialog primitive so the modal semantics are correct.

}

return (
<nav className="flex w-14 shrink-0 flex-col items-center overflow-hidden border-r border-sidebar-line bg-sidebar">
{/* Icon nav */}
<div className="flex flex-col items-center gap-1 pt-2">
<Link
to="/"
Expand Down
Loading