Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Rebuild the session sharing dialog and reader layout

Status: implemented
Translation: pending

## Abstract

The sharing dialog asked the author to audit a checklist of candidate conversations
and then tick a consent box that restated the disclosure directly above it, all in
one scrolling column whose action buttons could be pushed off a phone screen. The
dialog is now a fixed header over one scrolling body with a pinned action row,
sub-conversations collapse to a single switch, and the separate acknowledgement is
gone — the disclosure on the link card is the notice. Because the stored grant is
still an explicit id set, the switch can only mean "the sub-conversations that exist
and are ready now", and the copy says exactly that rather than implying later ones
join by themselves; the cost is that a partial subset can no longer be expressed. The
anonymous reader drops its permanent "Read only · Updates live" line in favour of an
appearance control that reuses the app's own `ThemeProvider`, default and storage key,
with the limit that `localStorage` is per-origin, so the hosted share domain cannot
inherit a choice made on the app domain and remembers its own.

## Decision

### Dialog

`SessionShareDialogFrame` no longer scrolls as a single box. It renders a fixed
header and one scrolling body, and `useKeyboardAwareScrollIntoView` now observes
that body — the element that actually scrolls — which is what the hook documents
as its contract. `SessionShareManager` puts its action row in a `sticky bottom-0`
container inside that body.

The composition control is one switch, "Include sub-conversations", replacing the
per-candidate checklist. The author makes a single decision instead of auditing a
list, and the common case — a conversation with no descendants — renders no switch
at all, leaving a dialog that is just the link, one disclosure sentence and the
actions. The candidate search field went away with the list it filtered.

The switch is a projection over the same explicit id set the server verifies target
by target; the wire contract did not change. Turning it on selects the root plus
every descendant that is currently eligible, capped at `SESSION_SHARE_MAX_TARGETS`
with a line stating the cap when it bites. Ineligible descendants are simply not
selected, and the helper line — "Shares the N sub-conversations that are ready now.
Later ones are not added automatically." — carries the two facts a bare boolean
cannot: not-ready targets are excluded, and the grant does not grow on its own. A
grant that still lists a since-unavailable target reads as on, so turning the switch
off is what repairs it.

The separate acknowledgement checkbox is gone and no longer gates the mutations. The
disclosure on the link card is the notice; it states the four things a user cannot
infer — original documents, history and attachments, later updates, and that links
are forwardable and not end-to-end encrypted.

The dialog also stopped listing other grants that include this conversation. That
section answered a question ("which other links can still read this?") that readers
did not recognise as being about _other_ conversations' links, and it cost a heading,
an explanatory line and a row per grant in a dialog whose point is one decision. The
`sources` data is still fetched — `copyableShareIds` needs it — only the section is
gone.

Two things that were only "disabled" now explain themselves: a root that has not
finished syncing says so above the actions instead of leaving Create inert with no
reason, and Save renders only once something actually changed rather than sitting
permanently greyed out.

### Reader

The header is one container: workspace name, visitor label and appearance control
on the first line, then the conversation title on a full-width line, then the
target navigation. Putting the visitor label on the workspace line is what makes a
390px-wide phone show a usable title instead of an ellipsis. The permanent
"Read only · Updates live" status is gone — the surface offers no composer, editing,
retry or approval affordance, so it is read-only by construction — and the status
region now renders only interruptions (paused, loading), collapsing when empty.

`ShareThemeToggle` reuses `nextCycledTheme` and `useTheme` from the app's
`theme-provider`, giving the reader the same light → dark → system cycle, the same
`system` default and the same VS Code theme application as the product. The private
host stopped overriding `storageKey`, so it uses the app's key: serving `/s` from the
app's origin now carries a visitor's existing choice over.

## Alternatives considered

Hiding the `h1` when several targets are shared would have removed the echo between
the title and the selected navigation entry, but it makes the existing heading
assertion in the built-page suite pass against a 1px element rather than something a
reader can see. The tabs were capped instead so a second target always peeks in and
the row reads as navigation.

A reader-only light/dark switch was rejected: it would have introduced a second
default and a second cached key competing with the app's, which is the problem the
previous `lody-share-theme` override already created.

The single switch supersedes the per-item selection this note first recorded, and with
it the phase-one guidance that the author picks each target individually; those
documents were updated in the same change. The authorization model is untouched — the
grant is still an explicit id list, each target is still verified separately, and later
conversations still never join on their own. What was given up is the ability to express
a partial subset: an author who wants some but not all descendants can no longer say so,
and toggling off then on widens a legacy partial grant to everything currently ready.
That trade was accepted deliberately in favour of one decision instead of a checklist.

