feat(ai): AI writing assistance across composer, lists and documents (G15) - #43
Merged
Conversation
The backend shipped `/api/ai/{status,suggest,generate}` — five documented,
Bearer-ready, subscriber-included features driving three web UIs — and iOS had
no consumer at all (work-consolidation.md G15). This adds all five.
Gating is one flag, `aiStatus().subscriber`. AI runs on the app's own
server-side provider key, so there is no key entry, no provider picker, and no
BYO-key state to model. A free user sees no AI control at all — never a disabled
one, and no price or upgrade copy anywhere (Guideline 3.1.1). `providers` /
`defaultModels` are display metadata only, never a precondition.
- `APIClient+AI.swift` is the one feature that deliberately bypasses the shared
transport seam. Its responses carry model-authored JSON that must reach
`/generate` byte-identical — the shared decoder's `convertFromSnakeCase` would
rewrite a Powered Template's DSL field keys (`scheduled_at` → `scheduledAt`)
and fail the server's re-validation — and `checkResponse` maps failures onto
`APIError`, dropping the machine `code` and `Retry-After` that the AI error
contract is built on. Plain coders and `AIServiceError` instead
- `AIJSON` carries that JSON losslessly (whole numbers stay integers, so a
`validation: { min: 1 }` doesn't come back as `1.0`)
- `AIServiceError` maps all seven documented codes. Per the product decision
that AI is an entitlement, `no_provider_configured` is now a transient server
outage, not a user-fixable state: neutral copy, no key prompt, control retires
for the session. The `subscription_required` body steers toward a purchase and
is never surfaced — a test asserts the words can't reach the UI
- `AIService` owns availability and the shared 50/day quota (both `suggest` and
`generate` decrement it). The local `customerStatus` gates the first render so
nothing flashes; a loaded `/status` supersedes it. Concurrent status loads from
the three entry points share one request
- Composer: pen menu (rewrite / tighten / expand / grammar / thread / tags) →
suggestion sheet with Replace draft / Use in draft / Add tags, tags merging
case-insensitively into the tags field
- Composer: Message + Article Series, inert under 10 words (a short draft is
rejected server-side but still spends a quota unit). Rotating loading captions
because these calls run tens of seconds. Message Series reads the composer's
live cross-post selection rather than offering a second picker — it sizes the
posts to the tightest selected service and, with `scheduleImmediately`, posts
to exactly those accounts
- `CreateListView` grows an "AI Template" tab. It creates through `/generate`
rather than the web's route via the schema editor, because `DraftProperty`
only models text/number/boolean/date/url/email and would silently drop the
generated `select`/`multiselect` columns, their options, and the starter rows
- `DocumentsView` grows Powered Document (article / from list / from article /
research URL), sending id-only references — the server re-fetches and
authorizes each source itself. Root-only, matching where the server writes it
- `CreateListView.onCreate` widens to `(UserList?)`: `/generate` returns only an
id, so nil means "created, refetch". The single call site is unchanged
- `MockURLSession` gained optional response headers, needed to test `Retry-After`
Verified: build succeeds; 942 tests, 0 failures (iPhone 16 · 302E002E-…,
-parallel-testing-enabled NO, E2E skipped) — 861 on this base plus 81 new. A new
read-only E2E test pins the live production `/api/ai/status` payload to the
client model and asserts its `subscriber` matches the local `customerStatus` the
composer gates on; it passed against the account in `.env`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K42aNvtDc3mSRceuTLohj9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes G15 from
work-consolidation.md. The backend shipped/api/ai/{status,suggest,generate}— five documented, Bearer-ready, subscriber-included features driving three web UIs — and iOS had no consumer at all. This adds all five (Thread B, slices B1–B5).Gating is one flag:
aiStatus().subscriber. AI runs on the app's own server-side provider key, so there is no key entry, no provider picker, and no BYO-key state to model. A free user sees no AI control at all — never a disabled one, and no price or upgrade copy anywhere (Guideline 3.1.1).providers/defaultModelsare display metadata only, never a precondition.What's included
All in commit
51d2d42.B1 · Foundation
APIClient+AI.swift—aiStatus/aiSuggest/aiGenerate. This is the one feature that deliberately bypasses the shared transport seam from refactor(api): extract the HTTP seam so endpoints can live in feature files #38, for two reasons: its responses carry model-authored JSON that must reach/generatebyte-identical (the shared decoder'sconvertFromSnakeCasewould rewrite a Powered Template's DSL field keys —scheduled_at→scheduledAt— and fail the server's re-validation), andcheckResponsemaps failures ontoAPIError, dropping the machinecodeandRetry-Afterthat the AI error contract is built on.AIJSON— lossless JSON carrier. Whole numbers stay integers, so avalidation: { min: 1 }doesn't come back as1.0.AIServiceError— all seven documented codes. Per the entitlement decision,no_provider_configuredis a transient server outage, not a user-fixable state: neutral copy, no key prompt, control retires for the session. Thesubscription_requiredbody steers toward a purchase and is never surfaced.AIService— owns availability and the shared 50/day quota (bothsuggestandgeneratedecrement it). The localcustomerStatusgates the first render so nothing flashes; a loaded/statussupersedes it. Concurrent status loads from the three entry points share one request.B2 · Composer Writing Assistant — pen menu (rewrite / tighten / expand / grammar / thread / tags) → suggestion sheet with Replace draft / Use in draft / Add tags. Tags merge case-insensitively into the tags field.
B3 · Message + Article Series — inert under 10 words (a short draft is rejected server-side but still spends a quota unit). Rotating loading captions, because these calls run tens of seconds and a silent spinner reads as hung. Message Series reads the composer's live cross-post selection rather than offering a second picker: it sizes the posts to the tightest selected service and, with
scheduleImmediately, posts to exactly those accounts.B4 · Powered Templates — an "AI Template" tab in
CreateListView.B5 · Powered Document —
DocumentsViewentry point, four modes (article / from list / from article / research URL), sending id-only references; the server re-fetches and authorizes each source itself.Judgment calls worth a look
/generate, not the web's route (schema editor →POST /api/lists).DraftPropertyonly models text/number/boolean/date/url/email, so routing the AI schema through it would silently drop the generatedselect/multiselectcolumns, their options, and the starter rows. Trade-off: no inline schema editing before create (you edit after), and the list is created private.folderId: null), so offering it inside a folder would be misleading.advancedToolbar, which replies don't render.CreateListView.onCreatewidens to(UserList?)—/generatereturns only an id, sonilmeans "created, refetch". The single call site was already{ _ in }and is unchanged.MockURLSessiongained optional response headers, needed to testRetry-After.work-consolidation.md/the-gaps.md) are untouched — Thread A owns them.Testing
xcodebuild test -scheme InterlinedList -destination 'platform=iOS Simulator,id=302E002E-…' -parallel-testing-enabled NO -skip-testing:InterlinedListTests/E2EReadOnlyTests— 942 tests, 0 failures (861 on this base plus 81 new).main(after refactor(api): extract the HTTP seam so endpoints can live in feature files #38–docs: record Thread A as complete and the two hazards it turned up #42) and re-verified there; theprivate-removal seam I had hand-made was dropped in favour of refactor(api): extract the HTTP seam so endpoints can live in feature files #38'sAPIClientTransport.swift.Retry-After; a Powered Template DSL round-trip asserting field keys survive suggest → generate; a Guideline 3.1.1 test asserting "subscription"/"subscribe"/"upgrade" cannot reach the UI; quota accounting; the cross-post → channel-label → char-limit derivation.GET /api/ai/statuspayload to the client model and asserts itssubscribermatches the localcustomerStatusthe composer gates on. Passed against the.envaccount./suggestand/generateare deliberately not exercised in E2E — both spend the account's daily quota and/generatewrites.🤖 Generated with Claude Code