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
12 changes: 7 additions & 5 deletions App/Features/AI/AIAssistantViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
// views stay declarative: a view asks for an action, watches `phase`, and applies
// an accepted result through `AIAssistantOutcome`.
//
// Two rules shape this type. First, **every run costs the user money** — a quota
// unit and a call against their own provider key — so nothing runs implicitly:
// no run on appear, no retry on failure, no speculative prefetch beyond the free
// availability read. Second, a preview is *never* applied on its own; the user
// confirms, and for series the confirm is what persists anything.
// Two rules shape this type. First, **every run spends a unit of the user's daily
// quota** — 50 on the probed account — so nothing runs implicitly: no run on
// appear, no retry on failure, no speculative prefetch beyond the free
// availability read. (The cost is quota, not money: AI is included in the
// subscription and the user holds no provider key — GitHub #39.) Second, a
// preview is *never* applied on its own; the user confirms, and for series the
// confirm is what persists anything.
//
// Per Decision 0003 this view model consumes only `InterlinedDomain`.

Expand Down
7 changes: 4 additions & 3 deletions App/Features/AI/AIComposerMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
//
// The menu is always visible but disables itself with a *reason* when AI is off,
// because "greyed out with no explanation" is the worst version of a gated
// feature — a subscriber with no provider key would otherwise have no way to
// learn what is missing. The reason comes from the server, not from a local
// guess at entitlements.
// feature. The reason comes from the server, not from a local guess at
// entitlements — which matters because the reasons are not all the user's to
// fix: a lapsed subscription is, a quota is until tomorrow, and an empty
// `providers[]` is a service outage they can do nothing about (GitHub #39).
//
// Per Decision 0003 this view consumes only `InterlinedDomain`.

Expand Down
30 changes: 23 additions & 7 deletions Packages/InterlinedDomain/Sources/InterlinedDomain/Models/AI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,21 @@ public enum AIDocumentMode: Sendable, Equatable {

// MARK: - Availability

/// Whether AI can be used right now, and if not, why. AI is gated on **two**
/// things the server owns: an active subscription and a provider key the user
/// supplied on the web Integrations page. Asking the server beats guessing from
/// `customerStatus`, because a paying user with no key still cannot call.
/// Whether AI can be used right now, and if not, why.
///
/// AI is gated on the **subscription alone**. There is no user-supplied provider
/// key and no separate AI bill: per `/help/ai` and `/help/settings`, AI is
/// powered by Claude and provided by InterlinedList as part of the subscription.
/// Live probe 2026-09-15 on a subscriber account:
///
/// ```
/// GET /api/ai/status → 200 {"subscriber":true,"providers":["anthropic"],
/// "quota":{"usedToday":0,"dailyLimit":50,"remaining":50}}
/// ```
///
/// So an empty `providers[]` is a **service-side** state, not a user
/// misconfiguration — see `unavailableReason`. Asking the server still beats
/// guessing from `customerStatus`, because it also carries the quota.
public struct AIAvailability: Sendable, Equatable {
public let isSubscriber: Bool
/// Provider slugs the account has configured, e.g. `["anthropic"]`.
Expand Down Expand Up @@ -172,8 +183,11 @@ public struct AIQuota: Sendable, Equatable {
public var hasRemaining: Bool { dailyLimit <= 0 || remaining > 0 }
}

/// Token spend and model for one AI call. Shown next to a preview so the cost of
/// the user's own provider key is visible rather than hidden.
/// Token spend and model for one AI call.
///
/// Shown next to a preview so the run is legible rather than opaque. It is **not**
/// a bill: the user has no provider key and pays nothing per call beyond their
/// daily quota. Quota, not money, is the scarce thing to show them.
public struct AIUsage: Sendable, Equatable {
public let inputTokens: Int?
public let outputTokens: Int?
Expand Down Expand Up @@ -359,7 +373,9 @@ public enum AIGenerationResult: Sendable, Equatable {
/// Failures the AI surface can present meaningfully. Anything else propagates as
/// the underlying `APIError`.
public enum AIError: Error, Sendable, Equatable {
/// The account is not a subscriber, or has no provider key configured.
/// AI cannot be used right now: no subscription, no quota left, or the
/// service reporting no providers. The `reason` is the sentence to show —
/// it never asks the user to configure something that does not exist.
case unavailable(reason: String)
/// The draft is shorter than the feature's minimum.
case inputTooShort(minimumWords: Int)
Expand Down
69 changes: 69 additions & 0 deletions docs/decisions/0007-list-folders-not-returning-to-macos.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 0007 — List folders do not return to macOS

- **Status:** Accepted (2026-09-13)
- **Date:** 2026-09-13
- **Decided by:** the repository owner, on [GitHub issue #49](https://github.com/CompositeCode/interlinedlist-macos-native/issues/49)
- **Context:** PR #19 (`refactor/lists-remove-list-folders`, merged 2026-09-06) removed the List Folders feature from the macOS client on the understanding that it had been retired platform-wide. The 2026-09-07 parity sweep established that it had **not** been.
- **Supersedes / superseded by:** re-opens and closes the old **G6**.

## Context

List folders are a **live, documented, current web feature**. Verified live on
2026-09-07:

```
GET /api/folders → 200 {"folders":[]}
POST /api/folders (subscribers only)
PUT /api/folders/{id}
DELETE /api/folders/{id}
```

`/help/lists` documents them in detail — arbitrary nesting, 1–80 character names
unique among siblings, no-move-under-your-own-descendant, and a delete that
cascades to subfolders while re-parenting their lists to the root. Every list row
on the wire carries a `folderId`.

So after PR #19 the macOS client has a **one-way divergence**: lists organised
into folders on the web appear flat on the Mac, and a `folderId` set elsewhere is
invisible and uneditable here.

Two positions were defensible:

- **Rebuild it.** The web ships it and the API serves it; restore parity.
- **Keep the divergence deliberately.** macOS already has owned-list
`parentID` parent/child nesting — untouched by PR #19 and still shipping —
which covers much of the same organising need.

What was *not* defensible was the state as found, where the divergence was
undocumented and read as an oversight.

## Decision

**macOS does not rebuild list folders.** The divergence is deliberate. The web
keeps its folder tree; the Mac organises lists with parent/child nesting.

## Consequences

**The capability gate ships 11 cases, not 12.** The `fix/account-status-gating`
branch carried a `.listFolderCreation` case in both `GatedAction` and `Feature`,
written while the client still had the feature. It was dropped before that branch
merged as PR #83. A gate for a capability the client does not have is dead code
that reads like a promise.

**Parent/child list nesting is a different feature and is unaffected.** It was
never removed, it is not folders, and a future sweep should not conflate them.

**A list's `folderId` is preserved, not managed.** macOS neither reads nor writes
it, and must not clear it — a list filed into a folder on the web stays filed
there after a macOS edit. (`UpdateListRequest` carries no `folderId`, so this
holds by construction today; anything that adds one must keep it.)

**Issue #49 stays open on purpose**, labelled `wontfix`, as the standing product
record. Closing it would make the divergence look like an oversight again, which
is the exact failure this decision exists to prevent.

## Notes

GitHub issue **#28** ("New Folder still shows up in Lists") was closed as stale
against a pre-PR-#19 binary. That was correct and is unrelated: #28 was about a
*leftover control*, this is about the *absent feature*.