letagents 0.12.35 → 0.12.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/mcp/server/runtime/workspace-capture.js +16 -5
- package/dist/mcp/server/tools/supervised-room-turn.js +1 -1
- package/package.json +5 -3
- package/shared/activation-routing.d.mts +21 -3
- package/shared/activation-routing.mjs +37 -0
- package/shared/codex-owner-isolation.mjs +4 -2
- package/shared/codex-read-only-room-tools.d.mts +2 -0
- package/shared/codex-read-only-room-tools.mjs +24 -0
- package/shared/mcp-workspace-capture-worker.mjs +2 -1
- package/shared/message-link-previews.d.mts +1 -0
- package/shared/message-link-previews.mjs +6 -0
- package/shared/room-settings.d.mts +16 -0
- package/shared/room-settings.mjs +14 -0
- package/shared/room-typing.d.mts +4 -2
- package/shared/room-typing.mjs +22 -5
- package/shared/ui/GitHubEventCard.vue +229 -0
- package/shared/ui/PinnedMessages.vue +100 -95
- package/shared/ui/TypingIndicator.vue +123 -11
- package/shared/ui/room-agent-work.css +178 -0
- package/shared/ui/room-message-motion.ts +143 -0
- package/shared/ui/usePinnedMessageVisibility.ts +20 -0
- package/shared/ui/useRoomMessageMotion.ts +195 -0
- package/shared/ui/useSlidingTabIndicator.ts +72 -0
- package/shared/workspace-change-capture.mjs +3 -1
- package/shared/workspace-turn-capture.d.mts +4 -1
- package/shared/workspace-turn-capture.mjs +203 -6
|
@@ -6,7 +6,7 @@ import { promisify } from 'node:util';
|
|
|
6
6
|
import { Worker } from 'node:worker_threads';
|
|
7
7
|
import { parseRoomAgentWorkSummary } from '../../../../shared/room-agent-work.mjs';
|
|
8
8
|
import { REVIEW_LIMIT, REVIEW_PAGE_SIZE } from '../../../../shared/workspace-review.mjs';
|
|
9
|
-
import { releaseWorkspaceTree } from '../../../../shared/workspace-turn-capture.mjs';
|
|
9
|
+
import { captureRemoteTips, releaseWorkspaceTree } from '../../../../shared/workspace-turn-capture.mjs';
|
|
10
10
|
import { getLocalStatePath, readLocalStateSnapshot, updateLocalState } from '../../local-state/storage.js';
|
|
11
11
|
import { assertWorkerConnection } from '../../worker-call-context.js';
|
|
12
12
|
import { encodeRoomIdPath } from '../../room-id.js';
|
|
@@ -18,6 +18,8 @@ import { requireValidWorkerBearerRuntime } from './worker-bearer.js';
|
|
|
18
18
|
const execute = promisify(execFile);
|
|
19
19
|
const activeCalls = new Set();
|
|
20
20
|
let captureWorkerActive = false;
|
|
21
|
+
// What the remotes held when each capture began. Memory only: after a restart the capture is compared plainly.
|
|
22
|
+
const remoteTips = new Map();
|
|
21
23
|
const validId = (id) => /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(id);
|
|
22
24
|
const directory = (id) => join(`${getLocalStatePath()}.workspaces`, id);
|
|
23
25
|
const preparationDirectory = (capture) => {
|
|
@@ -70,7 +72,7 @@ async function exclusive(key, operation) {
|
|
|
70
72
|
activeCalls.delete(key);
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
|
-
async function runCapture(operation, capture, text) {
|
|
75
|
+
async function runCapture(operation, capture, text, tips = null) {
|
|
74
76
|
if (captureWorkerActive)
|
|
75
77
|
throw new Error('Another workspace is being captured. Retry shortly; the original baseline is preserved.');
|
|
76
78
|
captureWorkerActive = true;
|
|
@@ -78,7 +80,7 @@ async function runCapture(operation, capture, text) {
|
|
|
78
80
|
try {
|
|
79
81
|
return await new Promise((resolve, reject) => {
|
|
80
82
|
worker = new Worker(new URL('../../../../shared/mcp-workspace-capture-worker.mjs', import.meta.url), {
|
|
81
|
-
execArgv: [], workerData: { operation, capture, text, directory: operation === 'finish' ? preparationDirectory(capture) : null },
|
|
83
|
+
execArgv: [], workerData: { operation, capture, text, remoteTips: tips, directory: operation === 'finish' ? preparationDirectory(capture) : null },
|
|
82
84
|
});
|
|
83
85
|
const timeout = setTimeout(() => { reject(new Error('Capture exceeded its time limit. Retry using the same capture_id.')); void worker?.terminate(); }, 120_000);
|
|
84
86
|
worker.once('message', result => { clearTimeout(timeout); result.error ? reject(new Error(result.error)) : resolve(result); });
|
|
@@ -111,6 +113,10 @@ export async function beginWorkspaceCapture(input) {
|
|
|
111
113
|
instruction: 'Continue this capture. Keep its capture_id and call publish_workspace_capture when finished.',
|
|
112
114
|
...(prior.baseline ? {} : { warning: 'The starting snapshot is unavailable. Exact changes for this piece of work cannot be attributed.' }) };
|
|
113
115
|
}
|
|
116
|
+
// Asked before the work starts, and not awaited: a slow remote must not delay it. The remotes
|
|
117
|
+
// are the ones this clone configures: nothing outside the clone records where it came from,
|
|
118
|
+
// and an independent worker reports its own work. (The supervisor daemon asks its own record.)
|
|
119
|
+
const tips = captureRemoteTips(workspace);
|
|
114
120
|
const baseRevision = await git(['rev-parse', '--verify', 'HEAD']);
|
|
115
121
|
const capture = { capture_id: randomUUID(), api_url: getApiUrl(), room_id: session.room_id,
|
|
116
122
|
agent_key: session.agent_key, agent_instance_id: session.agent_instance_id, workspace, base_revision: baseRevision,
|
|
@@ -125,8 +131,11 @@ export async function beginWorkspaceCapture(input) {
|
|
|
125
131
|
delete records[id];
|
|
126
132
|
records[capture.capture_id] = capture;
|
|
127
133
|
});
|
|
128
|
-
for (const record of obsolete)
|
|
134
|
+
for (const record of obsolete) {
|
|
135
|
+
remoteTips.delete(record.capture_id);
|
|
129
136
|
await rm(directory(record.capture_id), { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
remoteTips.set(capture.capture_id, tips);
|
|
130
139
|
try {
|
|
131
140
|
const result = await runCapture('begin', capture);
|
|
132
141
|
capture.baseline = result.baseline ?? null;
|
|
@@ -176,7 +185,7 @@ export async function publishWorkspaceCapture(input) {
|
|
|
176
185
|
throw new Error('Supply a short summary of at most 400 characters.');
|
|
177
186
|
const candidate = { ...capture, preparation_id: randomUUID() };
|
|
178
187
|
try {
|
|
179
|
-
await runCapture('finish', candidate, text);
|
|
188
|
+
await runCapture('finish', candidate, text, await remoteTips.get(capture.capture_id) ?? null);
|
|
180
189
|
await preparedCapture(candidate);
|
|
181
190
|
// Commit one immutable preparation under the same lock that fences
|
|
182
191
|
// reconnects. Stale processes only write their own disposable directory.
|
|
@@ -247,6 +256,7 @@ export async function publishWorkspaceCapture(input) {
|
|
|
247
256
|
if (capture.next_page === total) {
|
|
248
257
|
capture = { ...capture, phase: 'published' };
|
|
249
258
|
persist();
|
|
259
|
+
remoteTips.delete(capture.capture_id);
|
|
250
260
|
await rm(directory(capture.capture_id), { recursive: true, force: true });
|
|
251
261
|
}
|
|
252
262
|
return { status: capture.phase === 'published' ? 'published' : 'uploading', capture_id: capture.capture_id,
|
|
@@ -259,6 +269,7 @@ export async function publishWorkspaceCapture(input) {
|
|
|
259
269
|
if (error instanceof ApiError && error.status === 410) {
|
|
260
270
|
capture = { ...capture, phase: 'blocked' };
|
|
261
271
|
persist();
|
|
272
|
+
remoteTips.delete(capture.capture_id);
|
|
262
273
|
await rm(directory(capture.capture_id), { recursive: true, force: true });
|
|
263
274
|
}
|
|
264
275
|
throw error;
|
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
const MAX_ROOM_REPLY_BYTES = 32 * 1024;
|
|
3
3
|
/** Select routing only; the daemon remains the sole final-answer publisher. */
|
|
4
4
|
export function registerReplyThreadTool(server) {
|
|
5
|
-
const tool = server.tool("set_reply_thread", "
|
|
5
|
+
const tool = server.tool("set_reply_thread", "Opt into a new thread for your final answer only when the user asks for one or a sustained side discussion would distract from the room. Answer ordinary top-level questions in the main room by default, without this tool; existing thread replies stay threaded automatically. Takes no arguments, sends no message, and does not finish the turn. Continue working and use your normal final completion when ready. Unavailable for synthetic task continuations or corrections.", {}, async () => { throw new Error("set_reply_thread requires the active daemon-owned room turn."); });
|
|
6
6
|
// The legacy raw-shape overload strips unknown keys. Preserve a strict schema
|
|
7
7
|
// so a caller cannot mistake ignored room/recipient/text arguments for authority.
|
|
8
8
|
tool.inputSchema = z.object({}).strict();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letagents",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.36",
|
|
4
4
|
"description": "Let Agents Chat \u2014 MCP server for AI agent communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/mcp/server.js",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
},
|
|
51
51
|
"license": "BUSL-1.1",
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@modelcontextprotocol/sdk": "1.
|
|
53
|
+
"@modelcontextprotocol/sdk": "1.31.0",
|
|
54
54
|
"drizzle-orm": "0.45.2",
|
|
55
55
|
"express": "5.2.1",
|
|
56
56
|
"pg": "8.20.0",
|
|
@@ -71,6 +71,8 @@
|
|
|
71
71
|
},
|
|
72
72
|
"fast-uri": "3.1.8",
|
|
73
73
|
"hono": "4.13.8",
|
|
74
|
-
"ip-address": "10.7.2"
|
|
74
|
+
"ip-address": "10.7.2",
|
|
75
|
+
"@modelcontextprotocol/sdk": "1.31.0",
|
|
76
|
+
"proxy-addr": "2.0.8"
|
|
75
77
|
}
|
|
76
78
|
}
|
|
@@ -18,13 +18,33 @@ export declare function humanConversationFallback(input: {
|
|
|
18
18
|
* channel for a managed local worker.
|
|
19
19
|
*/
|
|
20
20
|
export declare function isUntrustedExternalActivationSource(source: unknown): boolean;
|
|
21
|
+
export interface AgentMessageReplyTurn {
|
|
22
|
+
/** 1-based position in the reply order; only positions 2 and later are sent. */
|
|
23
|
+
position: number;
|
|
24
|
+
count: number;
|
|
25
|
+
/** Labels of the agents before this one that answered, in turn order. */
|
|
26
|
+
prior_speakers: string[];
|
|
27
|
+
}
|
|
21
28
|
export interface AgentMessageActivation {
|
|
22
29
|
for_current_agent: {
|
|
23
30
|
decision: AgentMessageActivationDecision;
|
|
24
31
|
reason: AgentMessageActivationReason;
|
|
25
32
|
addressed: boolean;
|
|
33
|
+
turn?: AgentMessageReplyTurn;
|
|
34
|
+
guidance?: string;
|
|
26
35
|
};
|
|
27
36
|
}
|
|
37
|
+
/** A receipt as the activation authority sees it. Turn fields are optional. */
|
|
38
|
+
export type AgentMessageActivationReceipt = {
|
|
39
|
+
activation_reason: string;
|
|
40
|
+
turn_position?: number | null;
|
|
41
|
+
turn_count?: number | null;
|
|
42
|
+
prior_speakers?: readonly string[] | null;
|
|
43
|
+
/** Why the held turn was released: "turn", "deadline", "activation" or "skipped". */
|
|
44
|
+
hold_release_reason?: string | null;
|
|
45
|
+
};
|
|
46
|
+
/** The turn note for an agent that answers a shared message after others. */
|
|
47
|
+
export declare function replyTurnGuidance(position: number, count: number, priorSpeakers?: readonly string[], releaseReason?: string | null): string;
|
|
28
48
|
type MessageLike = {
|
|
29
49
|
id?: unknown;
|
|
30
50
|
sender?: unknown;
|
|
@@ -85,9 +105,7 @@ export declare function attachAgentMessageActivation<T extends MessageLike>(mess
|
|
|
85
105
|
* Only messages that predate routing snapshots keep the lazy per-reader
|
|
86
106
|
* decision, so legacy backlog mentions still activate rotated sessions.
|
|
87
107
|
*/
|
|
88
|
-
export declare function attachAgentMessageActivationsFromReceipts<T extends MessageLike>(messages: readonly T[], identity: ActivationIdentity | null, receiptsMap: ReadonlyMap<number | string, {
|
|
89
|
-
activation_reason: string;
|
|
90
|
-
}>, snapshotNumbers: ReadonlySet<number>, context?: AgentMessageActivationContext): T[] | Array<T & {
|
|
108
|
+
export declare function attachAgentMessageActivationsFromReceipts<T extends MessageLike>(messages: readonly T[], identity: ActivationIdentity | null, receiptsMap: ReadonlyMap<number | string, AgentMessageActivationReceipt>, snapshotNumbers: ReadonlySet<number>, context?: AgentMessageActivationContext): T[] | Array<T & {
|
|
91
109
|
activation: AgentMessageActivation;
|
|
92
110
|
}>;
|
|
93
111
|
export declare function attachAgentMessageActivations<T extends MessageLike>(messages: readonly T[], identity: ActivationIdentity | null, context?: AgentMessageActivationContext): T[] | Array<T & {
|
|
@@ -80,6 +80,7 @@ export function attachAgentMessageActivationsFromReceipts(messages, identity, re
|
|
|
80
80
|
decision: "activate",
|
|
81
81
|
reason: reason || "explicit_mention",
|
|
82
82
|
addressed: true,
|
|
83
|
+
...replyTurnActivationFields(receipt),
|
|
83
84
|
},
|
|
84
85
|
},
|
|
85
86
|
};
|
|
@@ -103,6 +104,42 @@ export function attachAgentMessageActivationsFromReceipts(messages, identity, re
|
|
|
103
104
|
return attachAgentMessageActivation(message, identity, context);
|
|
104
105
|
});
|
|
105
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* The turn note for an agent that answers a shared message after others.
|
|
109
|
+
* Desktop adapters print `for_current_agent` into the prompt verbatim, so this
|
|
110
|
+
* text reaches the model without a client change.
|
|
111
|
+
*/
|
|
112
|
+
export function replyTurnGuidance(position, count, priorSpeakers = [], releaseReason = null) {
|
|
113
|
+
const opening = `Turn order: you answer this message in position ${position} of ${count}.`;
|
|
114
|
+
const speakers = priorSpeakers.filter((speaker) => typeof speaker === "string" && speaker.trim());
|
|
115
|
+
if (speakers.length === 0) {
|
|
116
|
+
// Say "did not answer in time" only when that is what happened. A
|
|
117
|
+
// turn opened by a direct activation, or after agents that ended with
|
|
118
|
+
// nothing to say, may still have earlier agents at work.
|
|
119
|
+
return releaseReason === "deadline"
|
|
120
|
+
? `${opening} The agent before you did not answer in time. Answer now.`
|
|
121
|
+
: `${opening} The agents before you have not answered yet. Cover only what they are unlikely to say.`;
|
|
122
|
+
}
|
|
123
|
+
const names = speakers.length === 1
|
|
124
|
+
? speakers[0]
|
|
125
|
+
: `${speakers.slice(0, -1).join(", ")} and ${speakers.at(-1)}`;
|
|
126
|
+
const replies = speakers.length === 1 ? "their reply is" : "their replies are";
|
|
127
|
+
return `${opening} ${names} answered before you; ${replies} in the recent room context.`
|
|
128
|
+
+ " Do not repeat their points. Add only new points, agree in one short line, or disagree and give the reason.";
|
|
129
|
+
}
|
|
130
|
+
/** Position 1 and parallel receipts add nothing; later positions add turn and guidance. */
|
|
131
|
+
function replyTurnActivationFields(receipt) {
|
|
132
|
+
const position = Number(receipt.turn_position);
|
|
133
|
+
const count = Number(receipt.turn_count);
|
|
134
|
+
if (!Number.isSafeInteger(position) || !Number.isSafeInteger(count) || position < 2 || count < position) {
|
|
135
|
+
return {};
|
|
136
|
+
}
|
|
137
|
+
const priorSpeakers = Array.isArray(receipt.prior_speakers) ? receipt.prior_speakers : [];
|
|
138
|
+
return {
|
|
139
|
+
turn: { position, count, prior_speakers: [...priorSpeakers] },
|
|
140
|
+
guidance: replyTurnGuidance(position, count, priorSpeakers, receipt.hold_release_reason ?? null),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
106
143
|
export function attachAgentMessageActivations(messages, identity, context = {}) {
|
|
107
144
|
if (!identity || identity.session_kind !== "worker") {
|
|
108
145
|
return [...messages];
|
|
@@ -9,8 +9,10 @@ import { join, parse } from "node:path";
|
|
|
9
9
|
// The owner's config, auth and session history are not modified, and the
|
|
10
10
|
// project's own skills and AGENTS.md still load. Codex has no launch switch
|
|
11
11
|
// for its global AGENTS.md or its saved command rules, so those still reach
|
|
12
|
-
// the agent.
|
|
13
|
-
//
|
|
12
|
+
// the agent. A matching saved rule lets a command out of Codex's sandbox, so
|
|
13
|
+
// the desktop gives an agent at a sandboxed access level a Codex home that has
|
|
14
|
+
// none (codex-agent-home.ts). Shared by the desktop and the published MCP
|
|
15
|
+
// package, so it uses nothing but Node.
|
|
14
16
|
|
|
15
17
|
/** Plugins (with their MCP servers and skills), app connectors, computer and browser use, hooks, memories, notifier. */
|
|
16
18
|
export const CODEX_OWNER_FEATURE_OVERRIDES = Object.freeze([
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The room tools a Read-only Codex agent may use, and the only ones.
|
|
3
|
+
*
|
|
4
|
+
* These read what the room says, and say something in it: a message, a
|
|
5
|
+
* status, visible reasoning, a question for a person, and where the final
|
|
6
|
+
* answer goes. Each takes room identifiers and text, never a folder. None
|
|
7
|
+
* changes the task board, joins or creates a room, submits a review, saves
|
|
8
|
+
* room memory, sets a wake rule, or inspects or writes a repository on this
|
|
9
|
+
* Mac. A tool is allowed only by being named here, so one the room server
|
|
10
|
+
* gains later is refused until someone decides about it.
|
|
11
|
+
*
|
|
12
|
+
* Two places keep the list, and both read it from here. Codex is told to run
|
|
13
|
+
* these without asking and to refuse the others, since a Read-only agent has
|
|
14
|
+
* nobody to ask. The background service refuses the others itself, where
|
|
15
|
+
* every room tool call of such an agent passes, so the limit does not rest on
|
|
16
|
+
* what Codex does with an approval mode.
|
|
17
|
+
*/
|
|
18
|
+
export const CODEX_READ_ONLY_ROOM_TOOLS = new Set([
|
|
19
|
+
// What the room says.
|
|
20
|
+
"read_messages", "get_current_room", "get_room_guidelines", "get_room_memory", "get_human_requests",
|
|
21
|
+
"get_board", "get_board_settings", "list_board_intents", "list_wake_rules", "get_room_artifacts", "get_room_events",
|
|
22
|
+
// What the agent says in it.
|
|
23
|
+
"send_message", "send_thread_message", "post_status", "post_reasoning", "request_human_input", "set_reply_thread",
|
|
24
|
+
]);
|
|
@@ -12,7 +12,8 @@ try {
|
|
|
12
12
|
if (workerData.operation === 'begin') {
|
|
13
13
|
parentPort.postMessage({ baseline: await captureWorkspaceTree(capture.workspace, identity) });
|
|
14
14
|
} else {
|
|
15
|
-
|
|
15
|
+
// The starting snapshot was taken on `base_revision`. With what the remotes held then, it tells a base update from the work.
|
|
16
|
+
const pair = await captureWorkspacePair(capture.workspace, capture.base_revision, capture.baseline, `${identity}:${capture.preparation_id}`, capture.base_revision, workerData.remoteTips ?? null);
|
|
16
17
|
pair.contribution.summary = text;
|
|
17
18
|
const { review, ...preview } = pair;
|
|
18
19
|
const summary = { version: 3, recorded_state: 'completed', evidence_incomplete: true, elapsed_ms: null,
|
|
@@ -5,6 +5,7 @@ export interface GitHubLinkReference extends LinkPreviewReference { repository:
|
|
|
5
5
|
export interface MessageLinkPreview extends GitHubLinkReference { title: string; state: "merged" | "closed" | "draft" | "open" }
|
|
6
6
|
export interface MessageLinkPreviewsResponse { room_id: string; previews: MessageLinkPreview[]; available?: boolean }
|
|
7
7
|
export function parseGitHubLinkReference(value: unknown): GitHubLinkReference | null;
|
|
8
|
+
export function excludeGitHubEventLink(urls: readonly string[], eventUrl: string | null | undefined): string[];
|
|
8
9
|
export function normalizePreviewRepository(value: unknown): string | null;
|
|
9
10
|
export function linkPreviewKey(reference: LinkPreviewReference): string;
|
|
10
11
|
export function parseLinkPreviewReferences(value: unknown): LinkPreviewReference[] | null;
|
|
@@ -16,6 +16,12 @@ export function parseGitHubLinkReference(value) {
|
|
|
16
16
|
} catch { return null; }
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** The event card already represents its main PR/issue; keep any other links. */
|
|
20
|
+
export function excludeGitHubEventLink(urls, eventUrl) {
|
|
21
|
+
const represented = parseGitHubLinkReference(eventUrl);
|
|
22
|
+
return urls.filter(url => !represented || parseGitHubLinkReference(url)?.url !== represented.url);
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
export function normalizePreviewRepository(value) {
|
|
20
26
|
if (typeof value !== "string") return null;
|
|
21
27
|
const name = value.replace(/^https:\/\//i, "").replace(/^github\.com\//i, "").replace(/\/$/, "");
|
|
@@ -46,3 +46,19 @@ export declare function estimateRoomAgentGuidelineTokens(value: unknown): number
|
|
|
46
46
|
export declare function normalizeGitHubRoomChatEventKinds(value: unknown): GitHubRoomChatEventKind[] | null;
|
|
47
47
|
/** The kind a repository event is filed under, or null when it has none. */
|
|
48
48
|
export declare function githubRoomChatEventKind(event: { kind?: string } | null | undefined): GitHubRoomChatEventKind | null;
|
|
49
|
+
|
|
50
|
+
export type RoomAgentReplyOrder = "sequential" | "parallel";
|
|
51
|
+
export declare const ROOM_AGENT_REPLY_ORDERS: readonly RoomAgentReplyOrder[];
|
|
52
|
+
export declare const DEFAULT_ROOM_AGENT_REPLY_ORDER: RoomAgentReplyOrder;
|
|
53
|
+
|
|
54
|
+
export interface RoomAgentReplyOrderSetting {
|
|
55
|
+
room_id: string;
|
|
56
|
+
/** The order that applies; "parallel" when the room has not chosen. */
|
|
57
|
+
order: RoomAgentReplyOrder;
|
|
58
|
+
/** Whether a room admin chose it, rather than the default applying. */
|
|
59
|
+
chosen: boolean;
|
|
60
|
+
can_manage: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A known order, or null when the input is not one. */
|
|
64
|
+
export declare function normalizeRoomAgentReplyOrder(value: unknown): RoomAgentReplyOrder | null;
|
package/shared/room-settings.mjs
CHANGED
|
@@ -106,3 +106,17 @@ export function githubRoomChatEventKind(event) {
|
|
|
106
106
|
return null;
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* How agents answer a message that activates several of them at once (a
|
|
112
|
+
* broadcast, or the small-room fallback). "parallel" — the default when a
|
|
113
|
+
* room has not chosen — wakes them all together; "sequential", which a room
|
|
114
|
+
* admin turns on, gives each agent a turn after the one before has answered.
|
|
115
|
+
*/
|
|
116
|
+
export const ROOM_AGENT_REPLY_ORDERS = Object.freeze(["sequential", "parallel"]);
|
|
117
|
+
export const DEFAULT_ROOM_AGENT_REPLY_ORDER = "parallel";
|
|
118
|
+
|
|
119
|
+
/** A known order, or null when the input is not one. */
|
|
120
|
+
export function normalizeRoomAgentReplyOrder(value) {
|
|
121
|
+
return ROOM_AGENT_REPLY_ORDERS.includes(value) ? value : null;
|
|
122
|
+
}
|
package/shared/room-typing.d.mts
CHANGED
|
@@ -9,8 +9,10 @@ export function createTypingSender(options: {
|
|
|
9
9
|
schedule?: (callback: () => void, ms: number) => any; cancel?: (timer: any) => void;
|
|
10
10
|
}): { input(nonempty: boolean): void; stop(): void };
|
|
11
11
|
export function createTypingReceiver(now?: () => number): {
|
|
12
|
-
receive(raw: unknown, self: string | null): void; clear(): void; label(): string; nextExpiry(): number | null;
|
|
12
|
+
receive(raw: unknown, self: string | null): void; clear(): void; names(): string[]; label(): string; nextExpiry(): number | null;
|
|
13
13
|
};
|
|
14
|
-
export function
|
|
14
|
+
export function typingLabel(names: readonly string[]): string;
|
|
15
|
+
export function typingSentence(names: readonly string[]): Array<{ text: string; name: boolean }>;
|
|
16
|
+
export function createTypingDisplay(update: (label: string, names: string[]) => void, clock?: {
|
|
15
17
|
now?: () => number; schedule?: (callback: () => void, ms: number) => any; cancel?: (timer: any) => void;
|
|
16
18
|
}): { receive(raw: unknown, self: string | null): void; clear(): void };
|
package/shared/room-typing.mjs
CHANGED
|
@@ -60,6 +60,22 @@ export function createTypingSender({ send, clientId, now = Date.now, schedule =
|
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** Plain-text sentence for assistive technology and compact surfaces. */
|
|
64
|
+
export function typingLabel(names) {
|
|
65
|
+
if (names.length > 2) return 'Several people are typing…';
|
|
66
|
+
return names.length ? `${names.join(' and ')} ${names.length === 1 ? 'is' : 'are'} typing…` : '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The same sentence as parts, so a view can emphasise names without building HTML. */
|
|
70
|
+
export function typingSentence(names) {
|
|
71
|
+
const name = text => ({ text, name: true }), plain = text => ({ text, name: false });
|
|
72
|
+
if (names.length === 1) return [name(names[0]), plain(' is typing')];
|
|
73
|
+
if (names.length === 2) return [name(names[0]), plain(' and '), name(names[1]), plain(' are typing')];
|
|
74
|
+
if (names.length < 3) return [];
|
|
75
|
+
const others = names.length - 2;
|
|
76
|
+
return [name(names[0]), plain(', '), name(names[1]), plain(` and ${others} ${others === 1 ? 'other' : 'others'} are typing`)];
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
/** Sequence guards survive stops briefly; no snapshot or replay. */
|
|
64
80
|
export function createTypingReceiver(now = Date.now) {
|
|
65
81
|
const sources = new Map();
|
|
@@ -81,16 +97,16 @@ export function createTypingReceiver(now = Date.now) {
|
|
|
81
97
|
}
|
|
82
98
|
return Number.isFinite(earliest) ? earliest : null;
|
|
83
99
|
},
|
|
84
|
-
|
|
100
|
+
/** One name per account, in a stable order, so a row does not reshuffle between refreshes. */
|
|
101
|
+
names() {
|
|
85
102
|
const people = new Map();
|
|
86
103
|
for (const [key, value] of sources) {
|
|
87
104
|
if (value.forget <= now()) sources.delete(key);
|
|
88
105
|
else if (value.typing && value.until > now()) people.set(value.account_id, value.name);
|
|
89
106
|
}
|
|
90
|
-
|
|
91
|
-
if (names.length > 2) return 'Several people are typing…';
|
|
92
|
-
return names.length ? `${names.join(' and ')} ${names.length === 1 ? 'is' : 'are'} typing…` : '';
|
|
107
|
+
return [...people].sort(([a], [b]) => a.localeCompare(b)).map(([, name]) => name);
|
|
93
108
|
},
|
|
109
|
+
label() { return typingLabel(this.names()); },
|
|
94
110
|
};
|
|
95
111
|
}
|
|
96
112
|
|
|
@@ -101,7 +117,8 @@ export function createTypingDisplay(update, { now = Date.now, schedule = setTime
|
|
|
101
117
|
function refresh() {
|
|
102
118
|
if (timer !== null) cancel(timer);
|
|
103
119
|
timer = null;
|
|
104
|
-
|
|
120
|
+
const names = receiver.names();
|
|
121
|
+
update(typingLabel(names), names);
|
|
105
122
|
const expiry = receiver.nextExpiry();
|
|
106
123
|
if (expiry !== null) timer = schedule(refresh, Math.max(0, expiry - now()));
|
|
107
124
|
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<article class="github-event-card" :class="{ 'is-preview': compact, 'is-room-event': room, 'is-activity': room && !['pull-request', 'issue'].includes(event.kind) }" :data-tone="event.tone" :data-kind="event.kind" :data-status="event.statusLabel">
|
|
3
|
+
<div class="github-event-header">
|
|
4
|
+
<svg class="github-event-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
5
|
+
<template v-if="event.kind === 'pull-request'">
|
|
6
|
+
<circle cx="4" cy="3" r="1.5" /><circle cx="4" cy="13" r="1.5" /><circle cx="12" cy="13" r="1.5" />
|
|
7
|
+
<path d="M4 4.5v7M12 11.5V6a3 3 0 0 0-3-3H8m2-2L8 3l2 2" />
|
|
8
|
+
</template>
|
|
9
|
+
<template v-else-if="event.kind === 'issue'">
|
|
10
|
+
<circle cx="8" cy="8" r="5.5" /><path d="M8 5v3M8 11h.01" />
|
|
11
|
+
</template>
|
|
12
|
+
<template v-else-if="event.kind === 'review' || event.kind === 'comment'">
|
|
13
|
+
<path d="M3 3h10v8H8l-3 2v-2H3z" />
|
|
14
|
+
<path v-if="event.kind === 'review'" d="m5.5 7 1.5 1.5 3.5-3" />
|
|
15
|
+
<path v-else d="M5.5 6h5M5.5 8h3" />
|
|
16
|
+
</template>
|
|
17
|
+
<template v-else-if="event.kind === 'check'">
|
|
18
|
+
<rect x="2.5" y="2.5" width="11" height="11" rx="3" />
|
|
19
|
+
<path v-if="/^(success|passed)$/i.test(event.statusLabel || '')" d="m5 8 2 2 4-4" />
|
|
20
|
+
<path v-else-if="event.tone === 'rose'" d="m5.5 5.5 5 5m0-5-5 5" />
|
|
21
|
+
<path v-else d="M8 5v3l2 1" />
|
|
22
|
+
</template>
|
|
23
|
+
<path v-else-if="event.kind === 'repository'" d="M2.5 4h4l1.5 2h5.5v6.5h-11z" />
|
|
24
|
+
<template v-else><circle cx="8" cy="8" r="5.5" /><path d="M8 5v3M8 11h.01" /></template>
|
|
25
|
+
</svg>
|
|
26
|
+
<p v-if="room" class="github-event-title">{{ title }}</p>
|
|
27
|
+
<span v-else class="github-event-kind">{{ event.kindLabel }}</span>
|
|
28
|
+
<span v-if="event.statusLabel" class="github-event-status">{{ statusLabel }}</span>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div v-if="!room || description" class="github-event-content">
|
|
32
|
+
<p v-if="!room" class="github-event-title">{{ title }}</p>
|
|
33
|
+
<p v-if="description" class="github-event-description">{{ description }}</p>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div v-if="event.repository || event.taskId" class="github-event-context">
|
|
37
|
+
<span v-if="event.repository" class="github-event-repository">{{ event.repository }}</span>
|
|
38
|
+
<span v-if="event.repository && event.taskId" class="github-event-separator" aria-hidden="true">·</span>
|
|
39
|
+
<button
|
|
40
|
+
v-if="event.taskId && taskLinkEnabled"
|
|
41
|
+
type="button"
|
|
42
|
+
class="github-event-task"
|
|
43
|
+
:title="`Open ${event.taskId} on the Board`"
|
|
44
|
+
:data-task-reference-id="event.taskId"
|
|
45
|
+
@click="emit('openTask', event.taskId)"
|
|
46
|
+
>{{ event.taskId }}</button>
|
|
47
|
+
<span v-else-if="event.taskId" class="github-event-task">{{ event.taskId }}</span>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div v-if="event.url" class="github-event-actions">
|
|
51
|
+
<slot name="actions" />
|
|
52
|
+
<a class="github-event-action" :href="event.url" target="_blank" rel="noopener noreferrer">
|
|
53
|
+
{{ event.urlLabel }}
|
|
54
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 11 11 5M5 5h6v6" /></svg>
|
|
55
|
+
</a>
|
|
56
|
+
<button v-if="showEventLink && !compact" type="button" class="github-event-action is-secondary" @click="emit('openEvent', event.url)">
|
|
57
|
+
View in Events
|
|
58
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4" /></svg>
|
|
59
|
+
</button>
|
|
60
|
+
</div>
|
|
61
|
+
</article>
|
|
62
|
+
</template>
|
|
63
|
+
|
|
64
|
+
<script setup lang="ts">
|
|
65
|
+
import { computed } from 'vue';
|
|
66
|
+
|
|
67
|
+
const props = defineProps<{
|
|
68
|
+
event: {
|
|
69
|
+
kind: string;
|
|
70
|
+
tone: string;
|
|
71
|
+
kindLabel: string;
|
|
72
|
+
statusLabel: string | null;
|
|
73
|
+
headline: string;
|
|
74
|
+
detail: string | null;
|
|
75
|
+
repository: string | null;
|
|
76
|
+
taskId: string | null;
|
|
77
|
+
url: string | null;
|
|
78
|
+
urlLabel: string;
|
|
79
|
+
};
|
|
80
|
+
compact?: boolean;
|
|
81
|
+
room?: boolean;
|
|
82
|
+
taskLinkEnabled?: boolean;
|
|
83
|
+
showEventLink?: boolean;
|
|
84
|
+
}>();
|
|
85
|
+
|
|
86
|
+
const emit = defineEmits<{
|
|
87
|
+
openTask: [taskId: string];
|
|
88
|
+
openEvent: [url: string];
|
|
89
|
+
}>();
|
|
90
|
+
|
|
91
|
+
// PR/issue details carry the artifact title; other events carry supporting text.
|
|
92
|
+
const titleFirst = computed(() => Boolean(props.event.detail) && ['pull-request', 'issue'].includes(props.event.kind));
|
|
93
|
+
const title = computed(() => titleFirst.value ? props.event.detail : props.event.headline);
|
|
94
|
+
const description = computed(() => titleFirst.value ? props.event.headline : props.event.detail);
|
|
95
|
+
const statusLabel = computed(() => {
|
|
96
|
+
const label = props.event.statusLabel || '';
|
|
97
|
+
return label.charAt(0).toUpperCase() + label.slice(1);
|
|
98
|
+
});
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
<style scoped>
|
|
102
|
+
.github-event-card {
|
|
103
|
+
--event-accent: var(--text-secondary);
|
|
104
|
+
display: grid;
|
|
105
|
+
gap: 10px;
|
|
106
|
+
width: 100%;
|
|
107
|
+
max-width: 560px;
|
|
108
|
+
min-width: 0;
|
|
109
|
+
box-sizing: border-box;
|
|
110
|
+
padding: 14px 16px 10px;
|
|
111
|
+
border: 1px solid var(--border-strong);
|
|
112
|
+
border-radius: var(--radius-lg, 12px);
|
|
113
|
+
background: var(--bg-card);
|
|
114
|
+
color: var(--text);
|
|
115
|
+
font-family: var(--font-sans);
|
|
116
|
+
}
|
|
117
|
+
.github-event-card:not(.is-preview) { container-type: inline-size; }
|
|
118
|
+
.github-event-card[data-tone="violet"] { --event-accent: var(--task-assigned); }
|
|
119
|
+
.github-event-card[data-tone="emerald"] { --event-accent: var(--green-text); }
|
|
120
|
+
.github-event-card[data-tone="rose"] { --event-accent: var(--red-text); }
|
|
121
|
+
.github-event-card[data-tone="amber"] { --event-accent: var(--amber-text); }
|
|
122
|
+
.github-event-card[data-tone="sky"] { --event-accent: var(--blue-text); }
|
|
123
|
+
.github-event-header { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
124
|
+
.github-event-icon { width: 16px; height: 16px; flex: none; color: var(--event-accent); }
|
|
125
|
+
.github-event-kind { color: var(--text-secondary); font-size: 12px; font-weight: 500; line-height: 1.5; }
|
|
126
|
+
.github-event-status {
|
|
127
|
+
margin-left: auto;
|
|
128
|
+
padding: 2px 7px;
|
|
129
|
+
border-radius: 5px;
|
|
130
|
+
background: color-mix(in srgb, var(--event-accent) 10%, transparent);
|
|
131
|
+
color: var(--text);
|
|
132
|
+
font-size: 11px;
|
|
133
|
+
font-weight: 500;
|
|
134
|
+
line-height: 1.5;
|
|
135
|
+
overflow-wrap: anywhere;
|
|
136
|
+
}
|
|
137
|
+
.github-event-content { display: grid; gap: 4px; min-width: 0; }
|
|
138
|
+
.github-event-title { margin: 0; color: var(--text); font-size: 14px; font-weight: 600; line-height: 1.5; overflow-wrap: anywhere; }
|
|
139
|
+
.github-event-description { margin: 0; color: var(--text-secondary); font-size: 12px; line-height: 1.5; white-space: pre-line; overflow-wrap: anywhere; }
|
|
140
|
+
.github-event-context { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; color: var(--text-secondary); font-size: 11px; line-height: 1.5; }
|
|
141
|
+
.github-event-repository { overflow-wrap: anywhere; }
|
|
142
|
+
.github-event-task { display: inline-flex; align-items: center; min-height: 24px; padding: 0 4px; border: 0; border-radius: 4px; background: transparent; color: var(--text-secondary); font: inherit; font-family: var(--font-mono); }
|
|
143
|
+
button.github-event-task { cursor: pointer; }
|
|
144
|
+
.github-event-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; padding-top: 8px; border-top: 1px solid var(--border); }
|
|
145
|
+
.github-event-action,
|
|
146
|
+
.github-event-actions :deep(.pull-request-changes-button) {
|
|
147
|
+
display: inline-flex;
|
|
148
|
+
align-items: center;
|
|
149
|
+
justify-content: center;
|
|
150
|
+
gap: 5px;
|
|
151
|
+
min-height: 32px;
|
|
152
|
+
max-width: 100%;
|
|
153
|
+
box-sizing: border-box;
|
|
154
|
+
padding: 5px 9px;
|
|
155
|
+
border: 1px solid transparent;
|
|
156
|
+
border-radius: 6px;
|
|
157
|
+
background: transparent;
|
|
158
|
+
color: var(--text-secondary);
|
|
159
|
+
font: 500 12px/1.5 var(--font-sans);
|
|
160
|
+
text-align: center;
|
|
161
|
+
text-decoration: none;
|
|
162
|
+
cursor: pointer;
|
|
163
|
+
transition: background-color 120ms ease, color 120ms ease;
|
|
164
|
+
}
|
|
165
|
+
.github-event-action:first-child,
|
|
166
|
+
.github-event-actions :deep(.pull-request-changes-button) { border-color: var(--border); background: var(--accent-dim); color: var(--text); }
|
|
167
|
+
@container (min-width: 420px) {
|
|
168
|
+
.github-event-action.is-secondary { margin-left: auto; }
|
|
169
|
+
}
|
|
170
|
+
.github-event-action svg { width: 14px; height: 14px; flex: none; }
|
|
171
|
+
@media (hover: hover) and (pointer: fine) {
|
|
172
|
+
.github-event-action:hover,
|
|
173
|
+
.github-event-actions :deep(.pull-request-changes-button:hover),
|
|
174
|
+
button.github-event-task:hover { background: var(--accent-hover); color: var(--text); text-decoration: none; }
|
|
175
|
+
}
|
|
176
|
+
.github-event-action:active,
|
|
177
|
+
.github-event-actions :deep(.pull-request-changes-button:active),
|
|
178
|
+
button.github-event-task:active { background: var(--accent-active); color: var(--text); transition: none; }
|
|
179
|
+
.github-event-action:focus-visible,
|
|
180
|
+
.github-event-actions :deep(.pull-request-changes-button:focus-visible),
|
|
181
|
+
button.github-event-task:focus-visible { outline: 2px solid var(--blue-text); outline-offset: 2px; }
|
|
182
|
+
.github-event-card.is-preview { margin-top: 6px; gap: 8px; padding: 12px 14px 8px; }
|
|
183
|
+
@media (pointer: coarse) {
|
|
184
|
+
.github-event-action,
|
|
185
|
+
.github-event-actions :deep(.pull-request-changes-button),
|
|
186
|
+
button.github-event-task { min-height: 44px; }
|
|
187
|
+
}
|
|
188
|
+
@media (prefers-reduced-motion: reduce) {
|
|
189
|
+
.github-event-action,
|
|
190
|
+
.github-event-actions :deep(.pull-request-changes-button) { transition: none; }
|
|
191
|
+
}
|
|
192
|
+
@media (prefers-contrast: more) {
|
|
193
|
+
.github-event-card { border-color: var(--text-secondary); }
|
|
194
|
+
.github-event-status { outline: 1px solid var(--event-accent); }
|
|
195
|
+
}
|
|
196
|
+
/* Room events share the conversation column, with flat, semantic borders. */
|
|
197
|
+
.github-event-card.is-room-event {
|
|
198
|
+
max-width: 100%;
|
|
199
|
+
gap: 6px;
|
|
200
|
+
padding: 11px 14px 8px;
|
|
201
|
+
border-color: color-mix(in srgb, var(--event-accent) 45%, var(--border-strong));
|
|
202
|
+
border-radius: 9px;
|
|
203
|
+
background: color-mix(in srgb, var(--bg-card) 65%, var(--bg));
|
|
204
|
+
box-shadow: none;
|
|
205
|
+
}
|
|
206
|
+
.is-room-event .github-event-title { font-size: 13px; font-weight: 550; }
|
|
207
|
+
.is-room-event .github-event-description { font-size: 11px; }
|
|
208
|
+
.is-room-event .github-event-actions { border: 0; padding-top: 0; gap: 8px; }
|
|
209
|
+
.is-room-event .github-event-action,
|
|
210
|
+
.is-room-event .github-event-actions :deep(.pull-request-changes-button) {
|
|
211
|
+
min-height: 26px; padding: 3px 0; border: 0; background: transparent; font-size: 11px;
|
|
212
|
+
}
|
|
213
|
+
.is-activity { display: flex; flex-wrap: wrap; align-items: center; column-gap: 12px; }
|
|
214
|
+
.is-activity .github-event-header { flex: 1 1 220px; }
|
|
215
|
+
.is-activity .github-event-kind { display: none; }
|
|
216
|
+
.is-activity .github-event-status { margin-left: 0; }
|
|
217
|
+
.is-activity .github-event-content { display: none; }
|
|
218
|
+
.is-activity .github-event-title { font-size: 12px; font-weight: 500; color: var(--text-secondary); }
|
|
219
|
+
.is-activity .github-event-description, .is-activity .github-event-repository, .is-activity .github-event-separator { display: none; }
|
|
220
|
+
.is-activity .github-event-context,
|
|
221
|
+
.is-activity .github-event-actions { grid-column: 1 / -1; }
|
|
222
|
+
@media (pointer: coarse) {
|
|
223
|
+
.is-room-event .github-event-action,
|
|
224
|
+
.is-room-event .github-event-actions :deep(.pull-request-changes-button) { min-height: 44px; }
|
|
225
|
+
}
|
|
226
|
+
@media (prefers-contrast: more) {
|
|
227
|
+
.github-event-card.is-room-event { border-color: var(--event-accent); }
|
|
228
|
+
}
|
|
229
|
+
</style>
|