Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
150 changes: 7 additions & 143 deletions apps/desktop/src/main/ipc/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ import {
} from './claude-bridge';
import { ChatCancelledError } from './claude-errors';
import { invokeOpHandler } from './op-tool-bridge';
import {
buildReconcileSystemPrompt,
buildReconcileUserTurn,
extractOpsArray,
RECONCILE_MODEL,
} from './reconcile-query';

const execFileAsync = promisify(execFile);

Expand Down Expand Up @@ -360,7 +366,7 @@ export function registerClaudeIpc(mainWindow: BrowserWindow): ClaudeIpc {
systemPrompt: buildReconcileSystemPrompt(payload.targetKind),
allowedTools: [],
disallowedTools: DISALLOWED_BUILTINS,
model: currentModel,
model: RECONCILE_MODEL,
pathToClaudeCodeExecutable: detectedClaudePath ?? 'claude',
...(env ? { env } : {}),
},
Expand Down Expand Up @@ -478,148 +484,6 @@ function toSdkTool(descriptor: OpToolDescriptor) {
);
}

/**
* Target-kind descriptions used in the reconcile system prompt.
* These are lightly tuned per target so the model understands the
* output format it is reconciling against.
*/
const TARGET_KIND_DESCRIPTIONS: Record<string, string> = {
convex:
'a hand-edited `convex/schema.ts` file (Convex database schema). ' +
'Focus on `defineTable`, `v.*` validators, and Convex index definitions.',
zod:
'a hand-edited `.schema.ts` file (Zod schema mirror). ' +
'Focus on `z.object`, `z.enum`, `z.discriminatedUnion`, and inferred TypeScript types.',
'json-schema':
'a hand-edited `.schema.json` file (JSON Schema). ' +
'Focus on `$defs`, `properties`, `required`, `type`, and `enum` arrays.',
'schema-index':
'a hand-edited `index.ts` schema-index file that re-exports named Zod schemas. ' +
'Focus on the set of exported names and their re-export paths.',
};

/**
* Build the reconcile system prompt for `reconcile:query`. Varies
* lightly by `targetKind` so the model understands which file format
* it is reconciling against. TypeScript-constructed (not a skill file)
* to keep the prompt static, cacheable, and decoupled from the
* chat-session prompt builder.
*
* The op-vocabulary list is duplicated from `system-prompt.ts` rather
* than imported because that module lives in the renderer's path
* (`@renderer/...`) and we want this file (which runs in the main
* process bundle) to keep its imports narrow.
*/
function buildReconcileSystemPrompt(targetKind: string): string {
const kindDesc =
TARGET_KIND_DESCRIPTIONS[targetKind] ??
'a hand-edited generated file that has diverged from what Contexture would emit.';

return `You are a schema reconciliation assistant for Contexture.

The user has a Contexture IR (a JSON description of a schema) and ${kindDesc}
Your job is to return a JSON array of ops that, when applied to the IR, would make
Contexture emit an output as close as possible to the hand-edited file.

## Op vocabulary

- \`add_type\` { type: TypeDef }
- \`update_type\` { name: string; patch: Partial<Omit<TypeDef, 'kind'|'name'>> }
- \`rename_type\` { from: string; to: string }
- \`delete_type\` { name: string }
- \`add_field\` { typeName: string; field: FieldDef; index?: number }
- \`update_field\` { typeName: string; fieldName: string; patch: Partial<FieldDef> }
- \`remove_field\` { typeName: string; fieldName: string }
- \`reorder_fields\` { typeName: string; order: string[] }
- \`add_value\` { typeName: string; value: string; description?: string }
- \`update_value\` { typeName: string; value: string; patch: { value?: string; description?: string } }
- \`remove_value\` { typeName: string; value: string }
- \`add_variant\` { typeName: string; variant: string }
- \`set_discriminator\` { typeName: string; discriminator: string }
- \`add_import\` { import: ImportDecl }
- \`remove_import\` { alias: string }
- \`set_table_flag\` { typeName: string; table: boolean }
- \`add_index\` { typeName: string; index: { name: string; fields: string[] } }
- \`remove_index\` { typeName: string; name: string }
- \`update_index\` { typeName: string; name: string; patch: Partial<{ name: string; fields: string[] }> }
- \`replace_schema\` { schema: Schema } # escape hatch; full IR

## FieldDef

\`{ name: string; type: FieldType; optional?: boolean; nullable?: boolean; description?: string }\`

## FieldType

\`{ kind: 'string' | 'number' | 'boolean' | 'date' }\` (with optional constraints),
\`{ kind: 'literal'; value: string|number|boolean }\`,
\`{ kind: 'ref'; typeName: string }\`,
\`{ kind: 'array'; element: FieldType; min?: number; max?: number }\`.

## Output format

Return ONLY a JSON array — no prose, no markdown, no code fences.
Each element of the array MUST have this shape:

\`\`\`
{
"op": <one of the ops above, as JSON with its \`kind\` field>,
"label": "<human-readable one-line description>",
"lossy": <true if the op may destroy data, false otherwise>
}
\`\`\`

Mark \`lossy: true\` for:
- Deleting a type or field
- Renaming a field or type (data in the old column is lost unless migrated)
- Changing a field's type to an incompatible type

If no ops are needed (the IR already produces the hand-edited file), return \`[]\`.

The current IR and the hand-edited file are in the user message.`;
}

