feat: expand session context controls

This commit is contained in:
Benjamin Shafii
2026-01-25 18:55:54 -08:00
parent b27641a3e1
commit 2bca3449a7
6 changed files with 506 additions and 96 deletions

View File

@@ -10,5 +10,8 @@
]
}
},
"model": "openai/gpt-5.2-codex"
"model": "openai/gpt-5.2-codex",
"plugin": [
"opencode-scheduler"
]
}

View File

@@ -307,6 +307,9 @@ export default function App() {
const [commandPaletteQuery, setCommandPaletteQuery] = createSignal("");
const [keybindOverrides, setKeybindOverrides] = createSignal<Record<string, string>>({});
const [recentCommandIds, setRecentCommandIds] = createSignal<string[]>([]);
const [paletteAgents, setPaletteAgents] = createSignal<Agent[]>([]);
const [paletteAgentsReady, setPaletteAgentsReady] = createSignal(false);
const [paletteAgentsBusy, setPaletteAgentsBusy] = createSignal(false);
const buildPromptParts = (draft: ComposerDraft): Part[] => {
const parts: Part[] = [];
@@ -336,6 +339,10 @@ export default function App() {
} as Part);
}
const hasTextPart = parts.some((part) => part.type === "text");
if (!hasTextPart && draft.attachments.length) {
pushText(draft.text.trim());
}
if (!parts.length && draft.text.trim()) {
pushText(draft.text.trim());
}
@@ -454,6 +461,24 @@ export default function App() {
return list.filter((agent) => !agent.hidden && agent.mode !== "subagent");
}
const loadPaletteAgents = async (force = false) => {
if (paletteAgentsBusy()) return paletteAgents();
if (paletteAgentsReady() && !force) return paletteAgents();
setPaletteAgentsBusy(true);
try {
const agents = await listAgents();
const sorted = agents.slice().sort((a, b) => a.name.localeCompare(b.name));
setPaletteAgents(sorted);
setPaletteAgentsReady(true);
return sorted;
} catch {
setPaletteAgents([]);
return [];
} finally {
setPaletteAgentsBusy(false);
}
};
function setSessionAgent(sessionID: string, agent: string | null) {
const trimmed = agent?.trim() ?? "";
setSessionAgentById((current) => {
@@ -765,6 +790,41 @@ export default function App() {
const [showThinking, setShowThinking] = createSignal(false);
const [modelVariant, setModelVariant] = createSignal<string | null>(null);
const MODEL_VARIANT_OPTIONS = [
{ value: "none", label: "None" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "xhigh", label: "X-High" },
];
const normalizeModelVariant = (value: string | null) => {
if (!value) return null;
const trimmed = value.trim().toLowerCase();
if (trimmed === "balance" || trimmed === "balanced") return "none";
const match = MODEL_VARIANT_OPTIONS.find((option) => option.value === trimmed);
return match ? match.value : null;
};
const formatModelVariantLabel = (value: string | null) => {
const normalized = normalizeModelVariant(value) ?? "none";
return MODEL_VARIANT_OPTIONS.find((option) => option.value === normalized)?.label ?? "None";
};
const handleEditModelVariant = () => {
const next = window.prompt(
"Model variant (none, low, medium, high, xhigh)",
normalizeModelVariant(modelVariant()) ?? "none"
);
if (next == null) return;
const normalized = normalizeModelVariant(next);
if (!normalized) {
window.alert("Variant must be one of: none, low, medium, high, xhigh.");
return;
}
setModelVariant(normalized);
};
let loadCommandsRef: (options?: { workspaceRoot?: string; quiet?: boolean }) => Promise<void> = async () => {};
const workspaceStore = createWorkspaceStore({
@@ -956,6 +1016,56 @@ export default function App() {
})),
);
createEffect(() => {
if (!commandPaletteOpen() || commandPaletteMode() !== "command") return;
void loadPaletteAgents();
});
const paletteSessionItems = createMemo(() =>
activeSessions().map((session) => ({
id: `session:${session.id}`,
title: session.title || session.slug || "Untitled session",
description: session.id === activeSessionId() ? "Active" : session.slug ?? session.id,
onSelect: () => {
selectSession(session.id);
setView("session", session.id);
},
})),
);
const paletteAgentItems = createMemo(() => {
const sessionId = activeSessionId();
if (!sessionId) return [];
const selectedAgent = selectedSessionAgent();
const items = [
{
id: "agent:default",
title: "Default agent",
description: selectedAgent ? undefined : "Active",
onSelect: () => setSessionAgent(sessionId, null),
},
];
for (const agent of paletteAgents()) {
items.push({
id: `agent:${agent.name}`,
title: agent.name,
description: agent.name === selectedAgent ? "Active" : undefined,
onSelect: () => setSessionAgent(sessionId, agent.name),
});
}
return items;
});
const paletteVariantItems = createMemo(() => {
const currentVariant = normalizeModelVariant(modelVariant()) ?? "none";
return MODEL_VARIANT_OPTIONS.map((option) => ({
id: `variant:${option.value}`,
title: option.label,
description: option.value === currentVariant ? "Active" : undefined,
onSelect: () => setModelVariant(option.value),
}));
});
const commandPaletteGroups = createMemo<PaletteGroup[]>(() => {
const groups: PaletteGroup[] = [];
if (commandPaletteMode() === "command") {
@@ -965,6 +1075,15 @@ export default function App() {
if (paletteCommandItems().length) {
groups.push({ id: "commands", title: "Commands", items: paletteCommandItems() });
}
if (paletteSessionItems().length) {
groups.push({ id: "sessions", title: "Sessions", items: paletteSessionItems() });
}
if (paletteAgentItems().length) {
groups.push({ id: "agents", title: "Agents", items: paletteAgentItems() });
}
if (paletteVariantItems().length) {
groups.push({ id: "variants", title: "Variants", items: paletteVariantItems() });
}
if (paletteFileItems().length) {
groups.push({ id: "files", title: "Files", items: paletteFileItems() });
}
@@ -1275,6 +1394,9 @@ export default function App() {
progress: true,
artifacts: true,
context: true,
plugins: true,
skills: true,
authorizedFolders: true,
});
const [appVersion, setAppVersion] = createSignal<string | null>(null);
@@ -1954,7 +2076,10 @@ export default function App() {
const storedVariant = window.localStorage.getItem(VARIANT_PREF_KEY);
if (storedVariant && storedVariant.trim()) {
setModelVariant(storedVariant.trim());
const normalized = normalizeModelVariant(storedVariant);
if (normalized) {
setModelVariant(normalized);
}
}
const storedDemoMode = window.localStorage.getItem(DEMO_MODE_PREF_KEY);
@@ -2595,20 +2720,12 @@ export default function App() {
openDefaultModelPicker,
showThinking: showThinking(),
toggleShowThinking: () => setShowThinking((v) => !v),
modelVariantLabel: modelVariant() ?? t("common.default_parens", currentLocale()),
modelVariantLabel: formatModelVariantLabel(modelVariant()),
keybindItems: keybindSettings(),
onOverrideKeybind: updateKeybindOverride,
onResetKeybind: resetKeybindOverride,
onResetAllKeybinds: resetAllKeybinds,
editModelVariant: () => {
const next = window.prompt(
t("settings.model_variant_prompt", currentLocale()),
modelVariant() ?? ""
);
if (next == null) return;
const trimmed = next.trim();
setModelVariant(trimmed ? trimmed : null);
},
editModelVariant: handleEditModelVariant,
demoMode: demoMode(),
toggleDemoMode: () => setDemoMode((v) => !v),
demoSequence: demoSequence(),
@@ -2686,6 +2803,7 @@ export default function App() {
}
};
const sessionProps = () => ({
selectedSessionId: activeSessionId(),
setView,
@@ -2697,8 +2815,13 @@ export default function App() {
busyHint: busyHint(),
selectedSessionModelLabel: selectedSessionModelLabel(),
openSessionModelPicker: openSessionModelPicker,
modelVariantLabel: formatModelVariantLabel(modelVariant()),
modelVariant: modelVariant(),
setModelVariant: (value: string) => setModelVariant(value),
activePlugins: sidebarPluginList(),
activePluginStatus: sidebarPluginStatus(),
skills: skills(),
skillsStatus: skillsStatus(),
createSessionAndOpen: createSessionAndOpen,
sendPromptAsync: sendPrompt,
newTaskDisabled: newTaskDisabled(),

View File

@@ -33,6 +33,9 @@ type ComposerProps = {
onInsertCommand: (commandId: string) => void;
selectedModelLabel: string;
onModelClick: () => void;
modelVariantLabel: string;
modelVariant: string | null;
onModelVariantChange: (value: string) => void;
agentLabel: string;
selectedAgent: string | null;
agentPickerOpen: boolean;
@@ -53,8 +56,10 @@ type ComposerProps = {
};
const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"];
const isImageMime = (mime: string) => mime.startsWith("image/");
const isImageMime = (mime: string) => ACCEPTED_IMAGE_TYPES.includes(mime);
const fileToDataUrl = (file: File) =>
new Promise<string>((resolve, reject) => {
@@ -71,6 +76,14 @@ const clamp = (value: number, min: number, max: number) => Math.min(max, Math.ma
const normalizeText = (value: string) => value.replace(/\u00a0/g, " ");
const MODEL_VARIANT_OPTIONS = [
{ value: "none", label: "None" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "xhigh", label: "X-High" },
];
const partsToText = (parts: ComposerPart[]) =>
parts
.map((part) => {
@@ -244,6 +257,8 @@ const buildRangeFromOffsets = (root: HTMLElement, start: number, end: number) =>
export default function Composer(props: ComposerProps) {
let editorRef: HTMLDivElement | undefined;
let fileInputRef: HTMLInputElement | undefined;
let variantPickerRef: HTMLDivElement | undefined;
let suppressPromptSync = false;
const [commandIndex, setCommandIndex] = createSignal(0);
const [mentionIndex, setMentionIndex] = createSignal(0);
const [mentionQuery, setMentionQuery] = createSignal("");
@@ -257,6 +272,8 @@ export default function Composer(props: ComposerProps) {
const [historySnapshot, setHistorySnapshot] = createSignal<ComposerDraft | null>(null);
const [historyIndex, setHistoryIndex] = createSignal({ prompt: -1, shell: -1 });
const [history, setHistory] = createSignal({ prompt: [] as ComposerDraft[], shell: [] as ComposerDraft[] });
const [variantMenuOpen, setVariantMenuOpen] = createSignal(false);
const activeVariant = createMemo(() => props.modelVariant ?? "none");
const commandMenuOpen = createMemo(() => {
return props.prompt.trim().startsWith("/") && !props.busy && mode() === "prompt" && !mentionOpen();
@@ -339,15 +356,31 @@ export default function Composer(props: ComposerProps) {
if (!editorRef) return;
const parts = buildPartsFromEditor(editorRef);
const text = normalizeText(partsToText(parts));
suppressPromptSync = true;
props.onDraftChange({
mode: mode(),
parts,
attachments: attachments(),
text,
});
queueMicrotask(() => {
suppressPromptSync = false;
});
syncHeight();
};
const focusEditorEnd = () => {
if (!editorRef) return;
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
range.selectNodeContents(editorRef);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
editorRef.focus();
};
const renderParts = (parts: ComposerPart[], keepSelection = true) => {
if (!editorRef) return;
const selection = keepSelection ? getSelectionOffsets(editorRef) : null;
@@ -472,6 +505,18 @@ export default function Composer(props: ComposerProps) {
applyHistoryDraft(target);
};
const sendDraft = () => {
if (!editorRef) return;
const parts = buildPartsFromEditor(editorRef);
const text = normalizeText(partsToText(parts));
const draft: ComposerDraft = { mode: mode(), parts, attachments: attachments(), text };
recordHistory(draft);
props.onSend(draft);
setAttachments([]);
setEditorText("");
emitDraftChange();
};
const recordHistory = (draft: ComposerDraft) => {
const trimmed = draft.text.trim();
if (!trimmed && !draft.attachments.length) return;
@@ -490,6 +535,10 @@ export default function Composer(props: ComposerProps) {
}
const next: ComposerAttachment[] = [];
for (const file of files) {
if (!ACCEPTED_FILE_TYPES.includes(file.type)) {
props.onToast(`${file.name} is not a supported attachment type.`);
continue;
}
if (file.size > MAX_ATTACHMENT_BYTES) {
props.onToast(`${file.name} exceeds the 8MB limit.`);
continue;
@@ -516,10 +565,21 @@ export default function Composer(props: ComposerProps) {
const handlePaste = (event: ClipboardEvent) => {
if (!event.clipboardData) return;
const files = Array.from(event.clipboardData.files || []);
if (!files.length) return;
const clipboard = event.clipboardData;
const fileItems = Array.from(clipboard.items || []).filter((item) => item.kind === "file");
const files = Array.from(clipboard.files || []);
const itemFiles = fileItems
.map((item) => item.getAsFile())
.filter((file): file is File => !!file);
const allFiles = files.length ? files : itemFiles;
if (!allFiles.length) return;
event.preventDefault();
void addAttachments(files);
const hasSupported = allFiles.some((file) => ACCEPTED_FILE_TYPES.includes(file.type));
if (!hasSupported) {
props.onToast("Unsupported attachment type.");
return;
}
void addAttachments(allFiles);
};
const handleDrop = (event: DragEvent) => {
@@ -632,12 +692,7 @@ export default function Composer(props: ComposerProps) {
if (event.key === "Enter") {
event.preventDefault();
if (!editorRef) return;
const parts = buildPartsFromEditor(editorRef);
const text = normalizeText(partsToText(parts));
const draft: ComposerDraft = { mode: mode(), parts, attachments: attachments(), text };
recordHistory(draft);
props.onSend(draft);
sendDraft();
}
};
@@ -684,6 +739,16 @@ export default function Composer(props: ComposerProps) {
if (!editorRef) return;
const value = props.prompt;
const current = normalizeText(editorRef.innerText);
if (suppressPromptSync) {
if (!value && current) {
setEditorText("");
setAttachments([]);
setHistoryIndex((currentIndex: { prompt: number; shell: number }) => ({ ...currentIndex, [mode()]: -1 }));
setHistorySnapshot(null);
queueMicrotask(() => focusEditorEnd());
}
return;
}
if (value === current) return;
if (value.startsWith("!") && mode() === "prompt") {
setMode("shell");
@@ -696,10 +761,22 @@ export default function Composer(props: ComposerProps) {
setAttachments([]);
setHistoryIndex((currentIndex: { prompt: number; shell: number }) => ({ ...currentIndex, [mode()]: -1 }));
setHistorySnapshot(null);
queueMicrotask(() => focusEditorEnd());
}
emitDraftChange();
});
createEffect(() => {
if (!variantMenuOpen()) return;
const handler = (event: MouseEvent) => {
if (!variantPickerRef) return;
if (variantPickerRef.contains(event.target as Node)) return;
setVariantMenuOpen(false);
};
window.addEventListener("mousedown", handler);
onCleanup(() => window.removeEventListener("mousedown", handler));
});
createEffect(() => {
const handler = () => {
editorRef?.focus();
@@ -823,15 +900,60 @@ export default function Composer(props: ComposerProps) {
</div>
</Show>
<button
type="button"
class="absolute top-3 left-4 flex items-center gap-1.5 text-[10px] font-bold text-gray-7 hover:text-gray-11 transition-colors uppercase tracking-widest z-10"
onClick={props.onModelClick}
disabled={props.busy}
>
<Zap size={10} class="text-gray-7 group-hover:text-amber-11 transition-colors" />
<span>{props.selectedModelLabel}</span>
</button>
<div class="absolute top-3 left-4 flex items-center gap-3 text-[10px] font-bold text-gray-7 uppercase tracking-widest z-10">
<button
type="button"
class="flex items-center gap-1.5 text-gray-7 hover:text-gray-11 transition-colors"
onClick={props.onModelClick}
disabled={props.busy}
>
<Zap size={10} class="text-gray-7 group-hover:text-amber-11 transition-colors" />
<span>{props.selectedModelLabel}</span>
</button>
<div class="relative z-40" ref={(el) => (variantPickerRef = el)}>
<button
type="button"
class="flex items-center gap-2 rounded-full border border-gray-6/80 bg-gray-1/60 px-2 py-0.5 text-[9px] text-gray-9 hover:text-gray-11 hover:border-gray-7 transition-colors"
onClick={() => setVariantMenuOpen((open) => !open)}
disabled={props.busy}
aria-expanded={variantMenuOpen()}
>
<span class="text-gray-8">Variant</span>
<span class="font-mono text-gray-11">{props.modelVariantLabel}</span>
<ChevronDown size={12} class="text-gray-8" />
</button>
<Show when={variantMenuOpen()}>
<div class="absolute left-0 bottom-full mb-2 w-40 rounded-2xl border border-gray-6 bg-gray-1/95 shadow-2xl backdrop-blur-md overflow-hidden z-40">
<div class="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.2em] text-gray-8 border-b border-gray-6/30">
Thinking effort
</div>
<div class="p-2 space-y-1">
<For each={MODEL_VARIANT_OPTIONS}>
{(option) => (
<button
type="button"
class={`w-full flex items-center justify-between rounded-xl px-3 py-2 text-left text-xs transition-colors ${
activeVariant() === option.value
? "bg-gray-12/10 text-gray-12"
: "text-gray-11 hover:bg-gray-12/5"
}`}
onClick={() => {
props.onModelVariantChange(option.value);
setVariantMenuOpen(false);
}}
>
<span>{option.label}</span>
<Show when={activeVariant() === option.value}>
<span class="text-[10px] uppercase tracking-wider text-gray-9">Active</span>
</Show>
</button>
)}
</For>
</div>
</div>
</Show>
</div>
</div>
<div class="p-3 pt-8 pb-3 px-4">
<Show when={props.showNotionBanner}>
@@ -882,7 +1004,7 @@ export default function Composer(props: ComposerProps) {
</div>
</Show>
<div class="relative">
<div class="relative min-h-[120px]">
<Show when={props.toast}>
<div class="absolute bottom-full right-0 mb-2 z-30 rounded-xl border border-gray-6 bg-gray-1/90 px-3 py-2 text-xs text-gray-11 shadow-lg backdrop-blur-md">
{props.toast}
@@ -917,7 +1039,7 @@ export default function Composer(props: ComposerProps) {
class="bg-transparent border-none p-0 pb-12 pr-20 text-gray-12 focus:ring-0 text-[15px] leading-relaxed resize-none min-h-[24px] outline-none relative z-10"
/>
<div class="absolute bottom-0 left-0 z-20" ref={props.setAgentPickerRef}>
<div class="mt-3" ref={props.setAgentPickerRef}>
<button
type="button"
class="flex items-center gap-2 pl-3 pr-2 py-1.5 bg-gray-1/70 border border-gray-6 rounded-lg hover:border-gray-7 hover:bg-gray-3 transition-all group"
@@ -1000,6 +1122,7 @@ export default function Composer(props: ComposerProps) {
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden"
onChange={(event: Event) => {
const target = event.currentTarget as HTMLInputElement;
@@ -1025,14 +1148,7 @@ export default function Composer(props: ComposerProps) {
<button
disabled={!props.prompt.trim() && !attachments().length}
onClick={() => {
if (!editorRef) return;
const parts = buildPartsFromEditor(editorRef);
const text = normalizeText(partsToText(parts));
const draft: ComposerDraft = { mode: mode(), parts, attachments: attachments(), text };
recordHistory(draft);
props.onSend(draft);
}}
onClick={sendDraft}
class="p-2 bg-gray-12 text-gray-1 rounded-xl hover:scale-105 active:scale-95 transition-all disabled:opacity-0 disabled:scale-75 shadow-lg shrink-0 flex items-center justify-center"
title="Run"
>

View File

@@ -1,13 +1,24 @@
import { For, Show } from "solid-js";
import { ChevronDown, Circle, File, Folder } from "lucide-solid";
import { ChevronDown, Circle, File, Folder, Package } from "lucide-solid";
import { SUGGESTED_PLUGINS } from "../../constants";
import type { SkillCard } from "../../types";
import { stripPluginVersion } from "../../utils/plugins";
export type ContextPanelProps = {
activePlugins: string[];
activePluginStatus: string | null;
skills: SkillCard[];
skillsStatus: string | null;
authorizedDirs: string[];
workingFiles: string[];
expanded: boolean;
onToggle: () => void;
expandedSections: {
context: boolean;
plugins: boolean;
skills: boolean;
authorizedFolders: boolean;
};
onToggleSection: (section: "context" | "plugins" | "skills" | "authorizedFolders") => void;
};
const humanizePlugin = (name: string) => {
@@ -24,68 +35,47 @@ const humanizePlugin = (name: string) => {
.trim();
};
const matchSuggestedPlugin = (name: string) => {
const normalized = stripPluginVersion(name).toLowerCase();
if (!normalized) return null;
return (
SUGGESTED_PLUGINS.find((plugin) => {
const candidates = [plugin.packageName, plugin.name, ...(plugin.aliases ?? [])]
.map((candidate) => stripPluginVersion(candidate).toLowerCase())
.filter(Boolean);
return candidates.includes(normalized);
}) ?? null
);
};
const humanizeSkill = (name: string) => {
const cleaned = name.replace(/^@[^/]+\//, "").replace(/[-_]+/g, " ").trim();
if (!cleaned) return name;
return cleaned
.split(" ")
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
.trim();
};
export default function ContextPanel(props: ContextPanelProps) {
return (
<div class="flex flex-col h-full overflow-hidden">
<div class="flex-1 overflow-y-auto px-4 py-4">
<div class="flex-1 overflow-y-auto px-4 py-4 space-y-4">
<div class="rounded-2xl border border-gray-6 bg-gray-2/30" id="sidebar-context">
<button
class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium"
onClick={props.onToggle}
onClick={() => props.onToggleSection("context")}
>
<span>Context</span>
<ChevronDown
size={16}
class={`transition-transform text-gray-10 ${props.expanded ? "rotate-180" : ""}`.trim()}
class={`transition-transform text-gray-10 ${props.expandedSections.context ? "rotate-180" : ""}`.trim()}
/>
</button>
<Show when={props.expanded}>
<Show when={props.expandedSections.context}>
<div class="px-4 pb-4 pt-1 space-y-5">
<Show when={props.activePlugins.length || props.activePluginStatus}>
<div>
<div class="flex items-center justify-between text-[11px] uppercase tracking-wider text-gray-9 font-semibold mb-2">
<span>Active plugins</span>
</div>
<div class="space-y-2">
<Show
when={props.activePlugins.length}
fallback={
<div class="text-xs text-gray-9">
{props.activePluginStatus ?? "No plugins loaded."}
</div>
}
>
<For each={props.activePlugins}>
{(plugin) => (
<div class="flex items-center gap-2 text-xs text-gray-11">
<Circle size={6} class="text-green-9 fill-green-9" />
<span class="truncate">{humanizePlugin(plugin) || plugin}</span>
</div>
)}
</For>
</Show>
</div>
</div>
</Show>
<div>
<div class="flex items-center justify-between text-[11px] uppercase tracking-wider text-gray-9 font-semibold mb-2">
<span>Authorized folders</span>
</div>
<div class="space-y-2">
<For each={props.authorizedDirs.slice(0, 3)}>
{(folder) => (
<div class="flex items-center gap-2 text-xs text-gray-11">
<Folder size={12} class="text-gray-9" />
<span class="truncate" title={folder}>
{folder.split(/[/\\]/).pop()}
</span>
</div>
)}
</For>
</div>
</div>
<div>
<div class="flex items-center justify-between text-[11px] uppercase tracking-wider text-gray-9 font-semibold mb-2">
<span>Working files</span>
@@ -109,6 +99,139 @@ export default function ContextPanel(props: ContextPanelProps) {
</div>
</Show>
</div>
<div class="rounded-2xl border border-gray-6 bg-gray-2/30">
<button
class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium"
onClick={() => props.onToggleSection("plugins")}
>
<span>Plugins</span>
<ChevronDown
size={16}
class={`transition-transform text-gray-10 ${props.expandedSections.plugins ? "rotate-180" : ""}`.trim()}
/>
</button>
<Show when={props.expandedSections.plugins}>
<div class="px-4 pb-4 pt-1">
<div class="space-y-2">
<Show
when={props.activePlugins.length}
fallback={
<div class="text-xs text-gray-9">
{props.activePluginStatus ?? "No plugins loaded."}
</div>
}
>
<For each={props.activePlugins}>
{(plugin) => {
const suggested = matchSuggestedPlugin(plugin);
const normalized = stripPluginVersion(plugin) || plugin;
const label = humanizePlugin(suggested?.name ?? normalized) || normalized;
const description = suggested?.description?.trim();
const detail = description || (normalized !== label ? normalized : "");
return (
<div class="flex items-start gap-2 text-xs text-gray-11">
<Circle size={6} class="text-green-9 fill-green-9 mt-1" />
<div class="min-w-0">
<div class="truncate">{label}</div>
<Show when={detail}>
<div class="text-[11px] text-gray-9 truncate" title={detail}>
{detail}
</div>
</Show>
</div>
</div>
);
}}
</For>
</Show>
</div>
</div>
</Show>
</div>
<div class="rounded-2xl border border-gray-6 bg-gray-2/30">
<button
class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium"
onClick={() => props.onToggleSection("skills")}
>
<span>Skills</span>
<ChevronDown
size={16}
class={`transition-transform text-gray-10 ${props.expandedSections.skills ? "rotate-180" : ""}`.trim()}
/>
</button>
<Show when={props.expandedSections.skills}>
<div class="px-4 pb-4 pt-1">
<div class="space-y-2">
<Show
when={props.skills.length}
fallback={
<div class="text-xs text-gray-9">
{props.skillsStatus ?? "No skills loaded."}
</div>
}
>
<For each={props.skills}>
{(skill) => {
const label = humanizeSkill(skill.name) || skill.name;
const description = skill.description?.trim();
return (
<div class="flex items-start gap-2 text-xs text-gray-11">
<Package size={12} class="text-gray-9 mt-0.5" />
<div class="min-w-0">
<div class="truncate">{label}</div>
<Show when={description}>
<div class="text-[11px] text-gray-9 truncate" title={description}>
{description}
</div>
</Show>
</div>
</div>
);
}}
</For>
</Show>
</div>
</div>
</Show>
</div>
<div class="rounded-2xl border border-gray-6 bg-gray-2/30">
<button
class="w-full px-4 py-3 flex items-center justify-between text-sm text-gray-12 font-medium"
onClick={() => props.onToggleSection("authorizedFolders")}
>
<span>Authorized folders</span>
<ChevronDown
size={16}
class={`transition-transform text-gray-10 ${
props.expandedSections.authorizedFolders ? "rotate-180" : ""
}`.trim()}
/>
</button>
<Show when={props.expandedSections.authorizedFolders}>
<div class="px-4 pb-4 pt-1">
<div class="space-y-2">
<Show
when={props.authorizedDirs.length}
fallback={<div class="text-xs text-gray-9">None yet.</div>}
>
<For each={props.authorizedDirs.slice(0, 3)}>
{(folder) => (
<div class="flex items-center gap-2 text-xs text-gray-11">
<Folder size={12} class="text-gray-9" />
<span class="truncate" title={folder}>
{folder.split(/[/\\]/).pop()}
</span>
</div>
)}
</For>
</Show>
</div>
</div>
</Show>
</div>
</div>
</div>
);

View File

@@ -7,6 +7,9 @@ export type SidebarSectionState = {
progress: boolean;
artifacts: boolean;
context: boolean;
plugins: boolean;
skills: boolean;
authorizedFolders: boolean;
};
export type SidebarProps = {

View File

@@ -9,6 +9,7 @@ import type {
MessageGroup,
MessageWithParts,
PendingPermission,
SkillCard,
TodoItem,
View,
WorkspaceCommand,
@@ -67,11 +68,16 @@ export type SessionViewProps = {
authorizedDirs: string[];
activePlugins: string[];
activePluginStatus: string | null;
skills: SkillCard[];
skillsStatus: string | null;
busy: boolean;
prompt: string;
setPrompt: (value: string) => void;
selectedSessionModelLabel: string;
openSessionModelPicker: () => void;
modelVariantLabel: string;
modelVariant: string | null;
setModelVariant: (value: string) => void;
activePermission: PendingPermission | null;
showTryNotionPrompt: boolean;
onTryNotionPrompt: () => void;
@@ -542,6 +548,14 @@ export default function SessionView(props: SessionViewProps) {
return items.length > 4 ? `${preview}, ...` : preview;
};
const MODEL_VARIANT_OPTIONS = ["none", "low", "medium", "high", "xhigh"];
const normalizeVariantInput = (value: string) => {
const trimmed = value.trim().toLowerCase();
if (trimmed === "balance" || trimmed === "balanced") return "none";
return MODEL_VARIANT_OPTIONS.includes(trimmed) ? trimmed : null;
};
const openAgentPicker = () => {
setAgentPickerOpen((current) => !current);
if (!agentPickerReady()) {
@@ -669,6 +683,29 @@ export default function SessionView(props: SessionViewProps) {
}
},
},
{
id: "session.variant",
title: "Change model variant",
category: "Session",
description: "Adjust the model variant",
slash: "variant",
scope: "session",
onSelect: () => {
const rawArg = extractCommandArgs(props.prompt);
if (!rawArg) {
setCommandToast(`Use /variant ${MODEL_VARIANT_OPTIONS.join("/")}`);
return;
}
const normalized = normalizeVariantInput(rawArg);
if (!normalized) {
setCommandToast(`Variant must be: ${MODEL_VARIANT_OPTIONS.join(", ")}`);
return;
}
props.setModelVariant(normalized);
setCommandToast(`Variant set to ${normalized}`);
clearPrompt();
},
},
{
id: "session.new",
title: "Start a new task",
@@ -1078,13 +1115,15 @@ export default function SessionView(props: SessionViewProps) {
<ContextPanel
activePlugins={props.activePlugins}
activePluginStatus={props.activePluginStatus}
skills={props.skills}
skillsStatus={props.skillsStatus}
authorizedDirs={props.authorizedDirs}
workingFiles={props.workingFiles}
expanded={props.expandedSidebarSections.context}
onToggle={() =>
expandedSections={props.expandedSidebarSections}
onToggleSection={(section) =>
props.setExpandedSidebarSections((curr) => ({
...curr,
context: !curr.context,
[section]: !curr[section],
}))
}
/>
@@ -1101,6 +1140,9 @@ export default function SessionView(props: SessionViewProps) {
onInsertCommand={handleInsertCommand}
selectedModelLabel={props.selectedSessionModelLabel || "Model"}
onModelClick={props.openSessionModelPicker}
modelVariantLabel={props.modelVariantLabel}
modelVariant={props.modelVariant}
onModelVariantChange={props.setModelVariant}
agentLabel={agentLabel()}
selectedAgent={props.selectedSessionAgent}
agentPickerOpen={agentPickerOpen()}