mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
cb6667e991
* fix(ai): drive Codex Ask AI via app-server + answer-first review prompts (#971) Codex Ask AI previously ran via @openai/codex-sdk (codex exec), which forces approval_policy=never and breaks in enterprise-managed Codex environments that ban it (#971). Replace the transport with a long-lived 'codex app-server' process over JSON-RPC. - New provider packages/ai/providers/codex-app-server.ts (registered as 'codex-sdk' to preserve cookie/agents.ts/UI-gate); omits approvalPolicy so Codex resolves the user's + managed policy, pins read-only sandbox, and surfaces interactive approvals through the existing PermissionCard. - Delete codex-sdk.ts and drop the @openai/codex-sdk dependency (and its 6 prebuilt platform binaries); gate registration on 'which codex'. - SessionManager: additive, optional dispose?() hook to kill the spawned process on evict/remove — a no-op for Claude/OpenCode/Pi (they don't implement it). Also rework the Ask AI prompts (all providers, separate from the transport): - Every mode now instructs the agent to answer the user's message directly and not launch an unprompted review of the context. - Code review stops pasting the whole diff for git-reproducible diff types and instead tells the agent how to inspect it (git diff <base>..HEAD, three-dot for merge-base); non-git/PR/workspace types still paste. - Claude gains the Bash tool so it can run git (still gated by approvals). - The UI passes diffType/base (session) and what the user is viewing (per question) into the context. Verified: full typecheck, full test suite (101 ai tests), and a live end-to-end smoke against codex app-server. * fix(review): pin AI approval card above the input/model bar Render pending approval cards just above the input + provider/model bar in both the document chat (DocumentAIChatPanel) and code-review AI tab, instead of at the top of the scroll, so the user sees them where they act. * fix(ai): harden Codex abort/cancel + add Ask AI Stop button Addresses code-review findings on the codex app-server provider: - turn/interrupt was sent as a notification (no id) so Codex ignored it and abort never took effect. It's now a proper JSON-RPC request. - Filter turn events/approvals by turnId and reject an aborted turn's stragglers, so a stopped turn can no longer leak output into — or prematurely finish — the next turn (ask-stop-ask race). - Guard listeners by query generation and end the drain loop on the abort signal, so a superseded/stopped turn can't touch the live one and abort returns promptly instead of waiting for turn/completed. - Handle abort during startup: once the turn id is known, interrupt it instead of running it in the background. - Add a sendAndWait timeout so a stalled (alive-but-unresponsive) process errors instead of hanging forever. - Drain stderr (stdio 'ignore') to avoid a pipe-buffer deadlock. Also add a Stop button to both Ask AI surfaces (plan/annotate DocumentAIChatPanel and code-review AITab via ReviewSidebar). It replaces Send while streaming and calls the hook's abort -> /api/ai/abort -> session.abort(); the hook already exposed abort but nothing surfaced it. * feat(ai): drive Codex models + reasoning levels from model/list - Codex provider fetches model/list at startup (throwaway app-server, like Pi/OpenCode) and populates the real models plus each model's actual supportedReasoningEfforts + defaultReasoningEffort. Replaces the hardcoded model list and the static AI_REASONING_EFFORTS (which mislabeled xhigh as 'Max' and omitted minimal). - AIProviderBar + AIConfigBar now show the selected model's real efforts and hide the control when a model reports none. xhigh is shown verbatim. - Fix: the Stop button now also appears in the populated code-review chat state (a prior edit missed the second GeneralInput due to indentation). * fix(ai): scope Claude Ask AI Bash to read-only git; clear stale approval cards on Stop - Claude Ask AI no longer auto-allows bare Bash (which ran arbitrary shell with no Allow/Deny prompt). Replace it with scoped read-only git rules (Bash(git diff:*), show, log, status, rev-parse, merge-base, ls-files). git reads auto-run so the agent can inspect large diffs itself; any other command (write git, arbitrary, or injected compound) falls through to the permission card. Keeps the git-inspect approach (large diffs don't fit in the prompt) while closing the auto-exec hole. - useAIChat.abort() now drops still-undecided permission cards: abort cancels them server-side, so leaving them visible was a dead Allow/Deny. * feat(ai): code-review Ask AI shares the agent-review prompt machine, delivered as user messages Code-review Ask AI built its own diff description (gitInspectInstruction) in the system prompt from just diffType+base, which was wrong for full-stack, hide-whitespace, untracked files, and PR worktrees. Replace it with the same machine the review jobs use, delivered on the user's messages. - Server: buildCurrentAiReviewContext() reuses buildAgentReviewUserMessageForTarget (contextOnly) for the current view and ships it as aiReviewContext in every diff payload (/api/diff + switch/PR/scope). Mirrored in the Pi server. - Client: review-editor latches aiReviewContext onto each question via the pure buildReviewContextPreamble (packages/ui/utils/aiPrompt.ts) and buildDefaultPrompt — full block on the first message / when the view changes (incl. after a provider switch via the !sessionId fresh-session check), a short reminder otherwise (never re-pastes a large diff). - context.ts: delete the duplicate gitInspectInstruction; code-review system prompt is now role-only. Providers untouched (provider-agnostic user message). - Tests: machine scenario gaps (plain PR, full-stack default, PR-worktree origin/<base> + stale-main warning, untracked mention, jj-evolog, workspace lines); composition (first/reminder, command/pasted, preamble ordering). * fix(ai): agentic remote-PR context, UTF-8 stream decode, real Stop on supersede/disconnect Addresses review findings on the Ask AI prompt work: - Remote PR without a confirmed local checkout no longer gets URL-only. The agent is told it's in a PR worktree that's being prepared, to verify the PR files exist before relying on them, and to diff with git diff origin/<base>... HEAD (URL fallback). Inform + trust the agent rather than pasting. Shared machine, so review jobs get the same framing. - Decode Codex stdout with a streaming TextDecoder instead of per-chunk toString(), so multi-byte UTF-8 split across chunks no longer corrupts into U+FFFD (matches the Pi provider). - Stop now actually stops the server turn: ask() awaits /api/ai/abort when a new question supersedes a streaming one (awaiting avoids racing the new query into session_busy), and the /api/ai/query SSE stream gains a cancel handler so tab-close/navigation aborts the turn too. Both reuse the existing per-provider session.abort(); factored a shared postServerAbort helper. * fix(ai): per-model reasoning effort, shared PR-checkout readiness, Stop-then-ask race, type hole - Reasoning effort is now tracked per model (a map keyed by model) instead of one global value, so switching to a model that doesn't support the prior level (e.g. xhigh) no longer posts a stale/unsupported effort that Codex rejects. Each model keeps its own level; nothing leaks across. - Extract resolvePoolCwd into packages/shared/worktree-pool.ts (ready/pending/ absent) and use it from both servers' resolvePRLocalCwd so the readiness rule can't drift. Fix the Pi Ask AI helper to ready-check like Bun, so a warming PR checkout no longer claims 'checked out at PR head' and misdirects the diff. - Stop-then-ask no longer races into session_busy: the abort promise (from Stop or a superseding question) is stashed and the next ask() awaits it before sending. Stop still kills the turn instantly; a follow-up just waits the one abort round-trip. - Declare aiReviewContext on the initial /api/diff response type. * fix(ai): surface real Codex error messages, skip transient retries, auto-deny permission escalations Validated against codex-rs: - Codex's ErrorNotification nests the text at params.error.message (TurnError.message); we read params.message → always 'Unknown error'. Read the nested field (top-level fallback) so auth/usage-limit/stream failures show their real, actionable text. - Skip transient error notifications (willRetry=true): Codex retries on its own and the turn continues, so surfacing them flashed a spurious failure before the real answer. - Handle item/permissions/requestApproval: respond {permissions:{}, scope:'turn'} — byte-for-byte Codex's own cancel response (codex_delegate.rs) — instead of 'Unsupported request'. Ask AI is read-only, so denying the escalation lets the turn continue sandboxed rather than failing. Interactive grant deferred. * refactor(ai): share AI provider/model config in one hook; aggregate paginated model/list - Extract useAIProviderConfig (packages/ui/hooks): one home for provider/model/ reasoning-effort selection — initial state, auto-resolve on capabilities load, per-model effort (no leak across models), and persistence. Both the plan and code-review apps now call it and only compose the session reset (the hook can't own reset without a cycle through useAIChat). Plan editor adapted to the effect-based resolve (adds aiDefaultProvider state). This fixes the plan editor's stale-effort-on-model-switch bug by construction and stops the two apps' copies from drifting again. - fetchModels now follows model/list's nextCursor and aggregates every page into one list, so larger model catalogs aren't silently truncated (with a page guard against a misbehaving cursor). Skipped per discussion: legacy v1 approval methods (we're a v2 client). * feat(agents): per-agent review default; bound Codex model discovery so it can't stall the AI panel - The selected review profile is now tracked per review engine (claude/codex/ cursor/opencode) instead of one flat global value, so each agent keeps its own review default. Public hook API (reviewProfileId/setReviewProfileId) is unchanged — getter derives the current engine's value, setter writes it — so AgentsTab needs no changes. One-shot migration seeds every engine with any existing flat pick. Adds parseReviewProfileByEngine + tests. - fetchModels (Codex model discovery) now uses a short 6s timeout for its initialize + model/list RPCs instead of the 30s default. /api/ai/capabilities awaits model discovery, so an installed-but-unauthenticated codex could otherwise block the AI panel for ~30s; it now falls back to the static model list fast. Authed codex is unaffected (discovery completes in tens of ms).
262 lines
9.6 KiB
TypeScript
262 lines
9.6 KiB
TypeScript
import type React from 'react';
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import { getProviderMeta } from '@plannotator/ui/components/ProviderIcons';
|
|
import { type AIProviderOption } from '@plannotator/ui/utils/aiProvider';
|
|
|
|
interface AIConfigBarProps {
|
|
providers: AIProviderOption[];
|
|
selectedProviderId: string | null;
|
|
selectedModel: string | null;
|
|
selectedReasoningEffort: string | null;
|
|
onProviderChange: (providerId: string) => void;
|
|
onModelChange: (model: string) => void;
|
|
onReasoningEffortChange: (effort: string | null) => void;
|
|
hasSession: boolean;
|
|
}
|
|
|
|
export const AIConfigBar: React.FC<AIConfigBarProps> = ({
|
|
providers,
|
|
selectedProviderId,
|
|
selectedModel,
|
|
selectedReasoningEffort,
|
|
onProviderChange,
|
|
onModelChange,
|
|
onReasoningEffortChange,
|
|
hasSession,
|
|
}) => {
|
|
const [showSessionNote, setShowSessionNote] = useState(false);
|
|
const [openMenu, setOpenMenu] = useState<'provider' | 'model' | 'effort' | null>(null);
|
|
const [modelSearch, setModelSearch] = useState('');
|
|
const barRef = useRef<HTMLDivElement>(null);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Flash "New chat session" briefly when config changes while a session exists
|
|
useEffect(() => {
|
|
if (showSessionNote) {
|
|
const t = setTimeout(() => setShowSessionNote(false), 2000);
|
|
return () => clearTimeout(t);
|
|
}
|
|
}, [showSessionNote]);
|
|
|
|
// Close menu on click outside
|
|
useEffect(() => {
|
|
if (!openMenu) return;
|
|
const handler = (e: MouseEvent) => {
|
|
if (barRef.current && !barRef.current.contains(e.target as Node)) {
|
|
setOpenMenu(null);
|
|
setModelSearch('');
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [openMenu]);
|
|
|
|
if (providers.length === 0) {
|
|
return (
|
|
<div className="border-t border-border/50 px-2 py-1.5 text-[11px] text-muted-foreground/50">
|
|
No AI providers available
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const currentProvider = providers.find(p => p.id === selectedProviderId) ?? providers[0];
|
|
if (!currentProvider) return null;
|
|
const effectiveProviderId = currentProvider.id;
|
|
|
|
const meta = getProviderMeta(currentProvider.name);
|
|
const Icon = meta.icon;
|
|
const models = currentProvider.models ?? [];
|
|
const defaultModel = models.find(m => m.default) ?? models[0];
|
|
const effectiveModel = selectedModel ?? defaultModel?.id;
|
|
const currentModel = models.find(m => m.id === effectiveModel) ?? defaultModel;
|
|
const currentModelLabel = currentModel?.label;
|
|
const reasoningEfforts = currentModel?.reasoningEfforts ?? [];
|
|
const effortFallback = currentModel?.defaultReasoningEffort ?? reasoningEfforts[0]?.id ?? '';
|
|
const activeEffort = selectedReasoningEffort ?? effortFallback;
|
|
|
|
const handleProviderSelect = (id: string) => {
|
|
if (hasSession) setShowSessionNote(true);
|
|
onProviderChange(id);
|
|
setOpenMenu(null);
|
|
};
|
|
|
|
const handleModelSelect = (id: string) => {
|
|
if (hasSession) setShowSessionNote(true);
|
|
onModelChange(id);
|
|
setOpenMenu(null);
|
|
setModelSearch('');
|
|
};
|
|
|
|
const handleEffortSelect = (id: string) => {
|
|
if (hasSession) setShowSessionNote(true);
|
|
onReasoningEffortChange(id);
|
|
setOpenMenu(null);
|
|
};
|
|
|
|
const chevron = (
|
|
<svg className="w-2.5 h-2.5 text-muted-foreground/40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
);
|
|
|
|
return (
|
|
<div ref={barRef} className="relative border-t border-border/50 px-2 py-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
|
{/* Provider selector */}
|
|
{providers.length > 1 ? (
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpenMenu(openMenu === 'provider' ? null : 'provider')}
|
|
className="flex items-center gap-1.5 px-1 py-0.5 -mx-1 rounded hover:bg-muted/50 transition-colors"
|
|
>
|
|
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
|
|
<span>{meta.label}</span>
|
|
{chevron}
|
|
</button>
|
|
|
|
{openMenu === 'provider' && (
|
|
<div className="ai-config-menu">
|
|
{providers.map(p => {
|
|
const m = getProviderMeta(p.name);
|
|
const ProvIcon = m.icon;
|
|
const isActive = p.id === effectiveProviderId;
|
|
return (
|
|
<button
|
|
key={p.id}
|
|
type="button"
|
|
onClick={() => handleProviderSelect(p.id)}
|
|
className={`ai-config-menu-item ${isActive ? 'ai-config-menu-item-active' : ''}`}
|
|
>
|
|
<ProvIcon className="w-3.5 h-3.5 flex-shrink-0" />
|
|
<span>{m.label}</span>
|
|
{isActive && (
|
|
<svg className="w-3 h-3 ml-auto text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<span className="flex items-center gap-1.5">
|
|
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
|
|
<span>{meta.label}</span>
|
|
</span>
|
|
)}
|
|
|
|
{/* Model selector */}
|
|
{models.length > 1 ? (
|
|
<>
|
|
<span className="text-border/60">·</span>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpenMenu(openMenu === 'model' ? null : 'model')}
|
|
className="flex items-center gap-1 px-1 py-0.5 -mx-1 rounded hover:bg-muted/50 transition-colors"
|
|
>
|
|
<span>{currentModelLabel}</span>
|
|
{chevron}
|
|
</button>
|
|
|
|
{openMenu === 'model' && (
|
|
<div className="ai-config-menu">
|
|
{models.length > 8 && (
|
|
<div className="ai-config-menu-search">
|
|
<input
|
|
ref={searchInputRef}
|
|
type="text"
|
|
placeholder="Filter models…"
|
|
value={modelSearch}
|
|
onChange={e => setModelSearch(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className={models.length > 8 ? 'ai-config-menu-scroll' : ''}>
|
|
{models
|
|
.filter(m => !modelSearch || m.label.toLowerCase().includes(modelSearch.toLowerCase()))
|
|
.map(m => {
|
|
const isActive = m.id === effectiveModel;
|
|
return (
|
|
<button
|
|
key={m.id}
|
|
type="button"
|
|
onClick={() => handleModelSelect(m.id)}
|
|
className={`ai-config-menu-item ${isActive ? 'ai-config-menu-item-active' : ''}`}
|
|
>
|
|
<span>{m.label}</span>
|
|
{isActive && (
|
|
<svg className="w-3 h-3 ml-auto text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : currentModelLabel ? (
|
|
<>
|
|
<span className="text-border/60">·</span>
|
|
<span>{currentModelLabel}</span>
|
|
</>
|
|
) : null}
|
|
|
|
{/* Reasoning effort — shown when the selected model reports supported efforts */}
|
|
{reasoningEfforts.length > 0 && (
|
|
<>
|
|
<span className="text-border/60">·</span>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpenMenu(openMenu === 'effort' ? null : 'effort')}
|
|
className="flex items-center gap-1 px-1 py-0.5 -mx-1 rounded hover:bg-muted/50 transition-colors"
|
|
title="Reasoning effort"
|
|
>
|
|
<span>{reasoningEfforts.find(e => e.id === activeEffort)?.label ?? 'Auto'}</span>
|
|
{chevron}
|
|
</button>
|
|
|
|
{openMenu === 'effort' && (
|
|
<div className="ai-config-menu">
|
|
{reasoningEfforts.map(e => {
|
|
const isActive = e.id === activeEffort;
|
|
return (
|
|
<button
|
|
key={e.id}
|
|
type="button"
|
|
onClick={() => handleEffortSelect(e.id)}
|
|
className={`ai-config-menu-item ${isActive ? 'ai-config-menu-item-active' : ''}`}
|
|
>
|
|
<span>{e.label}</span>
|
|
{isActive && (
|
|
<svg className="w-3 h-3 ml-auto text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Spacer */}
|
|
<div className="flex-1" />
|
|
|
|
{/* Session reset note */}
|
|
{showSessionNote && (
|
|
<span className="text-[10px] text-amber-500 animate-pulse">New chat session</span>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|