diff --git a/CHANGELOG.md b/CHANGELOG.md index b28946b..48247e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable user-visible changes are documented here. This project follows ## [Unreleased] +### Added + +- Added an All computers Inbox for accounts with multiple linked + computers, with every thread action routed to its source computer. + ### Changed - Made the repository root a directly installable Omarchy marketplace plugin, diff --git a/README.md b/README.md index 494fcf5..fd94cad 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,9 @@ repository. - T3 Connect native Clerk browser authentication with the official Relay JWT template, a secret-authenticated loopback callback handoff, automatic panel return, restart persistence, and logout. -- T3 Connect environment discovery, selection, remembered preference, and - reconnect with bounded exponential backoff. +- T3 Connect computer discovery, selection, remembered preference, a unified + All computers Inbox when multiple computers are linked, and reconnect with + bounded exponential backoff. - Nightly Inbox semantics for pinned, active, snoozed, and settled threads, including Working, Ready, Input, Approval, and failure attention state. - Chat-first streamed thread detail with Markdown, per-turn changed-file trees, diff --git a/bridge/src/app.ts b/bridge/src/app.ts index d89f942..93f684a 100644 --- a/bridge/src/app.ts +++ b/bridge/src/app.ts @@ -4,9 +4,15 @@ import { NativeClerkProvider } from "./auth/nativeProvider.ts"; import { ConnectionCoordinator } from "./connection/coordinator.ts"; import { NdjsonChannel, type NdjsonHandler } from "./ipc/ndjson.ts"; import { event, failure, success } from "./protocol/output.ts"; -import { PROTOCOL_VERSION, type BridgeRequest } from "./protocol/types.ts"; +import { + ALL_COMPUTERS_ENVIRONMENT_ID, + PROTOCOL_VERSION, + type BridgeRequest, + type InboxDto, +} from "./protocol/types.ts"; import { asBridgeError, BridgeError } from "./security/redact.ts"; import { MemorySecretStore, SecretServiceStore } from "./security/secretStore.ts"; +import { AllComputersInbox } from "./t3/allComputers.ts"; import { T3Commands } from "./t3/commands.ts"; import { DpopKeyManager } from "./t3/dpop.ts"; import { T3RelayClient } from "./t3/relay.ts"; @@ -20,7 +26,9 @@ export class BridgeApp implements NdjsonHandler { readonly channel: NdjsonChannel; private readonly auth: NativeClerkProvider; private readonly connection: ConnectionCoordinator; + private readonly allComputers: AllComputersInbox; private readonly commands: T3Commands; + private openThreadSession: T3EnvironmentSession | null = null; private login: Promise | null = null; private shuttingDown = false; @@ -50,23 +58,58 @@ export class BridgeApp implements NdjsonHandler { process.env.T3CODE_RELAY_URL || "https://relay.t3.codes", ); let coordinator!: ConnectionCoordinator; - const session = new T3EnvironmentSession({ + const session = this.createSession( + (inbox) => { + if (this.allComputers.active) this.allComputers.updatePrimary(inbox); + else this.emit("inbox.changed", inbox); + }, + (error) => coordinator.handleClosed(error), + ); + coordinator = new ConnectionCoordinator(this.auth, relay, session, { + onConnection: (status) => this.emit("connection.changed", status), + onEnvironment: (payload) => this.emit("environment.changed", payload), + onError: (error) => this.emitError(error), + }); + this.connection = coordinator; + this.allComputers = new AllComputersInbox(relay, { + createSession: (_environmentId, onInbox, onClosed) => this.createSession(onInbox, onClosed), onInbox: (inbox) => this.emit("inbox.changed", inbox), + onError: (error) => this.emitError(error), + }); + this.commands = new T3Commands((environmentId) => this.sessionFor(environmentId)); + } + + private createSession( + onInbox: (inbox: InboxDto) => void, + onClosed: (error: unknown) => void, + ): T3EnvironmentSession { + return new T3EnvironmentSession({ + onInbox, onThread: (thread) => this.emit("thread.snapshot", thread), onMessageDelta: (payload) => this.emit("message.delta", payload), onMessageCompleted: (payload) => this.emit("message.completed", payload), onApproval: (payload) => this.emit("approval.requested", payload), onInput: (payload) => this.emit("input.requested", payload), - onClosed: (error) => coordinator.handleClosed(error), + onClosed, onError: (error) => this.emitError(error), }); - coordinator = new ConnectionCoordinator(this.auth, relay, session, { - onConnection: (status) => this.emit("connection.changed", status), - onEnvironment: (payload) => this.emit("environment.changed", payload), - onError: (error) => this.emitError(error), - }); - this.connection = coordinator; - this.commands = new T3Commands(session); + } + + private primaryInbox(): InboxDto | null { + const environmentId = this.connection.selectedId(); + if (environmentId === null) return null; + try { + return this.connection.session.projection.inbox(environmentId); + } catch { + return null; + } + } + + private sessionFor(environmentId?: string): T3EnvironmentSession { + const primaryEnvironmentId = this.connection.selectedId(); + if (!environmentId || environmentId === primaryEnvironmentId) return this.connection.session; + if (this.allComputers.active) return this.allComputers.sessionFor(environmentId, this.connection.session); + throw new BridgeError("ENVIRONMENT_NOT_SELECTED", "Select that computer before changing its thread."); } private emit(name: string, payload: unknown): void { @@ -88,7 +131,12 @@ export class BridgeApp implements NdjsonHandler { async start(): Promise { this.channel.start(); const auth = await this.auth.initialize(); - this.emit("bridge.ready", { protocolVersion: PROTOCOL_VERSION, bridgeVersion: packageMetadata.version, upstream: UPSTREAM }); + this.emit("bridge.ready", { + protocolVersion: PROTOCOL_VERSION, + bridgeVersion: packageMetadata.version, + upstream: UPSTREAM, + allComputersEnvironmentId: ALL_COMPUTERS_ENVIRONMENT_ID, + }); this.emit("connection.changed", this.connection.status()); if (auth.phase === "signedIn") { void this.connection.discoverAndConnectPreferred().catch((error) => this.emitUnexpectedConnectionError(error)); @@ -115,7 +163,12 @@ export class BridgeApp implements NdjsonHandler { let payload: unknown; switch (request.type) { case "bridge.ping": - payload = { ready: true, protocolVersion: PROTOCOL_VERSION, upstream: UPSTREAM }; + payload = { + ready: true, + protocolVersion: PROTOCOL_VERSION, + upstream: UPSTREAM, + allComputersEnvironmentId: ALL_COMPUTERS_ENVIRONMENT_ID, + }; break; case "bridge.shutdown": payload = { shuttingDown: true }; @@ -130,6 +183,7 @@ export class BridgeApp implements NdjsonHandler { payload = { started: true }; break; case "auth.logout": + await this.allComputers.deactivate().catch(() => undefined); await this.connection.disconnect(); payload = await this.auth.logout(); break; @@ -137,19 +191,51 @@ export class BridgeApp implements NdjsonHandler { payload = { environments: await this.connection.discover(), selected: this.connection.selectedId() }; break; case "environment.select": - await this.connection.select(String(request.payload.environmentId)); - payload = { selected: this.connection.selectedId() }; + if (String(request.payload.environmentId) === ALL_COMPUTERS_ENVIRONMENT_ID) { + payload = { + selected: ALL_COMPUTERS_ENVIRONMENT_ID, + inbox: await this.allComputers.activate( + this.connection.list(), + this.connection.selectedId(), + this.primaryInbox(), + ), + }; + } else { + await this.allComputers.deactivate(); + await this.connection.select(String(request.payload.environmentId)); + const inbox = this.primaryInbox(); + if (inbox !== null) this.emit("inbox.changed", inbox); + payload = { selected: this.connection.selectedId(), ...(inbox === null ? {} : { inbox }) }; + } break; case "inbox.get": + if (this.allComputers.active) { + payload = this.allComputers.current(); + break; + } if (this.connection.selectedId() === null) throw new BridgeError("ENVIRONMENT_REQUIRED", "Choose a T3 environment first."); payload = this.connection.session.projection.inbox(this.connection.selectedId()!); break; case "thread.open": - await this.connection.session.openThread(String(request.payload.threadId)); - payload = { opening: request.payload.threadId }; + { + const session = this.sessionFor( + typeof request.payload.environmentId === "string" ? request.payload.environmentId : undefined, + ); + if (this.openThreadSession !== null && this.openThreadSession !== session) { + await this.openThreadSession.closeThread(); + } + this.openThreadSession = null; + await session.openThread(String(request.payload.threadId)); + this.openThreadSession = session; + payload = { + opening: request.payload.threadId, + models: session.projection.models(), + }; + } break; case "thread.close": - await this.connection.session.closeThread(); + if (this.openThreadSession !== null) await this.openThreadSession.closeThread(); + this.openThreadSession = null; payload = {}; break; default: @@ -168,6 +254,7 @@ export class BridgeApp implements NdjsonHandler { if (this.shuttingDown) return; this.shuttingDown = true; this.commands.clearAttachments(); + await this.allComputers.deactivate().catch(() => undefined); await this.connection.disconnect().catch(() => undefined); this.channel.stop(); } diff --git a/bridge/src/protocol/decode.ts b/bridge/src/protocol/decode.ts index d7ddb01..80a66db 100644 --- a/bridge/src/protocol/decode.ts +++ b/bridge/src/protocol/decode.ts @@ -107,6 +107,14 @@ function optionalAttachmentIds(payload: Record): string[] { } function validatePayload(type: RequestType, payload: Record): void { + if ( + type.startsWith("thread.") || + type.startsWith("attachment.") || + type === "approval.respond" || + type === "input.respond" + ) { + if (payload.environmentId !== undefined) requiredString(payload, "environmentId", 256); + } switch (type) { case "attachment.clipboard.read": requiredString(payload, "threadId", 256); diff --git a/bridge/src/protocol/types.ts b/bridge/src/protocol/types.ts index bab5829..bdacecb 100644 --- a/bridge/src/protocol/types.ts +++ b/bridge/src/protocol/types.ts @@ -1,4 +1,5 @@ export const PROTOCOL_VERSION = 1 as const; +export const ALL_COMPUTERS_ENVIRONMENT_ID = "__all_computers__" as const; export type AuthPhase = "signedOut" | "signingIn" | "signedIn" | "error"; export interface AuthStatusDto { @@ -68,6 +69,7 @@ export type ThreadPhase = export type InboxSection = "pinned" | "active" | "snoozed" | "settled"; export interface ThreadSummaryDto { + environmentId: string; id: string; projectId: string; project: string; diff --git a/bridge/src/t3/allComputers.ts b/bridge/src/t3/allComputers.ts new file mode 100644 index 0000000..e9c9a85 --- /dev/null +++ b/bridge/src/t3/allComputers.ts @@ -0,0 +1,184 @@ +import type { PreparedConnection } from "@t3tools/client-runtime/connection"; + +import { + ALL_COMPUTERS_ENVIRONMENT_ID, + type CapabilitiesDto, + type EnvironmentDto, + type InboxDto, + type ThreadSummaryDto, +} from "../protocol/types.ts"; +import { BridgeError, redactText } from "../security/redact.ts"; +import type { T3EnvironmentSession } from "./session.ts"; + +interface ConnectionSource { + prepareConnection(environmentId: string): Promise; +} + +interface AllComputersCallbacks { + createSession( + environmentId: string, + onInbox: (inbox: InboxDto) => void, + onClosed: (error: unknown) => void, + ): T3EnvironmentSession; + onInbox(inbox: InboxDto): void; + onError(error: BridgeError): void; +} + +const EMPTY_CAPABILITIES: CapabilitiesDto = { + settlement: false, + snooze: false, + pinning: false, + pinReorder: false, + titleRegeneration: false, + threadPagination: false, +}; + +function newestFirst(left: ThreadSummaryDto, right: ThreadSummaryDto): number { + return Date.parse(right.latestActivityAt) - Date.parse(left.latestActivityAt) + || left.environmentId.localeCompare(right.environmentId) + || left.id.localeCompare(right.id); +} + +function snoozedFirst(left: ThreadSummaryDto, right: ThreadSummaryDto): number { + return Date.parse(left.snoozedUntil ?? "") - Date.parse(right.snoozedUntil ?? "") + || newestFirst(left, right); +} + +function sharedCapabilities(inboxes: readonly InboxDto[]): CapabilitiesDto { + if (inboxes.length === 0) return EMPTY_CAPABILITIES; + return { + settlement: inboxes.every((inbox) => inbox.capabilities.settlement), + snooze: inboxes.every((inbox) => inbox.capabilities.snooze), + pinning: inboxes.every((inbox) => inbox.capabilities.pinning), + pinReorder: inboxes.every((inbox) => inbox.capabilities.pinReorder), + titleRegeneration: inboxes.every((inbox) => inbox.capabilities.titleRegeneration), + threadPagination: inboxes.every((inbox) => inbox.capabilities.threadPagination), + }; +} + +export function combineComputerInboxes(inboxes: readonly InboxDto[]): InboxDto { + const updatedAt = inboxes + .map((inbox) => inbox.updatedAt) + .sort((left, right) => Date.parse(right) - Date.parse(left))[0] ?? ""; + return { + environmentId: ALL_COMPUTERS_ENVIRONMENT_ID, + updatedAt, + capabilities: sharedCapabilities(inboxes), + projects: [], + models: [], + pinned: inboxes.flatMap((inbox) => inbox.pinned).sort(newestFirst), + active: inboxes.flatMap((inbox) => inbox.active).sort(newestFirst), + snoozed: inboxes.flatMap((inbox) => inbox.snoozed).sort(snoozedFirst), + settled: inboxes.flatMap((inbox) => inbox.settled).sort(newestFirst), + }; +} + +export class AllComputersInbox { + private activeValue = false; + private generation = 0; + private primaryEnvironmentId: string | null = null; + private readonly inboxes = new Map(); + private readonly sessions = new Map(); + + constructor( + private readonly connections: ConnectionSource, + private readonly callbacks: AllComputersCallbacks, + ) {} + + get active(): boolean { + return this.activeValue; + } + + current(): InboxDto { + if (!this.activeValue) throw new BridgeError("ALL_COMPUTERS_INACTIVE", "Select All computers first."); + return combineComputerInboxes([...this.inboxes.values()]); + } + + updatePrimary(inbox: InboxDto): void { + if (!this.activeValue || inbox.environmentId !== this.primaryEnvironmentId) return; + this.inboxes.set(inbox.environmentId, inbox); + this.callbacks.onInbox(this.current()); + } + + sessionFor( + environmentId: string, + primarySession: T3EnvironmentSession, + ): T3EnvironmentSession { + if (!this.activeValue) throw new BridgeError("ALL_COMPUTERS_INACTIVE", "Select All computers first."); + if (environmentId === this.primaryEnvironmentId) return primarySession; + const session = this.sessions.get(environmentId); + if (!session) throw new BridgeError("ENVIRONMENT_NOT_CONNECTED", "That computer is not connected. Refresh All computers and try again.", true); + return session; + } + + async activate( + environments: readonly EnvironmentDto[], + primaryEnvironmentId: string | null, + primaryInbox: InboxDto | null, + ): Promise { + if (environments.length < 2) { + throw new BridgeError("ALL_COMPUTERS_UNAVAILABLE", "All computers requires at least two linked computers."); + } + await this.deactivate(); + const generation = ++this.generation; + this.activeValue = true; + this.primaryEnvironmentId = primaryEnvironmentId; + if (primaryInbox !== null) this.inboxes.set(primaryInbox.environmentId, primaryInbox); + this.callbacks.onInbox(this.current()); + + await Promise.all(environments + .filter((environment) => environment.id !== primaryEnvironmentId) + .map(async (environment) => { + let session: T3EnvironmentSession | null = null; + try { + session = this.callbacks.createSession( + environment.id, + (inbox) => { + if (!this.activeValue || generation !== this.generation) return; + this.inboxes.set(environment.id, inbox); + this.callbacks.onInbox(this.current()); + }, + (error) => { + if (!this.activeValue || generation !== this.generation) return; + const disconnectedSession = this.sessions.get(environment.id); + this.sessions.delete(environment.id); + this.inboxes.delete(environment.id); + this.callbacks.onInbox(this.current()); + if (disconnectedSession !== undefined) { + void disconnectedSession.close().catch(() => undefined); + } + this.callbacks.onError(new BridgeError( + "ENVIRONMENT_DISCONNECTED", + `${environment.label} disconnected: ${redactText(error)}`, + true, + )); + }, + ); + this.sessions.set(environment.id, session); + const prepared = await this.connections.prepareConnection(environment.id); + if (!this.activeValue || generation !== this.generation) return; + await session.connect(prepared); + } catch (error) { + if (this.sessions.get(environment.id) === session) this.sessions.delete(environment.id); + if (session !== null) await session.close().catch(() => undefined); + if (!this.activeValue || generation !== this.generation) return; + this.callbacks.onError(new BridgeError( + "ENVIRONMENT_CONNECT_FAILED", + `Could not connect ${environment.label}: ${redactText(error)}`, + true, + )); + } + })); + return this.current(); + } + + async deactivate(): Promise { + ++this.generation; + this.activeValue = false; + this.primaryEnvironmentId = null; + this.inboxes.clear(); + const sessions = [...this.sessions.values()]; + this.sessions.clear(); + await Promise.all(sessions.map((session) => session.close().catch(() => undefined))); + } +} diff --git a/bridge/src/t3/commands.ts b/bridge/src/t3/commands.ts index 71f8b8f..3e2708a 100644 --- a/bridge/src/t3/commands.ts +++ b/bridge/src/t3/commands.ts @@ -118,10 +118,14 @@ function shell(session: T3EnvironmentSession, threadId: string): OrchestrationTh export class T3Commands { constructor( - private readonly session: T3EnvironmentSession, + private readonly sessionSource: (environmentId?: string) => T3EnvironmentSession, private readonly attachments = new T3ImageAttachmentStore(), ) {} + private session(payload: Payload): T3EnvironmentSession { + return this.sessionSource(typeof payload.environmentId === "string" ? payload.environmentId : undefined); + } + async pasteClipboardImage(payload: Payload) { return this.attachments.pasteClipboard(string(payload, "threadId")); } @@ -132,19 +136,20 @@ export class T3Commands { } async create(payload: Payload): Promise<{ threadId: string; sequence: number }> { + const session = this.session(payload); const projectId = string(payload, "projectId"); const prompt = string(payload, "prompt"); - const project = this.session.projection.shell?.projects.find((entry) => entry.id === projectId); + const project = session.projection.shell?.projects.find((entry) => entry.id === projectId); if (!project) throw new BridgeError("PROJECT_NOT_FOUND", "Choose a project from the connected environment."); let selection = modelSelection(payload) ?? project.defaultModelSelection; if (selection === null) { const advertised = - this.session.projection.models().find((entry) => entry.available && entry.isDefault) ?? - this.session.projection.models().find((entry) => entry.available); + session.projection.models().find((entry) => entry.available && entry.isDefault) ?? + session.projection.models().find((entry) => entry.available); if (!advertised) throw new BridgeError("MODEL_REQUIRED", "No available T3 provider/model was advertised."); selection = { instanceId: advertised.instanceId, model: advertised.model } as ModelSelection; } - selection = applyModelOptions(this.session, selection, payload); + selection = applyModelOptions(session, selection, payload); const threadId = id(); const createdAt = now(); const title = @@ -153,7 +158,7 @@ export class T3Commands { "New thread"; const selectedRuntimeMode = runtimeMode(payload); const interactionMode = typeof payload.interactionMode === "string" ? payload.interactionMode : "default"; - const result = await this.session.dispatch({ + const result = await session.dispatch({ type: "thread.turn.start", commandId: id(), threadId, @@ -180,16 +185,17 @@ export class T3Commands { } async send(payload: Payload): Promise<{ sequence: number }> { + const session = this.session(payload); const threadId = string(payload, "threadId"); const attachmentIds = Array.isArray(payload.attachmentIds) ? payload.attachmentIds.map((attachmentId) => String(attachmentId)) : []; const attachments = this.attachments.resolve(threadId, attachmentIds); - const thread = shell(this.session, threadId); - const detailed = this.session.projection.thread; + const thread = shell(session, threadId); + const detailed = session.projection.thread; const current = detailed?.id === threadId ? detailed.modelSelection : thread.modelSelection; const selected = modelSelection(payload, current); - const result = await this.session.dispatch({ + const result = await session.dispatch({ type: "thread.turn.start", commandId: id(), threadId, @@ -205,8 +211,9 @@ export class T3Commands { } async interrupt(payload: Payload): Promise<{ sequence: number }> { - const thread = shell(this.session, string(payload, "threadId")); - return this.session.dispatch({ + const session = this.session(payload); + const thread = shell(session, string(payload, "threadId")); + return session.dispatch({ type: "thread.turn.interrupt", commandId: id(), threadId: thread.id, @@ -216,43 +223,49 @@ export class T3Commands { } async settle(payload: Payload): Promise<{ sequence: number }> { - capability(this.session, "settlement"); - return this.session.dispatch({ type: "thread.settle", commandId: id(), threadId: string(payload, "threadId") }); + const session = this.session(payload); + capability(session, "settlement"); + return session.dispatch({ type: "thread.settle", commandId: id(), threadId: string(payload, "threadId") }); } async unsettle(payload: Payload): Promise<{ sequence: number }> { - capability(this.session, "settlement"); - return this.session.dispatch({ + const session = this.session(payload); + capability(session, "settlement"); + return session.dispatch({ type: "thread.unsettle", commandId: id(), threadId: string(payload, "threadId"), reason: "user", }); } async snooze(payload: Payload): Promise<{ sequence: number }> { - capability(this.session, "snooze"); - return this.session.dispatch({ + const session = this.session(payload); + capability(session, "snooze"); + return session.dispatch({ type: "thread.snooze", commandId: id(), threadId: string(payload, "threadId"), snoozedUntil: string(payload, "until"), }); } async unsnooze(payload: Payload): Promise<{ sequence: number }> { - capability(this.session, "snooze"); - return this.session.dispatch({ + const session = this.session(payload); + capability(session, "snooze"); + return session.dispatch({ type: "thread.unsnooze", commandId: id(), threadId: string(payload, "threadId"), reason: "user", }); } async pin(payload: Payload): Promise<{ sequence: number }> { - capability(this.session, "pinning"); - return this.session.dispatch({ type: "thread.pin", commandId: id(), threadId: string(payload, "threadId") }); + const session = this.session(payload); + capability(session, "pinning"); + return session.dispatch({ type: "thread.pin", commandId: id(), threadId: string(payload, "threadId") }); } async unpin(payload: Payload): Promise<{ sequence: number }> { - capability(this.session, "pinning"); - return this.session.dispatch({ type: "thread.unpin", commandId: id(), threadId: string(payload, "threadId") }); + const session = this.session(payload); + capability(session, "pinning"); + return session.dispatch({ type: "thread.unpin", commandId: id(), threadId: string(payload, "threadId") }); } async setModel(payload: Payload): Promise<{ sequence: number }> { - return this.session.dispatch({ + return this.session(payload).dispatch({ type: "thread.meta.update", commandId: id(), threadId: string(payload, "threadId"), @@ -261,11 +274,12 @@ export class T3Commands { } async setModelOption(payload: Payload): Promise<{ sequence: number }> { + const session = this.session(payload); const threadId = string(payload, "threadId"); - const shellThread = shell(this.session, threadId); - const detailed = this.session.projection.thread; + const shellThread = shell(session, threadId); + const detailed = session.projection.thread; const selection = detailed?.id === threadId ? detailed.modelSelection : shellThread.modelSelection; - const provider = this.session.projection.config?.providers.find( + const provider = session.projection.config?.providers.find( (entry) => entry.instanceId === selection.instanceId, ); const model = provider?.models.find((entry) => entry.slug === selection.model); @@ -295,7 +309,7 @@ export class T3Commands { ? { ...descriptor, currentValue: value } : descriptor, ); - return this.session.dispatch({ + return session.dispatch({ type: "thread.meta.update", commandId: id(), threadId, @@ -308,7 +322,7 @@ export class T3Commands { } async rename(payload: Payload): Promise<{ sequence: number }> { - return this.session.dispatch({ + return this.session(payload).dispatch({ type: "thread.meta.update", commandId: id(), threadId: string(payload, "threadId"), @@ -317,11 +331,12 @@ export class T3Commands { } async regenerateTitle(payload: Payload): Promise<{ sequence: number }> { - const config = this.session.projection.config; + const session = this.session(payload); + const config = session.projection.config; if (!config?.environment.capabilities.threadTitleRegeneration) { throw new BridgeError("CAPABILITY_UNSUPPORTED", "This T3 environment does not support title regeneration."); } - return this.session.dispatch({ + return session.dispatch({ type: "thread.meta.update", commandId: id(), threadId: string(payload, "threadId"), @@ -330,7 +345,7 @@ export class T3Commands { } async setRuntime(payload: Payload): Promise<{ sequence: number }> { - return this.session.dispatch({ + return this.session(payload).dispatch({ type: "thread.runtime-mode.set", commandId: id(), threadId: string(payload, "threadId"), @@ -340,7 +355,7 @@ export class T3Commands { } async setInteraction(payload: Payload): Promise<{ sequence: number }> { - return this.session.dispatch({ + return this.session(payload).dispatch({ type: "thread.interaction-mode.set", commandId: id(), threadId: string(payload, "threadId"), @@ -350,7 +365,7 @@ export class T3Commands { } async respondApproval(payload: Payload): Promise<{ sequence: number }> { - return this.session.dispatch({ + return this.session(payload).dispatch({ type: "thread.approval.respond", commandId: id(), threadId: string(payload, "threadId"), @@ -361,7 +376,7 @@ export class T3Commands { } async respondInput(payload: Payload): Promise<{ sequence: number }> { - return this.session.dispatch({ + return this.session(payload).dispatch({ type: "thread.user-input.respond", commandId: id(), threadId: string(payload, "threadId"), diff --git a/bridge/src/t3/projection.ts b/bridge/src/t3/projection.ts index baa7479..ce570d2 100644 --- a/bridge/src/t3/projection.ts +++ b/bridge/src/t3/projection.ts @@ -110,11 +110,12 @@ function summary( snapshot: OrchestrationShellSnapshot, config: ServerConfig, section: InboxSection, - now: string, + options: { now: string; environmentId: string }, ): ThreadSummaryDto { const project = snapshot.projects.find((entry) => entry.id === thread.projectId); const model = thread.modelSelection; return { + environmentId: options.environmentId, id: thread.id, projectId: thread.projectId, project: project?.title ?? "Unknown project", @@ -130,8 +131,8 @@ function summary( snoozedUntil: thread.snoozedUntil ?? null, settled: section === "settled", canPin: capabilities(config).pinning, - canSettle: capabilities(config).settlement && canSettle(thread, { now }), - canSnooze: capabilities(config).snooze && canSnooze(thread, { now }), + canSettle: capabilities(config).settlement && canSettle(thread, { now: options.now }), + canSnooze: capabilities(config).snooze && canSnooze(thread, { now: options.now }), }; } @@ -289,10 +290,10 @@ export class T3Projection { capabilities: capabilities(this.config), projects: this.shell.projects.map((project) => ({ id: project.id, title: project.title })), models: this.models(), - pinned: pinned.map((thread) => summary(thread, this.shell!, this.config!, "pinned", now)), - active: active.map((thread) => summary(thread, this.shell!, this.config!, "active", now)), - snoozed: snoozed.map((thread) => summary(thread, this.shell!, this.config!, "snoozed", now)), - settled: settled.map((thread) => summary(thread, this.shell!, this.config!, "settled", now)), + pinned: pinned.map((thread) => summary(thread, this.shell!, this.config!, "pinned", { now, environmentId })), + active: active.map((thread) => summary(thread, this.shell!, this.config!, "active", { now, environmentId })), + snoozed: snoozed.map((thread) => summary(thread, this.shell!, this.config!, "snoozed", { now, environmentId })), + settled: settled.map((thread) => summary(thread, this.shell!, this.config!, "settled", { now, environmentId })), }; } diff --git a/qml/ApprovalCard.qml b/qml/ApprovalCard.qml index 245b7f0..2711217 100644 --- a/qml/ApprovalCard.qml +++ b/qml/ApprovalCard.qml @@ -5,6 +5,7 @@ import qs.Ui BorderSurface { id: root required property var approvalData + required property string environmentId required property string threadId required property var service @@ -41,9 +42,9 @@ BorderSurface { Flow { width: parent.width spacing: Style.spacing.md - Button { text: "Decline"; foreground: Color.urgent; onClicked: root.service.respondApproval(root.threadId, String(root.approvalData.requestId), "decline") } - Button { text: "Allow session"; onClicked: root.service.respondApproval(root.threadId, String(root.approvalData.requestId), "acceptForSession") } - Button { text: "Approve"; active: true; onClicked: root.service.respondApproval(root.threadId, String(root.approvalData.requestId), "accept") } + Button { text: "Decline"; foreground: Color.urgent; onClicked: root.service.respondApproval(root.environmentId, root.threadId, String(root.approvalData.requestId), "decline") } + Button { text: "Allow session"; onClicked: root.service.respondApproval(root.environmentId, root.threadId, String(root.approvalData.requestId), "acceptForSession") } + Button { text: "Approve"; active: true; onClicked: root.service.respondApproval(root.environmentId, root.threadId, String(root.approvalData.requestId), "accept") } } } } diff --git a/qml/Composer.qml b/qml/Composer.qml index 59a064b..c0182e0 100644 --- a/qml/Composer.qml +++ b/qml/Composer.qml @@ -38,7 +38,7 @@ BorderSurface { attachments = [] attachmentError = "" sending = true - service.send(String(threadData.id), value, attachmentIds, function(ok, result) { + service.send(String(threadData.environmentId), String(threadData.id), value, attachmentIds, function(ok, result) { root.sending = false if (ok) return root.attachments = sentAttachments.concat(root.attachments) @@ -55,7 +55,7 @@ BorderSurface { } pastingImage = true attachmentError = "" - service.pasteScreenshot(String(threadData.id), function(ok, result) { + service.pasteScreenshot(String(threadData.environmentId), String(threadData.id), function(ok, result) { root.pastingImage = false if (!ok) { root.attachmentError = String(result && result.message ? result.message : "The screenshot could not be pasted.") @@ -72,7 +72,7 @@ BorderSurface { next.splice(index, 1) attachments = next attachmentError = "" - service.discardAttachment(String(threadData.id), String(attachment.id)) + service.discardAttachment(String(threadData.environmentId), String(threadData.id), String(attachment.id)) } function handlePasteShortcut(event) { @@ -84,7 +84,7 @@ BorderSurface { function discardAttachments() { for (var i = 0; i < attachments.length; i++) - service.discardAttachment(String(threadData.id), String(attachments[i].id)) + service.discardAttachment(String(threadData.environmentId), String(threadData.id), String(attachments[i].id)) } width: parent ? parent.width : implicitWidth @@ -206,7 +206,7 @@ BorderSurface { value: root.selectedModel onChanged: function(value) { var split = value.split("\u001f") - root.service.setModel(String(root.threadData.id), split[0], split[1]) + root.service.setModel(String(root.threadData.environmentId), String(root.threadData.id), split[0], split[1]) } } ModelOptionsPicker { @@ -216,7 +216,7 @@ BorderSurface { triggerFontSize: Style.font.caption descriptors: root.threadData.modelOptions || [] onChanged: function(optionId, value) { - root.service.setModelOption(String(root.threadData.id), optionId, value) + root.service.setModelOption(String(root.threadData.environmentId), String(root.threadData.id), optionId, value) } } ModelDropdown { @@ -232,7 +232,9 @@ BorderSurface { { value: "full-access", label: "Full access" } ] value: String(root.threadData.runtimeMode) - onChanged: function(value) { root.service.setRuntime(String(root.threadData.id), value) } + onChanged: function(value) { + root.service.setRuntime(String(root.threadData.environmentId), String(root.threadData.id), value) + } } Item { width: Math.max(0, actionRow.selectorCapacity - actionRow.selectorWidth) @@ -259,7 +261,7 @@ BorderSurface { foreground: Color.urgent horizontalPadding: Style.spacing.sm verticalPadding: Style.spacing.sm - onClicked: root.service.interrupt(String(root.threadData.id)) + onClicked: root.service.interrupt(String(root.threadData.environmentId), String(root.threadData.id)) } Button { id: sendButton diff --git a/qml/InboxSection.qml b/qml/InboxSection.qml index 090bb9c..fd663f5 100644 --- a/qml/InboxSection.qml +++ b/qml/InboxSection.qml @@ -8,13 +8,22 @@ Column { id: root required property string title required property var items + property var environments: [] + property bool showEnvironment: false property bool initiallyExpanded: true property bool expanded: initiallyExpanded - signal threadActivated(string threadId) - signal pinRequested(string threadId, bool pinned) - signal settleRequested(string threadId, bool settled) - signal snoozeRequested(string threadId, bool snoozed) + signal threadActivated(string environmentId, string threadId) + signal pinRequested(string environmentId, string threadId, bool pinned) + signal settleRequested(string environmentId, string threadId, bool settled) + signal snoozeRequested(string environmentId, string threadId, bool snoozed) + + function environmentLabel(environmentId) { + var values = root.environments || [] + for (var i = 0; i < values.length; i++) + if (String(values[i].id) === String(environmentId)) return String(values[i].label) + return "Unknown computer" + } width: parent ? parent.width : implicitWidth spacing: Style.spacing.md @@ -41,10 +50,11 @@ Column { ThreadRow { required property var modelData threadData: modelData - onActivated: function(threadId) { root.threadActivated(threadId) } - onPinRequested: function(threadId, pinned) { root.pinRequested(threadId, pinned) } - onSettleRequested: function(threadId, settled) { root.settleRequested(threadId, settled) } - onSnoozeRequested: function(threadId, snoozed) { root.snoozeRequested(threadId, snoozed) } + environmentLabel: root.showEnvironment ? root.environmentLabel(modelData.environmentId) : "" + onActivated: function(threadId) { root.threadActivated(String(modelData.environmentId), threadId) } + onPinRequested: function(threadId, pinned) { root.pinRequested(String(modelData.environmentId), threadId, pinned) } + onSettleRequested: function(threadId, settled) { root.settleRequested(String(modelData.environmentId), threadId, settled) } + onSnoozeRequested: function(threadId, snoozed) { root.snoozeRequested(String(modelData.environmentId), threadId, snoozed) } } } } diff --git a/qml/InboxView.qml b/qml/InboxView.qml index cb69e6b..e2bf072 100644 --- a/qml/InboxView.qml +++ b/qml/InboxView.qml @@ -26,6 +26,8 @@ Item { function environmentOptions() { var result = [] var values = root.service.environments || [] + if (root.service.allComputersAvailable) + result.push({ value: root.service.allComputersEnvironmentId, label: "All computers" }) for (var i = 0; i < values.length; i++) result.push({ value: String(values[i].id), label: String(values[i].label) + (values[i].status ? " · " + values[i].status : "") }) return result @@ -180,11 +182,15 @@ Item { resetNewTaskAccess() } - function pin(threadId, pinned) { pinned ? root.service.unpin(threadId) : root.service.pin(threadId) } - function settle(threadId, settled) { settled ? root.service.unsettle(threadId) : root.service.settle(threadId) } - function snooze(threadId, snoozed) { - if (snoozed) root.service.unsnooze(threadId) - else root.service.snooze(threadId, new Date(Date.now() + 86400000).toISOString()) + function pin(environmentId, threadId, pinned) { + pinned ? root.service.unpin(environmentId, threadId) : root.service.pin(environmentId, threadId) + } + function settle(environmentId, threadId, settled) { + settled ? root.service.unsettle(environmentId, threadId) : root.service.settle(environmentId, threadId) + } + function snooze(environmentId, threadId, snoozed) { + if (snoozed) root.service.unsnooze(environmentId, threadId) + else root.service.snooze(environmentId, threadId, new Date(Date.now() + 86400000).toISOString()) } Connections { @@ -248,8 +254,12 @@ Item { width: parent.width showLabel: false options: root.environmentOptions() - value: root.service.selectedEnvironmentId - onChanged: function(value) { root.service.selectEnvironment(value) } + value: root.service.inboxScopeId + onChanged: function(value) { + root.creating = false + root.resetNewTaskAccess() + root.service.selectInboxScope(value) + } } BorderSurface { @@ -277,7 +287,9 @@ Item { Text { width: parent.width - (newButton.visible ? newButton.implicitWidth + parent.spacing : 0) text: root.service.connectionPhase === "connected" - ? "Connected" + (root.formattedInboxUpdatedAt ? " · updated " + root.formattedInboxUpdatedAt : "") + ? (root.service.showingAllComputers + ? String((root.service.environments || []).length) + " computers" + : "Connected") + (root.formattedInboxUpdatedAt ? " · updated " + root.formattedInboxUpdatedAt : "") : (root.service.connectionPhase === "blocked" ? "Connection blocked" : String(root.service.connectionPhase) + (root.service.connectionDetail ? " · " + root.service.connectionDetail : "") @@ -293,8 +305,9 @@ Item { iconText: root.creating ? "󰅖" : "󰐕" text: root.creating ? "Cancel" : "New task" visible: root.service.connectionPhase === "connected" - enabled: root.service.connectionPhase === "connected" + enabled: root.service.connectionPhase === "connected" && !root.service.showingAllComputers active: enabled && !root.creating + tooltipText: root.service.showingAllComputers ? "Select a computer to create a task" : "" onClicked: { root.creating = !root.creating root.resetNewTaskAccess() @@ -304,7 +317,7 @@ Item { } BorderSurface { - visible: root.creating && root.service.connectionPhase === "connected" + visible: root.creating && root.service.connectionPhase === "connected" && !root.service.showingAllComputers width: parent.width height: createColumn.implicitHeight + Style.spacing.rowPaddingX * 2 radius: Style.cornerRadius @@ -475,36 +488,44 @@ Item { InboxSection { title: "PINNED" items: root.service.inbox.pinned || [] - onThreadActivated: function(threadId) { root.service.openThread(threadId) } - onPinRequested: function(threadId, pinned) { root.pin(threadId, pinned) } - onSettleRequested: function(threadId, settled) { root.settle(threadId, settled) } - onSnoozeRequested: function(threadId, snoozed) { root.snooze(threadId, snoozed) } + environments: root.service.environments + showEnvironment: root.service.showingAllComputers + onThreadActivated: function(environmentId, threadId) { root.service.openThread(environmentId, threadId) } + onPinRequested: function(environmentId, threadId, pinned) { root.pin(environmentId, threadId, pinned) } + onSettleRequested: function(environmentId, threadId, settled) { root.settle(environmentId, threadId, settled) } + onSnoozeRequested: function(environmentId, threadId, snoozed) { root.snooze(environmentId, threadId, snoozed) } } InboxSection { title: "INBOX / ACTIVE" items: root.service.inbox.active || [] - onThreadActivated: function(threadId) { root.service.openThread(threadId) } - onPinRequested: function(threadId, pinned) { root.pin(threadId, pinned) } - onSettleRequested: function(threadId, settled) { root.settle(threadId, settled) } - onSnoozeRequested: function(threadId, snoozed) { root.snooze(threadId, snoozed) } + environments: root.service.environments + showEnvironment: root.service.showingAllComputers + onThreadActivated: function(environmentId, threadId) { root.service.openThread(environmentId, threadId) } + onPinRequested: function(environmentId, threadId, pinned) { root.pin(environmentId, threadId, pinned) } + onSettleRequested: function(environmentId, threadId, settled) { root.settle(environmentId, threadId, settled) } + onSnoozeRequested: function(environmentId, threadId, snoozed) { root.snooze(environmentId, threadId, snoozed) } } InboxSection { title: "SNOOZED" items: root.service.inbox.snoozed || [] initiallyExpanded: false - onThreadActivated: function(threadId) { root.service.openThread(threadId) } - onPinRequested: function(threadId, pinned) { root.pin(threadId, pinned) } - onSettleRequested: function(threadId, settled) { root.settle(threadId, settled) } - onSnoozeRequested: function(threadId, snoozed) { root.snooze(threadId, snoozed) } + environments: root.service.environments + showEnvironment: root.service.showingAllComputers + onThreadActivated: function(environmentId, threadId) { root.service.openThread(environmentId, threadId) } + onPinRequested: function(environmentId, threadId, pinned) { root.pin(environmentId, threadId, pinned) } + onSettleRequested: function(environmentId, threadId, settled) { root.settle(environmentId, threadId, settled) } + onSnoozeRequested: function(environmentId, threadId, snoozed) { root.snooze(environmentId, threadId, snoozed) } } InboxSection { title: "SETTLED" items: root.service.inbox.settled || [] initiallyExpanded: false - onThreadActivated: function(threadId) { root.service.openThread(threadId) } - onPinRequested: function(threadId, pinned) { root.pin(threadId, pinned) } - onSettleRequested: function(threadId, settled) { root.settle(threadId, settled) } - onSnoozeRequested: function(threadId, snoozed) { root.snooze(threadId, snoozed) } + environments: root.service.environments + showEnvironment: root.service.showingAllComputers + onThreadActivated: function(environmentId, threadId) { root.service.openThread(environmentId, threadId) } + onPinRequested: function(environmentId, threadId, pinned) { root.pin(environmentId, threadId, pinned) } + onSettleRequested: function(environmentId, threadId, settled) { root.settle(environmentId, threadId, settled) } + onSnoozeRequested: function(environmentId, threadId, snoozed) { root.snooze(environmentId, threadId, snoozed) } } Text { diff --git a/qml/InputCard.qml b/qml/InputCard.qml index 43dd4b2..41a6e01 100644 --- a/qml/InputCard.qml +++ b/qml/InputCard.qml @@ -8,6 +8,7 @@ import "AttentionState.js" as AttentionState BorderSurface { id: root required property var inputData + required property string environmentId required property string threadId required property var service property var answers: ({}) @@ -198,7 +199,7 @@ BorderSurface { accent: root.inputColor active: true enabled: root.complete() - onClicked: root.service.respondInput(root.threadId, String(root.inputData.requestId), root.answers) + onClicked: root.service.respondInput(root.environmentId, root.threadId, String(root.inputData.requestId), root.answers) } } } diff --git a/qml/Service.qml b/qml/Service.qml index 58a95ed..4fe7c4c 100644 --- a/qml/Service.qml +++ b/qml/Service.qml @@ -17,16 +17,21 @@ Item { property string connectionDetail: "" property var environments: [] property string selectedEnvironmentId: "" + property string inboxScopeId: "" + property bool allComputersActive: false + property bool allComputersOpening: false property var inbox: ({ pinned: [], active: [], snoozed: [], settled: [], projects: [], models: [] }) property var thread: null property var models: [] property string lastError: "" property string openThreadId: "" + property string openThreadEnvironmentId: "" property bool openingThread: false property bool threadSubscriptionActive: false property int requestSerial: 0 property var callbacks: ({}) property var queuedWrites: [] + property string allComputersEnvironmentId: "" signal authCompleted() signal navigateThread(string threadId) @@ -34,6 +39,9 @@ Item { readonly property string pluginDir: manifest && manifest.__sourceDir ? String(manifest.__sourceDir) : "" readonly property string bridgePath: pluginDir + "/bin/t3-mini-bridge" + readonly property bool allComputersAvailable: allComputersEnvironmentId.length > 0 && environments.length > 1 + readonly property bool showingAllComputers: allComputersEnvironmentId.length > 0 + && inboxScopeId === allComputersEnvironmentId readonly property int attentionCount: countAttention() function countAttention() { @@ -73,18 +81,24 @@ Item { var pending = callbacks callbacks = ({}) queuedWrites = [] + allComputersActive = false + allComputersOpening = false for (var key in pending) pending[key](false, { code: "BRIDGE_RESTARTED", message: "The T3 bridge restarted; live state will be restored automatically.", retryable: true }) } function resumeOpenThread() { - if (!openThreadId || openingThread || threadSubscriptionActive || connectionPhase !== "connected") return + if (!openThreadId || !openThreadEnvironmentId || openingThread || threadSubscriptionActive || connectionPhase !== "connected") return openingThread = true - request("thread.open", { threadId: openThreadId }, function(ok, result) { - if (ok) return + request("thread.open", { environmentId: openThreadEnvironmentId, threadId: openThreadId }, function(ok, result) { + if (ok) { + models = result.models || models + return + } openingThread = false lastError = String(result && result.message ? result.message : "The thread could not be opened.") openThreadId = "" + openThreadEnvironmentId = "" threadSubscriptionActive = false thread = null navigateInbox() @@ -106,6 +120,7 @@ Item { var payload = message.payload || {} switch (message.event) { case "bridge.ready": + allComputersEnvironmentId = String(payload.allComputersEnvironmentId || "") ready = true flushWrites() break @@ -116,6 +131,10 @@ Item { authDetail = String(payload.detail || "") if (authPhase === "signedOut") { openThreadId = "" + openThreadEnvironmentId = "" + inboxScopeId = "" + allComputersActive = false + allComputersOpening = false openingThread = false threadSubscriptionActive = false thread = null @@ -134,26 +153,40 @@ Item { connectionPhase = String(payload.phase || "disconnected") connectionDetail = String(payload.detail || "") selectedEnvironmentId = String(payload.environmentId || selectedEnvironmentId) + if (!inboxScopeId) inboxScopeId = selectedEnvironmentId if (connectionPhase !== "connected") { openingThread = false threadSubscriptionActive = false } + if (connectionPhase === "connected") restoreAllComputers() break case "environment.changed": environments = payload.environments || [] selectedEnvironmentId = String(payload.selected || "") + if (!inboxScopeId) inboxScopeId = selectedEnvironmentId + if (showingAllComputers && !allComputersAvailable) { + if (selectedEnvironmentId) selectInboxScope(selectedEnvironmentId) + else { + inboxScopeId = "" + allComputersActive = false + allComputersOpening = false + } + } + restoreAllComputers() break case "inbox.changed": + if (!inboxScopeId || String(payload.environmentId || "") !== inboxScopeId) break inbox = payload models = payload.models || [] + if (String(payload.environmentId || "") === allComputersEnvironmentId) allComputersActive = true resumeOpenThread() break case "thread.snapshot": + if (!payload.id || String(payload.id) !== openThreadId + || String(payload.environmentId || "") !== openThreadEnvironmentId) break thread = payload - if (payload.id && String(payload.id) === openThreadId) { - openingThread = false - threadSubscriptionActive = true - } + openingThread = false + threadSubscriptionActive = true break case "error": lastError = String(payload.message || "T3 bridge error") @@ -173,22 +206,65 @@ Item { function startLogin() { request("auth.login", {}) } function logout() { request("auth.logout", {}, function() { navigateInbox() }) } function refreshEnvironments() { request("environment.list", {}) } - function selectEnvironment(environmentId) { request("environment.select", { environmentId: environmentId }) } - function refreshInbox() { request("inbox.get", {}, function(ok, payload) { if (ok) { inbox = payload; models = payload.models || [] } }) } + function restoreAllComputers() { + if (!showingAllComputers || allComputersActive || allComputersOpening + || !allComputersAvailable || connectionPhase !== "connected") return + selectInboxScope(allComputersEnvironmentId) + } + function selectInboxScope(environmentId) { + var requested = String(environmentId) + if (requested === allComputersEnvironmentId && !allComputersAvailable) return + inboxScopeId = requested + if (requested === allComputersEnvironmentId) { + allComputersOpening = true + allComputersActive = false + } else { + allComputersOpening = false + allComputersActive = false + } + request("environment.select", { environmentId: requested }, function(ok, result) { + allComputersOpening = false + if (!ok) { + inboxScopeId = selectedEnvironmentId + allComputersActive = false + return + } + inboxScopeId = String(result.selected || requested) + allComputersActive = inboxScopeId === allComputersEnvironmentId + if (result.inbox) { + inbox = result.inbox + models = result.inbox.models || [] + } + resumeOpenThread() + }) + } + function refreshInbox() { + if (showingAllComputers) { + allComputersActive = false + selectInboxScope(allComputersEnvironmentId) + return + } + request("inbox.get", {}, function(ok, payload) { if (ok) { inbox = payload; models = payload.models || [] } }) + } function refreshConnection() { + if (showingAllComputers && connectionPhase === "connected") { + refreshInbox() + return + } if (connectionPhase === "connected") { refreshInbox() return } if (selectedEnvironmentId) { - selectEnvironment(selectedEnvironmentId) + selectInboxScope(selectedEnvironmentId) return } refreshEnvironments() } - function openThread(threadId) { + function openThread(environmentId, threadId) { thread = null openThreadId = String(threadId) + openThreadEnvironmentId = String(environmentId) openingThread = false threadSubscriptionActive = false navigateThread(threadId) @@ -196,6 +272,7 @@ Item { } function closeThread() { openThreadId = "" + openThreadEnvironmentId = "" openingThread = false threadSubscriptionActive = false request("thread.close", {}) @@ -209,46 +286,46 @@ Item { if (modelOptions && modelOptions.length > 0) payload.modelOptions = modelOptions if (runtimeMode) payload.runtimeMode = runtimeMode request("thread.create", payload, function(ok, result) { - if (ok && result && result.threadId) openThread(String(result.threadId)) + if (ok && result && result.threadId) openThread(selectedEnvironmentId, String(result.threadId)) }) } - function pasteScreenshot(threadId, callback) { - request("attachment.clipboard.read", { threadId: threadId }, callback) + function pasteScreenshot(environmentId, threadId, callback) { + request("attachment.clipboard.read", { environmentId: environmentId, threadId: threadId }, callback) } - function discardAttachment(threadId, attachmentId) { - request("attachment.discard", { threadId: threadId, attachmentId: attachmentId }) + function discardAttachment(environmentId, threadId, attachmentId) { + request("attachment.discard", { environmentId: environmentId, threadId: threadId, attachmentId: attachmentId }) } - function send(threadId, text, attachmentIds, callback) { - var payload = { threadId: threadId, text: text } + function send(environmentId, threadId, text, attachmentIds, callback) { + var payload = { environmentId: environmentId, threadId: threadId, text: text } if (attachmentIds && attachmentIds.length > 0) payload.attachmentIds = attachmentIds request("thread.send", payload, callback) } - function interrupt(threadId) { request("thread.interrupt", { threadId: threadId }) } - function settle(threadId) { request("thread.settle", { threadId: threadId }) } - function unsettle(threadId) { request("thread.unsettle", { threadId: threadId }) } - function snooze(threadId, until) { request("thread.snooze", { threadId: threadId, until: until }) } - function unsnooze(threadId) { request("thread.unsnooze", { threadId: threadId }) } - function pin(threadId) { request("thread.pin", { threadId: threadId }) } - function unpin(threadId) { request("thread.unpin", { threadId: threadId }) } - function setModel(threadId, providerInstanceId, model) { - request("thread.model.set", { threadId: threadId, providerInstanceId: providerInstanceId, model: model }) + function interrupt(environmentId, threadId) { request("thread.interrupt", { environmentId: environmentId, threadId: threadId }) } + function settle(environmentId, threadId) { request("thread.settle", { environmentId: environmentId, threadId: threadId }) } + function unsettle(environmentId, threadId) { request("thread.unsettle", { environmentId: environmentId, threadId: threadId }) } + function snooze(environmentId, threadId, until) { request("thread.snooze", { environmentId: environmentId, threadId: threadId, until: until }) } + function unsnooze(environmentId, threadId) { request("thread.unsnooze", { environmentId: environmentId, threadId: threadId }) } + function pin(environmentId, threadId) { request("thread.pin", { environmentId: environmentId, threadId: threadId }) } + function unpin(environmentId, threadId) { request("thread.unpin", { environmentId: environmentId, threadId: threadId }) } + function setModel(environmentId, threadId, providerInstanceId, model) { + request("thread.model.set", { environmentId: environmentId, threadId: threadId, providerInstanceId: providerInstanceId, model: model }) } - function setModelOption(threadId, optionId, value) { - request("thread.model.option.set", { threadId: threadId, optionId: optionId, value: value }) + function setModelOption(environmentId, threadId, optionId, value) { + request("thread.model.option.set", { environmentId: environmentId, threadId: threadId, optionId: optionId, value: value }) } - function rename(threadId, title) { request("thread.rename", { threadId: threadId, title: title }) } - function regenerateTitle(threadId) { request("thread.title.regenerate", { threadId: threadId }) } - function setRuntime(threadId, runtimeMode) { - request("thread.runtime.set", { threadId: threadId, runtimeMode: runtimeMode }) + function rename(environmentId, threadId, title) { request("thread.rename", { environmentId: environmentId, threadId: threadId, title: title }) } + function regenerateTitle(environmentId, threadId) { request("thread.title.regenerate", { environmentId: environmentId, threadId: threadId }) } + function setRuntime(environmentId, threadId, runtimeMode) { + request("thread.runtime.set", { environmentId: environmentId, threadId: threadId, runtimeMode: runtimeMode }) } - function setInteraction(threadId, interactionMode) { - request("thread.interaction.set", { threadId: threadId, interactionMode: interactionMode }) + function setInteraction(environmentId, threadId, interactionMode) { + request("thread.interaction.set", { environmentId: environmentId, threadId: threadId, interactionMode: interactionMode }) } - function respondApproval(threadId, requestId, decision) { - request("approval.respond", { threadId: threadId, requestId: requestId, decision: decision }) + function respondApproval(environmentId, threadId, requestId, decision) { + request("approval.respond", { environmentId: environmentId, threadId: threadId, requestId: requestId, decision: decision }) } - function respondInput(threadId, requestId, answers) { - request("input.respond", { threadId: threadId, requestId: requestId, answers: answers }) + function respondInput(environmentId, threadId, requestId, answers) { + request("input.respond", { environmentId: environmentId, threadId: threadId, requestId: requestId, answers: answers }) } Process { @@ -263,6 +340,8 @@ Item { onExited: { root.ready = false root.connectionPhase = "disconnected" + root.allComputersActive = false + root.allComputersOpening = false root.openingThread = false root.threadSubscriptionActive = false root.failPendingRequests() diff --git a/qml/ThreadRow.qml b/qml/ThreadRow.qml index 8bbea2d..2ab4b4c 100644 --- a/qml/ThreadRow.qml +++ b/qml/ThreadRow.qml @@ -6,6 +6,7 @@ import "AttentionState.js" as AttentionState BorderSurface { id: root required property var threadData + property string environmentLabel: "" readonly property bool inputNeeded: threadData.phase === "inputNeeded" readonly property color attentionColor: AttentionState.attentionColor(String(threadData.phase), Color.urgent) @@ -89,7 +90,10 @@ BorderSurface { Text { width: parent.width - actions.implicitWidth - parent.spacing - text: String(root.threadData.project || "") + " · " + String(root.threadData.model || root.threadData.provider || "") + " · " + root.relativeTime(root.threadData.latestActivityAt) + text: (root.environmentLabel ? root.environmentLabel + " · " : "") + + String(root.threadData.project || "") + " · " + + String(root.threadData.model || root.threadData.provider || "") + " · " + + root.relativeTime(root.threadData.latestActivityAt) color: Color.muted font.family: Style.font.family font.pixelSize: Style.font.caption diff --git a/qml/ThreadView.qml b/qml/ThreadView.qml index 0988165..2b0df07 100644 --- a/qml/ThreadView.qml +++ b/qml/ThreadView.qml @@ -36,7 +36,7 @@ Item { } function snoozeUntilTomorrow() { - service.snooze(String(threadData.id), new Date(Date.now() + 86400000).toISOString()) + service.snooze(String(threadData.environmentId), String(threadData.id), new Date(Date.now() + 86400000).toISOString()) } Connections { @@ -90,19 +90,25 @@ Item { visible: root.threadData && root.threadData.capabilities.pinning iconText: root.threadData && root.threadData.lifecycle === "pinned" ? "󰐃" : "󰤱" tooltipText: root.threadData && root.threadData.lifecycle === "pinned" ? "Unpin" : "Pin" - onClicked: root.threadData.lifecycle === "pinned" ? root.service.unpin(String(root.threadData.id)) : root.service.pin(String(root.threadData.id)) + onClicked: root.threadData.lifecycle === "pinned" + ? root.service.unpin(String(root.threadData.environmentId), String(root.threadData.id)) + : root.service.pin(String(root.threadData.environmentId), String(root.threadData.id)) } Button { visible: root.threadData && root.threadData.capabilities.snooze iconText: root.threadData && root.threadData.lifecycle === "snoozed" ? "󰒱" : "󰒲" tooltipText: root.threadData && root.threadData.lifecycle === "snoozed" ? "Wake" : "Snooze for one day" - onClicked: root.threadData.lifecycle === "snoozed" ? root.service.unsnooze(String(root.threadData.id)) : root.snoozeUntilTomorrow() + onClicked: root.threadData.lifecycle === "snoozed" + ? root.service.unsnooze(String(root.threadData.environmentId), String(root.threadData.id)) + : root.snoozeUntilTomorrow() } Button { visible: root.threadData && root.threadData.capabilities.settlement iconText: root.threadData && root.threadData.lifecycle === "settled" ? "󰅖" : "󰄬" tooltipText: root.threadData && root.threadData.lifecycle === "settled" ? "Unsettle" : "Settle" - onClicked: root.threadData.lifecycle === "settled" ? root.service.unsettle(String(root.threadData.id)) : root.service.settle(String(root.threadData.id)) + onClicked: root.threadData.lifecycle === "settled" + ? root.service.unsettle(String(root.threadData.environmentId), String(root.threadData.id)) + : root.service.settle(String(root.threadData.environmentId), String(root.threadData.id)) } } } @@ -176,12 +182,24 @@ Item { Repeater { model: root.threadData ? root.threadData.approvals : [] - ApprovalCard { required property var modelData; approvalData: modelData; threadId: String(root.threadData.id); service: root.service } + ApprovalCard { + required property var modelData + approvalData: modelData + environmentId: String(root.threadData.environmentId) + threadId: String(root.threadData.id) + service: root.service + } } Repeater { model: root.threadData ? root.threadData.inputs : [] - InputCard { required property var modelData; inputData: modelData; threadId: String(root.threadData.id); service: root.service } + InputCard { + required property var modelData + inputData: modelData + environmentId: String(root.threadData.environmentId) + threadId: String(root.threadData.id) + service: root.service + } } } } diff --git a/tests/all-computers.test.ts b/tests/all-computers.test.ts new file mode 100644 index 0000000..cf4c055 --- /dev/null +++ b/tests/all-computers.test.ts @@ -0,0 +1,193 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { PreparedConnection } from "@t3tools/client-runtime/connection"; + +import { + ALL_COMPUTERS_ENVIRONMENT_ID, + type CapabilitiesDto, + type EnvironmentDto, + type InboxDto, + type ThreadSummaryDto, +} from "../bridge/src/protocol/types.ts"; +import { AllComputersInbox, combineComputerInboxes } from "../bridge/src/t3/allComputers.ts"; +import type { T3EnvironmentSession } from "../bridge/src/t3/session.ts"; + +const capabilities: CapabilitiesDto = { + settlement: true, + snooze: true, + pinning: true, + pinReorder: true, + titleRegeneration: true, + threadPagination: true, +}; + +function summary( + environmentId: string, + id: string, + latestActivityAt: string, + snoozedUntil: string | null = null, +): ThreadSummaryDto { + return { + environmentId, + id, + projectId: `project-${environmentId}`, + project: `Project ${environmentId}`, + title: `Thread ${id}`, + provider: "codex", + model: "gpt-5.6", + phase: "ready", + lifecycle: snoozedUntil === null ? "active" : "snoozed", + updatedAt: latestActivityAt, + latestActivityAt, + attention: false, + pinned: false, + snoozedUntil, + settled: false, + canPin: true, + canSettle: true, + canSnooze: true, + }; +} + +function inbox( + environmentId: string, + groups: Partial> = {}, + capabilityOverrides: Partial = {}, +): InboxDto { + return { + environmentId, + updatedAt: `2026-08-23T00:00:0${environmentId.at(-1) ?? "0"}.000Z`, + capabilities: { ...capabilities, ...capabilityOverrides }, + projects: [{ id: `project-${environmentId}`, title: `Project ${environmentId}` }], + models: [], + pinned: groups.pinned ?? [], + active: groups.active ?? [], + snoozed: groups.snoozed ?? [], + settled: groups.settled ?? [], + }; +} + +function environment(id: string): EnvironmentDto { + return { id, label: `Computer ${id}`, status: "online", serverVersion: null, lastSeenAt: null }; +} + +test("combined Inbox keeps lifecycle groups and orders threads across computers", () => { + const combined = combineComputerInboxes([ + inbox("computer-1", { + active: [summary("computer-1", "older", "2026-08-23T10:00:00.000Z")], + snoozed: [summary("computer-1", "later", "2026-08-23T08:00:00.000Z", "2026-08-25T00:00:00.000Z")], + }), + inbox("computer-2", { + active: [summary("computer-2", "newer", "2026-08-23T12:00:00.000Z")], + snoozed: [summary("computer-2", "sooner", "2026-08-23T09:00:00.000Z", "2026-08-24T00:00:00.000Z")], + }, { pinning: false }), + ]); + + assert.equal(combined.environmentId, ALL_COMPUTERS_ENVIRONMENT_ID); + assert.deepEqual(combined.active.map((thread) => thread.id), ["newer", "older"]); + assert.deepEqual(combined.snoozed.map((thread) => thread.id), ["sooner", "later"]); + assert.equal(combined.capabilities.settlement, true); + assert.equal(combined.capabilities.pinning, false); + assert.deepEqual(combined.projects, []); + assert.deepEqual(combined.models, []); +}); + +test("All computers connects the non-primary computers and routes their thread sessions", async () => { + const prepared: string[] = []; + const closed: string[] = []; + const sessions = new Map(); + const closeCallbacks = new Map void>(); + const published: InboxDto[] = []; + const errors: Array<{ code: string }> = []; + const manager = new AllComputersInbox({ + prepareConnection: async (environmentId) => { + prepared.push(environmentId); + return { environmentId } as PreparedConnection; + }, + }, { + createSession: (environmentId, onInbox, onClosed) => { + const session = { + connect: async () => { + onInbox(inbox(environmentId, { + active: [summary(environmentId, `thread-${environmentId}`, "2026-08-23T12:00:00.000Z")], + })); + }, + close: async () => { closed.push(environmentId); }, + } as unknown as T3EnvironmentSession; + sessions.set(environmentId, session); + closeCallbacks.set(environmentId, onClosed); + return session; + }, + onInbox: (value) => published.push(value), + onError: (error) => errors.push(error), + }); + const primarySession = {} as T3EnvironmentSession; + + const combined = await manager.activate( + [environment("computer-1"), environment("computer-2"), environment("computer-3")], + "computer-1", + inbox("computer-1", { + active: [summary("computer-1", "thread-computer-1", "2026-08-23T11:00:00.000Z")], + }), + ); + + assert.deepEqual(prepared, ["computer-2", "computer-3"]); + assert.deepEqual(combined.active.map((thread) => thread.environmentId), [ + "computer-2", + "computer-3", + "computer-1", + ]); + assert.equal(manager.sessionFor("computer-1", primarySession), primarySession); + assert.equal(manager.sessionFor("computer-2", primarySession), sessions.get("computer-2")); + assert.ok(published.length >= 3); + + closeCallbacks.get("computer-2")?.(new Error("offline")); + await Promise.resolve(); + assert.deepEqual(manager.current().active.map((thread) => thread.environmentId), ["computer-3", "computer-1"]); + assert.throws( + () => manager.sessionFor("computer-2", primarySession), + (error: unknown) => (error as { code?: string }).code === "ENVIRONMENT_NOT_CONNECTED", + ); + assert.deepEqual(errors.map((error) => error.code), ["ENVIRONMENT_DISCONNECTED"]); + + await manager.deactivate(); + assert.deepEqual(closed.sort(), ["computer-2", "computer-3"]); + assert.throws(() => manager.current(), /Select All computers first/u); +}); + +test("All computers stays unavailable for a single linked computer", async () => { + const manager = new AllComputersInbox({ + prepareConnection: async () => assert.fail("No background connection should be prepared."), + }, { + createSession: () => assert.fail("No background session should be created."), + onInbox: () => undefined, + onError: () => undefined, + }); + + await assert.rejects( + manager.activate([environment("computer-1")], "computer-1", inbox("computer-1")), + (error: unknown) => (error as { code?: string }).code === "ALL_COMPUTERS_UNAVAILABLE", + ); +}); + +test("All computers activates when two computers are linked", async () => { + const manager = new AllComputersInbox({ + prepareConnection: async (environmentId) => ({ environmentId }) as PreparedConnection, + }, { + createSession: (environmentId, onInbox) => ({ + connect: async () => onInbox(inbox(environmentId)), + close: async () => undefined, + }) as unknown as T3EnvironmentSession, + onInbox: () => undefined, + onError: () => assert.fail("The second computer should connect cleanly."), + }); + + const combined = await manager.activate( + [environment("computer-1"), environment("computer-2")], + "computer-1", + inbox("computer-1"), + ); + + assert.equal(combined.environmentId, ALL_COMPUTERS_ENVIRONMENT_ID); + await manager.deactivate(); +}); diff --git a/tests/commands.test.ts b/tests/commands.test.ts index fcdb353..b50e685 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -101,7 +101,7 @@ function harness( return { sequence: dispatched.length }; }, } as unknown as T3EnvironmentSession; - return { commands: new T3Commands(session, attachments), dispatched }; + return { commands: new T3Commands(() => session, attachments), dispatched, session }; } test("thread creation uses Nightly atomic bootstrap + a supervised access fallback", async () => { @@ -196,6 +196,22 @@ test("lifecycle, turns, approvals, and input map to real orchestration commands" assert.equal(dispatched[9]?.regenerateTitle, true); }); +test("thread commands route to the computer named in the payload", async () => { + const primary = harness(); + const remote = harness(); + const routed: Array = []; + const commands = new T3Commands((environmentId) => { + routed.push(environmentId); + return environmentId === "computer-2" ? remote.session : primary.session; + }); + + await commands.pin({ environmentId: "computer-2", threadId: "thread-1" }); + + assert.deepEqual(routed, ["computer-2"]); + assert.equal(primary.dispatched.length, 0); + assert.equal(remote.dispatched[0]?.type, "thread.pin"); +}); + test("unsupported server capabilities reject lifecycle mutation locally", async () => { const { commands } = harness({ threadSettlement: false, threadSnooze: false, threadPinning: false, threadTitleRegeneration: false }); await assert.rejects(commands.settle({ threadId: "thread-1" }), (error: unknown) => (error as { code?: string }).code === "CAPABILITY_UNSUPPORTED"); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 586a2ee..2806f3e 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -16,9 +16,10 @@ test("protocol decoder validates envelopes and operation payloads", () => { protocolVersion: 1, requestId: "request-1", type: "thread.snooze", - payload: { threadId: "thread-1", until: "2026-08-23T00:00:00.000Z" }, + payload: { environmentId: "computer-2", threadId: "thread-1", until: "2026-08-23T00:00:00.000Z" }, })); assert.equal(decoded.requestId, "request-1"); + assert.equal(decoded.payload.environmentId, "computer-2"); const option = decodeRequestLine(JSON.stringify({ protocolVersion: 1, requestId: "option-1", @@ -60,6 +61,7 @@ test("protocol decoder validates envelopes and operation payloads", () => { assert.throws(() => decodeRequestLine("not json"), ProtocolDecodeError); assert.throws(() => decodeRequestLine(JSON.stringify({ protocolVersion: 2, requestId: "x", type: "bridge.ping" })), /protocolVersion/u); assert.throws(() => decodeRequestLine(JSON.stringify({ protocolVersion: 1, requestId: "x", type: "thread.open", payload: {} })), /threadId/u); + assert.throws(() => decodeRequestLine(JSON.stringify({ protocolVersion: 1, requestId: "x", type: "thread.open", payload: { environmentId: "", threadId: "t" } })), /environmentId/u); assert.throws(() => decodeRequestLine(JSON.stringify({ protocolVersion: 1, requestId: "x", type: "approval.respond", payload: { threadId: "t", requestId: "a", decision: "yes" } })), /decision/u); assert.throws(() => decodeRequestLine(JSON.stringify({ protocolVersion: 1, requestId: "x", type: "thread.model.option.set", payload: { threadId: "t", optionId: "reasoningEffort", value: "" } })), /value/u); assert.throws(() => decodeRequestLine(JSON.stringify({ protocolVersion: 1, requestId: "x", type: "thread.create", payload: { projectId: "p", prompt: "go", modelOptions: [{ id: "reasoningEffort", value: "high" }, { id: "reasoningEffort", value: "low" }] } })), /duplicate/u); @@ -96,7 +98,8 @@ test("NDJSON bridge correlates concurrent responses and survives malformed input throw new Error("Timed out waiting for bridge output."); } - await waitFor((message) => message.event === "bridge.ready"); + const ready = await waitFor((message) => message.event === "bridge.ready"); + assert.equal((ready.payload as { allComputersEnvironmentId: string }).allComputersEnvironmentId, "__all_computers__"); child.stdin.write("{bad json\n"); child.stdin.write(`${JSON.stringify({ protocolVersion: 1, requestId: "one", type: "bridge.ping", payload: {} })}\n`); child.stdin.write(`${JSON.stringify({ protocolVersion: 1, requestId: "two", type: "auth.status", payload: {} })}\n`); diff --git a/tests/ui-state.test.ts b/tests/ui-state.test.ts index e6039c3..cf8145c 100644 --- a/tests/ui-state.test.ts +++ b/tests/ui-state.test.ts @@ -102,7 +102,7 @@ test("Inbox header omits login identity and presents a localized update time", a inbox, /function formatUpdatedAt\(value\)[\s\S]*new Date\([\s\S]*toLocaleString\(Qt\.locale\(\), Locale\.ShortFormat\)/u, ); - assert.match(inbox, /"Connected" \+ \(root\.formattedInboxUpdatedAt/u); + assert.match(inbox, /: "Connected"\) \+ \(root\.formattedInboxUpdatedAt/u); assert.doesNotMatch(inbox, /String\(root\.service\.inbox\.updatedAt/u); }); @@ -111,7 +111,7 @@ test("bridge restart preserves and resubscribes the active thread", async () => assert.match(service, /property string openThreadId/u); assert.match(service, /case "inbox\.changed"[\s\S]*resumeOpenThread\(\)/u); assert.match(service, /onExited:[\s\S]*threadSubscriptionActive = false/u); - assert.match(service, /request\("thread\.open", \{ threadId: openThreadId \}/u); + assert.match(service, /request\("thread\.open", \{ environmentId: openThreadEnvironmentId, threadId: openThreadId \}/u); assert.match(service, /function failPendingRequests\(\)[\s\S]*queuedWrites = \[\]/u); const openResponse = service.slice(service.indexOf("function resumeOpenThread"), service.indexOf("function handleResponse")); assert.doesNotMatch(openResponse, /threadSubscriptionActive = true/u); @@ -198,7 +198,7 @@ test("composer actions fit one row with compact labels", async () => { assert.doesNotMatch(modelDropdown, /root\.value =/u); assert.match(composer, /readonly property string selectedModel/u); assert.doesNotMatch(composer, /root\.selectedModel =/u); - assert.match(composer, /service\.send\(String\(threadData\.id\), value, attachmentIds/u); + assert.match(composer, /service\.send\(String\(threadData\.environmentId\), String\(threadData\.id\), value, attachmentIds/u); }); test("assistant Markdown cannot request resources or open unsafe URL schemes", async () => { @@ -258,6 +258,24 @@ test("new-thread composer mirrors model options and access controls from replies assert.match(service, /function createThread\([^)]*modelOptions, runtimeMode\)[\s\S]*payload\.modelOptions = modelOptions[\s\S]*payload\.runtimeMode = runtimeMode/u); }); +test("the Inbox offers one unified list when multiple computers are linked", async () => { + const inbox = await readFile(join(root, "qml", "InboxView.qml"), "utf8"); + const section = await readFile(join(root, "qml", "InboxSection.qml"), "utf8"); + const row = await readFile(join(root, "qml", "ThreadRow.qml"), "utf8"); + const service = await readFile(join(root, "qml", "Service.qml"), "utf8"); + + assert.match(inbox, /if \(root\.service\.allComputersAvailable\)[\s\S]*label: "All computers"/u); + assert.match(inbox, /value: root\.service\.inboxScopeId/u); + assert.match(inbox, /enabled: root\.service\.connectionPhase === "connected" && !root\.service\.showingAllComputers/u); + assert.match(section, /environmentLabel: root\.showEnvironment \? root\.environmentLabel\(modelData\.environmentId\) : ""/u); + assert.match(row, /root\.environmentLabel \? root\.environmentLabel \+ " · " : ""/u); + assert.match(service, /request\("thread\.open", \{ environmentId: openThreadEnvironmentId, threadId: openThreadId \}/u); + assert.match(service, /property string allComputersEnvironmentId: ""[\s\S]*allComputersAvailable: allComputersEnvironmentId\.length > 0 && environments\.length > 1/u); + assert.match(service, /case "bridge\.ready":[\s\S]*allComputersEnvironmentId = String\(payload\.allComputersEnvironmentId \|\| ""\)/u); + assert.match(service, /if \(!inboxScopeId \|\| String\(payload\.environmentId \|\| ""\) !== inboxScopeId\) break/u); + assert.match(service, /case "thread\.snapshot":[\s\S]*String\(payload\.environmentId \|\| ""\) !== openThreadEnvironmentId\) break[\s\S]*thread = payload/u); +}); + test("new tasks require an explicit confirmation before broader access", async () => { const inbox = await readFile(join(root, "qml", "InboxView.qml"), "utf8"); assert.match(inbox, /property string selectedRuntimeMode: "approval-required"/u); @@ -354,7 +372,7 @@ test("blocked Relay connection hides and disables task creation", async () => { ); assert.match( service, - /function refreshConnection\(\)[\s\S]*connectionPhase === "connected"[\s\S]*refreshInbox\(\)[\s\S]*selectedEnvironmentId[\s\S]*selectEnvironment\(selectedEnvironmentId\)/u, + /function refreshConnection\(\)[\s\S]*connectionPhase === "connected"[\s\S]*refreshInbox\(\)[\s\S]*selectedEnvironmentId[\s\S]*selectInboxScope\(selectedEnvironmentId\)/u, ); assert.match( inbox,