mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
feat: expand session context controls
This commit is contained in:
@@ -10,5 +10,8 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"model": "openai/gpt-5.2-codex"
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"plugin": [
|
||||
"opencode-scheduler"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,9 @@ export type SidebarSectionState = {
|
||||
progress: boolean;
|
||||
artifacts: boolean;
|
||||
context: boolean;
|
||||
plugins: boolean;
|
||||
skills: boolean;
|
||||
authorizedFolders: boolean;
|
||||
};
|
||||
|
||||
export type SidebarProps = {
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user