diff --git a/docs/architecture/conversations.md b/docs/architecture/conversations.md index c11e6ae99..679e588ea 100644 --- a/docs/architecture/conversations.md +++ b/docs/architecture/conversations.md @@ -162,6 +162,20 @@ owner and are resolved only when a user inspects a span. ## Storage and retention +Instrument's People indicator and Zen activity strip read the ordinary owner-scoped +inbox with `attentionOnly: true` and the incoming request list. The Kernel filters +unread, active, unmuted, unblocked conversations before pagination; the caller selects +the usual archived state. Existing `conversation.changed`, `contact.changed` and +`approach.changed` signals invalidate those bounded reads, and reconnect refreshes +them. Attention survives reload through the private inbox projection. Zen and People +observe the same paginated conversation cache; Zen has no separate live-message or +replied-notice store. Only unfinished reply drafts and their submitted send identities +remain local. A draft retains the read position where it began, so reads elsewhere do +not hide its context or pull older read history into the panel. Each send retains the +exact sequence it answers, so a later arrival stays unread when that reply completes. +A failed read update leaves the inbox authoritative +and surfaces the error without repeating the successful send. + The Kernel Durable Object stores the conversation directory, membership, optional handler, surface mapping, latest sequence and private inbox projection. Contact previews, read positions and archive state stay in that projection; canonical messages have one owner. Each conversation has its own installation-scoped Conversation Durable diff --git a/docs/get-started/index.md b/docs/get-started/index.md index 5bc15f2a8..24c91e90c 100644 --- a/docs/get-started/index.md +++ b/docs/get-started/index.md @@ -71,6 +71,13 @@ The web console is called **Instrument** and has five views: The Settings sidebar highlights the section you're viewing. +Open **People** to try making plans or working together with someone on GSV. +**connect** creates a shareable invitation; accepting it opens your conversation. +You can talk directly or choose **ask Ship** to prepare a request in your Ship chat. +A dot beside **people** marks waiting activity, and Zen shows unread conversations +and message requests above the prompt, including after a reload. See +[Contact people](/how-to/contact-people) for the full flow. + If your operator enables cloud browsers, Fleet also offers **start browser**. You can [sign in and save website logins](/how-to/cloud-browsers) there so Ship can use those sites while your personal devices are offline. diff --git a/docs/how-to/contact-people.md b/docs/how-to/contact-people.md index 52dc76660..5be105de9 100644 --- a/docs/how-to/contact-people.md +++ b/docs/how-to/contact-people.md @@ -9,17 +9,37 @@ Search inside a conversation with **search** or `Ctrl/Cmd+F`. Open **details** f the private name, mute and connection controls. **Save contact** adds someone to Contacts without changing their permissions or how Ship handles the conversation. -## Start a conversation +On your first visit, try an example: make plans, plan a trip, or work together. +Choose **try this with someone** to connect. The example remains available when +your conversation opens, so you can turn it into a request to Ship. -Choose **new** to find a saved contact by name or enter someone's public profile address. Review -the profile, choose the display name they will see, and send a first message. -They can accept or decline it. Acceptance keeps that first message in the same -conversation and enables further messages and attachments. +## Start a conversation -Alternatively, choose **use a private invitation** to create or accept a one-use -contact code. These invitations are separate from +Choose **connect**, choose who should handle new messages, then **create invitation +link** and share it wherever you already talk. It connects one person and lasts seven days; you can cancel it before it is +accepted. In managed spaces, the link opens the existing Accounts space chooser. +The recipient chooses a listed space or enters its address, signs in if needed, +and chooses who should handle their new messages before accepting the invitation. Entering an address also works for people +using another operator or a space they do not own. Other deployments open the +space-address form directly. +Neither person needs to publish a profile. Acceptance opens the conversation. + +If someone gives you a link or an older contact code, choose **connect → I have an +invitation** to paste it. These invitations are separate from [inviting an account into your space](/how-to/invite-people). +You can also ask Ship to create an invitation, or give it someone's invitation +link to accept. Ship asks who should handle new messages unless you already told +it, then completes the connection with that choice. It can also change the choice +later when you ask. This works in your ordinary Ship conversation, including +messaging apps; opening People is optional. + +Choose **connect → use a profile address** to find a saved contact by name or enter +someone's public profile address. Review the profile and your prefilled display +name, choose who handles new messages, then send a first message. +They make their own handling choice when accepting, or can decline it. Acceptance keeps that first message in the same +conversation, opens it directly, and enables further messages and attachments. + Your profile starts private. In **Settings → Profile**, save a draft, review it and explicitly publish it. A profile can accept message requests, require a private invitation, or close new contact. Saving later edits does not change the public @@ -28,11 +48,38 @@ conversations remain. People who already viewed it may retain copies. ## Decide who handles it -Accepting a contact does not start Ship. Enable **Ship replies** beside the person's -name to let Ship respond to new incoming messages using its ordinary permissions -and approval rules. Turning it on waits for the next message; it does not start work -on the existing conversation. Turn it off to stop ongoing handling. Messages -distinguish the person from their Ship. +When connecting in People, both people explicitly choose **I’ll handle them** or +**Let Ship handle them** for their own side. Neither option is preselected. The +choice stays with the invitation or request, even if acceptance happens later. +Existing contacts keep their settings. Ship-led invitations use the same explicit +choice; creating or accepting one does not silently choose automatic handling. + +**Automatically handle new messages**, beside the person's name, changes this +choice. When on, Ship can respond using its ordinary permissions and approval +rules. Accepting or enabling the setting does not wake Ship or replay existing +messages; the next incoming message starts handling. Turning it off ends that +standing assignment. Replies to tasks you assign separately can still resume +those tasks until they finish. Messages distinguish the person from their Ship. + +For a particular task, choose **ask Ship** in the conversation. This opens an +editable request in your ordinary Ship chat. Review it and send it when ready. +It does not turn on automatic replies for that person. A first-use example can +prefill a more specific request, such as finding a time for dinner. + +The **people** line above the Ship prompt keeps unread conversations and incoming +requests within reach, including after reload or reconnect. New messages update +the line without opening a panel or moving your place in the Ship conversation. +Select a person's name to read and reply in a compact panel. A **PERSON** or +**GSV** badge identifies who wrote the latest message. **Open conversation** takes +you to the full history in People; selecting a connection request opens it there. +Conversation previews label outgoing messages **You** or **Your Ship**, so they +cannot be mistaken for a new reply from the other person. + +Closing the panel keeps an unfinished reply for this session, marked **draft** on +the person's name. Sending threads the reply to the message it answers and clears +that answered activity; a newer arrival still waits. A dot beside **people** in +the main navigation also marks unread activity. Muted, blocked, ended and archived +conversations stay quiet; an unfinished draft remains reachable. Work for a particular task is separate from that standing preference. When Ship sends with `message send --to contact:ID --responsibility R12Y_ID`, replies continue @@ -43,7 +90,8 @@ ends that association. The receiving person independently chooses who handles their side. A message’s **reply** action quotes that message in the same conversation and keeps -its exact reference. Reply and successful delivery information appear on hover or +its exact reference, including on the first incoming message before you have sent +anything back. Reply and successful delivery information appear on hover or keyboard focus; touch screens keep the actions visible. Delivery problems remain visible until resolved. @@ -51,7 +99,8 @@ visible until resolved. Read position, archive, saved contacts, aliases and mute are private to your space. New messages bring an archived conversation back unless muted. Muting also suppresses -tab attention; it does not revoke Ship's existing assignment. +tab attention and the Ship chat's new-message line; it does not revoke Ship's existing +assignment. Blocking ends the connection, withdraws its attachment grants and refuses new messages and requests from that identity. Unblocking permits a new connection; diff --git a/docs/how-to/install-host-apps.md b/docs/how-to/install-host-apps.md index 8ae930aaa..7d98678bf 100644 --- a/docs/how-to/install-host-apps.md +++ b/docs/how-to/install-host-apps.md @@ -72,6 +72,11 @@ to use text explicitly. ## Sign-in and multiple windows +**Open your space** lets you choose an owned space or enter a handle or domain. +The address form also works for spaces owned by someone else or served by another +operator. Contact invitation links use the same address entry before your normal +space sign-in and explicit acceptance. + Desktop and browser windows can stay connected to the same space at once. They receive new conversation messages live and refresh server state after a reconnect. A remembered space sign-in lasts 30 days and renews during use. diff --git a/docs/reference/cli-commands.md b/docs/reference/cli-commands.md index ce2447628..daa069252 100644 --- a/docs/reference/cli-commands.md +++ b/docs/reference/cli-commands.md @@ -121,10 +121,11 @@ message send --to DESTINATION [--message TEXT] [--attach PATH]... [--mime TYPE] contact identity contact list [--all] [--json] contact alias CONTACT_ID NAME|--clear -contact invite create [--expires DURATION] -contact invite accept CODE +contact invite create --handling manual|ship [--expires DURATION] +contact invite accept LINK_OR_CODE --handling manual|ship contact invite list [--all] [--json] contact invite cancel INVITE_ID +contact handling CONTACT_ID manual|ship --revision N contact revoke CONTACT_ID contact request list [--contact CONTACT_ID] [--all] [--json] contact request create --contact CONTACT_ID --kind KIND --title TITLE [--details JSON] [--delivery-id ID] @@ -313,13 +314,22 @@ automatic retry. An outcome that may have reached the provider is reported as `sent=false`, `delivery_confirmed=false`, and `delivery_state=ambiguous`. `contact` manages relationships with people on other GSV installations. -`contact invite create` produces a short-lived one-use code; the other person -accepts that code while signed in to their own GSV. Pairing and revocation may +`contact invite create --handling manual|ship` returns a short-lived one-use code and a shareable `url`; +the other person accepts either while signed in to their own GSV. The default +lifetime is one hour; `--expires` accepts up to seven days. Pairing and revocation may be performed by the signed-in human or their canonical Ship. `contact list` prints the opaque contact id accepted by `message send --to`; `message destinations` exposes the same active contacts alongside messaging endpoints. `contact alias` changes only the local display name; the remote Ship's authenticated identity remains visible and unchanged. +Creating and accepting through Shell require `--handling manual|ship`. Ship asks +for the owner's choice unless it was already given: `manual` leaves new messages +for the person, while `ship` enables automatic handling. Each side chooses +independently. To change an existing contact, read `preferences.revision` from +`contact list --json` and use `contact handling CONTACT_ID manual|ship --revision N`. +The Kernel accepts changes from the owner or their canonical Ship, keeps capability +and revision checks, and starts no work merely because the setting changed. + `contact invite list --all` exposes retained invitation lifecycle metadata but never a recoverable code. `message history --with contact:...` reads the Contact conversation. A Contact send reports durable local acceptance separately from @@ -328,7 +338,7 @@ remote confirmation; use `message delivery show` with its delivery id. When Ship contacts someone for an existing task, pass `--responsibility ID` to associate replies with that open Ship responsibility. A reply continues the same work without enabling permanent handling of that contact. Acceptance and new -messages stay in People unless the person chooses **Let Ship handle this**. +messages stay in People unless the person chooses **Let Ship handle them**. Use `contact request create` and `contact request update` when the exchange has a durable lifecycle rather than being only a message. Request revisions prevent diff --git a/docs/reference/syscalls.md b/docs/reference/syscalls.md index 5afbf17e2..a911b65c0 100644 --- a/docs/reference/syscalls.md +++ b/docs/reference/syscalls.md @@ -696,7 +696,7 @@ shared account system. Each installation keeps its own conversation, request, resource grants, delivery receipts, and Process state. Pairing is explicit and human-controlled. `contact.invite.create` returns a -short-lived, one-use code; the other signed-in person accepts it with +short-lived, one-use code and a shareable `url`; the other signed-in person accepts either with `contact.invite.accept`. The Kernel derives the remote installation and subject from the signed exchange. Callers never choose a remote local uid, Process, conversation, filesystem path, or installation id. @@ -705,16 +705,27 @@ Trust changes may be initiated by the signed-in human or by that owner's exact canonical Ship Process. Delegated work Processes and remote callers cannot create, accept, cancel, or revoke Contact trust. +`contact.invite.create` and `contact.invite.accept` accept an optional +`shipHandlesMessages` boolean for the local owner's new contact. Setting it +requires the signed-in owner or their canonical Ship with `contact.preferences.update`. +Ship must explicitly supply the owner's choice; omission is rejected for Processes. +The Kernel retains it with the invitation or acceptance attempt and applies it when +that contact generation activates. Older human clients may omit it to keep automatic +handling off. Replaying a completed acceptance does not overwrite later +preference changes. `approach.create` and an `approach.decide` acceptance support +the same local choice, retained through asynchronous pairing and retries. It is +never supplied by the remote person, and activation admits no Ship work itself. + | Syscall | Behavior | |---|---| | `contact.identity` | Returns this installation's signed Ship document and the caller's local federation subject. | -| `contact.invite.create` | Creates a one-use pairing code, optionally with a shorter expiry. | -| `contact.invite.accept` | Verifies and consumes a remote invite, creates both contact records, and ensures the local Contact conversation. | +| `contact.invite.create` | Creates a one-use pairing code and shareable link, with an optional expiry of up to seven days. | +| `contact.invite.accept` | Accepts a link or code, verifies and consumes the remote invite, creates both contact records, and ensures the local Contact conversation. | | `contact.invite.list` | Lists invitation lifecycle metadata without exposing recoverable invitation secrets. | | `contact.invite.cancel` | Cancels one unaccepted invitation. | | `contact.list` | Lists the caller's active contacts; `includeRevoked` includes terminal relationships. | | `contact.alias.set` | Sets or clears the owner's local name for a Contact without changing or federating its authenticated remote identity. | -| `contact.preferences.update` | Human-only, revision-checked changes to saved, muted and standing Ship handling preferences. | +| `contact.preferences.update` | Owner or canonical Ship, revision-checked changes to saved, muted and standing Ship handling preferences. | | `contact.block.set` | Human-only block or unblock of one remote actor; blocking also ends its active connection and pending first-contact requests. | | `contact.block.list` | Reads private blocks, optionally filtered by `actor`, with cursor paging. | | `contact.notice.dismiss` | Dismisses the one-time notice that global contact auto-wake has been retired. | @@ -823,7 +834,7 @@ type ContactSyscalls = { }; "contact.invite.create": { args: { expiresInSeconds?: number }; - result: { inviteId: string; code: string; expiresAtMs: number }; + result: { inviteId: string; code: string; url?: string; expiresAtMs: number }; }; "contact.invite.accept": { args: { code: string }; @@ -1030,7 +1041,7 @@ actor independently of whether a contact was ever accepted. Unblocking does not ```ts type ConversationInboxSyscalls = { "conversation.inbox": { - args: { archived?: boolean; before?: { updatedAt: number; conversationId: string }; limit?: number }; + args: { archived?: boolean; attentionOnly?: boolean; before?: { updatedAt: number; conversationId: string }; limit?: number }; result: { entries: ConversationInboxEntry[]; next?: { updatedAt: number; conversationId: string } }; }; "conversation.view.get": { @@ -1045,7 +1056,9 @@ type ConversationInboxSyscalls = { ``` `conversation.inbox` lists accepted contact conversations, optionally filtered by `archived`, with -`before` and `limit` paging. Entries contain the contact ID, conversation, latest preview, unread +`before` and `limit` paging. `attentionOnly: true` selects unread conversations with active, +unmuted, unblocked contacts before pagination. This uses the same owner-private read state +as the ordinary inbox. Entries contain the contact ID, conversation, latest preview, unread state and private view state. `conversation.view.get` reads one entry; `conversation.view.update` advances `readThroughSequence` monotonically or sets `archived`. Both use `conversationId`. Changing `archived` requires `expectedRevision` from the current view to avoid overwriting a newer change. diff --git a/engineering/social-onboarding-2026-10-08.md b/engineering/social-onboarding-2026-10-08.md new file mode 100644 index 000000000..380744640 --- /dev/null +++ b/engineering/social-onboarding-2026-10-08.md @@ -0,0 +1,62 @@ +# People: a useful first connection + +People owns conversations with people in other GSV spaces, incoming message +requests, and the private address book. A first visit should answer what this +enables, whom to connect with, and how to start. An established user should see +who needs a reply and get back to their conversation immediately. + +The first-use surface introduces talking directly and asking Ship to coordinate +something together. Concrete, selectable examples lead into the same connection +flow as the ordinary Connect action. Invitations are shareable links; existing +codes remain accepted. Managed installations reuse the existing Accounts space +chooser, then the recipient's normal space login and explicit acceptance. Every +invitation entry point also accepts a space address, including spaces owned by +someone else or served by another operator. No public profile is required. +Public profiles remain an alternative for reaching someone by address. + +Acceptance opens the resulting conversation. A task chosen before connecting +remains available afterward. Ask Ship opens an editable draft in the user's +ordinary Ship conversation with the contact identified; the user sends it. +Each person explicitly chooses who handles new messages before inviting or +accepting. Neither option is preselected. Kernel retains that local choice with +the invitation or request, and applies it when pairing completes. Ship can create +and accept invitations in the ordinary conversation after asking +for this choice, and can change it later at the owner’s request. The canonical +Ship uses the same Kernel authority and revision checks; delegated work cannot +change these settings. Connection alone does not start Ship or replay existing +messages. The conversation's +Automatically handle new messages setting changes the choice later; replies to +assigned tasks can still resume those tasks independently. + +Keep Instrument's list/detail layout, typography, thin rules, and quiet text +actions. The empty state must work on a narrow screen too. Incoming requests and +unread conversations are visible from the shared navigation and Zen, including +after reconnect or reload. Existing owner-scoped signals invalidate the existing +inbox and request queries; there is no new notification service or polling loop. + +Kernel owns invitation verification, contact authority, and unread selection. +Accounts owns choosing an existing space. Instrument owns presentation and view +launches. Invitations stay out of request URLs and telemetry by travelling in +fragments. Publication, permissions, credentials, and standing prompt text retain +their existing owners. Containers, a new CLI, a public directory, group chat, and +changes to agent isolation are outside this change. + +Validate the shared invitation format and inbox selection at their boundaries, +the web connection and navigation transitions, and a fresh two-space connection, +message, acceptance and reload flow. Inspect light, dark, and narrow layouts. + +## People while talking to Ship + +Zen keeps one compact People line above its composer. Incoming messages do not +become moving entries in the Ship transcript and never force its scroll position. +The line combines unread conversations kept current by signals and recovered after reload, +incoming requests and unfinished replies. A selected person opens one bounded +panel above the line, with their messages, a reply field and a link to People. +Closing the panel keeps the draft. Replying clears the answered messages; arrivals +during a send remain waiting. The panel does not open itself for new messages. + +Instrument owns this presentation, using the existing inbox, shared conversation cache, +signals and reply intents. Zen keeps only draft state; it has no independent message +store. People retains the full conversation and request +workflow. Form placeholders share the Zen prompt's typographic treatment across +Instrument instead of falling back to native gray placeholders. diff --git a/packages/gsv/src/protocol/contact-invitation.test.ts b/packages/gsv/src/protocol/contact-invitation.test.ts new file mode 100644 index 000000000..1d85ff127 --- /dev/null +++ b/packages/gsv/src/protocol/contact-invitation.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { contactInvitationDestination, contactInvitationUrl, encodeContactInvitation, parseContactInvitation } from "./contact-invitation"; + +const invitation = { version: 1 as const, origin: "https://alice.example", shipId: "ship:alice", + subject: { id: "person:alice", displayName: "Alícia 宇" }, token: "fixture-token", expiresAtMs: 2_000_000_000_000 }; + +describe("contact invitation links", () => { + it("preserves the original invitation through an Accounts chooser and recipient login", () => { + const code = encodeContactInvitation(invitation); + expect(parseContactInvitation(code).invitation).toEqual(invitation); + const shared = contactInvitationUrl(code, "https://accounts.example/owner/signup/?resume=1"); + expect(new URL(shared).search).toBe("?resume=1"); + expect(new URL(shared).search).not.toContain(code); + const selected = contactInvitationDestination("https://bob.example", parseContactInvitation(shared).code); + expect(new URL(selected).pathname).toBe("/people"); + expect(new URL(selected).search).toBe(""); + expect(parseContactInvitation(selected)).toEqual({ code, invitation }); + }); + + it("rejects broken invitations and non-space destinations", () => { + for (const value of ["", "gsv-contact-v1:%%%", "gsv-contact-v1:e30", "https://example.com/?contact=secret", "x".repeat(12_001)]) { + expect(() => parseContactInvitation(value)).toThrow("invitation"); + } + for (const origin of ["javascript:alert(1)", "http://example.com", "https://user:password@example.com", "https://example.com/path", "https://example.com#other"]) { + expect(() => contactInvitationDestination(origin, "code")).toThrow("space address"); + } + expect(new URL(contactInvitationDestination("http://bob.localhost:8976", "code")).pathname).toBe("/people"); + }); +}); diff --git a/packages/gsv/src/protocol/contact-invitation.ts b/packages/gsv/src/protocol/contact-invitation.ts new file mode 100644 index 000000000..1dd0ba904 --- /dev/null +++ b/packages/gsv/src/protocol/contact-invitation.ts @@ -0,0 +1,56 @@ +import { z } from "zod/mini"; +import { federationSubjectSchema } from "./syscalls/contact"; + +const PREFIX = "gsv-contact-v1:"; +const invitationSchema = z.strictObject({ + version: z.literal(1), + origin: z.string().check(z.minLength(1), z.maxLength(2_048)), + shipId: z.string().check(z.minLength(1), z.maxLength(128)), + subject: federationSubjectSchema, + token: z.string().check(z.minLength(1), z.maxLength(128)), + expiresAtMs: z.int().check(z.minimum(0)), +}); +export type ContactInvitation = z.infer; +export type ParsedContactInvitation = { code: string; invitation: ContactInvitation }; + +export function encodeContactInvitation(value: ContactInvitation): string { + const bytes = new TextEncoder().encode(JSON.stringify(value)); + return PREFIX + btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** A link and its original code identify the same invitation. Parsing does not verify the peer. */ +export function parseContactInvitation(input: string): ParsedContactInvitation { + try { + const value = input.trim(); + if (value.length > 12_000) throw new Error(); + let code = value; + if (!code.startsWith(PREFIX)) { + const url = new URL(value); + if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) throw new Error(); + code = new URLSearchParams(url.hash.slice(1)).get("contact") ?? ""; + } + if (!code.startsWith(PREFIX)) throw new Error(); + const encoded = code.slice(PREFIX.length); + if (!/^[A-Za-z0-9_-]+$/.test(encoded)) throw new Error(); + const bytes = Uint8Array.from(atob(encoded.replace(/-/g, "+").replace(/_/g, "/")), (char) => char.charCodeAt(0)); + const invitation = invitationSchema.parse(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))); + return { code, invitation }; + } catch { + throw new Error("This invitation is not valid. Paste the complete link or code."); + } +} + +/** Keep the invitation out of HTTP requests, access logs and referrer headers. */ +export function contactInvitationUrl(code: string, destination: string): string { + const url = new URL(destination); + url.hash = new URLSearchParams({ contact: code }).toString(); + return url.href; +} + +export function contactInvitationDestination(origin: string, code: string): string { + const url = new URL(origin); + const local = url.protocol === "http:" && (url.hostname === "localhost" || url.hostname.endsWith(".localhost") + || ["127.0.0.1", "[::1]"].includes(url.hostname)); + if (url.origin !== origin || (url.protocol !== "https:" && !local)) throw new Error("Enter your GSV space address."); + return contactInvitationUrl(code, `${origin}/people`); +} diff --git a/packages/gsv/src/protocol/index.ts b/packages/gsv/src/protocol/index.ts index abf77bd94..1dccf8b27 100644 --- a/packages/gsv/src/protocol/index.ts +++ b/packages/gsv/src/protocol/index.ts @@ -29,6 +29,7 @@ export { orderAiModelIds } from "./syscalls/ai"; export type * from "./syscalls/mail"; export type * from "./syscalls/conversation"; export * from "./syscalls/contact"; +export * from "./contact-invitation"; export * from "./syscalls/profile"; export * from "./syscalls/approach"; export * from "./social"; diff --git a/packages/gsv/src/protocol/syscalls/approach.ts b/packages/gsv/src/protocol/syscalls/approach.ts index bc1d9dca8..7be94a5f4 100644 --- a/packages/gsv/src/protocol/syscalls/approach.ts +++ b/packages/gsv/src/protocol/syscalls/approach.ts @@ -8,6 +8,8 @@ export type ApproachCreateArgs = { displayName: string; text: string; idempotencyKey: string; + /** Local owner's choice for new messages once the request is accepted. */ + shipHandlesMessages?: boolean; }; export type ApproachGetArgs = { approachId: string }; export type ApproachResult = { approach: ApproachSummary }; @@ -25,5 +27,7 @@ export type ApproachDecideArgs = { approachId: string; expectedRevision: number; decision: "accept" | "decline" | "withdraw"; + /** Local owner's choice when accepting; ignored for decline and withdraw. */ + shipHandlesMessages?: boolean; }; export type ApproachRetryArgs = { approachId: string; expectedRevision: number }; diff --git a/packages/gsv/src/protocol/syscalls/contact.ts b/packages/gsv/src/protocol/syscalls/contact.ts index c5cc5b75e..3058d18a4 100644 --- a/packages/gsv/src/protocol/syscalls/contact.ts +++ b/packages/gsv/src/protocol/syscalls/contact.ts @@ -110,16 +110,22 @@ export type ContactIdentityResult = { export type ContactInviteCreateArgs = { expiresInSeconds?: number; + /** Owner's choice for the new contact. Ship must supply it; older human clients may omit it. */ + shipHandlesMessages?: boolean; }; export type ContactInviteCreateResult = { inviteId: string; code: string; + /** Shareable link; older gateways return only the equivalent code. */ + url?: string; expiresAtMs: number; }; export type ContactInviteAcceptArgs = { code: string; + /** Owner's choice, retained for retries of this acceptance. Ship must supply it. */ + shipHandlesMessages?: boolean; }; export type ContactInviteAcceptResult = { diff --git a/packages/gsv/src/protocol/syscalls/conversation.ts b/packages/gsv/src/protocol/syscalls/conversation.ts index 649b9e74c..9cfe07dcd 100644 --- a/packages/gsv/src/protocol/syscalls/conversation.ts +++ b/packages/gsv/src/protocol/syscalls/conversation.ts @@ -112,6 +112,8 @@ export type ConversationInboxEntry = { export type ConversationInboxArgs = { archived?: boolean; + /** Unread conversations with active, unmuted, unblocked contacts. */ + attentionOnly?: boolean; before?: { updatedAt: number; conversationId: string }; limit?: number; }; diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 2578f3aac..32b3a5482 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -6,10 +6,14 @@ import { HumanInvitationScreen } from "./features/session/HumanInvitationScreen" import { AuthScene } from "./features/session/AuthLayout"; import { useSessionLocation } from "./features/session/sessionNavigation"; import { useSession } from "./services/session/SessionProvider"; +import { ContactInvitationScreen } from "./features/session/ContactInvitationScreen"; +import { pendingContactInvitation } from "./services/session/contactInvitationIntent"; +import { useState } from "preact/hooks"; function AppRoutes() { const { pathname, revision } = useSessionLocation(); const { snapshot } = useSession(); + const [contactInvitation] = useState(pendingContactInvitation); const recovery = pathname === "/recover-member" ? : pathname === "/recover" ? : pathname === "/join" ? : null; @@ -18,9 +22,11 @@ function AppRoutes() { {recovery ?? } ; } - return ; + return ; } export function App(dependencies: AppProviderDependencies = {}) { + const { pathname } = useSessionLocation(); + if (pathname === "/connect") return ; return ; } diff --git a/web/src/app/features/instrument/Instrument.tsx b/web/src/app/features/instrument/Instrument.tsx index c32ef293d..4b7c05b15 100644 --- a/web/src/app/features/instrument/Instrument.tsx +++ b/web/src/app/features/instrument/Instrument.tsx @@ -6,11 +6,14 @@ import { useSession } from "../../services/session/SessionProvider"; import { TerminalProvider } from "../../services/terminal/TerminalProvider"; import { DevicePairingProvider } from "../../services/machines/DevicePairingProvider"; import { Zen } from "./zen/Zen"; +import { useContactReplies } from "./people/useContactReplies"; +import { useConsoleAccounts } from "../../services/system/useConsoleData"; import { Fleet, type FleetProps } from "./fleet/Fleet"; import { BrowserControlProvider, BrowserControlOverlay } from "./browser/BrowserControl"; import { Memory } from "./memory/Memory"; import { Settings } from "./settings/Settings"; -import { People } from "./people/People"; +import { People, type PeopleOpenRequest } from "./people/People"; +import { usePeopleActivity } from "./people/usePeopleActivity"; import type { FleetReference } from "./fleet/fleetModel"; import { WireSync } from "./wire/WireSync"; import type { MemoryPageRef } from "./shared/navigation"; @@ -111,15 +114,21 @@ function InstrumentReady({ initialPath }: { initialPath: string }) { }, []); /* which process Zen shows: null is the ship; Fleet can open a helper's conversation */ const [zenPid, setZenPid] = useState(null); + /* Ship's contact notices live here, above the keyed Zen view, so opening a helper and coming back keeps them */ + const accounts = useConsoleAccounts(); + const viewer = accounts.data?.find((account) => account.relation === "self"); + const contactReplies = useContactReplies(viewer); + /* unsaved work the instrument guards as a whole: the chat's prompt, and replies typed under Ship's notices even while a helper is shown */ + const unsaved = zenDirty || contactReplies.dirty; const selectingProcess = useRef(false); - const controlState = useRef({ zenDirty }); - controlState.current = { zenDirty }; + const controlState = useRef({ unsaved }); + controlState.current = { unsaved }; useClientControl(["status", "new", "use"], async ({ command, checkpoint, signal }) => { if (command.type === "status") return { type: "status", status: { gateway: status.state === "connected" ? "connected" : status.state === "connecting" ? "connecting" : "disconnected", window: "visible", selectedProcess: zenPid, } }; - if (zenDirty || selectingProcess.current) throw new ClientControlError("busy"); + if (unsaved || selectingProcess.current) throw new ClientControlError("busy"); if (status.state !== "connected") throw new ClientControlError("unavailable"); selectingProcess.current = true; try { @@ -135,7 +144,7 @@ function InstrumentReady({ initialPath }: { initialPath: string }) { pid = command.processId; } else throw new ClientControlError("unavailable"); await checkpoint(); - if (controlState.current.zenDirty) throw new ClientControlError("busy"); + if (controlState.current.unsaved) throw new ClientControlError("busy"); setZenPid(pid); setDistance("zen"); history.replaceState(null, "", "/zen"); @@ -143,6 +152,9 @@ function InstrumentReady({ initialPath }: { initialPath: string }) { } finally { selectingProcess.current = false; } }); + /* a contact conversation Zen asked People to open; a fresh object each time so the same contact reopens */ + const [peopleRequest, setPeopleRequest] = useState(null); + const peopleActivity = usePeopleActivity(viewer); const move = useCallback( (to: Distance, reference: FleetReference | null = null) => { if (reference && fleetDirty && !window.confirm("Discard unsaved Fleet edits and open this item?")) return false; @@ -219,7 +231,7 @@ function InstrumentReady({ initialPath }: { initialPath: string }) {
- 0 || peopleActivity.requests.length > 0} helper={distance === "zen" && zenPid !== null} onShip={() => { if (!zenDirty || window.confirm("Discard your unsent message and attachments?")) setZenPid(null); }} help={help} onHelp={() => setHelp((open) => !open)} helpButtonRef={helpButtonRef} /> @@ -303,7 +315,10 @@ function InstrumentReady({ initialPath }: { initialPath: string }) { if (page && memoryDirty && !window.confirm("Discard your unsaved page changes and open this page?")) return; if (!move("memory")) return; if (page) setSelectedMemoryPage({ ...page }); - }} initialTarget={zenTarget} prefill={zenPrefill} onPrefillUsed={() => setZenPrefill(null)} pid={zenPid} /> + }} initialTarget={zenTarget} prefill={zenPrefill} onPrefillUsed={() => setZenPrefill(null)} pid={zenPid} + contactReplies={zenPid ? undefined : contactReplies} + peopleActivity={zenPid ? undefined : peopleActivity} + onPeopleActivity={(request) => { if (move("people") && request) setPeopleRequest(request); }} /> { @@ -316,12 +331,16 @@ function InstrumentReady({ initialPath }: { initialPath: string }) { { move("fleet", `proc:${pid}`); }} onSignOut={() => { - if ((settingsDirty || zenDirty || fleetDirty || memoryDirty || peopleDirty) && !window.confirm("Discard your unsaved work and sign out?")) return; + if ((settingsDirty || unsaved || fleetDirty || memoryDirty || peopleDirty) && !window.confirm("Discard your unsaved work and sign out?")) return; void session.lock("Signed out"); }} /> - { if (move("settings")) setSettingsEntry({ section: "profile" }); }} /> + { if (move("settings")) setSettingsEntry({ section: "profile" }); }} onAsk={(prompt) => { + if (zenDirty && !window.confirm("Replace your unsent message and attachments with this request?")) return; + if (!move("zen")) return; + setZenPid(null); setZenTarget(null); setZenPrefill(prompt); + }} />