feat(FN-3004): merge fusion/fn-3004

- **New `fn custom-provider` CLI commands** (`register`, `unregister`, `list`) for managing custom AI providers; `dashboard.ts` and `serve.ts` now initialize the registry, and `packages/engine/src/custom-providers.ts` exposes the engine integration
- **Custom provider registration API** in `packages/dashboard/src/routes/register-agent-core-routes.ts` with `POST /api/agent-core/register-custom-provider`; providers are persisted in `store.ts` and exposed via `GET /api/agent-core/custom-providers`
- **Agent heartbeat improvements** (`packages/engine/src/agent-heartbeat.ts`): clearer conflict semantics when task is checked out by another agent, refactored `WakeContext` handling, and budget enforcement at heartbeat time
- **Inline quick chat composer** (FN-3004): `QuickChatFAB` reworked with inline attachment row, improved prompt template logic, and full TypeScript coverage in `QuickChatFAB.test.tsx`
- **Settings and onboarding**: `SettingsModal.tsx` surfaces custom provider management; `ModelOnboardingModal.tsx` updated to use the new registry
- **Agent instructions expanded** (`packages/engine/src/agent-instructions.ts`): richer system prompt injection for spawned agents including agent personality/soul, ratings context, and parent assignment details
- **`agent-store.ts`** now tracks `reportsTo` for spawned agent hierarchy and exposes `list_agents` and `delegate_task` tools via the pi extension
- **Database layer**: `db.ts` uses `prepareSelect()` helpers for query consistency; WAL mode enforced; foreign-key checks enabled in test suite
- **New tests**: `custom-provider-registry.test.ts`, `pi-create-fn-agent.test.ts`, `agent-heartbeat.test.ts`, `notifier.test.ts`, `routes.test.ts` for dashboard core routes, plus coverage for `QuickChatFAB`, `TaskCard`, and `UpdateAvailableBanner`
- **TaskCard**: status badge now uses `--card-status-badge--{status}` CSS classes instead of inline style helpers; `TaskCard.test.tsx` updated to match

Commits merged:
- fix(FN-3004): restore dashboard typecheck for custom provider state
- test(FN-3004): complete Step 2 — cover inline composer attachment row
- feat(FN-3004): complete Step 1 — inline quick chat composer row
- feat(FN-3003): merge fusion/fn-3003
- feat(FN-2962): merge fusion/fn-2962
- fix(FN-XXX): update release notes link in update notification

Files changed:
.changeset/fix-update-release-notes-link.md        |   5 +
 .changeset/register-custom-providers.md            |   5 +
 docs/architecture.md                               |   2 +
 .../__tests__/custom-provider-registry.test.ts     | 167 +++++++++++++++++++++
 .../cli/src/commands/__tests__/dashboard.test.ts   |   4 +
 packages/cli/src/commands/__tests__/serve.test.ts  |   3 +
 .../cli/src/commands/custom-provider-registry.ts   |  96 ++++++++++++
 packages/cli/src/commands/dashboard.ts             |  28 ++++
 packages/cli/src/commands/serve.ts                 |  28 ++++
 packages/core/src/__tests__/db.test.ts             |  26 ++--
 packages/core/src/__tests__/insight-store.test.ts  |   8 +-
 packages/core/src/__tests__/mission-store.test.ts  |   2 +-
 packages/core/src/__tests__/roadmap-store.test.ts  |   2 +-
 packages/core/src/__tests__/run-audit.test.ts      |   2 +-
 packages/core/src/__tests__/task-documents.test.ts |   2 +-
 packages/core/src/agent-store.ts                   |  16 +-
 packages/core/src/db.ts                            |  13 +-
 packages/core/src/index.ts                         |   2 +-
 packages/core/src/store.ts                         |  35 ++++-
 packages/core/src/types.ts                         |  33 ++++
 packages/dashboard/app/api/legacy.ts               |  11 ++
 .../dashboard/app/components/AgentDetailView.tsx   |  92 +++++++++++-
 .../app/components/ModelOnboardingModal.tsx        |  15 +-
 packages/dashboard/app/components/QuickChatFAB.css |  19 ++-
 packages/dashboard/app/components/QuickChatFAB.tsx |  95 ++++++------
 .../dashboard/app/components/SettingsModal.tsx     |  39 ++++-
 packages/dashboard/app/components/TaskCard.tsx     |  46 ++++--
 .../app/components/UpdateAvailableBanner.tsx       |   2 +-
 .../app/components/__tests__/QuickChatFAB.test.tsx |  23 +++
 .../app/components/__tests__/TaskCard.test.tsx     |   2 +-
 .../__tests__/UpdateAvailableBanner.test.tsx       |   2 +-
 .../src/__tests__/routes-agent-skills.test.ts      |   1 +
 packages/dashboard/src/__tests__/routes.test.ts    |  34 +++++
 .../src/routes/register-agent-core-routes.ts       |  78 ++++++++++
 .../dashboard/src/routes/register-git-github.ts    |  16 +-
 .../engine/src/__tests__/agent-heartbeat.test.ts   |  59 ++++++++
 packages/engine/src/__tests__/notifier.test.ts     |  33 ++++
 .../src/__tests__/pi-create-fn-agent.test.ts       |  36 +++++
 .../engine/src/__tests__/project-engine.test.ts    |  53 ++++++-
 packages/engine/src/agent-heartbeat.ts             |  86 ++++++++++-
 packages/engine/src/agent-instructions.ts          | 103 ++++++++++++-
 packages/engine/src/custom-providers.ts            |  15 ++
 packages/engine/src/index.ts                       |   3 +
 packages/engine/src/notifier.ts                    |   3 +-
 packages/engine/src/pi.ts                          |  30 ++++
 packages/engine/src/project-engine.ts              |  12 +-
 46 files changed, 1254 insertions(+), 133 deletions(-)

