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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ the archive; if it broke production, it belongs in `docs/incidents/`. See

## [Unreleased]

- **Insights** — each conclusion says when you settled it, and the four-category enum was dropped before it was built — web, mobile, shared · [details](docs/changelog-archive/2026-H2.md#2026-09-10-insights-a-conclusion-can-be-removed)
- **Insights** — a conclusion filed against the wrong chapter can be removed; it used to be permanent — backend, web, mobile · [details](docs/changelog-archive/2026-H2.md#2026-09-10-insights-a-conclusion-can-be-removed)
- **Progress** — one "mark as finished" across web and mobile, and the first write for a book stops being the exception — backend, web, mobile, shared · [details](docs/changelog-archive/2026-H2.md#2026-09-10-progress-the-same-action-writes-the-same-thing)
- **MCP** — the assistant can see where you are, and can record a chapter you finished somewhere else — backend · [details](docs/changelog-archive/2026-H2.md#2026-09-10-mcp-the-assistant-can-see-where-you-are)
Expand Down
9 changes: 7 additions & 2 deletions apps/mobile/src/components/library/BookInsightsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'
import { insightsApi, insightChapterLabel, type BookInsight } from '@textstack/shared'
import { insightsApi, insightChapterLabel, insightDateLabel, type BookInsight } from '@textstack/shared'
import { useTheme } from '../../context/ThemeContext'
import { useLanguage } from '../../context/LanguageContext'
import { Ionicons } from '@expo/vector-icons'
Expand Down Expand Up @@ -84,6 +84,10 @@ export function BookInsightsSection({ userBookId, editionId }: Props) {
<Text style={[styles.scope, { color: colors.textSecondary }]}>
{(insightChapterLabel(insight) ?? t('library.insights.wholeBook')).toUpperCase()}
</Text>
{/* When it was last written — a конспект is read months later. */}
{insightDateLabel(insight) ? (
<Text style={[styles.date, { color: colors.textSecondary }]}>{insightDateLabel(insight)}</Text>
) : null}
<TouchableOpacity
onPress={() => remove(insight.id)}
disabled={removing === insight.id}
Expand Down Expand Up @@ -118,7 +122,8 @@ const styles = StyleSheet.create({
title: { fontSize: 18, fontFamily: fonts.sansBold, marginBottom: 2 },
lead: { fontSize: 13, fontFamily: fonts.sans, marginBottom: 16, lineHeight: 18 },
item: { paddingLeft: 12, borderLeftWidth: 3, marginBottom: 20 },
head: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
head: { flexDirection: 'row', alignItems: 'center', gap: 8 },
date: { fontSize: 11, fontFamily: fonts.sans, opacity: 0.75, marginRight: 'auto' },
scope: { fontSize: 11, fontFamily: fonts.sansBold, letterSpacing: 0.6 },
question: { fontSize: 15, fontFamily: fonts.sansBold, marginTop: 2, lineHeight: 20 },
body: { marginTop: 6 },
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/components/library/BookInsightsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { insightChapterLabel, type BookInsight } from '@textstack/shared'
import { insightChapterLabel, insightDateLabel, type BookInsight } from '@textstack/shared'
import { getBookInsights, deleteBookInsight } from '../../api/insights'
import { useTranslation } from '../../hooks/useTranslation'

Expand Down Expand Up @@ -84,6 +84,13 @@ export function BookInsightsSection({ userBookId, editionId }: Props) {
<div className="book-insights__scope">
{insightChapterLabel(insight) ?? t('library.insights.wholeBook')}
</div>
{/* When it was last written. A конспект is read months later, and without a date it
cannot answer "is this what I thought then, or what I think now". */}
{insightDateLabel(insight) && (
<time className="book-insights__date" dateTime={insight.updatedAt}>
{insightDateLabel(insight)}
</time>
)}
{/* A conclusion filed against the wrong chapter is never revisited by the assistant —
it only ever replaces its own row for the chapter it MEANT. Removing it is the
reader's, and only the reader's. Its own element, not inside the label: the label
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/styles/books.css
Original file line number Diff line number Diff line change
Expand Up @@ -8338,6 +8338,7 @@ a.read-later__title:hover { text-decoration: underline; }
.book-insights__scope { font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--color-text-secondary, #666); }
/* Pushed to the end of the eyebrow row rather than given a corner of its own: removing a note
is a rare correction, not an action the panel should advertise. */
.book-insights__date { font-size: 11px; font-variant-numeric: tabular-nums; color: var(--color-text-secondary, #666); opacity: 0.75; }
.book-insights__remove { margin-left: auto; border: 0; background: none; cursor: pointer; font-size: 18px; line-height: 1; padding: 0 4px; color: var(--color-text-secondary, #666); }
.book-insights__remove:hover:not(:disabled) { color: var(--color-error, #c0392b); }
.book-insights__remove:disabled { opacity: 0.4; cursor: default; }
Expand Down
47 changes: 25 additions & 22 deletions docs/05-features/assistant-handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,22 +160,15 @@ required", and `last_used_at` is stamped. That covers the bridge path
to-do here in error. **Do not re-raise it** — the earlier wording here has already misled one agent
into reporting it as an urgent deadline.

### Left behind by the cut — a follow-up, found 2026-09-10 after PR #596 opened

Removing the chat left inert plumbing on the mobile side. None of it is a correctness risk — the
modules have no importers and the one live-looking block cannot execute — but it is exactly the
residue this work exists to remove, so it goes in its own small PR rather than riding along:

- **`apps/mobile/src/lib/sse.ts` and `sseParser.ts` (+ its test) have no consumers at all.** Their
only caller was `bookChat.ts`. Note the web's `lib/sse.ts` is NOT dead — `useExplain` still streams
through it.
- **`ReaderShell.tsx` still imports `citationChapterSlug` and `makeSnippet`**, and keeps
`pendingCitationRef` + `scrollToCitation` alive at :653-654 and :960-963. The only writer of that
ref was the deleted `handleCitation`, so the ref is permanently null and the block at :960 can
never run.
- **`packages/shared/src/reader/citation.ts`** exists for that path only.
- **`AskCitation`, `AskResponse`, `AskTurnDto`, `AskTarget`** in `packages/shared/src/types/api.ts`
are now referenced only by the dead `sseParser` and by `citation.ts`'s own doc comment.
### Left behind by the cut — ~~a follow-up~~ done in PR #597

Removing the chat left inert plumbing on the mobile side: `apps/mobile/src/lib/sse.ts` and
`sseParser.ts` (+ its test), `ReaderShell`'s `citationChapterSlug` / `makeSnippet` imports and its
permanently-null `pendingCitationRef`, `packages/shared/src/reader/citation.ts`, and the `AskCitation`
/ `AskResponse` / `AskTurnDto` / `AskTarget` types. **All removed in #597** — verified 2026-09-10:
every one of those names now has zero references anywhere in `apps/` or `packages/`, and the files
are gone. The list is kept struck through rather than deleted because the reasoning (the web's own
`lib/sse.ts` is NOT dead — `useExplain` still streams through it) is the part worth not re-deriving.

### Found while cutting — decisions still open

Expand Down Expand Up @@ -212,7 +205,7 @@ residue this work exists to remove, so it goes in its own small PR rather than r
| 9 | Catalog screens pass progress into the handoff | 1 |
| 10 | Mobile handoff stops swallowing the open failure | 1 |
| 11a | ~~DELETE for an insight~~ — shipped 2026-09-10, reader-only, no MCP counterpart | 2 |
| 11b | Insight categories (**Conclusions · Watch for · Discussed · Questions**) — **open, see below** | 2 |
| 11b | ~~Insight categories~~ — **not being built**, owner chose per-book retrieval; see below | 2 |
| 12 | Hide the six chats and the RAG UI behind a flag | 3 |
| 13 | One mark-as-read locator across web and mobile (defect 4) | later |
| 14 | `LocatorKind` + `MayReplace` on the catalog path (defects 5, 6) | later |
Expand Down Expand Up @@ -459,11 +452,21 @@ over the lifecycle. They disagreed on the answer and converged on the question.
documents at once. Facet → key later is a free re-index on tens of rows; key → facet later forces
you to choose which row per chapter survives.

**Open, and only the owner can answer it.** Both voices arrived at the same question from opposite
directions: **is the return path per-book or cross-book?** Opening *Dracula* and seeing four buckets
needs no categories — a label and reading order carry it. Seeing every open question across all 33
books cannot be done without a typed field, and also needs a `/me/insights` route with no book
filter, which today answers 400.
**Answered by the owner, 2026-09-10 — and the answer removes the work.** Asked concretely, both
questions came back the way that needs no schema:

- **The return path is per-book.** "I open *Dracula* and see what I worked out about it." Categories
buy nothing there: a chapter label and reading order carry it, and both already ship. No column, no
migration, no tabs.
- **One insight per chapter, refined over time** — so even if a category is ever added, it stays a
label and the unique key does not move. Re-running keeps refreshing the конспект instead of
accumulating four rows per chapter.

So the category enum is **not being built**. What the panel was actually missing for "come back a
month later" was a date, which is one pure function and no schema — shipped instead. Revisit only if
the retrieval ever turns cross-book ("every open question across all 33 books"), which is the one
shape that genuinely needs a typed field, and which would also need the filter-less `/me/insights`
route that answers 400 today.

**Two findings from the lifecycle read, not yet acted on:**

Expand Down
11 changes: 8 additions & 3 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,14 @@ answers "what happened" and nothing answered "what is half-finished right now".
progress-path defects they could not work around (unvalidated chapter slug, a refusal reporting
success, `chapterId` missing from `get_book`, `chapterSlug` selected by the shelf and discarded).

**Not done:** insight categories (Conclusions · Watch for · Discussed · Questions) with tabs and a
DELETE, so a wrong conclusion can be removed; and the owner-only check that the mobile Claude and
ChatGPT apps accept a custom connector at all.
**Since decided:** insight categories are **not being built**. A three-way consilium argued it out
and the owner's answer settled it — the return path is per-book ("I open Dracula and see what I
worked out"), which a chapter label and reading order already serve. DELETE shipped (the part all
three voices agreed on); a date on each note shipped in place of the enum. Revisit only if
retrieval turns cross-book.

**Not done:** the owner-only check that the mobile Claude and ChatGPT apps accept a custom
connector at all.

**Not yet run:** CI, and both destructive migrations (`DropBookChat`, `DropRagSpine`) against
production. Back up first.
Expand Down
11 changes: 11 additions & 0 deletions docs/changelog-archive/2026-H2.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ This came out of a three-way consilium on insight categories, which agreed on th
disagreed on everything else; what it settled and what it left for the owner is written up in
[`assistant-handoff.md`](../05-features/assistant-handoff.md).

**Follow-up, same day — the categories were dropped before they were built.** The plan called for
four fixed categories (Conclusions · Watch for · Discussed · Questions) with tabs. Three independent
readings of the code disagreed about them and converged on one question for the owner: *when you come
back in a month, do you open one book or all of them?* The answer was one book — and per-book
retrieval is served by a chapter label and reading order, both of which already shipped. No column,
no migration, no tabs. What the panel was genuinely missing was a **date**: a конспект is read months
later, and without one it cannot answer "is this what I thought then, or what I think now". That is
one pure function (`insightDateLabel`), absolute rather than relative, ISO so it is unambiguous in
every locale. The enum is revisitable if retrieval ever turns cross-book — the one shape that really
needs a typed field.

<a id="2026-09-10-progress-the-same-action-writes-the-same-thing"></a>

## Progress — the same action writes the same thing, and the first write stops being the exception — backend, web, mobile, shared — 2026-09-10
Expand Down
21 changes: 21 additions & 0 deletions packages/shared/src/library/insightDateLabel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest'
import { insightDateLabel } from './insightScope'

describe('insightDateLabel', () => {
it('dates a conclusion by when it was last written, not when it was created', () => {
// A re-run REPLACES the row and moves updatedAt. The reader asking "is this still what I think"
// is asking about the text in front of them, which is the updated one.
expect(insightDateLabel({ updatedAt: '2026-09-10T22:31:00+00:00' })).toBe('2026-09-10')
})

it('normalises an offset rather than shifting the day', () => {
// 23:30 in +03:00 is still the 10th in UTC. Picking the local calendar day here would make the
// same insight show two different dates on two devices.
expect(insightDateLabel({ updatedAt: '2026-09-11T02:30:00+03:00' })).toBe('2026-09-10')
})

it('returns null for an unparseable timestamp instead of printing Invalid Date', () => {
expect(insightDateLabel({ updatedAt: 'not a date' })).toBeNull()
expect(insightDateLabel({ updatedAt: '' })).toBeNull()
})
})
23 changes: 23 additions & 0 deletions packages/shared/src/library/insightScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,26 @@ export function insightChapterLabel(insight: InsightScopeInput): string | null {
if (insight.chapterSlug === null) return null
return insight.chapterTitle ?? insight.chapterSlug
}

/**
* When the conclusion was last written, as a plain calendar date.
*
* <p>The panel is something a reader comes back to after a month — that was the whole ask — and a
* list of conclusions with no dates cannot answer "is this what I thought then, or what I think
* now". A re-run replaces the row and moves `updatedAt`, so this is genuinely the age of the
* *current* text rather than of the conversation that started it.</p>
*
* <p>Absolute, not relative. "3 days ago" is the right register for a shelf of things in progress
* and the wrong one here: the question a конспект answers is *when did I settle this*, and by the
* time it matters the answer is months, where relative time stops being informative. ISO rather
* than a locale format because this package holds no locale — the clients render it verbatim, and
* the value is unambiguous in every one of them.</p>
*
* <p>Returns null for a timestamp that does not parse, so a bad row loses its date rather than
* printing `Invalid Date` next to a conclusion.</p>
*/
export function insightDateLabel(insight: { updatedAt: string }): string | null {
const at = new Date(insight.updatedAt)
if (Number.isNaN(at.getTime())) return null
return at.toISOString().slice(0, 10)
}
Loading