Skip to content

Commit 34d7669

Browse files
oceansethclaude
andcommitted
Threads: a Reddit layer over session turns
- Server: threads/posts/votes tables + endpoints (create/list/detail, nested replies, idempotent up/down votes with score deltas); creator X handle denormalized from identities - Threads tab: newest-first list with vote arrows (optimistic), creator shown by X handle, expandable description + nested reply tree with composers - SessionView: every turn gets '+ thread' (title + description form) and a 💬 badge jumping to existing threads; turn excerpt/speaker/ts anchor the thread - Title suggestion via Chrome's built-in Prompt API when available; plain 'Enter thread title' placeholder otherwise Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b1c8e16 commit 34d7669

8 files changed

Lines changed: 1016 additions & 11 deletions

File tree

app/src/App.css

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,205 @@
357357
}
358358
}
359359

360+
/* ── threads (Reddit layer) ───────────────────────────── */
361+
362+
.thread-list {
363+
list-style: none;
364+
margin: 0;
365+
padding: 0;
366+
display: flex;
367+
flex-direction: column;
368+
gap: 0.75rem;
369+
}
370+
371+
.thread-item {
372+
display: flex;
373+
gap: 0.4rem;
374+
padding: 0.9rem 1rem;
375+
}
376+
377+
.vote-col {
378+
display: flex;
379+
flex-direction: column;
380+
align-items: center;
381+
gap: 0.1rem;
382+
flex-shrink: 0;
383+
width: 2.2rem;
384+
}
385+
386+
.vote {
387+
background: transparent;
388+
color: var(--text-dim);
389+
padding: 0 0.3em;
390+
font-size: 0.85rem;
391+
border: none;
392+
}
393+
394+
.vote:hover {
395+
color: var(--text-h);
396+
}
397+
398+
.vote.active {
399+
color: var(--accent);
400+
}
401+
402+
.vote.active.down {
403+
color: var(--danger);
404+
}
405+
406+
.vote.mini {
407+
font-size: 0.7rem;
408+
}
409+
410+
.score {
411+
font-weight: 700;
412+
color: var(--text-h);
413+
font-size: 0.9rem;
414+
}
415+
416+
.score.mini {
417+
font-size: 0.78rem;
418+
margin: 0 0.15rem;
419+
}
420+
421+
.thread-main {
422+
flex: 1;
423+
min-width: 0;
424+
}
425+
426+
.thread-title {
427+
background: transparent;
428+
border: none;
429+
color: var(--text-h);
430+
font-size: 1.05rem;
431+
font-weight: 700;
432+
padding: 0;
433+
text-align: left;
434+
}
435+
436+
.thread-title:hover {
437+
color: var(--accent);
438+
}
439+
440+
.thread-meta {
441+
margin: 0.2rem 0 0;
442+
font-size: 0.8rem;
443+
color: var(--text-dim);
444+
}
445+
446+
.turn-quote {
447+
margin: 0.5rem 0 0;
448+
padding: 0.3rem 0.8rem;
449+
border-left: 3px solid var(--border);
450+
color: var(--text-dim);
451+
font-size: 0.85rem;
452+
font-style: italic;
453+
}
454+
455+
.thread-detail {
456+
margin-top: 0.8rem;
457+
border-top: 1px solid var(--border);
458+
padding-top: 0.8rem;
459+
}
460+
461+
.thread-description {
462+
margin: 0 0 0.8rem;
463+
white-space: pre-wrap;
464+
}
465+
466+
.post-tree {
467+
display: flex;
468+
flex-direction: column;
469+
gap: 0.6rem;
470+
}
471+
472+
.post.nested {
473+
margin-left: 1.4rem;
474+
border-left: 2px solid var(--border);
475+
padding-left: 0.8rem;
476+
margin-top: 0.6rem;
477+
}
478+
479+
.post-head {
480+
display: flex;
481+
align-items: center;
482+
gap: 0.35rem;
483+
font-size: 0.82rem;
484+
}
485+
486+
.post-author {
487+
font-weight: 600;
488+
color: var(--text-h);
489+
}
490+
491+
.post-text {
492+
margin: 0.25rem 0 0;
493+
font-size: 0.92rem;
494+
white-space: pre-wrap;
495+
}
496+
497+
.post-composer {
498+
display: flex;
499+
gap: 0.5rem;
500+
margin-top: 0.7rem;
501+
}
502+
503+
.post-composer input {
504+
flex: 1;
505+
}
506+
507+
.linklike {
508+
background: transparent;
509+
border: none;
510+
color: var(--accent);
511+
padding: 0;
512+
font-size: inherit;
513+
font-weight: 500;
514+
}
515+
516+
.linklike:hover {
517+
text-decoration: underline;
518+
}
519+
520+
.turn-actions {
521+
margin-left: auto;
522+
display: flex;
523+
align-items: center;
524+
gap: 0.6rem;
525+
}
526+
527+
.thread-badge {
528+
background: transparent;
529+
border: 1px solid var(--accent-dim);
530+
color: var(--accent);
531+
border-radius: 99px;
532+
font-size: 0.75rem;
533+
padding: 0.05em 0.6em;
534+
}
535+
536+
.create-thread {
537+
margin-top: 0.7rem;
538+
padding: 0.9rem 1rem;
539+
display: flex;
540+
flex-direction: column;
541+
gap: 0.6rem;
542+
}
543+
544+
.create-thread textarea {
545+
font: inherit;
546+
background: var(--bg);
547+
color: var(--text-h);
548+
border: 1px solid var(--border);
549+
border-radius: 6px;
550+
padding: 0.5em 0.75em;
551+
resize: vertical;
552+
}
553+
554+
.create-thread textarea:focus {
555+
outline: none;
556+
border-color: var(--accent-dim);
557+
}
558+
360559
/* ── layout ───────────────────────────────────────────── */
361560

