diff --git a/src/workspaces/templates/auto-quant/README.md b/src/workspaces/templates/auto-quant/README.md index a58e269da..0673dd170 100644 --- a/src/workspaces/templates/auto-quant/README.md +++ b/src/workspaces/templates/auto-quant/README.md @@ -26,6 +26,7 @@ Each workspace gets its own `user_data/data/`. First run, the agent runs `uv run ## Parameters - **Tag** — becomes the branch name (`autoresearch/`). -- **Agents** — default Claude; Codex works too if you prefer. + +All available CLI runtimes are enabled (Claude, Codex, shell). The `+` new-session button defaults to Claude. Power-user override: set `AQ_TEMPLATE_DIR` in the launcher env to point at a pre-existing Auto-Quant clone (e.g. one you've already populated with data). diff --git a/src/workspaces/templates/auto-quant/template.json b/src/workspaces/templates/auto-quant/template.json index 2b8dd85be..161b3f3d2 100644 --- a/src/workspaces/templates/auto-quant/template.json +++ b/src/workspaces/templates/auto-quant/template.json @@ -2,5 +2,5 @@ "displayName": "Auto-Quant", "groupOrder": 20, "description": "Auto-Quant autoresearch workspace — clones the public Auto-Quant repo on first use (~/.openalice/workspaces/auto-quant-mirror), then makes per-workspace local clones with an autoresearch/ branch and symlinked .feather data. Set AQ_TEMPLATE_DIR to override the source location.", - "defaultAgents": ["claude"] + "defaultAgents": ["claude", "codex"] } diff --git a/src/workspaces/templates/chat/README.md b/src/workspaces/templates/chat/README.md index 83797bea9..e2e7c8f2d 100644 --- a/src/workspaces/templates/chat/README.md +++ b/src/workspaces/templates/chat/README.md @@ -29,6 +29,5 @@ Things Alice will route here: When spawning, you'll configure: - **Tag** — short identifier for this workspace (lowercase, dashes ok). -- **Agents** — which CLI runtimes to enable (default: Claude + Codex). -That's it. No template-specific parameters — everything else is shaped by what you say to the agent. +That's it. All available CLI runtimes (Claude, Codex, shell) are enabled by default; the template's first listed adapter is what the `+` "new session" button defaults to. diff --git a/src/workspaces/templates/finance-research/README.md b/src/workspaces/templates/finance-research/README.md index 65517a6fd..d3bd7327e 100644 --- a/src/workspaces/templates/finance-research/README.md +++ b/src/workspaces/templates/finance-research/README.md @@ -29,6 +29,7 @@ Both layers load the same SKILL.md trees (market-analysis, social-readers, data- ## Parameters - **Tag** — short identifier for this workspace. -- **Agents** — default Claude + Codex (both discover the same skill trees). + +All available CLI runtimes are enabled (Claude, Codex, shell — both Claude and Codex discover the same skill trees). The `+` new-session button defaults to Claude. Finance-skills is cloned fresh on every spawn — no shared cache. Keeps upstream traffic visible to its maintainer, who's part of the ecosystem we want to grow. diff --git a/ui/src/components/workspace/ChatWorkspaceSection.tsx b/ui/src/components/workspace/ChatWorkspaceSection.tsx index 7913090a1..555cb0b87 100644 --- a/ui/src/components/workspace/ChatWorkspaceSection.tsx +++ b/ui/src/components/workspace/ChatWorkspaceSection.tsx @@ -26,12 +26,11 @@ import { useWorkspaces } from '../../contexts/WorkspacesContext' import { useWorkspace } from '../../tabs/store' import { getFocusedTab } from '../../tabs/types' import { ConfirmDialog } from '../ConfirmDialog' -import { createWorkspace, deleteWorkspace, type SessionRecord, type Workspace } from './api' +import { deleteWorkspace, type SessionRecord, type Workspace } from './api' import { SessionRow } from './Sidebar' +import { useCreateWorkspace } from '../../hooks/useCreateWorkspace' const CHAT_TEMPLATE = 'chat' -const TAG_HINT = 'a-z, 0-9, "-", "_", up to 33 chars' -const TAG_RE = /^[a-z0-9][a-z0-9_-]{0,32}$/ function defaultTagFor(workspaces: readonly Workspace[]): string { const now = new Date() @@ -63,32 +62,23 @@ export function ChatWorkspaceSection(): ReactElement | null { const chatTemplate = ctx.templates.find((t) => t.name === CHAT_TEMPLATE) const [showCreate, setShowCreate] = useState(false) - const [submitting, setSubmitting] = useState(false) - const [tag, setTag] = useState('') - const [pickedAgents, setPickedAgents] = useState | null>(null) - const [createError, setCreateError] = useState(null) const inputRef = useRef(null) const [pendingDelete, setPendingDelete] = useState(null) - const checkedAgents: ReadonlySet = useMemo(() => { - if (pickedAgents) return pickedAgents - return new Set(chatTemplate?.defaultAgents ?? ['claude']) - }, [pickedAgents, chatTemplate]) - - const toggleAgent = (id: string): void => { - setPickedAgents((prev) => { - const base = prev ?? new Set(chatTemplate?.defaultAgents ?? ['claude']) - const next = new Set(base) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) - } + const create = useCreateWorkspace({ + template: CHAT_TEMPLATE, + templateDefaultAgents: chatTemplate?.defaultAgents, + availableAgents: ctx.agents, + onCreated: (workspace) => { + closeCreate() + ctx.refresh() + openOrFocus({ kind: 'workspace', params: { wsId: workspace.id } }) + }, + }) const openCreate = (): void => { setShowCreate(true) - setTag(defaultTagFor(ctx.workspaces)) - setCreateError(null) + create.setTag(defaultTagFor(ctx.workspaces)) // Focus + select on next paint so users can type to replace the // default in one keystroke. setTimeout(() => { @@ -99,34 +89,12 @@ export function ChatWorkspaceSection(): ReactElement | null { const closeCreate = (): void => { setShowCreate(false) - setTag('') - setPickedAgents(null) - setCreateError(null) + create.reset() } const submit = async (e: FormEvent): Promise => { e.preventDefault() - const t = tag.trim() - if (!TAG_RE.test(t)) { - setCreateError(`invalid tag (${TAG_HINT})`) - return - } - if (checkedAgents.size === 0) { - setCreateError('pick at least one agent') - return - } - setSubmitting(true) - setCreateError(null) - const result = await createWorkspace(t, CHAT_TEMPLATE, Array.from(checkedAgents)) - setSubmitting(false) - if (result.ok) { - closeCreate() - ctx.refresh() - openOrFocus({ kind: 'workspace', params: { wsId: result.workspace.id } }) - } else { - const msg = result.error.message ?? result.error.error ?? `HTTP ${result.status}` - setCreateError(msg) - } + await create.submit() } const handleConfirmDelete = async (): Promise => { @@ -140,10 +108,10 @@ export function ChatWorkspaceSection(): ReactElement | null { } useEffect(() => { - if (showCreate && tag === '' && chatTemplate) { - setTag(defaultTagFor(ctx.workspaces)) + if (showCreate && create.tag === '' && chatTemplate) { + create.setTag(defaultTagFor(ctx.workspaces)) } - }, [showCreate, tag, chatTemplate, ctx.workspaces]) + }, [showCreate, create, chatTemplate, ctx.workspaces]) if (!chatTemplate) return null @@ -175,9 +143,9 @@ export function ChatWorkspaceSection(): ReactElement | null { ref={inputRef} type="text" placeholder="tag (e.g. may1)" - value={tag} - onChange={(e) => setTag(e.target.value)} - disabled={submitting} + value={create.tag} + onChange={(e) => create.setTag(e.target.value)} + disabled={create.creating} spellCheck={false} autoCorrect="off" autoCapitalize="off" @@ -185,34 +153,14 @@ export function ChatWorkspaceSection(): ReactElement | null { /> - {ctx.agents.length > 0 && ( -
- {ctx.agents.map((a) => ( - - ))} -
- )} - {createError && ( -
{createError}
+ {create.error && ( +
{create.error}
)} )} diff --git a/ui/src/components/workspace/Sidebar.tsx b/ui/src/components/workspace/Sidebar.tsx index cc25c3783..c3322683e 100644 --- a/ui/src/components/workspace/Sidebar.tsx +++ b/ui/src/components/workspace/Sidebar.tsx @@ -1,18 +1,15 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { FormEvent, ReactElement } from 'react'; import { Cpu, LayoutGrid, Library, Sparkles, Terminal, type LucideIcon } from 'lucide-react'; import { - createWorkspace, deleteWorkspace, type AgentInfo, type SessionRecord, type TemplateInfo, type Workspace, } from './api'; - -const TAG_HINT = 'a-z, 0-9, "-", "_", up to 33 chars'; -const TAG_RE = /^[a-z0-9][a-z0-9_-]{0,32}$/; +import { useCreateWorkspace } from '../../hooks/useCreateWorkspace'; export interface Selection { readonly wsId: string; @@ -50,11 +47,7 @@ export interface SidebarProps { } export function Sidebar(props: SidebarProps): ReactElement { - const [creating, setCreating] = useState(false); - const [tag, setTag] = useState(''); const [template, setTemplate] = useState(''); - const [pickedAgents, setPickedAgents] = useState | null>(null); - const [createError, setCreateError] = useState(null); const inputRef = useRef(null); useEffect(() => { @@ -65,49 +58,19 @@ export function Sidebar(props: SidebarProps): ReactElement { }, [props.templates, template]); const selectedTemplate = props.templates.find((t) => t.name === template); - const checkedAgents: ReadonlySet = useMemo(() => { - if (pickedAgents) return pickedAgents; - return new Set(selectedTemplate?.defaultAgents ?? ['claude']); - }, [pickedAgents, selectedTemplate]); - - const toggleAgent = (id: string): void => { - setPickedAgents((prev) => { - const base = prev ?? new Set(selectedTemplate?.defaultAgents ?? ['claude']); - const next = new Set(base); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; + const create = useCreateWorkspace({ + template, + templateDefaultAgents: selectedTemplate?.defaultAgents, + availableAgents: props.agents, + onCreated: (workspace) => { + props.onChanged(); + props.onSelectWorkspace(workspace.id); + }, + }); const submit = async (e: FormEvent): Promise => { e.preventDefault(); - const t = tag.trim(); - if (!TAG_RE.test(t)) { - setCreateError(`invalid tag (${TAG_HINT})`); - return; - } - if (template === '') { - setCreateError('no template selected'); - return; - } - if (checkedAgents.size === 0) { - setCreateError('pick at least one agent'); - return; - } - setCreating(true); - setCreateError(null); - const result = await createWorkspace(t, template, Array.from(checkedAgents)); - setCreating(false); - if (result.ok) { - setTag(''); - setPickedAgents(null); - props.onChanged(); - props.onSelectWorkspace(result.workspace.id); - } else { - const msg = result.error.message ?? result.error.error ?? `HTTP ${result.status}`; - setCreateError(msg); - } + await create.submit(); }; const onDelete = async (id: string): Promise => { @@ -138,11 +101,8 @@ export function Sidebar(props: SidebarProps): ReactElement { toggleAgent(a.id)} - disabled={creating} - /> - {a.id} - - ))} - - )} - {createError &&
{createError}
} + {create.error &&
{create.error}
}
    {props.onOpenOverview && ( diff --git a/ui/src/hooks/useCreateWorkspace.ts b/ui/src/hooks/useCreateWorkspace.ts new file mode 100644 index 000000000..41e3c0c76 --- /dev/null +++ b/ui/src/hooks/useCreateWorkspace.ts @@ -0,0 +1,91 @@ +import { useCallback, useState } from 'react' +import { createWorkspace, type AgentInfo, type Workspace } from '../components/workspace/api' + +export const TAG_HINT = 'a-z, 0-9, "-", "_", up to 33 chars' +export const TAG_RE = /^[a-z0-9][a-z0-9_-]{0,32}$/ + +interface UseCreateWorkspaceOpts { + /** Workspace template to create from. Empty string = not yet selected. */ + template: string + /** + * Template's declared defaultAgents. Used to determine agents[0], which + * is the default adapter when the user spawns a new session via "+". + * The full set of adapters is always enabled regardless — this only + * sets the head of the list. + */ + templateDefaultAgents?: readonly string[] + /** All adapters registered with the workspace launcher. All get enabled. */ + availableAgents: readonly AgentInfo[] + /** Called with the new workspace after a successful create. */ + onCreated: (workspace: Workspace) => void +} + +interface UseCreateWorkspaceState { + tag: string + setTag: (s: string) => void + creating: boolean + error: string | null + submit: () => Promise + reset: () => void +} + +/** + * Shared "create workspace" form logic. The three create surfaces + * (Workspaces sidebar quick-create, Chat workspace section, Template + * detail page) used to each carry their own copy of tag validation + + * agent-checkbox state + submit handler. They've drifted in small ways + * over time; bundling here keeps them in lockstep. + * + * Agent policy: every workspace gets every available adapter enabled. + * The CLI-checkbox row that previously asked users to pick was a + * decision with no first-action judgement basis; defaults were also + * wrong (only claude when a template didn't explicitly opt in to more). + * `templateDefaultAgents` is still honored as the head of the list so + * `agents[0]` — the "spawn a new session" default — follows template + * intent. Template authors can still steer the new-session default + * without restricting what's available. + */ +export function useCreateWorkspace(opts: UseCreateWorkspaceOpts): UseCreateWorkspaceState { + const [tag, setTag] = useState('') + const [creating, setCreating] = useState(false) + const [error, setError] = useState(null) + + const submit = useCallback(async (): Promise => { + const t = tag.trim() + if (!TAG_RE.test(t)) { + setError(`invalid tag (${TAG_HINT})`) + return + } + if (opts.template === '') { + setError('no template selected') + return + } + const head = opts.templateDefaultAgents ?? [] + const seen = new Set(head) + const agents: string[] = [...head] + for (const a of opts.availableAgents) { + if (!seen.has(a.id)) { + agents.push(a.id) + seen.add(a.id) + } + } + setCreating(true) + setError(null) + const result = await createWorkspace(t, opts.template, agents) + setCreating(false) + if (result.ok) { + setTag('') + opts.onCreated(result.workspace) + } else { + const msg = result.error.message ?? result.error.error ?? `HTTP ${result.status}` + setError(msg) + } + }, [tag, opts]) + + const reset = useCallback((): void => { + setTag('') + setError(null) + }, []) + + return { tag, setTag, creating, error, submit, reset } +} diff --git a/ui/src/pages/TemplateDetailPage.tsx b/ui/src/pages/TemplateDetailPage.tsx index d626c9cd4..eb1c92232 100644 --- a/ui/src/pages/TemplateDetailPage.tsx +++ b/ui/src/pages/TemplateDetailPage.tsx @@ -16,13 +16,8 @@ import type { FormEvent } from 'react' import { MarkdownContent } from '../components/MarkdownContent' import { useWorkspaces } from '../contexts/WorkspacesContext' import { useWorkspace } from '../tabs/store' -import { - createWorkspace, - fetchTemplateReadme, -} from '../components/workspace/api' - -const TAG_HINT = 'a-z, 0-9, "-", "_", up to 33 chars' -const TAG_RE = /^[a-z0-9][a-z0-9_-]{0,32}$/ +import { fetchTemplateReadme } from '../components/workspace/api' +import { TAG_HINT, useCreateWorkspace } from '../hooks/useCreateWorkspace' interface Props { spec: { kind: 'template-detail'; params: { name: string } } @@ -71,59 +66,30 @@ export function TemplateDetailPage({ spec }: Props) { } }, [templateName]) + const inputRef = useRef(null) + // Spawn form state — same shape as the sidebar's inline form, but laid // out as a panel rather than a compact column. - const [tag, setTag] = useState('') - const [pickedAgents, setPickedAgents] = useState | null>(null) - const [creating, setCreating] = useState(false) - const [createError, setCreateError] = useState(null) - const inputRef = useRef(null) + const create = useCreateWorkspace({ + template: template?.name ?? '', + templateDefaultAgents: template?.defaultAgents, + availableAgents: agents, + onCreated: (workspace) => { + refresh() + openOrFocus({ kind: 'workspace', params: { wsId: workspace.id } }) + }, + }) - // Reset form state when the user navigates to a different template tab. + // Reset when the user navigates to a different template tab. useEffect(() => { - setTag('') - setPickedAgents(null) - setCreateError(null) + create.reset() + // eslint-disable-next-line react-hooks/exhaustive-deps }, [templateName]) - const checkedAgents: ReadonlySet = useMemo(() => { - if (pickedAgents) return pickedAgents - return new Set(template?.defaultAgents ?? ['claude']) - }, [pickedAgents, template]) - - const toggleAgent = (id: string): void => { - setPickedAgents((prev) => { - const base = prev ?? new Set(template?.defaultAgents ?? ['claude']) - const next = new Set(base) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) - } - const submit = async (e: FormEvent): Promise => { e.preventDefault() if (!template) return - const t = tag.trim() - if (!TAG_RE.test(t)) { - setCreateError(`invalid tag (${TAG_HINT})`) - return - } - if (checkedAgents.size === 0) { - setCreateError('pick at least one agent') - return - } - setCreating(true) - setCreateError(null) - const result = await createWorkspace(t, template.name, Array.from(checkedAgents)) - setCreating(false) - if (result.ok) { - refresh() - openOrFocus({ kind: 'workspace', params: { wsId: result.workspace.id } }) - } else { - const msg = result.error.message ?? result.error.error ?? `HTTP ${result.status}` - setCreateError(msg) - } + await create.submit() } if (!template) { @@ -176,9 +142,9 @@ export function TemplateDetailPage({ spec }: Props) { ref={inputRef} type="text" placeholder="e.g. may1" - value={tag} - onChange={(e) => setTag(e.target.value)} - disabled={creating} + value={create.tag} + onChange={(e) => create.setTag(e.target.value)} + disabled={create.creating} spellCheck={false} autoCorrect="off" autoCapitalize="off" @@ -187,36 +153,17 @@ export function TemplateDetailPage({ spec }: Props) {

    {TAG_HINT}

    - {agents.length > 0 && ( -
    -
    Agents
    -
    - {agents.map((a) => ( - - ))} -
    -
    - )} - - {createError && ( -
    {createError}
    + {create.error && ( +
    {create.error}
    )}