Skip to content

Commit 99c0666

Browse files
authored
fix: Fix diff view not clearing on sidebar tab switch (#199)
* 🐛 Clear diff view when switching from Versions to Contents tab sidebar.activeTab and isPlanDiffActive were independent states with no synchronization. Switching to TOC tab left the diff viewer active in the main area. Add useEffect to reset isPlanDiffActive when tab changes to "toc". * 🐛 Dismiss diff view on Escape key and add dev mock API for version history - Add keydown listener to clear isPlanDiffActive on Escape - Add Vite dev plugin (dev-mock-api.ts) serving mock /api/plan endpoints with 3 plan versions so the Versions tab works during local development - Guard setMarkdown against undefined plan from API response
1 parent cc44f6a commit 99c0666

3 files changed

Lines changed: 279 additions & 2 deletions

File tree

apps/hook/dev-mock-api.ts

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
/**
2+
* Vite plugin that mocks plannotator API endpoints for local development.
3+
* Provides plan data with version history so the Versions tab works in dev mode.
4+
*/
5+
import type { Plugin } from 'vite';
6+
7+
// Version 1: earlier draft (shorter, missing sections)
8+
const PLAN_V1 = `# Implementation Plan: Real-time Collaboration
9+
10+
## Overview
11+
Add real-time collaboration features to the editor using WebSocket connections.
12+
13+
## Phase 1: Infrastructure
14+
15+
### WebSocket Server
16+
Set up a WebSocket server to handle concurrent connections:
17+
18+
\`\`\`typescript
19+
const server = new WebSocketServer({ port: 8080 });
20+
21+
server.on('connection', (socket) => {
22+
const sessionId = generateSessionId();
23+
sessions.set(sessionId, socket);
24+
25+
socket.on('message', (data) => {
26+
broadcast(sessionId, data);
27+
});
28+
});
29+
\`\`\`
30+
31+
### Client Connection
32+
- Establish persistent connection on document load
33+
- Implement reconnection logic with exponential backoff
34+
- Handle offline state gracefully
35+
36+
### Database Schema
37+
38+
\`\`\`sql
39+
CREATE TABLE documents (
40+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
41+
title VARCHAR(255) NOT NULL,
42+
content JSONB NOT NULL DEFAULT '{}',
43+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
44+
);
45+
\`\`\`
46+
47+
## Phase 2: Operational Transforms
48+
49+
> The key insight is that we need to transform operations against concurrent operations to maintain consistency.
50+
51+
Key requirements:
52+
- Transform insert against insert
53+
- Transform insert against delete
54+
- Transform delete against delete
55+
56+
## Pre-launch Checklist
57+
58+
- [ ] Infrastructure ready
59+
- [ ] WebSocket server deployed
60+
- [ ] Database migrations applied
61+
- [ ] Security audit complete
62+
- [ ] Documentation updated
63+
64+
---
65+
66+
**Target:** Ship MVP in next sprint
67+
`;
68+
69+
// Version 2: expanded (added architecture diagram, more details)
70+
const PLAN_V2 = `# Implementation Plan: Real-time Collaboration
71+
72+
## Overview
73+
Add real-time collaboration features to the editor using WebSocket connections and operational transforms.
74+
75+
### Architecture
76+
77+
\`\`\`mermaid
78+
flowchart LR
79+
subgraph Client["Client Browser"]
80+
UI[React UI] --> OT[OT Engine]
81+
OT <--> WS[WebSocket Client]
82+
end
83+
84+
subgraph Server["Backend"]
85+
WSS[WebSocket Server] <--> OTS[OT Transform]
86+
OTS <--> DB[(PostgreSQL)]
87+
end
88+
89+
WS <--> WSS
90+
\`\`\`
91+
92+
## Phase 1: Infrastructure
93+
94+
### WebSocket Server
95+
Set up a WebSocket server to handle concurrent connections:
96+
97+
\`\`\`typescript
98+
const server = new WebSocketServer({ port: 8080 });
99+
100+
server.on('connection', (socket, request) => {
101+
const sessionId = generateSessionId();
102+
sessions.set(sessionId, socket);
103+
104+
socket.on('message', (data) => {
105+
broadcast(sessionId, data);
106+
});
107+
});
108+
\`\`\`
109+
110+
### Client Connection
111+
- Establish persistent connection on document load
112+
- Initialize WebSocket with authentication token
113+
- Set up heartbeat ping/pong every 30 seconds
114+
- Implement reconnection logic with exponential backoff
115+
- Start with 1 second delay
116+
- Double delay on each retry (max 30 seconds)
117+
- Handle offline state gracefully
118+
- Queue local changes in IndexedDB
119+
- Show offline indicator in UI
120+
121+
### Database Schema
122+
123+
\`\`\`sql
124+
CREATE TABLE documents (
125+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
126+
title VARCHAR(255) NOT NULL,
127+
content JSONB NOT NULL DEFAULT '{}',
128+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
129+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
130+
);
131+
132+
CREATE TABLE collaborators (
133+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
134+
document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
135+
user_id UUID NOT NULL,
136+
role VARCHAR(50) DEFAULT 'editor',
137+
cursor_position JSONB,
138+
last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
139+
);
140+
141+
CREATE INDEX idx_collaborators_document ON collaborators(document_id);
142+
\`\`\`
143+
144+
## Phase 2: Operational Transforms
145+
146+
> The key insight is that we need to transform operations against concurrent operations to maintain consistency.
147+
148+
Key requirements:
149+
- Transform insert against insert
150+
- Same position: use user ID for deterministic ordering
151+
- Different positions: adjust offset of later operation
152+
- Transform insert against delete
153+
- Insert before delete: no change needed
154+
- Insert inside deleted range: special handling required
155+
- Transform delete against delete
156+
- Non-overlapping: adjust positions
157+
- Overlapping: merge or split operations
158+
- Maintain cursor positions across transforms
159+
160+
## Phase 3: UI Updates
161+
162+
1. Show collaborator cursors in real-time
163+
2. Display presence indicators
164+
3. Add conflict resolution UI
165+
4. Implement undo/redo stack per user
166+
167+
## Pre-launch Checklist
168+
169+
- [ ] Infrastructure ready
170+
- [x] WebSocket server deployed
171+
- [x] Database migrations applied
172+
- [ ] Load balancer configured
173+
- [ ] Security audit complete
174+
- [x] Authentication flow reviewed
175+
- [ ] Rate limiting implemented
176+
- [x] Documentation updated
177+
178+
---
179+
180+
**Target:** Ship MVP in next sprint
181+
`;
182+
183+
// Version 3 is the current PLAN_CONTENT from App.tsx (loaded by the editor itself)
184+
// We don't duplicate it here — the editor already has it as the default state.
185+
186+
const now = Date.now();
187+
const versions = [
188+
{ version: 1, timestamp: new Date(now - 3600_000 * 2).toISOString() },
189+
{ version: 2, timestamp: new Date(now - 3600_000).toISOString() },
190+
{ version: 3, timestamp: new Date(now - 60_000).toISOString() },
191+
];
192+
193+
const versionPlans: Record<number, string> = {
194+
1: PLAN_V1,
195+
2: PLAN_V2,
196+
// Version 3 is the current plan — served via /api/plan
197+
};
198+
199+
export function devMockApi(): Plugin {
200+
return {
201+
name: 'plannotator-dev-mock-api',
202+
configureServer(server) {
203+
server.middlewares.use((req, res, next) => {
204+
if (req.url === '/api/plan') {
205+
res.setHeader('Content-Type', 'application/json');
206+
res.end(JSON.stringify({
207+
plan: undefined, // Let editor use its own PLAN_CONTENT
208+
origin: 'claude-code',
209+
previousPlan: PLAN_V2,
210+
versionInfo: { version: 3, totalVersions: 3, project: 'demo' },
211+
sharingEnabled: true,
212+
}));
213+
return;
214+
}
215+
216+
if (req.url === '/api/plan/versions') {
217+
res.setHeader('Content-Type', 'application/json');
218+
res.end(JSON.stringify({
219+
project: 'demo',
220+
slug: 'implementation-plan-real-time-collab',
221+
versions,
222+
}));
223+
return;
224+
}
225+
226+
if (req.url?.startsWith('/api/plan/version?')) {
227+
const url = new URL(req.url, 'http://localhost');
228+
const v = Number(url.searchParams.get('v'));
229+
const plan = versionPlans[v];
230+
if (plan) {
231+
res.setHeader('Content-Type', 'application/json');
232+
res.end(JSON.stringify({ plan, version: v }));
233+
} else {
234+
res.statusCode = 404;
235+
res.end(JSON.stringify({ error: 'Version not found' }));
236+
}
237+
return;
238+
}
239+
240+
if (req.url === '/api/plan/history') {
241+
res.setHeader('Content-Type', 'application/json');
242+
res.end(JSON.stringify({
243+
project: 'demo',
244+
plans: [{
245+
slug: 'implementation-plan-real-time-collab',
246+
versions: 3,
247+
lastModified: new Date(now - 60_000).toISOString(),
248+
}],
249+
}));
250+
return;
251+
}
252+
253+
next();
254+
});
255+
},
256+
};
257+
}

apps/hook/vite.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react';
44
import { viteSingleFile } from 'vite-plugin-singlefile';
55
import tailwindcss from '@tailwindcss/vite';
66
import pkg from '../../package.json';
7+
import { devMockApi } from './dev-mock-api';
78

89
export default defineConfig({
910
server: {
@@ -13,7 +14,7 @@ export default defineConfig({
1314
define: {
1415
__APP_VERSION__: JSON.stringify(pkg.version),
1516
},
16-
plugins: [react(), tailwindcss(), viteSingleFile()],
17+
plugins: [react(), tailwindcss(), devMockApi(), viteSingleFile()],
1718
resolve: {
1819
alias: {
1920
'@': path.resolve(__dirname, '.'),

packages/editor/App.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,25 @@ const App: React.FC = () => {
406406
}
407407
}, [uiPrefs.tocEnabled]);
408408

409+
// Clear diff view when switching away from versions tab
410+
useEffect(() => {
411+
if (sidebar.activeTab === 'toc' && isPlanDiffActive) {
412+
setIsPlanDiffActive(false);
413+
}
414+
}, [sidebar.activeTab]);
415+
416+
// Clear diff view on Escape key
417+
useEffect(() => {
418+
if (!isPlanDiffActive) return;
419+
const handleKeyDown = (e: KeyboardEvent) => {
420+
if (e.key === 'Escape') {
421+
setIsPlanDiffActive(false);
422+
}
423+
};
424+
document.addEventListener('keydown', handleKeyDown);
425+
return () => document.removeEventListener('keydown', handleKeyDown);
426+
}, [isPlanDiffActive]);
427+
409428
// Plan diff computation
410429
const planDiff = usePlanDiff(markdown, previousPlan, versionInfo);
411430

@@ -488,7 +507,7 @@ const App: React.FC = () => {
488507
return res.json();
489508
})
490509
.then((data: { plan: string; origin?: 'claude-code' | 'opencode' | 'pi'; mode?: 'annotate'; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string } }) => {
491-
setMarkdown(data.plan);
510+
if (data.plan) setMarkdown(data.plan);
492511
setIsApiMode(true);
493512
if (data.mode === 'annotate') {
494513
setAnnotateMode(true);

0 commit comments

Comments
 (0)