362561
main {

app/src/App.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import './App.css';
33
import { Chat } from './components/Chat';
44
import { Feed } from './components/Feed';
55
import { SessionView } from './components/SessionView';
6+
import { Threads } from './components/Threads';
67
import { completeLogin } from './lib/auth';
78
import { GitHubClient } from './lib/github';
89
import {
@@ -14,7 +15,7 @@ import {
1415
import { parseOpenSessionJsonl, type ParsedArchive } from './lib/opensession';
1516
import { XChatConnector } from './lib/xchat';
1617

17-
type Tab = 'activity' | 'chat';
18+
type Tab = 'activity' | 'threads' | 'chat';
1819
type View = { name: 'tabs' } | { name: 'session'; title: string; archive: ParsedArchive; sourceUrl?: string };
1920

2021
const TOKEN_KEY = 'opensession.github.token';
@@ -24,6 +25,7 @@ export default function App() {
2425
const [token, setToken] = useState<string>(() => localStorage.getItem(TOKEN_KEY) ?? '');
2526
const [tab, setTab] = useState<Tab>('activity');
2627
const [view, setView] = useState<View>({ name: 'tabs' });
28+
const [focusThread, setFocusThread] = useState<string>();
2729
const [loadError, setLoadError] = useState<string>();
2830
const [identity, setIdentity] = useState<Identity | null>(null);
2931
const [identityLoaded, setIdentityLoaded] = useState(false);
@@ -209,6 +211,9 @@ export default function App() {
209211
<button className={tab === 'activity' ? 'tab active' : 'tab'} onClick={() => setTab('activity')}>
210212
Activity
211213
</button>
214+
<button className={tab === 'threads' ? 'tab active' : 'tab'} onClick={() => setTab('threads')}>
215+
Threads
216+
</button>
212217
<button className={tab === 'chat' ? 'tab active' : 'tab'} onClick={() => setTab('chat')}>
213218
Chat
214219
</button>
@@ -226,6 +231,15 @@ export default function App() {
226231
loadError={loadError}
227232
/>
228233
)}
234+
{view.name === 'tabs' && tab === 'threads' && token && (
235+
<Threads
236+
token={token}
237+
initialThreadId={focusThread}
238+
onOpenSession={(repo) =>
239+
void openUrl(`https://github.com/${repo}/blob/HEAD/llm-turn-history.jsonl`)
240+
}
241+
/>
242+
)}
229243
{view.name === 'tabs' && tab === 'chat' && token && (
230244
<Chat client={client} token={token} myIdentity={identity} connector={connector} />
231245
)}
@@ -234,7 +248,13 @@ export default function App() {
234248
title={view.title}
235249
archive={view.archive}
236250
sourceUrl={view.sourceUrl}
251+
token={token || undefined}
237252
onBack={() => setView({ name: 'tabs' })}
253+
onOpenThread={(threadId) => {
254+
setFocusThread(threadId);
255+
setTab('threads');
256+
setView({ name: 'tabs' });
257+
}}
238258
/>
239259
)}
240260
</main>

0 commit comments

Comments
 (0)