diff --git a/db.js b/db.js
index 31d91e0..9ecee90 100644
--- a/db.js
+++ b/db.js
@@ -39,7 +39,7 @@ async function getConversation(sessionId) {
if (!p) return null;
const result = await p.query(
- 'SELECT messages, user_id, todo_markdown, name FROM conversations WHERE session_id = $1',
+ 'SELECT messages, user_id, todo_markdown, name, collapsed_sections FROM conversations WHERE session_id = $1',
[sessionId]
);
if (result.rows.length === 0) return null;
@@ -47,7 +47,8 @@ async function getConversation(sessionId) {
messages: result.rows[0].messages,
userId: result.rows[0].user_id,
todoMarkdown: result.rows[0].todo_markdown || '',
- name: result.rows[0].name || null
+ name: result.rows[0].name || null,
+ collapsedSections: result.rows[0].collapsed_sections || {}
};
}
@@ -107,6 +108,21 @@ async function updateTodoMarkdown(sessionId, userId, todoMarkdown) {
return result.rowCount;
}
+// Persist a per-section collapsed/expanded state map so the same chat looks the
+// same on every device. Same ownership rule as updateTodoMarkdown.
+async function updateCollapsedSections(sessionId, userId, collapsedSections) {
+ const p = getPool();
+ if (!p) return 0;
+
+ const result = await p.query(
+ `UPDATE conversations
+ SET collapsed_sections = $1, updated_at = NOW()
+ WHERE session_id = $2 AND (user_id IS NULL OR user_id = $3)`,
+ [JSON.stringify(collapsedSections || {}), sessionId, userId]
+ );
+ return result.rowCount;
+}
+
// Explicit user-initiated rename. Scoped to the owner so one user can't rename
// another user's chats by guessing sessionIds.
async function renameConversation(sessionId, userId, name) {
@@ -135,7 +151,7 @@ async function listConversationsByUser(userId) {
if (!p) return [];
const result = await p.query(
- `SELECT session_id, messages, todo_markdown, name, updated_at
+ `SELECT session_id, messages, todo_markdown, name, collapsed_sections, updated_at
FROM conversations
WHERE user_id = $1
ORDER BY updated_at DESC`,
@@ -146,6 +162,7 @@ async function listConversationsByUser(userId) {
messages: r.messages,
todoMarkdown: r.todo_markdown || '',
name: r.name || null,
+ collapsedSections: r.collapsed_sections || {},
updatedAt: r.updated_at
}));
}
@@ -181,6 +198,7 @@ module.exports = {
getConversation,
saveConversation,
updateTodoMarkdown,
+ updateCollapsedSections,
renameConversation,
deleteConversation,
listConversationsByUser,
diff --git a/index.html b/index.html
index 70016b1..0149c44 100644
--- a/index.html
+++ b/index.html
@@ -1186,6 +1186,9 @@
No tasks yet
if (sc.name) {
existing.name = sc.name;
}
+ if (sc.collapsedSections && typeof sc.collapsedSections === 'object') {
+ existing.collapsedSections = sc.collapsedSections;
+ }
if (existing.id === currentSessionId) {
currentChatUpdated = true;
}
@@ -1201,7 +1204,8 @@ No tasks yet
id: sc.sessionId,
name: sc.name || derived,
todoMarkdown: sc.todoMarkdown || '',
- messages: sc.messages || []
+ messages: sc.messages || [],
+ collapsedSections: sc.collapsedSections || {}
});
addedNewChat = true;
}
@@ -1429,6 +1433,7 @@ No tasks yet
if (!collapseChat.collapsedSections) collapseChat.collapsedSections = {};
collapseChat.collapsedSections[sectionName] = isCollapsed;
saveChats();
+ pushCollapsedSections(collapseChat.id, collapseChat.collapsedSections);
}
});
});
@@ -1526,6 +1531,24 @@ No tasks yet
} catch (e) { /* best-effort */ }
}
+ async function pushCollapsedSections(sessionId, collapsedSections) {
+ if (!sessionId) return;
+ const headers = { 'Content-Type': 'application/json' };
+ try {
+ if (window.Clerk && window.Clerk.session) {
+ const token = await window.Clerk.session.getToken();
+ if (token) headers['Authorization'] = `Bearer ${token}`;
+ }
+ } catch (e) { /* fall through as guest */ }
+ try {
+ await fetch(`${API_BASE}/api/collapsed-sections`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({ sessionId, collapsedSections: collapsedSections || {} })
+ });
+ } catch (e) { /* best-effort */ }
+ }
+
// API base URL - use Railway in production, relative URL in development
const API_BASE = window.location.hostname === 'localhost'
? ''
diff --git a/migrations/005_add-collapsed-sections-to-conversations.cjs b/migrations/005_add-collapsed-sections-to-conversations.cjs
new file mode 100644
index 0000000..24df407
--- /dev/null
+++ b/migrations/005_add-collapsed-sections-to-conversations.cjs
@@ -0,0 +1,17 @@
+/* eslint-disable camelcase */
+
+exports.shorthands = undefined;
+
+exports.up = (pgm) => {
+ pgm.addColumn('conversations', {
+ collapsed_sections: {
+ type: 'jsonb',
+ notNull: true,
+ default: pgm.func("'{}'::jsonb")
+ }
+ });
+};
+
+exports.down = (pgm) => {
+ pgm.dropColumn('conversations', 'collapsed_sections');
+};
diff --git a/server.js b/server.js
index d252f93..a20f64f 100644
--- a/server.js
+++ b/server.js
@@ -216,6 +216,21 @@ app.post('/api/tasks', clerkAuth, async (req, res) => {
res.json({ success: true });
});
+// Persist the per-section collapsed/expanded state map for a chat so the
+// section toggle state syncs across devices.
+app.post('/api/collapsed-sections', clerkAuth, async (req, res) => {
+ const { sessionId, collapsedSections } = req.body;
+ if (!sessionId || typeof collapsedSections !== 'object' || collapsedSections === null || Array.isArray(collapsedSections)) {
+ return res.status(400).json({ error: 'sessionId and collapsedSections object are required' });
+ }
+ const row = await db.getConversation(sessionId);
+ if (row !== null && !canAccess(row.userId, req.userId)) {
+ return res.status(403).json({ error: 'forbidden' });
+ }
+ await db.updateCollapsedSections(sessionId, req.userId, collapsedSections).catch(() => {});
+ res.json({ success: true });
+});
+
// Rename a conversation — persists the title so other devices see it on sync.
// Scoped to the authenticated user so renames can't be spoofed across accounts.
app.post('/api/rename', clerkAuth, requireAuth, async (req, res) => {
diff --git a/server.test.js b/server.test.js
index 4c9cdec..a9320b2 100644
--- a/server.test.js
+++ b/server.test.js
@@ -5,6 +5,7 @@ jest.mock('./db', () => ({
getConversation: jest.fn().mockResolvedValue(null),
saveConversation: jest.fn().mockResolvedValue(false),
updateTodoMarkdown: jest.fn().mockResolvedValue(0),
+ updateCollapsedSections: jest.fn().mockResolvedValue(0),
renameConversation: jest.fn().mockResolvedValue(0),
deleteConversation: jest.fn().mockResolvedValue(false),
listConversationsByUser: jest.fn().mockResolvedValue([]),
@@ -40,6 +41,7 @@ describe('PadTask Server', () => {
db.getConversation.mockReset().mockResolvedValue(null);
db.saveConversation.mockReset().mockResolvedValue(false);
db.updateTodoMarkdown.mockReset().mockResolvedValue(0);
+ db.updateCollapsedSections.mockReset().mockResolvedValue(0);
db.renameConversation.mockReset().mockResolvedValue(0);
db.deleteConversation.mockReset().mockResolvedValue(false);
db.listConversationsByUser.mockReset().mockResolvedValue([]);
@@ -703,6 +705,105 @@ describe('PadTask Server', () => {
});
});
+ describe('POST /api/collapsed-sections', () => {
+ it('should return 400 when sessionId is missing', async () => {
+ const response = await request(app)
+ .post('/api/collapsed-sections')
+ .send({ collapsedSections: { 'Today': true } });
+ expect(response.status).toBe(400);
+ expect(db.updateCollapsedSections).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 when collapsedSections is not an object', async () => {
+ const response = await request(app)
+ .post('/api/collapsed-sections')
+ .send({ sessionId: 's1', collapsedSections: 'oops' });
+ expect(response.status).toBe(400);
+ expect(db.updateCollapsedSections).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 when collapsedSections is an array', async () => {
+ const response = await request(app)
+ .post('/api/collapsed-sections')
+ .send({ sessionId: 's1', collapsedSections: ['Today'] });
+ expect(response.status).toBe(400);
+ expect(db.updateCollapsedSections).not.toHaveBeenCalled();
+ });
+
+ it('should return 400 when collapsedSections is null', async () => {
+ const response = await request(app)
+ .post('/api/collapsed-sections')
+ .send({ sessionId: 's1', collapsedSections: null });
+ expect(response.status).toBe(400);
+ expect(db.updateCollapsedSections).not.toHaveBeenCalled();
+ });
+
+ it('should update collapsed state for a guest on an unowned row', async () => {
+ db.getConversation.mockResolvedValue({ messages: [], userId: null, todoMarkdown: '', name: null });
+ db.updateCollapsedSections.mockResolvedValue(1);
+
+ const response = await request(app)
+ .post('/api/collapsed-sections')
+ .send({ sessionId: 's1', collapsedSections: { 'Today': true } });
+
+ expect(response.status).toBe(200);
+ expect(response.body.success).toBe(true);
+ expect(db.updateCollapsedSections).toHaveBeenCalledWith('s1', null, { 'Today': true });
+ });
+
+ it('should update collapsed state for the owner', async () => {
+ process.env.CLERK_SECRET_KEY = 'sk_test_dummy';
+ verifyToken.mockResolvedValue({ sub: 'user_a' });
+ db.getConversation.mockResolvedValue({ messages: [], userId: 'user_a', todoMarkdown: '', name: null });
+ db.updateCollapsedSections.mockResolvedValue(1);
+
+ const response = await request(app)
+ .post('/api/collapsed-sections')
+ .set('Authorization', 'Bearer tok')
+ .send({ sessionId: 's1', collapsedSections: { 'Today': true, 'Later': false } });
+
+ expect(response.status).toBe(200);
+ expect(db.updateCollapsedSections).toHaveBeenCalledWith('s1', 'user_a', { 'Today': true, 'Later': false });
+ });
+
+ it('should reject when the row is owned by another user', async () => {
+ process.env.CLERK_SECRET_KEY = 'sk_test_dummy';
+ verifyToken.mockResolvedValue({ sub: 'user_b' });
+ db.getConversation.mockResolvedValue({ messages: [], userId: 'user_a', todoMarkdown: '', name: null });
+
+ const response = await request(app)
+ .post('/api/collapsed-sections')
+ .set('Authorization', 'Bearer tok')
+ .send({ sessionId: 's1', collapsedSections: { 'Today': true } });
+
+ expect(response.status).toBe(403);
+ expect(db.updateCollapsedSections).not.toHaveBeenCalled();
+ });
+
+ it('should succeed when no DB row exists yet', async () => {
+ db.getConversation.mockResolvedValue(null);
+ db.updateCollapsedSections.mockResolvedValue(0);
+
+ const response = await request(app)
+ .post('/api/collapsed-sections')
+ .send({ sessionId: 's-new', collapsedSections: {} });
+
+ expect(response.status).toBe(200);
+ expect(db.updateCollapsedSections).toHaveBeenCalledWith('s-new', null, {});
+ });
+
+ it('should still respond 200 when the DB write rejects', async () => {
+ db.getConversation.mockResolvedValue(null);
+ db.updateCollapsedSections.mockRejectedValue(new Error('db down'));
+
+ const response = await request(app)
+ .post('/api/collapsed-sections')
+ .send({ sessionId: 's1', collapsedSections: { 'Today': true } });
+
+ expect(response.status).toBe(200);
+ });
+ });
+
describe('POST /api/rename', () => {
it('should return 401 when not authenticated', async () => {
const response = await request(app)