function buildReconcileUserTurn(irJson: string, onDiskSource: string, targetKind: string): string {
return [
'<current_ir>',
irJson,
'</current_ir>',
'',
`<on_disk_source kind="${targetKind}">`,
onDiskSource,
'</on_disk_source>',
'',
'Return the reconcile ops JSON array.',
].join('\n');
}

/**
* Pull the JSON ops array out of an assistant response. The model is
* instructed to return only the array, but a stray code fence or
* leading sentence shouldn't fail the whole reconcile — match the
* outermost \`[…]\` block.
*/
function extractOpsArray(
text: string,
): { ok: true; ops: unknown[] } | { ok: false; error: string } {
const start = text.indexOf('[');
const end = text.lastIndexOf(']');
if (start === -1 || end === -1 || end < start) {
return { ok: false, error: 'No JSON array found in response.' };
}
const slice = text.slice(start, end + 1);
let parsed: unknown;
try {
parsed = JSON.parse(slice);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { ok: false, error: `Failed to parse ops JSON: ${message}` };
}
if (!Array.isArray(parsed)) {
return { ok: false, error: 'Response did not parse as a JSON array.' };
}
return { ok: true, ops: parsed };
}

// Keep Zod in the surface area so downstream importers can build
// matching schemas without separately importing it.
export { z };
145 changes: 145 additions & 0 deletions apps/desktop/src/main/ipc/reconcile-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* Pure helpers for the `reconcile:query` IPC handler.
*
* Extracted to a separate module so they can be unit-tested without
* wiring up Electron's ipcMain or the Agent SDK.
*/

/** Dedicated model for reconcile queries — Haiku is fast enough for structured JSON extraction. */
export const RECONCILE_MODEL = 'claude-haiku-4-5-20251001' as const;

/**
* Target-kind descriptions used in the reconcile system prompt.
* Lightly tuned per target so the model understands the output format.
*/
const TARGET_KIND_DESCRIPTIONS: Record<string, string> = {
convex:
'a hand-edited `convex/schema.ts` file (Convex database schema). ' +
'Focus on `defineTable`, `v.*` validators, and Convex index definitions.',
zod:
'a hand-edited `.schema.ts` file (Zod schema mirror). ' +
'Focus on `z.object`, `z.enum`, `z.discriminatedUnion`, and inferred TypeScript types.',
'json-schema':
'a hand-edited `.schema.json` file (JSON Schema). ' +
'Focus on `$defs`, `properties`, `required`, `type`, and `enum` arrays.',
'schema-index':
'a hand-edited `index.ts` schema-index file that re-exports named Zod schemas. ' +
'Focus on the set of exported names and their re-export paths.',
};

export function buildReconcileSystemPrompt(targetKind: string): string {
const kindDesc =
TARGET_KIND_DESCRIPTIONS[targetKind] ??
'a hand-edited generated file that has diverged from what Contexture would emit.';

return `You are a schema reconciliation assistant for Contexture.

The user has a Contexture IR (a JSON description of a schema) and ${kindDesc}
Your job is to return a JSON array of ops that, when applied to the IR, would make
Contexture emit an output as close as possible to the hand-edited file.

## Op vocabulary

- \`add_type\` { type: TypeDef }
- \`update_type\` { name: string; patch: Partial<Omit<TypeDef, 'kind'|'name'>> }
- \`rename_type\` { from: string; to: string }
- \`delete_type\` { name: string }
- \`add_field\` { typeName: string; field: FieldDef; index?: number }
- \`update_field\` { typeName: string; fieldName: string; patch: Partial<FieldDef> }
- \`remove_field\` { typeName: string; fieldName: string }
- \`reorder_fields\` { typeName: string; order: string[] }
- \`add_value\` { typeName: string; value: string; description?: string }
- \`update_value\` { typeName: string; value: string; patch: { value?: string; description?: string } }
- \`remove_value\` { typeName: string; value: string }
- \`add_variant\` { typeName: string; variant: string }
- \`set_discriminator\` { typeName: string; discriminator: string }
- \`add_import\` { import: ImportDecl }
- \`remove_import\` { alias: string }
- \`set_table_flag\` { typeName: string; table: boolean }
- \`add_index\` { typeName: string; index: { name: string; fields: string[] } }
- \`remove_index\` { typeName: string; name: string }
- \`update_index\` { typeName: string; name: string; patch: Partial<{ name: string; fields: string[] }> }
- \`replace_schema\` { schema: Schema } # escape hatch; full IR

## FieldDef

\`{ name: string; type: FieldType; optional?: boolean; nullable?: boolean; description?: string }\`

## FieldType

\`{ kind: 'string' | 'number' | 'boolean' | 'date' }\` (with optional constraints),
\`{ kind: 'literal'; value: string|number|boolean }\`,
\`{ kind: 'ref'; typeName: string }\`,
\`{ kind: 'array'; element: FieldType; min?: number; max?: number }\`.

## Output format

Return ONLY a JSON array — no prose, no markdown, no code fences.
Each element of the array MUST have this shape:

\`\`\`
{
"op": <one of the ops above, as JSON with its \`kind\` field>,
"label": "<human-readable one-line description>",
"lossy": <true if the op may destroy data, false otherwise>
}
\`\`\`

Mark \`lossy: true\` for:
- Deleting a type or field
- Renaming a field or type (data in the old column is lost unless migrated)
- Changing a field's type to an incompatible type

If no ops are needed (the IR already produces the hand-edited file), return \`[]\`.

The current IR and the hand-edited file are in the user message.`;
}