Fusion-Task-Id: FN-3004
This commit is contained in:
Fusion
2026-04-29 21:50:38 -07:00
committed by gsxdsm
parent 7211363845
commit c3d2e98f81
5 changed files with 105 additions and 56 deletions

View File

@@ -605,7 +605,7 @@ export function ModelOnboardingModal({
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
const [apiKeySuccess, setApiKeySuccess] = useState<Record<string, string | null>>({});
const [customProviders, setCustomProviders] = useState<CustomProviderConfig[]>([]);
const [customProviders, setCustomProviders] = useState<CustomProvider[]>([]);
const [showCustomProviderForm, setShowCustomProviderForm] = useState(false);
const [customProviderSaving, setCustomProviderSaving] = useState(false);
const [customProviderError, setCustomProviderError] = useState<string | undefined>();

View File

@@ -471,9 +471,8 @@
.quick-chat-input-wrapper {
position: relative;
display: flex;
align-items: center;
gap: var(--space-sm);
flex: 1;
flex-direction: column;
min-width: 0;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
@@ -481,6 +480,13 @@
padding: var(--space-xs);
}
.quick-chat-input-row {
display: flex;
align-items: center;
gap: var(--space-sm);
min-width: 0;
}
.quick-chat-input-wrapper--dragover {
border-color: var(--todo);
background: color-mix(in srgb, var(--todo) 5%, var(--bg));
@@ -510,8 +516,8 @@
flex-shrink: 0;
}
/* Only the outer send/stop action keeps the larger touch target. */
.quick-chat-panel-input > button {
/* The send/stop action stays in the same inline composer row as attach + input. */
.quick-chat-send-btn {
min-width: calc(var(--space-lg) + var(--space-xl));
min-height: calc(var(--space-lg) + var(--space-xl));
width: calc(var(--space-lg) + var(--space-xl));
@@ -523,9 +529,10 @@
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.quick-chat-panel-input > button:disabled {
.quick-chat-send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@@ -643,7 +650,7 @@
height: calc(var(--space-lg) * 2 + var(--space-sm));
}
.quick-chat-panel-input > button {
.quick-chat-send-btn {
min-width: calc(var(--space-lg) + var(--space-xl));
min-height: calc(var(--space-lg) + var(--space-xl));
}

View File

@@ -1756,30 +1756,55 @@ export function QuickChatFAB({
event.target.value = "";
}}
/>
<button
type="button"
className="btn-icon quick-chat-attach-btn"
data-testid="quick-chat-attach-btn"
aria-label="Attach files"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip size={16} />
</button>
<input
ref={inputRef}
type="text"
value={messageInput}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onKeyUp={handleInputKeyUp}
onClick={handleInputSelectionChange}
onBlur={handleInputBlur}
onFocus={handleInputFocus}
onPaste={handlePaste}
placeholder={inputPlaceholder}
disabled={inputDisabled}
data-testid="quick-chat-input"
/>
<div className="quick-chat-input-row" data-testid="quick-chat-input-row">
<button
type="button"
className="btn-icon quick-chat-attach-btn"
data-testid="quick-chat-attach-btn"
aria-label="Attach files"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip size={16} />
</button>
<input
ref={inputRef}
type="text"
value={messageInput}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onKeyUp={handleInputKeyUp}
onClick={handleInputSelectionChange}
onBlur={handleInputBlur}
onFocus={handleInputFocus}
onPaste={handlePaste}
placeholder={inputPlaceholder}
disabled={inputDisabled}
data-testid="quick-chat-input"
/>
{isStreaming ? (
<button
type="button"
className="chat-input-stop quick-chat-send-btn"
onClick={stopStreaming}
aria-label="Stop generation"
data-testid="quick-chat-stop"
>
<Square size={14} />
</button>
) : (
<button
type="button"
className="quick-chat-send-btn"
onClick={() => {
void handleSendMessage();
}}
disabled={inputDisabled || (messageInput.trim().length === 0 && pendingAttachments.length === 0)}
data-testid="quick-chat-send"
>
<Send size={16} />
</button>
)}
</div>
<AgentMentionPopup
agents={agents}
filter={mentionFilter}
@@ -1817,28 +1842,6 @@ export function QuickChatFAB({
</div>
)}
</div>
{isStreaming ? (
<button
type="button"
className="chat-input-stop"
onClick={stopStreaming}
aria-label="Stop generation"
data-testid="quick-chat-stop"
>
<Square size={14} />
</button>
) : (
<button
type="button"
onClick={() => {
void handleSendMessage();
}}
disabled={inputDisabled || (messageInput.trim().length === 0 && pendingAttachments.length === 0)}
data-testid="quick-chat-send"
>
<Send size={16} />
</button>
)}
</div>
</div>
)}

View File

@@ -415,7 +415,7 @@ export function SettingsModal({
const [loginInstructions, setLoginInstructions] = useState<Record<string, string>>({});
const [apiKeyInputs, setApiKeyInputs] = useState<Record<string, string>>({});
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
const [customProviders, setCustomProviders] = useState<CustomProviderConfig[]>([]);
const [customProviders, setCustomProviders] = useState<CustomProvider[]>([]);
const [customProviderEditing, setCustomProviderEditing] = useState<CustomProviderConfig | null>(null);
const [showCustomProviderForm, setShowCustomProviderForm] = useState(false);
const [customProviderSaving, setCustomProviderSaving] = useState(false);
@@ -833,7 +833,7 @@ export function SettingsModal({
}
}, [customProviderEditing, loadCustomProviders]);
const handleDeleteCustomProvider = useCallback(async (provider: CustomProviderConfig) => {
const handleDeleteCustomProvider = useCallback(async (provider: CustomProvider) => {
const ok = await confirm({
title: "Delete custom provider",
message: `Delete custom provider '${provider.id}'?`,
@@ -4711,7 +4711,23 @@ export function SettingsModal({
<small className="settings-muted">{provider.id}</small>
</div>
<div className="auth-custom-provider-actions">
<button type="button" className="btn btn-sm" onClick={() => { setCustomProviderEditing(provider); setShowCustomProviderForm(true); }}>Edit</button>
<button
type="button"
className="btn btn-sm"
onClick={() => {
setCustomProviderEditing({
id: provider.id,
name: provider.name,
baseUrl: provider.baseUrl,
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-completions",
apiKey: provider.apiKey,
models: provider.models?.map((model) => ({ id: model.id, name: model.name })) ?? [],
});
setShowCustomProviderForm(true);
}}
>
Edit
</button>
<button type="button" className="btn btn-sm" onClick={() => { void handleDeleteCustomProvider(provider); }}>Delete</button>
</div>
</div>

View File

@@ -2309,6 +2309,29 @@ describe("QuickChatFAB", () => {
clickSpy.mockRestore();
});
it("keeps paperclip trigger and message input in the same composer row", async () => {
render(<QuickChatFAB addToast={addToast} />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));
const composerRow = await screen.findByTestId("quick-chat-input-row");
const input = screen.getByTestId("quick-chat-input");
const attachBtn = screen.getByTestId("quick-chat-attach-btn");
const sendBtn = screen.getByTestId("quick-chat-send");
expect(composerRow).toContainElement(attachBtn);
expect(composerRow).toContainElement(input);
expect(composerRow).toContainElement(sendBtn);
const fileInput = document.querySelector(".quick-chat-attachment-input") as HTMLInputElement;
const imageFile = new File(["img"], "photo.png", { type: "image/png" });
fireEvent.change(fileInput, { target: { files: [imageFile] } });
await waitFor(() => {
expect(screen.getByTestId("quick-chat-attachment-preview-0")).toBeInTheDocument();
expect(screen.getByTestId("quick-chat-send")).not.toBeDisabled();
});
});
it("adds/removes previews for selected attachments and filters unsupported files", async () => {
render(<QuickChatFAB addToast={addToast} />);
fireEvent.click(screen.getByTestId("quick-chat-fab"));