Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler d1 migrations apply cyrus --local && wrangler dev",
"dev": "CI=1 wrangler d1 migrations apply cyrus --local && wrangler dev",
"check:types": "tsc --noEmit",
"db:push": "dotenvx run -- drizzle-kit push",
"db:generate": "dotenvx run -- drizzle-kit generate",
Expand Down
67 changes: 62 additions & 5 deletions apps/web/src/components/chat/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,31 @@ import type { ComposerProps } from "@/types/composer";

export type { ComposerSubject } from "@/types/composer";

type ComposerBodyStatus =
| "loading"
| "agents-error"
| "unavailable"
| "settling"
| "interactive"
| "ready";

function getComposerBodyStatus(options: {
agentsLoading: boolean;
agentsError: boolean;
agentsReady: boolean;
hasAgents: boolean;
localDraft: boolean;
draftCatalogSettled: boolean;
isInteractivePending: boolean;
}): ComposerBodyStatus {
if (options.agentsLoading) return "loading";
if (options.agentsError) return "agents-error";
if (options.agentsReady && !options.hasAgents) return "unavailable";
if (options.localDraft && !options.draftCatalogSettled) return "settling";
if (options.isInteractivePending) return "interactive";
return "ready";
}

export function Composer({
projectId,
threadId,
Expand Down Expand Up @@ -69,6 +94,27 @@ export function Composer({
useEffect(() => {
setDraftGitOpen(false);
}, [threadId, projectId]);

// One-way latch: keeps a fresh draft in skeleton until its default agent
// and model have both settled, so the composer never paints an
// agent-selected-but-no-model-yet frame. Never re-arms after the first
// settle, so a later agent switch (mid-typing) never re-hides the editor.
const [draftCatalogSettled, setDraftCatalogSettled] = useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: reset only on identity change
useEffect(() => {
setDraftCatalogSettled(false);
}, [threadId, projectId]);
useEffect(() => {
if (draftCatalogSettled) return;
if (!localDraft) return;
if (!catalog.displayAgent || catalog.modelsLoading) return;
setDraftCatalogSettled(true);
}, [
draftCatalogSettled,
localDraft,
catalog.displayAgent,
catalog.modelsLoading,
]);
const threadGitStatus = useGitStatus(localDraft ? undefined : threadId);
const projectGitStatus = useProjectGitStatus(
localDraft && draftGitOpen ? projectId : undefined
Expand All @@ -87,7 +133,8 @@ export function Composer({
!agentsLoading &&
!agentsQuery.isError &&
!(agentsReady && !hasAgents) &&
!isInteractivePending;
!isInteractivePending &&
(!localDraft || draftCatalogSettled);

const editor = useComposerEditor({
threadId,
Expand All @@ -103,12 +150,22 @@ export function Composer({
onSend,
});

const composerBodyStatus = getComposerBodyStatus({
agentsLoading,
agentsError: agentsQuery.isError,
agentsReady,
draftCatalogSettled,
hasAgents,
isInteractivePending,
localDraft,
});

function renderComposerBody() {
if (agentsLoading) {
if (composerBodyStatus === "loading" || composerBodyStatus === "settling") {
return <ComposerSkeleton />;
}

if (agentsQuery.isError) {
if (composerBodyStatus === "agents-error") {
return (
<div className="group rounded-[22px] p-px transition-colors duration-200">
<div className="chat-composer-glass rounded-4xl border border-border px-4 py-5 text-center transition-colors duration-200">
Expand All @@ -123,11 +180,11 @@ export function Composer({
);
}

if (agentsReady && !hasAgents) {
if (composerBodyStatus === "unavailable") {
return <ComposerUnavailable />;
}

if (isInteractivePending) {
if (composerBodyStatus === "interactive") {
return (
<div className="space-y-2">
<ComposerQueueChips threadId={threadId} />
Expand Down
39 changes: 39 additions & 0 deletions shared/hooks/src/agent-catalog/selectors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, test } from "vitest";
import {
type CatalogOption,
pickDisplayOption,
pickExplicitOption,
} from "./selectors";

const AGENTS: CatalogOption[] = [
{ id: "claude", name: "Claude" },
{ id: "codex", name: "Codex" },
];

describe("pickExplicitOption", () => {
test("returns empty string when no id is given", () => {
expect(pickExplicitOption(undefined, AGENTS)).toBe("");
});

test("returns empty string when the id is not in the options", () => {
expect(pickExplicitOption("missing", AGENTS)).toBe("");
});

test("returns the id when it matches an option", () => {
expect(pickExplicitOption("codex", AGENTS)).toBe("codex");
});
});

describe("pickDisplayOption", () => {
test("falls back to the first option when no id is given", () => {
expect(pickDisplayOption(undefined, AGENTS)).toBe("claude");
});

test("falls back to the first option when the id is not in the options", () => {
expect(pickDisplayOption("missing", AGENTS)).toBe("claude");
});

test("returns the id when it matches an option", () => {
expect(pickDisplayOption("codex", AGENTS)).toBe("codex");
});
});
10 changes: 8 additions & 2 deletions shared/hooks/src/agent-catalog/use-agent-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,6 @@ export function useAgentCatalog({
pendingAgent ?? liveBinding?.agentName ?? thread?.agentName;

const boundAgent = pickExplicitOption(preferredAgent, agents);
// Drafts require an explicit agent selection before probing — do not fall
// back to agents[0], which would fire getDraftCatalog on open.
const displayAgent = isDraft
? pickExplicitOption(pendingAgent, agents)
: pickDisplayOption(preferredAgent, agents);
Expand Down Expand Up @@ -167,6 +165,14 @@ export function useAgentCatalog({
}),
});

useEffect(() => {
if (!isDraft) return;
if (pendingAgent) return;
const defaultAgent = agents[0]?.id;
if (!defaultAgent) return;
setPendingAgent(threadId, defaultAgent);
}, [agents, isDraft, pendingAgent, setPendingAgent, threadId]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

useEffect(() => {
if (!catalogAgent || models.length === 0) return;
const currentModelId =
Expand Down