From a4e56bdb729c53333bc1b717ab3a5580a2aa9e09 Mon Sep 17 00:00:00 2001 From: Cameron Pfiffer Date: Wed, 12 Aug 2026 22:12:40 -0700 Subject: [PATCH] Tighten the mobile Stream feed. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let the header scroll away, reduce pill height without narrowing controls, and render X activities with concrete post and reply labels instead of a generic observation. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- spec/ui.md | 2 +- src/projections/activity.ts | 43 ++++++++++++++++++++++++++++++++++++- src/web/inspector.ts | 6 +++--- test/inspector.test.ts | 3 +++ test/x-webhook.test.ts | 38 ++++++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 5 deletions(-) diff --git a/spec/ui.md b/spec/ui.md index ab044d5..a57bed7 100644 --- a/spec/ui.md +++ b/spec/ui.md @@ -23,7 +23,7 @@ Deterministic transforms are labeled **rules**, not agents. The interface states Filters stay collapsed above the feed until requested. The current controls cover source, activity kind, processing state, and text. -On phone screens, the private interface behaves as an app shell rather than a compressed desktop document. A compact sticky header keeps the uppercase `STREAM` wordmark visible above four touch-sized destinations: **Feed**, **Review**, **Artifacts**, and **System**. Review contains local **Suggestions** and **Comparisons** views; System contains local **Runs** and **Sources** views. These local choices appear as one segmented control only after their destination is selected, rather than expanding the global navigation. The selected destination uses a high-contrast pill; inactive destinations use quiet outlined pills. The navigation may scroll horizontally on compact phones without making the document overflow. Medium and desktop widths retain the site's quieter text-navigation grammar rather than scaling the phone pills into oversized chrome. Selecting any detail hides global and local navigation until the reader returns to the list, so the object itself owns the viewport. +On phone screens, the private interface behaves as an app shell rather than a compressed desktop document. A compact top header scrolls with the document so the feed can reclaim the viewport after the reader moves down. It places the uppercase `STREAM` wordmark above four low-profile destinations: **Feed**, **Review**, **Artifacts**, and **System**. Review contains local **Suggestions** and **Comparisons** views; System contains local **Runs** and **Sources** views. These local choices appear as one segmented control only after their destination is selected, rather than expanding the global navigation. The selected destination uses a high-contrast pill; inactive destinations use quiet outlined pills. Mobile pills preserve their horizontal width while reducing vertical padding. The navigation may scroll horizontally on compact phones without making the document overflow. Medium and desktop widths retain the site's quieter text-navigation grammar rather than scaling the phone pills into oversized chrome. Selecting any detail hides global and local navigation until the reader returns to the list, so the object itself owns the viewport. Feed and Sources expose filtering as an app control, not an expanded form or a separate title/toolbar row embedded in the document. The active navigation already names the view, so the Filter control lives at the far right of the global navigation line on medium/desktop widths and becomes the next horizontally scrollable phone control. It opens a modal sheet with full-width, touch-sized fields. On phones the sheet is anchored to the bottom safe area; wider screens use a centered modal. Active filters return to the main view as individually removable chips and a numeric count; zero active filters render no count. The feed runs edge to edge inside safe-area padding. Detail selection replaces the feed in the same viewport and preserves the existing back affordance. The page publishes standalone-app metadata and an exact private manifest/icon route; it does not cache private HTML, JSON, media, or Jazz-derived content offline. diff --git a/src/projections/activity.ts b/src/projections/activity.ts index b708ff4..7e121e5 100644 --- a/src/projections/activity.ts +++ b/src/projections/activity.ts @@ -387,6 +387,47 @@ export function presentActivityEvent(event: ThoughtEvent): ActivityPresentation }; } } + if (event.type === "stream.thought.source.x.activity") { + const eventType = typeof event.payload.eventType === "string" ? event.payload.eventType : ""; + const post = isObject(event.payload.post) ? event.payload.post : undefined; + const like = isObject(event.payload.like) ? event.payload.like : undefined; + const postId = typeof post?.postId === "string" + ? post.postId + : typeof like?.postId === "string" + ? like.postId + : undefined; + const postUrl = postId ? `https://x.com/i/web/status/${encodeURIComponent(postId)}` : ""; + if (eventType === "post.create") { + const reply = typeof post?.inReplyToUserId === "string" + || (Array.isArray(post?.referencedPosts) && post.referencedPosts.some((reference) => ( + isObject(reference) && reference.type === "replied_to" + ))); + return { + renderer: "generic", + title: reply ? "Replied on X" : "Posted on X", + body: typeof post?.text === "string" ? post.text : "", + objectLabel: "X post", + ...(postUrl ? { url: postUrl, urlLabel: reply ? "Open reply" : "Open post" } : {}), + }; + } + if (eventType === "post.delete") { + return { + renderer: "generic", + title: "Deleted an X post", + body: "", + objectLabel: "X post", + }; + } + if (eventType === "like.create") { + return { + renderer: "generic", + title: "Liked a post on X", + body: "", + objectLabel: "X post", + ...(postUrl ? { url: postUrl, urlLabel: "Open liked post" } : {}), + }; + } + } if (event.type === "stream.thought.source.email.observed") { const operation = event.payload.operation === "created" || event.payload.operation === "destroyed" ? event.payload.operation @@ -410,7 +451,7 @@ export function presentActivityEvent(event: ThoughtEvent): ActivityPresentation const summary = summarizeRoot(event); return { renderer: "generic", - title: event.type === "stream.thought.source.x.activity" ? "X activity" : capitalize(event.type.split(".").at(-1) ?? "observation"), + title: capitalize(event.type.split(".").at(-1) ?? "observation"), body: summary === event.type ? "" : summary, objectLabel: "Observation", }; diff --git a/src/web/inspector.ts b/src/web/inspector.ts index 50dd560..6dfb47f 100644 --- a/src/web/inspector.ts +++ b/src/web/inspector.ts @@ -1020,15 +1020,15 @@ export function renderInspectorHtml(): string { body.detail-selected .primary-nav,body.detail-selected .context-nav { display:none } @media(max-width:640px){ body { overscroll-behavior-y:none; font-size:15px } - .app-header { position:sticky; top:0; z-index:20; width:100%; padding:max(16px,env(safe-area-inset-top)) 16px 13px; border-bottom:1px solid var(--line); background:color-mix(in srgb,var(--bg) 94%,transparent); backdrop-filter:blur(22px) saturate(1.15); -webkit-backdrop-filter:blur(22px) saturate(1.15) } + .app-header { width:100%; padding:max(16px,env(safe-area-inset-top)) 16px 13px; border-bottom:1px solid var(--line); background:var(--bg) } h1 { margin:0 0 14px; font-size:24px; letter-spacing:.05em } main { width:100%; padding:0 16px max(44px,env(safe-area-inset-bottom)) } .primary-nav { width:auto; gap:8px; margin:0 -16px; padding:0 16px 2px; overscroll-behavior-x:contain; -webkit-overflow-scrolling:touch } - .primary-nav button { min-height:44px; padding:9px 16px; border:1px solid var(--line-strong); border-radius:var(--radius-control); background:var(--surface-control); font-size:13px; line-height:1 } + .primary-nav button { min-height:36px; padding:5px 16px; border:1px solid var(--line-strong); border-radius:var(--radius-control); background:var(--surface-control); font-size:13px; line-height:1 } .primary-nav button:hover { border-color:var(--accent) } .primary-nav button.active { border-color:var(--selected-bg); background:var(--selected-bg); color:var(--selected-text) } .context-nav { margin-top:10px } - .context-nav button { min-height:44px; padding:9px 15px } + .context-nav button { min-height:36px; padding:5px 15px } .primary-nav .filter-nav { margin-left:0 } .candidate-grid,.human-grid,.suggestion-compare { grid-template-columns:1fr } .human-card.wide { grid-column:auto } diff --git a/test/inspector.test.ts b/test/inspector.test.ts index 307e909..d2353a7 100644 --- a/test/inspector.test.ts +++ b/test/inspector.test.ts @@ -114,6 +114,9 @@ describe("thought stream inspector", () => { expect(pageHtml).toContain('id="review-nav" class="context-nav"'); expect(pageHtml).toContain('id="system-nav" class="context-nav"'); expect(pageHtml).toContain(".primary-nav button { min-width:max-content; min-height:32px"); + expect(pageHtml).toContain(".primary-nav button { min-height:36px; padding:5px 16px"); + expect(pageHtml).toContain(".context-nav button { min-height:36px; padding:5px 15px }"); + expect(pageHtml).not.toContain(".app-header { position:sticky"); expect(pageHtml).toContain(".primary-nav button.active { border-color:var(--selected-bg); background:var(--selected-bg); color:var(--selected-text) }"); expect(pageHtml).toContain(".telegram-inlay { border:1px solid var(--line); border-radius:20px; background:var(--surface-control)"); expect(pageHtml).toContain("overflow-x:auto"); diff --git a/test/x-webhook.test.ts b/test/x-webhook.test.ts index 1ad4559..5acdcf1 100644 --- a/test/x-webhook.test.ts +++ b/test/x-webhook.test.ts @@ -10,6 +10,7 @@ import { } from "../src/connectors/x-webhook.js"; import { sha256 } from "../src/core/json.js"; import type { JazzThoughtStore } from "../src/jazz/store.js"; +import { buildRootActivity, presentActivityEvent } from "../src/projections/activity.js"; import { temporaryProject, testStore } from "./helpers.js"; const roots: string[] = []; @@ -49,6 +50,35 @@ describe("X Activity webhook", () => { postId: "2080761269309813134", authorId: "1111111111111111111", }); + const activity = await buildRootActivity(store); + expect(activity.items.find((item) => item.id === events[0]?.id)?.presentation).toEqual({ + renderer: "generic", + title: "Posted on X", + body: "Hello from the Activity API", + objectLabel: "X post", + url: "https://x.com/i/web/status/2080761390344937796", + urlLabel: "Open post", + }); + expect(activity.items.find((item) => item.id === events[1]?.id)?.presentation).toEqual({ + renderer: "generic", + title: "Deleted an X post", + body: "", + objectLabel: "X post", + }); + expect(presentActivityEvent({ + ...events[0]!, + payload: { + ...events[0]!.payload, + post: { + ...(events[0]!.payload.post as Record), + inReplyToUserId: "2222222222222222222", + referencedPosts: [{ type: "replied_to", postId: "2080761000000000000" }], + }, + }, + })).toMatchObject({ + title: "Replied on X", + urlLabel: "Open reply", + }); for (const event of events) { expect(JSON.stringify(event.payload)).not.toContain("public_metrics"); expect(JSON.stringify(event.payload)).not.toContain("followers_count"); @@ -96,6 +126,14 @@ describe("X Activity webhook", () => { }, }); expect(JSON.stringify(event?.payload)).not.toContain("includes"); + expect((await buildRootActivity(store)).items.find((item) => item.id === event?.id)?.presentation).toEqual({ + renderer: "generic", + title: "Liked a post on X", + body: "", + objectLabel: "X post", + url: "https://x.com/i/web/status/2079814480427442556", + urlLabel: "Open liked post", + }); const lifecycle = await store.listEvents({ source: "connector:x:cameron-private" }); expect(lifecycle.every((item) => item.privacy === "sensitive")).toBe(true); -- 2.51.2