From ca562c0a048e2ea60ca740afc79438cee19e286c Mon Sep 17 00:00:00 2001 From: jessicat <8797119+jess-cat@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:03:58 +0100 Subject: [PATCH 01/21] notice contact messages in the ship chat --- .../app/features/instrument/Instrument.tsx | 7 +- .../instrument/people/ContactConversation.tsx | 3 +- .../app/features/instrument/people/People.tsx | 8 +- .../app/features/instrument/people/people.css | 2 - .../shared/committedMessageSignal.ts | 26 ++++ .../instrument/shared/senderBadge.css | 3 + .../instrument/shared/useTabAttention.ts | 12 +- .../instrument/zen/ContactNotice.test.tsx | 49 ++++++++ .../features/instrument/zen/ContactNotice.tsx | 97 ++++++++++++++ web/src/app/features/instrument/zen/Zen.tsx | 35 +++++- .../instrument/zen/useContactNotices.test.ts | 119 ++++++++++++++++++ .../instrument/zen/useContactNotices.ts | 87 +++++++++++++ web/src/app/features/instrument/zen/zen.css | 16 +++ 13 files changed, 446 insertions(+), 18 deletions(-) create mode 100644 web/src/app/features/instrument/shared/committedMessageSignal.ts create mode 100644 web/src/app/features/instrument/shared/senderBadge.css create mode 100644 web/src/app/features/instrument/zen/ContactNotice.test.tsx create mode 100644 web/src/app/features/instrument/zen/ContactNotice.tsx create mode 100644 web/src/app/features/instrument/zen/useContactNotices.test.ts create mode 100644 web/src/app/features/instrument/zen/useContactNotices.ts diff --git a/web/src/app/features/instrument/Instrument.tsx b/web/src/app/features/instrument/Instrument.tsx index f9db136fe..128e1b558 100644 --- a/web/src/app/features/instrument/Instrument.tsx +++ b/web/src/app/features/instrument/Instrument.tsx @@ -142,6 +142,8 @@ 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<{ contactId: string } | null>(null); const move = useCallback( (to: Distance, reference: FleetReference | null = null) => { if (reference && fleetDirty && !window.confirm("Discard unsaved Fleet edits and open this item?")) return false; @@ -301,7 +303,8 @@ 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} + onPeople={(contactId) => { if (move("people")) setPeopleRequest({ contactId }); }} /> { @@ -319,7 +322,7 @@ function InstrumentReady({ initialPath }: { initialPath: string }) { }} /> - { if (move("settings")) setSettingsEntry({ section: "profile" }); }} /> + { if (move("settings")) setSettingsEntry({ section: "profile" }); }} /> -
{authorName}{provenance && {provenance === "process" && }{provenance === "process" ? "GSV" : "PERSON"}}
+
{authorName}{provenance && {provenance === "process" && }{provenance === "process" ? "GSV" : "PERSON"}}
{message.social?.replyTo &&
{messages.find((candidate) => sameReference(candidate.social?.reference, message.social!.replyTo!))?.text.slice(0, 240) || "Reply to an earlier message"}
} {message.text &&

{message.text}

} {message.media?.map((media, index) => )} diff --git a/web/src/app/features/instrument/people/People.tsx b/web/src/app/features/instrument/people/People.tsx index a4162743d..b26f2e751 100644 --- a/web/src/app/features/instrument/people/People.tsx +++ b/web/src/app/features/instrument/people/People.tsx @@ -23,7 +23,12 @@ type PeopleView = "inbox" | "requests" | "contacts"; const NO_CURSOR: ApproachListArgs["before"] = undefined; const NO_INBOX_CURSOR: ConversationInboxArgs["before"] = undefined; -export function People({ onDirtyChange, onProfile }: { onDirtyChange: (dirty: boolean) => void; onProfile: () => void }) { +export function People({ onDirtyChange, onProfile, openRequest }: { + onDirtyChange: (dirty: boolean) => void; + onProfile: () => void; + /** A conversation to land on, asked for from another view; a fresh object reopens the same contact. */ + openRequest?: { contactId: string } | null; +}) { const active = useViewActive(); const { client, connected } = useGateway(); const cache = useQueryClient(); @@ -95,6 +100,7 @@ export function People({ onDirtyChange, onProfile }: { onDirtyChange: (dirty: bo setArchived(false); openContact(id); }; + useLayoutEffect(() => { if (openRequest) showConversation(openRequest.contactId); }, [openRequest]); const sent = (value: ApproachSummary) => { setDialog(null); setCompose(emptyApproachDraft()); setView("requests"); setDirection("outgoing"); setHistory(false); diff --git a/web/src/app/features/instrument/people/people.css b/web/src/app/features/instrument/people/people.css index f0ac08c59..ea91c6220 100644 --- a/web/src/app/features/instrument/people/people.css +++ b/web/src/app/features/instrument/people/people.css @@ -54,8 +54,6 @@ .people-message[data-author="you"] > p { font-weight: 500; } .people-message[data-author="your-ship"] .people-message-author, .people-message[data-author="your-ship"] > p { color: var(--accent); } -.people-message-badge { display: inline-flex; align-items: center; gap: 6px; padding: 3px 7px; border: 1px solid; border-color: color-mix(in srgb, currentColor 42%, transparent); font: 500 8.5px/1 var(--gsv-font-mono); letter-spacing: .14em; } -.people-message-dot { width: 6px; height: 6px; border-radius: 50%; flex: none; background: currentColor; box-shadow: 0 0 6px currentColor; } .people-message time { font-size: 9px; color: var(--faint); } .people-message > p { margin: 12px 0 0; white-space: pre-wrap; font: 17px/1.65 var(--gsv-font-prose); } .people-composer { border-top: 1px solid var(--border); flex-shrink: 0; margin-top: 8px; padding-block: 12px 16px; } diff --git a/web/src/app/features/instrument/shared/committedMessageSignal.ts b/web/src/app/features/instrument/shared/committedMessageSignal.ts new file mode 100644 index 000000000..ef4d5e069 --- /dev/null +++ b/web/src/app/features/instrument/shared/committedMessageSignal.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +/* the part of a committed message the instrument reads live: who wrote it, where it belongs, and what it says */ +export const committedMessageSchema = z.object({ + message: z.object({ + id: z.string(), + conversationId: z.string(), + sequence: z.number(), + text: z.string(), + createdAt: z.number(), + author: z.union([ + z.object({ kind: z.literal("process"), pid: z.string() }), + z.object({ kind: z.literal("contact"), contactId: z.string(), displayName: z.string() }), + ]), + /* absent on request-state lines and on messages from v1 peers */ + social: z.object({ + reference: z.object({ actor: z.object({ shipId: z.string(), subjectId: z.string() }), messageId: z.string() }), + provenance: z.object({ kind: z.enum(["human", "process"]) }), + }).optional(), + }), + directed: z.boolean().optional(), + /* the Kernel's call for a People message: muted, blocked and ended contacts are quiet */ + attention: z.enum(["notify", "quiet"]).optional(), +}); + +export type CommittedMessageSignal = z.infer; diff --git a/web/src/app/features/instrument/shared/senderBadge.css b/web/src/app/features/instrument/shared/senderBadge.css new file mode 100644 index 000000000..73ece94f4 --- /dev/null +++ b/web/src/app/features/instrument/shared/senderBadge.css @@ -0,0 +1,3 @@ +/* who sent a message, beside their name: a small mono label in the sender's colour; the dot marks their GSV */ +.sender-badge { display: inline-flex; align-items: center; gap: 6px; padding: 3px 7px; border: 1px solid; border-color: color-mix(in srgb, currentColor 42%, transparent); font: 500 8.5px/1 var(--gsv-font-mono); letter-spacing: .14em; } +.sender-dot { width: 6px; height: 6px; border-radius: 50%; flex: none; background: currentColor; box-shadow: 0 0 6px currentColor; } diff --git a/web/src/app/features/instrument/shared/useTabAttention.ts b/web/src/app/features/instrument/shared/useTabAttention.ts index a9c87df0a..d36032aee 100644 --- a/web/src/app/features/instrument/shared/useTabAttention.ts +++ b/web/src/app/features/instrument/shared/useTabAttention.ts @@ -1,22 +1,12 @@ import { useEffect, useRef } from "preact/hooks"; import { useQuery } from "@tanstack/preact-query"; -import { z } from "zod"; import { findConsolePersonalProcess } from "../../../domain/system/consoleProcesses"; import { useGateway } from "../../../services/gateway/GatewayProvider"; import { loadConsoleProcesses } from "../../../services/system/consoleService"; import { INSTRUMENT_PROCESSES_KEY } from "../wire/queryKeys"; +import { committedMessageSchema } from "./committedMessageSignal"; import { badgeIcon, createTabAttention, type TabAttention } from "./tabAttention"; -/* the part of a committed message the tab signal reads: which message it is, and which process wrote it */ -const committedMessageSchema = z.object({ - message: z.object({ id: z.string(), author: z.union([ - z.object({ kind: z.literal("process"), pid: z.string() }), - z.object({ kind: z.literal("contact") }), - ]) }), - directed: z.boolean().optional(), - attention: z.enum(["notify", "quiet"]).optional(), -}); - /** * Shows Ship messages that land while the person is looking elsewhere in the browser * tab: "(N) GSV" in the title and a dot on the favicon until the tab is viewed again. diff --git a/web/src/app/features/instrument/zen/ContactNotice.test.tsx b/web/src/app/features/instrument/zen/ContactNotice.test.tsx new file mode 100644 index 000000000..5a9fa6f79 --- /dev/null +++ b/web/src/app/features/instrument/zen/ContactNotice.test.tsx @@ -0,0 +1,49 @@ +import type { ContactSummary } from "@humansandmachines/gsv/protocol"; +import { describe, expect, it, vi } from "vitest"; +import { collectNodes, collectText } from "../../../testing/testHarness"; +import { ContactNoticeRow, noticeName } from "./ContactNotice"; +import type { ContactNotice } from "./useContactNotices"; + +const notice: ContactNotice = { + contactId: "contact:ada", conversationId: "conversation:ada", displayName: "Ada Lovelace", + messageId: "message:1", sequence: 1, text: "hey — free to look at the release notes?", createdAt: 1, + byShip: false, reference: { actor: { shipId: "ship:ada", subjectId: "subject:ada" }, messageId: "origin:1" }, count: 1, +}; +const contact: ContactSummary = { + id: notice.contactId, ownerUid: 1000, state: "active", generation: "generation:one", remoteShipId: "ship:ada", + remoteSubject: { id: "subject:ada", displayName: "Ada Lovelace" }, remoteOrigin: "https://ada.example", + conversationId: notice.conversationId, localAlias: "Ada", createdAtMs: 1, updatedAtMs: 1, +}; + +function buttons(tree: ReturnType) { + return collectNodes(tree).filter((node) => node.type === "button"); +} +/* the harness joins text fragments with spaces; read the row the way a person does */ +function text(tree: ReturnType) { + return collectText(tree).replace(/\s+/g, " ").trim(); +} + +describe("the contact notice row", () => { + it("names the person with their badge and offers show and go to chat", () => { + const onShow = vi.fn(); + const onGoToChat = vi.fn(); + const tree = ContactNoticeRow({ notice, contact: undefined, onShow, onGoToChat }); + expect(text(tree)).toBe("new message from Ada Lovelace PERSON show go to chat"); + const [show, goToChat] = buttons(tree); + void show.props.onClick?.(); + void goToChat.props.onClick?.(); + expect(onShow).toHaveBeenCalledOnce(); + expect(onGoToChat).toHaveBeenCalledOnce(); + }); + + it("counts several messages, marks the contact's GSV, and prefers the local alias", () => { + const tree = ContactNoticeRow({ notice: { ...notice, byShip: true, count: 3 }, contact, onShow: vi.fn(), onGoToChat: vi.fn() }); + expect(text(tree)).toBe("3 new messages from Ada GSV show go to chat"); + expect(collectNodes(tree).some((node) => node.props.class === "sender-dot")).toBe(true); + }); + + it("falls back to the name the peer sent when the contact is not loaded", () => { + expect(noticeName(notice, undefined)).toBe("Ada Lovelace"); + expect(noticeName(notice, contact)).toBe("Ada"); + }); +}); diff --git a/web/src/app/features/instrument/zen/ContactNotice.tsx b/web/src/app/features/instrument/zen/ContactNotice.tsx new file mode 100644 index 000000000..824acc9d8 --- /dev/null +++ b/web/src/app/features/instrument/zen/ContactNotice.tsx @@ -0,0 +1,97 @@ +import { contactDisplayName, type ContactSummary } from "@humansandmachines/gsv/protocol"; +import type { JSX } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import type { ConsoleAccount } from "../../../domain/system/consoleModels"; +import { useGateway } from "../../../services/gateway/GatewayProvider"; +import { randomId } from "../../../services/ids"; +import { FleetDialog } from "../fleet/FleetDialog"; +import { canConfigure } from "../settings/settingsModel"; +import type { ContactNotice } from "./useContactNotices"; +import "../shared/senderBadge.css"; + +/* the same ceiling the People composer applies */ +const MAX_REPLY_BYTES = 32_768; + +/** The local alias when the person set one, else the name the peer sent. */ +export function noticeName(notice: ContactNotice, contact: ContactSummary | undefined): string { + return contact ? contactDisplayName(contact) : notice.displayName; +} + +/* inline markup rather than a component, so the badge reads in the row's own tree */ +function senderBadge(byShip: boolean) { + return {byShip && }{byShip ? "GSV" : "PERSON"}; +} + +/** One line per contact with new messages: who wrote, how many, and what to do about it. */ +export function ContactNoticeRow({ notice, contact, onShow, onGoToChat }: { + notice: ContactNotice; + contact: ContactSummary | undefined; + onShow: () => void; + onGoToChat: () => void; +}) { + const name = noticeName(notice, contact); + return ( +
+ {notice.count > 1 ? `${notice.count} new messages` : "new message"} from {name}{senderBadge(notice.byShip)} + + + + +
+ ); +} + +/** The message itself with a reply beneath it; sending threads the reply to that message. */ +export function ContactNoticeDialog({ notice, contact, account, open, onClose, onSent }: { + notice: ContactNotice; + contact: ContactSummary | undefined; + account: ConsoleAccount | undefined; + open: boolean; + onClose: () => void; + onSent: () => void; +}) { + const { client, connected } = useGateway(); + const [text, setText] = useState(""); + const [state, setState] = useState<"idle" | "sending" | "failed">("idle"); + const [error, setError] = useState(null); + const name = noticeName(notice, contact); + const maySend = connected && !!account && !!contact && contact.state === "active" && !contact.blocked + && canConfigure(account, "contact.send") && (account.uid === 0 || account.uid === contact.ownerUid); + const tooLong = new TextEncoder().encode(text).length > MAX_REPLY_BYTES; + useEffect(() => { + if (!open) return; + setText(""); setState("idle"); setError(null); + }, [open, notice.messageId]); + + const send = async () => { + const body = text.trim(); + if (!maySend || !body || tooLong || state === "sending") return; + setState("sending"); setError(null); + try { + await client.contact.send({ contactId: notice.contactId, text: body, idempotencyKey: randomId(), replyTo: notice.reference }); + onSent(); + } catch (cause) { + setState("failed"); + setError(cause instanceof Error ? cause.message : String(cause)); + } + }; + + return ( + +
+
{name}{senderBadge(notice.byShip)}{notice.count > 1 && latest of {notice.count}}
+

{notice.text || "(an attachment, with no text)"}

+