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
12 changes: 7 additions & 5 deletions memory/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,12 +139,14 @@
- Branch: `ln/fe-537-observer-agent`
- **Verification approach**: inner — unit tests for entity writes with dependency edges, observer-complete DomainEvent emission post-commit, SSE adapter data-part encoding, sdk translateStreamEvents parity, observer-error non-fatality, agent-metrics shape. Middle — differential oracle from spike fixtures (deferred to manual testing). Outer — debug mode and fixture capture (deferred to slice 6). → SPEC.md §Oracle Strategy

6. **Entity sidebar (read-only)** — React sidebar in interview workspace showing decisions, assumptions, requirements, and criteria on the active path. Tabbed display. TanStack Query (`useQuery`) for entity data; cache populated via `queryClient.setQueryData` from `useChat`'s `onData` callback when `observer-complete` data parts arrive (in-band sync per D22). Dependency edges visible. Stale badges for soft-invalidated entities. `not-started`
6. **Entity sidebar (read-only)** `FE-538` — React sidebar in interview workspace showing decisions and assumptions in categorized tabs. TanStack Query manages entity state via `useQuery`; cache invalidated on chat stream completion (status transition `streaming` → `ready`). Entities API at `GET /api/projects/:id/entities`. Note: `onData` → `setQueryData` bridge from D22 not used — AI SDK `useChat` doesn't expose an `onData` callback for custom data parts; status-based invalidation used instead. Dependency edge display and stale badges deferred (require slices 11/12 infrastructure). `done`
- Requirements: → SPEC.md §Requirements #6
- Assumptions: → SPEC.md §Assumptions A21
- Decisions: → SPEC.md §Decisions D22 (TanStack Query + in-band sync)
- Invariants to respect: → SPEC.md §Invariants I9, I10
- Acceptance: entities appear in categorized tabs as interview progresses, `onData` → `setQueryData` reactively updates sidebar, dependency links navigable, stale badges render correctly
- Assumptions: → SPEC.md §Assumptions A21 (partially validated — status-based invalidation works; onData bridge not needed)
- Decisions: → SPEC.md §Decisions D22 (TanStack Query — yes; in-band onData sync — replaced with status-based invalidation)
- Invariants established: → SPEC.md §Invariants I23
- Invariants respected: → SPEC.md §Invariants I9, I10, I14, I20, I21
- Acceptance: 149 tests (2 new API tests); entities API returns decisions + assumptions; sidebar renders in categorized tabs; TanStack Query cache invalidated on stream completion; entities appear as interview progresses
- Branch: `ln/fe-538-entity-sidebar`
- Ref: → docs/design/BREADBOARD.md §UI Affordances → P2 Entity sidebar
- **Verification approach**: inner — unit tests for entity query on active path, stale badge computation. Middle — validate A21: `onData` → `setQueryData` updates sidebar without stale closure (if stale, fall back to parallel `EventSource`). Outer — manual visual inspection (entities render correctly, tabs work, stale badges appear). Debug mode overlay (observer extraction detail per-turn) should land here or in slice 5. → SPEC.md §Oracle Strategy (outer loop), §Acknowledged Blind Spots (cumulative graph integrity)

Expand Down
3 changes: 2 additions & 1 deletion memory/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ The architecture (layered: db → core → adapters):
| I20 | Entity persistence with turn linkage | Slice 5 (observer) | db.test.ts (7 tests), observer.test.ts | D4, D5 |
| I21 | Observer-complete post-commit | Slice 5 (observer) | observer.test.ts (6 tests), sse-adapter.test.ts (3 tests) | D22 |
| I22 | Agent generator composition | Slice 5 (observer) | core.test.ts, sdk.test.ts (7 tests) | D27 |
| I23 | Entity sidebar reactive update | Slice 6 (sidebar) | app.test.ts (2 tests), manual (outer loop) | D22 |

## Lexicon

Expand Down Expand Up @@ -334,7 +335,7 @@ This projection difference is a deliberate design choice, not an implementation