export function buildReconcileUserTurn(
irJson: string,
onDiskSource: string,
targetKind: string,
): string {
return [
'<current_ir>',
irJson,
'</current_ir>',
'',
`<on_disk_source kind="${targetKind}">`,
onDiskSource,
'</on_disk_source>',
'',
'Return the reconcile ops JSON array.',
].join('\n');
}

/**
* Pull the JSON ops array out of an assistant response. The model is
* instructed to return only the array, but a stray code fence or
* leading sentence shouldn't fail the whole reconcile — scan forward
* through every `[` position until we find a slice that parses as an
* array. This handles prose that contains `[…]` spans before the
* actual payload.
*/
export function extractOpsArray(
text: string,
): { ok: true; ops: unknown[] } | { ok: false; error: string } {
const end = text.lastIndexOf(']');
let start = text.indexOf('[');
let lastError = 'No JSON array found in response.';

while (start !== -1 && start <= end) {
const slice = text.slice(start, end + 1);
try {
const parsed = JSON.parse(slice);
if (Array.isArray(parsed)) return { ok: true, ops: parsed };
lastError = 'Response did not parse as a JSON array.';
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
lastError = `Failed to parse ops JSON: ${message}`;
}
start = text.indexOf('[', start + 1);
}

return { ok: false, error: lastError };
}
21 changes: 6 additions & 15 deletions apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ export function ChatPanel({ chat }: ChatPanelProps): React.JSX.Element {
const history = useChatThreads();

const [input, setInput] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);

// Track previous filePath so file-change and initial-mount paths
Expand Down Expand Up @@ -161,19 +160,11 @@ export function ChatPanel({ chat }: ChatPanelProps): React.JSX.Element {
});
}, [history.activeThreadId, history.updateThreadSessionId]);

// biome-ignore lint/correctness/useExhaustiveDependencies: scroll whenever the message list changes; biome can't see `messages` is the intended trigger
const messageCount = messages.length;
useEffect(() => {
if (messageCount === 0) return;
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);

// Auto-grow the textarea up to a cap so prompts with a few lines
// don't force the user to scroll a single-line input.
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
}, []);
}, [messageCount]);

const fileThreads = useMemo(
() => history.threadsForFile(filePath),
Expand Down Expand Up @@ -367,17 +358,17 @@ export function ChatPanel({ chat }: ChatPanelProps): React.JSX.Element {
)}
>
<textarea
ref={textareaRef}
value={input}
onChange={(ev) => setInput(ev.target.value)}
onKeyDown={handleKeyDown}
placeholder={isReady ? 'Ask anything…' : 'Configure auth first…'}
disabled={!isReady || isStreaming}
rows={1}
className={cn(
'w-full resize-none bg-transparent px-3 pt-3 pb-2 text-sm',
'w-full resize-none bg-transparent px-3 pt-3 pb-2 text-sm leading-5',
'field-sizing-content max-h-[calc(8*1.25rem+1.25rem)]',
'placeholder:text-muted-foreground focus:outline-none',
'disabled:cursor-not-allowed max-h-32 overflow-y-auto',
'disabled:cursor-not-allowed overflow-y-auto',
)}
data-testid="chat-input"
/>
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/tests/components/chat/ChatPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,20 @@ describe('ChatPanel', () => {
expect(screen.getByTitle('Stop')).toBeInTheDocument();
expect(screen.queryByTitle('Send')).not.toBeInTheDocument();
});

it('auto-grows the textarea via CSS field-sizing with an 8-line cap and scroll overflow', async () => {
render(<ChatPanel chat={makeChat()} />);
await waitFor(() => {
const ta = screen.getByTestId('chat-input') as HTMLTextAreaElement;
expect(ta.disabled).toBe(false);
});
const textarea = screen.getByTestId('chat-input') as HTMLTextAreaElement;

// Auto-grow is delivered by CSS (`field-sizing: content`) rather
// than a useEffect that pokes inline height — verify the contract
// is expressed on the element.
expect(textarea.className).toMatch(/field-sizing-content/);
expect(textarea.className).toMatch(/max-h-\[/);
expect(textarea.className).toMatch(/overflow-y-auto/);
});
});
Loading
Loading