Hiding the other-grants list is a second deliberate gap, and a sharper one. Q12 in
`docs/session-sharing.md` requires the management surface to show every still-valid
sharing source precisely so that revoking one link is not mistaken for "this
conversation is no longer shared anywhere". With the section gone, an author who
revokes the link shown here can still be readable through a grant rooted at another
conversation, and nothing in the dialog says so. The requirement was not reinterpreted
away: it is recorded as an open gap in that document, and the section is expected to
return in a clearer form rather than stay absent.

## Evidence and limits

`session-share-manager.test.tsx` covers the switch's real obligations: a new link is
created without extra confirmation and does not widen itself, turning the switch on
adds every ready descendant and excludes the unavailable one, turning it off returns
to the root alone, the switch is absent when there is nothing to include and frozen
during a mutation, and an unshareable root is explained rather than only disabled.
The administrator and expired-grant cases keep their existing assertions.
The built-page suite in the private Web host adds two tests against the real
`dist-share` artifact — the reader resolves an unset preference through `system` and
stores a chosen mode under the app's `vite-ui-theme` key, and a value cached on that
origin overrides the system preference. All twelve built-page tests pass.

Layout was reviewed from rendered screenshots rather than by reading CSS: the manager
stories at 1200px and 390px in both themes and in Chinese, and the built reader at
1280px and 390px in both themes. That review is what caught the status text being
truncated to "Link …" by its own buttons on a narrow phone, and the reader title
being crushed by the visitor label. After the switch replaced the checklist, the whole
dialog fits a 390px viewport without scrolling.

Limits: this entry is a client-rendered SPA, so a cached theme is applied when the
reader mounts, not before first paint. Cross-origin theme sharing is not possible and
was not attempted — `localStorage` is per-origin, so `share.lody.ai` keeps a separate
selection from the app domain, which is why the in-page control exists. The screenshots
used synthetic fixtures; no deployed backend was exercised.
75 changes: 75 additions & 0 deletions .agents/notes/implemented/feature/2026-09-09-session-sharing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Read original conversations through explicit share links

Status: implemented
Translation: pending

## Abstract

A live conversation link must provide continuing access without granting the
recipient workspace membership or write authority. The client now combines an
explicit target picker with an isolated, in-memory reader of each original Loro
stream. Link secrets belong to the creating device and travel to the sharing
API as bearer credentials. Reading the original stream preserves history and
live updates, but also exposes stored content beyond the current presentation;
the sharing disclosure states that trade-off. Hosted authorization and native
device acceptance remain separate verification responsibilities.

## Decision and boundaries

The [draft specification](../../../../specs/session-sharing.md) owns intended
behavior; [the sharing module rules](../../../../packages/components/src/components/sharing/AGENTS.md)
own implementation invariants. This complements
[local image export](2026-09-08-chat-share-image.md), which captures a selected
snapshot rather than authorizing continuing stream access.

Original-stream reuse avoids maintaining a second transcript projection and
preserves existing snapshot and update compatibility. It deliberately forgoes
field redaction and per-message publication. Related Tabs are discovery hints;
only explicit selection and the authorized manifest determine what a reader
may open. Independent child links retain separate authority.

```text
Cloud-capable client
management dialog -> public cloud DTOs -> sharing service
successful create/reset -> device secret -> explicit-host link

Anonymous reader
link fragment -> bearer API -> authorized manifest
selected target -> one in-memory LoroDoc -> shared message renderer
attachment reference -> bearer API -> transient object URL
```

The host supplies the share origin. Public packages contain no hosted deployment
default, backend implementation, or generated backend API. Local composition
hides management through the existing capability boundary. The reader does not
mount the authenticated application, and its stream adapter disables uploads
independently of a method/path gate that allows only reads.

Device secrets are credentials rather than replaceable cache. Expected versions
and identity checks prevent a delayed management result from saving a secret to
another account or accepting an obsolete edit. Failure to persist a successful
secret leaves a temporary copy action and explains the need to reset later.

## Reconnection discovery

The stream SDK retries live reads, but a failed initial join removes its status
listener. Simply retrying on that same transport can leave the page permanently
paused or lose subsequent status updates. The reader retains the original
LoroDoc and recreates only the closed transport for retryable initial failures,
using bounded backoff. Disposal and access denial cancel pending retries;
non-retryable failures do not start a retry loop.

## Evidence and remaining limits