| File | Tests | Protects |
| ------------------- | ----- | --------------------------- |
| sse-adapter.test.ts | 21 | I1, I3, I7, I21 |
| sse-adapter.test.ts | 8 | I1, I7, I21 |
| db.test.ts | 32 | I5, I6, I9, I10, I11, I18, I20 |
| app.test.ts | 22 | I2, I3, I6, I7, I13, I14 |
| core.test.ts | 16 | I12, I13, I18, I22 |
Expand Down
31 changes: 30 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-query": "^5.96.1",
"@tanstack/react-router": "^1.168.10",
"@vitejs/plugin-react": "^5.2.0",
"ai": "^6.0.143",
Expand Down
102 changes: 102 additions & 0 deletions src/client/components/EntitySidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';

import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';

type Decision = { id: number; project_id: number; content: string; rationale: string | null };
type Assumption = { id: number; project_id: number; content: string };

type EntitiesData = {
decisions: Decision[];
assumptions: Assumption[];
};

const tabs = ['Decisions', 'Assumptions'] as const;
type Tab = (typeof tabs)[number];

export function useEntities(projectId: number) {
return useQuery<EntitiesData>({
queryKey: ['entities', projectId],
queryFn: async () => {
const res = await fetch(`/api/projects/${projectId}/entities`);
if (!res.ok) throw new Error('Failed to fetch entities');
return res.json();
},
});
}

export function EntitySidebar({ projectId }: { projectId: number }) {
const [activeTab, setActiveTab] = useState<Tab>('Decisions');
const { data, isLoading } = useEntities(projectId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

src/client/components/EntitySidebar.tsx:31: useEntities failures aren’t surfaced, so a network/500 error will render the “No decisions/assumptions yet” empty state and silently mask the problem. Consider rendering an explicit error state using isError/error from React Query so users can distinguish “no entities” from “failed to load”.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.


const decisions = data?.decisions ?? [];
const assumptions = data?.assumptions ?? [];

return (
<div className="flex h-full w-72 flex-col border-l bg-card">
{/* Tab bar */}
<div className="flex border-b">
{tabs.map((tab) => {
const count = tab === 'Decisions' ? decisions.length : assumptions.length;
return (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
'flex-1 px-3 py-2 text-sm font-medium transition-colors',
activeTab === tab
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground',
)}
>
{tab}
{count > 0 && (
<Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-[10px]">
{count}
</Badge>
)}
</button>
);
})}
</div>

{/* Content */}
<div className="flex-1 overflow-y-auto p-3">
{isLoading && <p className="text-sm text-muted-foreground">Loading...</p>}

{activeTab === 'Decisions' && (
<div className="flex flex-col gap-2">
{decisions.length === 0 && !isLoading && (
<p className="text-sm italic text-muted-foreground">
No decisions yet. They'll appear as the interview progresses.
</p>
)}
{decisions.map((d) => (
<div key={d.id} className="rounded-md border p-2.5">
<p className="text-sm">{d.content}</p>
{d.rationale && <p className="mt-1 text-xs text-muted-foreground">{d.rationale}</p>}
</div>
))}
</div>
)}

{activeTab === 'Assumptions' && (
<div className="flex flex-col gap-2">
{assumptions.length === 0 && !isLoading && (
<p className="text-sm italic text-muted-foreground">
No assumptions yet. They'll appear as the interview progresses.
</p>
)}
{assumptions.map((a) => (
<div key={a.id} className="rounded-md border p-2.5">
<p className="text-sm">{a.content}</p>
</div>
))}
</div>
)}
</div>
</div>
);
}
14 changes: 13 additions & 1 deletion src/client/main.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import './index.css';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from '@tanstack/react-router';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';

import { router } from './router.js';

const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
},
},
});

createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);
89 changes: 55 additions & 34 deletions src/client/routes/InterviewWorkspace.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useChat } from '@ai-sdk/react';
import { useQueryClient } from '@tanstack/react-query';
import { useLoaderData, useParams, Link, useRouter } from '@tanstack/react-router';
import type { UIMessage } from 'ai';
import { DefaultChatTransport } from 'ai';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';

