Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Turborepo monorepo (Bun workspaces). See [README.md](README.md) for the app/pack

Coding standards live in [.sandcastle/CODING_STANDARDS.md](.sandcastle/CODING_STANDARDS.md) — they apply to all contributors, not just AFK agents. Read them before writing code.

**No lint suppressions.** Never write `biome-ignore` or `eslint-disable`. If a rule fires, fix the underlying code. `bun run ci` enforces this with the `check:no-suppressions` script in [package.json](package.json).

## Commands

```bash
Expand Down
8 changes: 5 additions & 3 deletions apps/desktop/src/renderer/src/chat/useClaude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* tool callbacks — those live in `useClaudeSchemaChat`, driven by the
* Agent SDK turn pipeline.
*/
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';

export type AuthMode = 'max' | 'api-key';
export type ModelId = 'claude-haiku-4-5-20251001' | 'claude-sonnet-4-6' | 'claude-opus-4-6';
Expand Down Expand Up @@ -81,12 +81,14 @@ export function useClaude(): UseClaudeReturn {

// Push the initial auth + model snapshot to main so the first turn
// uses it without requiring the user to open the popover first.
// biome-ignore lint/correctness/useExhaustiveDependencies: one-shot initial sync
const syncedRef = useRef(false);
useEffect(() => {
if (syncedRef.current) return;
syncedRef.current = true;
if (authMode === 'max') pushAuth({ mode: 'max' });
else pushAuth({ mode: 'api-key', key: apiKey });
pushModelOptions(model, thinkingBudget);
}, []);
}, [authMode, apiKey, model, thinkingBudget]);

const setAuthMode = useCallback(
(mode: AuthMode) => {
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src/renderer/src/components/chat/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,10 @@ export function ChatPanel({ chat }: ChatPanelProps): React.JSX.Element {
const prevMessagesRef = useRef<ChatMessage[] | null>(null);

// Restore the active thread's messages on first mount.
// biome-ignore lint/correctness/useExhaustiveDependencies: run once at mount
const restoredRef = useRef(false);
useEffect(() => {
if (restoredRef.current) return;
restoredRef.current = true;
const thread = history.getActiveThread();
if (thread && thread.messages.length > 0) {
hydrate(thread.messages);
Expand All @@ -98,7 +100,7 @@ export function ChatPanel({ chat }: ChatPanelProps): React.JSX.Element {
}
}
prevMessagesRef.current = thread?.messages ?? [];
}, []);
}, [history, hydrate]);

// On schema-file change, switch to the most recent thread for that
// file — or clear the transcript and the SDK session if this file
Expand Down
101 changes: 45 additions & 56 deletions apps/desktop/src/renderer/src/components/chat/ChatThreadList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,77 +56,66 @@ export function ChatThreadList({
const isActive = thread.id === activeThreadId;

return (
// biome-ignore lint/a11y/useSemanticElements: div contains nested interactive buttons, cannot use <button>
<div
key={thread.id}
role="button"
tabIndex={0}
className={cn(
'group relative px-3 py-2.5 cursor-pointer border-b border-border/50',
'group relative border-b border-border/50',
'hover:bg-secondary/60 transition-colors',
isActive && 'bg-secondary/40',
)}
onClick={() => onSelect(thread.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(thread.id);
}
}}
>
<div className="flex items-start gap-2">
{isActive && <span className="mt-1.5 size-1.5 rounded-full bg-primary shrink-0" />}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{thread.title}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{formatDate(thread.updatedAt)} · {messageCount} message
{messageCount !== 1 ? 's' : ''}
</p>
<button
type="button"
className="w-full text-left px-3 py-2.5 cursor-pointer"
onClick={() => onSelect(thread.id)}
>
<div className="flex items-start gap-2">
{isActive && <span className="mt-1.5 size-1.5 rounded-full bg-primary shrink-0" />}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{thread.title}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{formatDate(thread.updatedAt)} · {messageCount} message
{messageCount !== 1 ? 's' : ''}
</p>
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
{confirmDeleteId === thread.id ? (
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="size-6 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete(thread.id);
setConfirmDeleteId(null);
}}
title="Confirm delete"
>
<Trash2 className="size-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={(e) => {
e.stopPropagation();
setConfirmDeleteId(null);
}}
title="Cancel"
>
×
</Button>
</div>
) : (
</button>
<div className="absolute top-1/2 -translate-y-1/2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
{confirmDeleteId === thread.id ? (
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
setConfirmDeleteId(thread.id);
className="size-6 text-destructive hover:text-destructive"
onClick={() => {
onDelete(thread.id);
setConfirmDeleteId(null);
}}
title="Delete chat"
title="Confirm delete"
>
<Trash2 className="size-3" />
</Button>
)}
</div>
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => setConfirmDeleteId(null)}
title="Cancel"
>
×
</Button>
</div>
) : (
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={() => setConfirmDeleteId(thread.id)}
title="Delete chat"
>
<Trash2 className="size-3" />
</Button>
)}
</div>
</div>
);
Expand Down
24 changes: 13 additions & 11 deletions apps/desktop/src/renderer/src/components/graph/GraphCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,12 @@ function GraphCanvasInner({ positions, onPositionsChange }: GraphCanvasProps): R
[click, setSidebarVisible, setSidebarTab],
);

const onKeyDown = useCallback(
(ev: React.KeyboardEvent<HTMLDivElement>) => {
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const el = containerRef.current;
if (!el) return;
const handler = (ev: KeyboardEvent): void => {
const e: InteractionKeyEvent = {
key: ev.key,
metaKey: ev.metaKey,
Expand All @@ -318,17 +322,15 @@ function GraphCanvasInner({ positions, onPositionsChange }: GraphCanvasProps): R
if (action.kind === 'op') dispatch(action.op);
else if (action.command === 'undo') undo();
else if (action.command === 'redo') redo();
},
[dispatch, undo, redo, selectedNodeId],
);
};
el.addEventListener('keydown', handler);
return () => el.removeEventListener('keydown', handler);
}, [dispatch, undo, redo, selectedNodeId]);

return (
<section
aria-label="Schema canvas"
<div
ref={containerRef}
className="w-full h-full outline-none"
// biome-ignore lint/a11y/noNoninteractiveTabindex: canvas receives keydown for delete / undo / redo shortcuts
tabIndex={0}
onKeyDown={onKeyDown}
data-testid="graph-canvas"
style={{ background: 'var(--graph-bg)' }}
>
Expand Down Expand Up @@ -359,6 +361,6 @@ function GraphCanvasInner({ positions, onPositionsChange }: GraphCanvasProps): R
<Controls showInteractive={false} />
<GraphLegend />
</ReactFlow>
</section>
</div>
);
}
11 changes: 7 additions & 4 deletions apps/desktop/src/renderer/src/components/schema/SchemaPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
* path for user-authored source to introduce raw tags.
*/
import { AArrowDown, AArrowUp, Check, Copy, FileBracesCorner, FileCode } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Button } from '../ui/button';
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '../ui/empty';
import { getHighlighter, SHIKI_THEMES } from './shiki-highlighter';
Expand Down Expand Up @@ -96,6 +96,11 @@ export function SchemaPanel({
const [fontSizeIndex, setFontSizeIndex] = useState<number>(DEFAULT_FONT_SIZE_INDEX);
const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<number | null>(null);
const codeRef = useRef<HTMLDivElement>(null);

useLayoutEffect(() => {
if (codeRef.current) codeRef.current.innerHTML = highlightedHtml ?? '';
}, [highlightedHtml]);

// Pre-warm shiki on first mount so it loads in the background.
// By the time the user opens the Schema tab the highlighter is
Expand Down Expand Up @@ -278,9 +283,7 @@ export function SchemaPanel({
data-testid="schema-code"
>
{highlightedHtml !== null ? (
/* shiki emits pre-escaped HTML tokens; see security note in the file header. */
/* biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is trusted, tokenised, escaped HTML */
<div dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
<div ref={codeRef} />
) : (
<pre className="p-4 m-0 font-mono">{activeSource}</pre>
)}
Expand Down
5 changes: 2 additions & 3 deletions apps/desktop/src/renderer/src/hooks/useSessionPersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function useSessionPersistence({
} catch {
store.removeItem(SESSION_KEY);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, []);

// Persistence loop: schema and layout changes both trigger a debounced
// write. A `pagehide` listener flushes synchronously so a dev-server
Expand Down Expand Up @@ -133,12 +133,11 @@ export function useSessionPersistence({
window.removeEventListener('beforeunload', onPageHide);
}
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, []);

// Layout-only changes (e.g. a node drag with no schema mutation) also
// need to land in storage. Schedule a debounced flush whenever the
// caller-supplied layout reference changes.
// biome-ignore lint/correctness/useExhaustiveDependencies: storageRef is stable
useEffect(() => {
const store = storageRef.current;
if (!store) return;
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/tests/store/ops.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Schema } from '@renderer/model/ir';
import type { Op } from '@renderer/store/ops';
import { apply } from '@renderer/store/ops';
import { describe, expect, it } from 'vitest';

Expand All @@ -11,8 +12,7 @@ function ok(res: ReturnType<typeof apply>): Schema {

describe('apply', () => {
it('returns an error for an unknown op kind', () => {
// biome-ignore lint/suspicious/noExplicitAny: invalid kind by design
const res = apply(empty, { kind: 'nope' } as any);
const res = apply(empty, { kind: 'nope' } as unknown as Op);
expect('error' in res).toBe(true);
});

Expand Down
28 changes: 16 additions & 12 deletions apps/web/src/components/ui/mobile-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,16 @@ export function MobileNav() {
{open && (
<div className="md:hidden absolute top-16 inset-x-0 border-b border-border/50 bg-background/95 backdrop-blur-md">
<div className="flex flex-col gap-1 px-6 py-4">
{/* biome-ignore lint/a11y/useValidAnchor: anchor with valid href, onClick only closes menu */}
<a
href="#features"
onClick={() => setOpen(false)}
className="py-2.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
<button
type="button"
onClick={() => {
setOpen(false);
document.getElementById('features')?.scrollIntoView({ behavior: 'smooth' });
}}
className="py-2.5 text-sm text-muted-foreground hover:text-foreground transition-colors text-left"
>
Features
</a>
</button>
<a
href="/brand"
onClick={() => setOpen(false)}
Expand All @@ -55,14 +57,16 @@ export function MobileNav() {
<div className="flex items-center gap-3 py-2.5">
<AnimatedThemeToggler className="size-9 flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground transition-colors [&_svg]:size-4" />
</div>
{/* biome-ignore lint/a11y/useValidAnchor: anchor with valid href, onClick only closes menu */}
<a
href="#download"
onClick={() => setOpen(false)}
className="mt-1 bg-primary text-primary-foreground px-4 py-2.5 rounded-lg text-sm font-medium text-center hover:opacity-90 transition-opacity"
<button
type="button"
onClick={() => {
setOpen(false);
document.getElementById('download')?.scrollIntoView({ behavior: 'smooth' });
}}
className="mt-1 bg-primary text-primary-foreground px-4 py-2.5 rounded-lg text-sm font-medium text-center hover:opacity-90 transition-opacity w-full"
>
Download
</a>
</button>
</div>
</div>
)}
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"format": "biome format --write .",
"format:check": "biome format .",
"check": "biome check .",
"ci": "TURBO_NO_UPDATE_NOTIFIER=1 turbo typecheck test --output-logs=errors-only && biome check .",
"check:no-suppressions": "bun scripts/check-no-suppressions.ts",
"ci": "TURBO_NO_UPDATE_NOTIFIER=1 turbo typecheck test --output-logs=errors-only && biome check . && bun run check:no-suppressions",
"prepare": "husky",
"sandcastle": "bun .sandcastle/main.ts",
"sandcastle:analyze": "bun .sandcastle/analyze.ts",
Expand Down
Loading
Loading