Synthetic [reader tests](../../../../packages/components/tests/session-share-reader.test.ts)
cover read-only transport, snapshots, incremental updates, invalidation, and
initial-join recovery. [Management tests](../../../../packages/components/tests/use-session-share-management.test.tsx)
cover credential persistence, identity changes, version conflicts, and explicit
origin injection. Candidate, secret-storage, manager, and mobile-menu tests cover
their public client boundaries; controlled Storybook surfaces use synthetic data.

Local typechecks, tests, and isolated browser integration were exercised during
implementation; the PR gate must validate the final integrated revision.
Public tests cannot establish hosted token enforcement, service replication,
attachment lifecycle configuration, or native-device keyboard behavior. The
draft spec remains unapproved and its translation pending. Implementation review: [PR #539](https://github.com/LodyAI/Lody/pull/539).
50 changes: 49 additions & 1 deletion locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3947,5 +3947,53 @@
"sessions.fileViewer.previousPage": "Previous page",
"sessions.fileViewer.nextPage": "Next page",
"sessions.fileViewer.page": "Page",
"sessions.fileViewer.pageUnavailable": "File changed or is unavailable. Reopen the preview."
"sessions.fileViewer.pageUnavailable": "File changed or is unavailable. Reopen the preview.",
"sharing.unavailable": "This share is unavailable",
"sharing.unavailableDetail": "The link may be incomplete, reset, or revoked.",
"sharing.paused": "Updates paused · Reconnecting…",
"sharing.loading": "Loading shared conversation…",
"sharing.defaultTitle": "Shared conversation",
"sharing.anonymous": "Anonymous visitor",
"sharing.appearance": "Appearance: {{mode}}",
"sharing.conversations": "Shared conversations",
"sharing.empty": "No messages yet",
"sharing.attachmentUnavailable": "Attachment unavailable or expired",
"sharing.loadingAttachment": "Loading attachment…",
"sharing.closePreview": "Close preview",
"sharing.permissionPending": "Waiting for the author",
"sharing.manager.disclosure": "Anyone with the link can read the shared conversations in full — original documents, history, attachments and later updates. Links can be forwarded and are not end-to-end encrypted.",
"sharing.manager.loading": "Loading sharing settings…",
"sharing.manager.active": "Link active",
"sharing.manager.revoked": "Link revoked",
"sharing.manager.unavailable": "Link is currently unavailable",
"sharing.manager.noLink": "No share link yet",
"sharing.manager.missingSecret": "This device does not have the link secret. Reset the link to copy it again; the old link stops working.",
"sharing.manager.includeChildren": "Include sub-conversations",
"sharing.manager.childrenReady": "Shares the {{count}} sub-conversations that are ready now. Later ones are not added automatically.",
"sharing.manager.childrenReady_one": "Shares the sub-conversation that is ready now. Later ones are not added automatically.",
"sharing.manager.childrenReady_other": "Shares the {{count}} sub-conversations that are ready now. Later ones are not added automatically.",
"sharing.manager.childrenNotReady": "None of the sub-conversations are ready to share yet. They need to finish syncing to the cloud.",
"sharing.manager.childrenLimit": "One link covers at most {{max}} conversations, so only the first {{count}} are included.",
"sharing.manager.rootNotReady": "This conversation is not ready to share yet. It needs to finish syncing to the cloud.",
"sharing.manager.copy": "Copy share link",
"sharing.manager.revoke": "Revoke link",
"sharing.manager.create": "Create share link",
"sharing.manager.save": "Save changes",
"sharing.manager.reset": "Reset link",
"sharing.manager.conflict": "Sharing changed on another device. Reload before making changes.",
"sharing.manager.reload": "Reload selection",
"sharing.manager.confirmReset": "The old link stops working and you get a new one. Readers may finish an in-progress download before access ends.",
"sharing.manager.confirmRevoke": "This link and every conversation in it stop being readable. Other links and already downloaded content are unaffected.",
"sharing.manager.confirm": "Confirm",
"sharing.manager.cancel": "Cancel",
"sharing.manager.title": "Share conversation",
"sharing.manager.failed": "Could not update sharing. Check the current settings and try again.",
"sharing.manager.created": "Link ready. Copy it to share.",
"sharing.manager.storageUnavailable": "Link ready, but this device could not save its secret. Copy it now; after closing this page you may need to reset the link.",
"sharing.manager.saved": "Selection saved.",
"sharing.manager.copied": "Share link copied.",
"sharing.manager.revokedNotice": "Link revoked. In-progress reads may take a short time to stop.",
"sharing.renderError": "This conversation could not be displayed",
"sharing.renderErrorDetail": "The shared document may contain unsupported content. Try reloading the page.",
"sharing.reloadPage": "Reload page"
}
Loading
Loading