import {
Conversation,
Expand All @@ -20,6 +21,7 @@ import {
} from '@/components/ai-elements/prompt-input';
import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning';
import { Tool, ToolHeader, ToolContent, type ToolPart } from '@/components/ai-elements/tool';
import { EntitySidebar } from '@/components/EntitySidebar';
import { cn } from '@/lib/utils';

type LoaderTurn = {
Expand Down Expand Up @@ -185,9 +187,20 @@ export function InterviewWorkspace() {
const router = useRouter();
const [selecting, setSelecting] = useState(false);

const queryClient = useQueryClient();
const transport = useMemo(() => new DefaultChatTransport({ api: `/api/projects/${id}/chat` }), [id]);
const { messages, sendMessage, setMessages, status } = useChat({ transport });
const isLoading = status === 'submitted' || status === 'streaming';
const prevStatusRef = useRef(status);

// Refresh data when chat finishes: entities (observer) + turns (ask_question TurnCard)
useEffect(() => {
if (prevStatusRef.current === 'streaming' && status === 'ready') {
void queryClient.invalidateQueries({ queryKey: ['entities', Number(id)] });
void router.invalidate(); // Refresh turn data so TurnCard appears after ask_question
}
prevStatusRef.current = status;
}, [status, queryClient, id, router]);

useEffect(() => {
setMessages(hydrateMessages(turns));
Expand Down Expand Up @@ -237,42 +250,50 @@ export function InterviewWorkspace() {
<h1 className="text-lg font-semibold">{project.name}</h1>
</header>

<Conversation className="flex-1">
<ConversationContent className="mx-auto max-w-2xl">
{messages.map((msg, msgIdx) => {
const isLastAssistant = msg.role === 'assistant' && msgIdx === messages.length - 1;
return (
<Message key={msg.id} from={msg.role}>
<MessageContent>
{msg.role === 'user'
? msg.parts?.filter((p) => p.type === 'text').map((p, i) => <span key={i}>{p.text}</span>)
: renderParts(msg, isLastAssistant && status === 'streaming')}
</MessageContent>
</Message>
);
})}
<div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 flex-col">
<Conversation className="flex-1">
<ConversationContent className="mx-auto max-w-2xl">
{messages.map((msg, msgIdx) => {
const isLastAssistant = msg.role === 'assistant' && msgIdx === messages.length - 1;
return (
<Message key={msg.id} from={msg.role}>
<MessageContent>
{msg.role === 'user'
? msg.parts
?.filter((p) => p.type === 'text')
.map((p, i) => <span key={i}>{p.text}</span>)
: renderParts(msg, isLastAssistant && status === 'streaming')}
</MessageContent>
</Message>
);
})}

{showTurnCard && !isLoading && (
<TurnCard turn={lastTurn!} onSelect={handleSelect} disabled={selecting || isLoading} />
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
{showTurnCard && !isLoading && (
<TurnCard turn={lastTurn!} onSelect={handleSelect} disabled={selecting || isLoading} />
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>

{(!showTurnCard || lastTurnHasSelection) && (
<div className="border-t px-4 py-3">
<div className="mx-auto max-w-2xl">
<PromptInput onSubmit={handleSubmit}>
<PromptInputBody>
<PromptInputTextarea placeholder="Type a message..." disabled={isLoading || selecting} />
</PromptInputBody>
<PromptInputFooter>
<PromptInputSubmit status={status} />
</PromptInputFooter>
</PromptInput>
</div>
{(!showTurnCard || lastTurnHasSelection) && (
<div className="border-t px-4 py-3">
<div className="mx-auto max-w-2xl">
<PromptInput onSubmit={handleSubmit}>
<PromptInputBody>
<PromptInputTextarea placeholder="Type a message..." disabled={isLoading || selecting} />
</PromptInputBody>
<PromptInputFooter>
<PromptInputSubmit status={status} />
</PromptInputFooter>
</PromptInput>
</div>
</div>
)}
</div>
)}

<EntitySidebar projectId={Number(id)} />
</div>
</div>
);
}
12 changes: 12 additions & 0 deletions src/server/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,18 @@ describe('POST /api/projects', () => {
});
});

describe('GET /api/projects/:id/entities', () => {
it('returns empty entities for a new project', async () => {
const projectId = await createTestProject('Test');
const res = await request(app).get(`/api/projects/${projectId}/entities`).expect(200);
expect(res.body).toEqual({ decisions: [], assumptions: [] });
});

it('returns 400 for invalid project ID', async () => {
await request(app).get('/api/projects/abc/entities').expect(400);
});
});

describe('GET /api/projects/:id', () => {
it('returns a project with empty turns when no history exists', async () => {
const projectId = await createTestProject('Test');
Expand Down
Loading