@korso/shepherd-ui 0.1.0 → 0.2.0

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.
Files changed (99) hide show
  1. package/dist/ShepherdProvider.d.ts +12 -0
  2. package/dist/ShepherdProvider.js +33 -0
  3. package/dist/ShepherdProvider.test.d.ts +1 -0
  4. package/dist/ShepherdProvider.test.js +47 -0
  5. package/dist/ShepherdRoot.d.ts +9 -0
  6. package/dist/ShepherdRoot.js +88 -0
  7. package/dist/ShepherdRoot.routing.test.d.ts +1 -0
  8. package/dist/ShepherdRoot.routing.test.js +61 -0
  9. package/dist/ShepherdRoot.test.d.ts +1 -0
  10. package/dist/ShepherdRoot.test.js +37 -0
  11. package/dist/app/assets/index-CDOCIg6s.js +11 -0
  12. package/dist/app/index.html +12 -0
  13. package/dist/client.d.ts +107 -0
  14. package/dist/client.js +238 -0
  15. package/dist/client.test.d.ts +1 -0
  16. package/dist/client.test.js +298 -0
  17. package/dist/components/ActiveList.d.ts +22 -0
  18. package/dist/components/ActiveList.js +59 -0
  19. package/dist/components/Chat.d.ts +30 -0
  20. package/dist/components/Chat.js +61 -0
  21. package/dist/components/Composer.d.ts +37 -0
  22. package/dist/components/Composer.js +146 -0
  23. package/dist/components/Crew.d.ts +23 -0
  24. package/dist/components/Crew.js +31 -0
  25. package/dist/components/Dashboard.d.ts +34 -0
  26. package/dist/components/Dashboard.js +209 -0
  27. package/dist/components/DoneList.d.ts +29 -0
  28. package/dist/components/DoneList.js +50 -0
  29. package/dist/components/RepoSelect.d.ts +38 -0
  30. package/dist/components/RepoSelect.js +56 -0
  31. package/dist/components/Territory.d.ts +21 -0
  32. package/dist/components/Territory.js +21 -0
  33. package/dist/config/ConnectAgent.d.ts +10 -0
  34. package/dist/config/ConnectAgent.js +119 -0
  35. package/dist/config/ConnectAgent.test.d.ts +1 -0
  36. package/dist/config/ConnectAgent.test.js +92 -0
  37. package/dist/config/EmptyState.d.ts +12 -0
  38. package/dist/config/EmptyState.js +4 -0
  39. package/dist/config/EmptyState.test.d.ts +1 -0
  40. package/dist/config/EmptyState.test.js +32 -0
  41. package/dist/config/Members.d.ts +10 -0
  42. package/dist/config/Members.js +83 -0
  43. package/dist/config/Members.test.d.ts +1 -0
  44. package/dist/config/Members.test.js +66 -0
  45. package/dist/config/Workspaces.d.ts +12 -0
  46. package/dist/config/Workspaces.js +96 -0
  47. package/dist/config/Workspaces.test.d.ts +1 -0
  48. package/dist/config/Workspaces.test.js +90 -0
  49. package/dist/config/index.d.ts +8 -0
  50. package/dist/config/index.js +6 -0
  51. package/dist/context.d.ts +28 -0
  52. package/dist/context.js +36 -0
  53. package/dist/index.cjs +5289 -0
  54. package/dist/index.cjs.map +1 -0
  55. package/dist/index.d.cts +149 -0
  56. package/dist/index.d.ts +18 -0
  57. package/dist/index.js +15 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/lib/Dashboard-CG6KeFQ3.js +1214 -0
  60. package/dist/lib/index.d.ts +438 -16
  61. package/dist/lib/index.js +455 -6
  62. package/dist/lib/selfhost.js +9 -9
  63. package/dist/lib/styles.css +27 -0
  64. package/dist/logic.d.ts +208 -0
  65. package/dist/logic.js +372 -0
  66. package/dist/main.d.ts +1 -0
  67. package/dist/main.js +11 -0
  68. package/dist/selfhost/assets/{index-tSyzirZa.css → index-BZmImfYZ.css} +1 -1
  69. package/dist/selfhost/assets/index-CS268KSw.js +40 -0
  70. package/dist/selfhost/index.html +2 -2
  71. package/dist/selfhost.d.ts +22 -0
  72. package/dist/selfhost.js +97 -0
  73. package/dist/test/mockClient.d.ts +6 -0
  74. package/dist/test/mockClient.js +45 -0
  75. package/dist/test/setup.d.ts +1 -0
  76. package/dist/test/setup.js +2 -0
  77. package/dist/useLandscapePolling.d.ts +63 -0
  78. package/dist/useLandscapePolling.js +129 -0
  79. package/dist/views/Chat.d.ts +5 -0
  80. package/dist/views/Chat.js +60 -0
  81. package/dist/views/Chat.test.d.ts +1 -0
  82. package/dist/views/Chat.test.js +92 -0
  83. package/dist/views/Config.d.ts +12 -0
  84. package/dist/views/Config.js +6 -0
  85. package/dist/views/Tasks.d.ts +5 -0
  86. package/dist/views/Tasks.js +75 -0
  87. package/dist/views/Tasks.test.d.ts +1 -0
  88. package/dist/views/Tasks.test.js +115 -0
  89. package/dist/views/useLandscape.d.ts +14 -0
  90. package/dist/views/useLandscape.js +74 -0
  91. package/dist/views/useLandscape.test.d.ts +1 -0
  92. package/dist/views/useLandscape.test.js +101 -0
  93. package/dist/views/wallboard.d.ts +46 -0
  94. package/dist/views/wallboard.js +209 -0
  95. package/dist/views/wallboard.test.d.ts +1 -0
  96. package/dist/views/wallboard.test.js +143 -0
  97. package/package.json +1 -1
  98. package/dist/lib/Dashboard-CB6SL-J2.js +0 -1064
  99. package/dist/selfhost/assets/index-BBx3vo2Y.js +0 -40
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Pure, auth-agnostic page logic for the Shepherd wallboard.
3
+ *
4
+ * Ported verbatim from packages/hub/public/app.js (the original plain-JS single
5
+ * static page), adding TypeScript types only — behavior is byte-for-byte
6
+ * identical, as the characterization suite in test/logic.test.ts pins. These are
7
+ * the page's "Pure helpers" plus the "Glob containment + active-claim grouping"
8
+ * section; the browser-only DOM-wiring half of app.js is NOT ported here.
9
+ *
10
+ * Times are computed against the server's clock (`serverTime` in the payload),
11
+ * not the browser's, so countdowns and "last seen" stay correct under skew —
12
+ * hence every time helper takes an explicit `nowMs`/`endedIso` rather than
13
+ * reading `Date.now()`.
14
+ *
15
+ * Note: this module intentionally keeps its OWN browser-subset glob matcher
16
+ * (normalizeGlob/segmentCovers/patternCovers below); it does not import the
17
+ * server-side packages/hub/src/globs.ts, which is a deliberately-separate
18
+ * duplicate.
19
+ */
20
+ import type { WorkspaceTaskT } from "@shepherd/shared";
21
+ /**
22
+ * Human "N ago" for a past ISO timestamp, relative to `nowMs` (epoch ms).
23
+ *
24
+ * @param iso - A past instant as an ISO 8601 string.
25
+ * @param nowMs - The reference "now" in epoch milliseconds (the server clock).
26
+ * @returns A short relative label, e.g. `"just now"`, `"30s ago"`, `"2h ago"`.
27
+ */
28
+ export declare function formatRelative(iso: string, nowMs: number): string;
29
+ /**
30
+ * Human "expires in N" / "expired" for a future ISO timestamp, relative to
31
+ * `nowMs`.
32
+ *
33
+ * @param iso - The expiry instant as an ISO 8601 string.
34
+ * @param nowMs - The reference "now" in epoch milliseconds (the server clock).
35
+ * @returns `"expired"` once at/past the instant, else e.g. `"expires in 4m"`.
36
+ */
37
+ export declare function formatCountdown(iso: string, nowMs: number): string;
38
+ /**
39
+ * Deterministic chat color for an agent name — same name always maps to the
40
+ * same hue, so each speaker reads consistently across the announcement thread.
41
+ * Saturation/lightness are tuned to stay legible on the dark background.
42
+ *
43
+ * @param name - The agent name (may be empty, which yields hue 0 — never throws).
44
+ * @returns An `hsl(...)` CSS color string.
45
+ */
46
+ export declare function colorForName(name: string): string;
47
+ /**
48
+ * Up to two initials for an avatar. Prefers the capital letters of a CamelCase
49
+ * agent name (RedDragon → RD); otherwise the first two letters (alice → AL).
50
+ *
51
+ * @param name - The agent name; empty yields the `"?"` placeholder.
52
+ * @returns A 1–2 character uppercase label.
53
+ */
54
+ export declare function initialsFor(name: string): string;
55
+ /**
56
+ * The slice of a mention token the autocomplete operates on: `start`/`end` are
57
+ * the text range to replace when a suggestion is accepted (start = the `@`),
58
+ * and `query` is the text typed after the `@` (possibly empty).
59
+ */
60
+ export interface MentionMatch {
61
+ /** Index of the `@` — the start of the replaceable slice. */
62
+ start: number;
63
+ /** The caret index — the end of the replaceable slice. */
64
+ end: number;
65
+ /** The mention-name characters between the `@` and the caret. */
66
+ query: string;
67
+ }
68
+ /**
69
+ * The active `@mention` immediately to the LEFT of the caret, or null when the
70
+ * caret isn't in one. Drives the autocomplete: the `@` must start the text or
71
+ * follow whitespace (so email addresses like `a@b` don't trigger it), and only
72
+ * mention-name characters may sit between it and the caret.
73
+ *
74
+ * @param text - The full input text.
75
+ * @param caretIndex - The caret position within `text`.
76
+ * @returns The mention slice and query, or `null` when not in a mention.
77
+ */
78
+ export declare function parseMention(text: string, caretIndex: number): MentionMatch | null;
79
+ /**
80
+ * The first `@mention` in `text` that matches a known agent name, returned in
81
+ * the name's canonical casing (so `@reddragon` resolves to `RedDragon`), or null
82
+ * when none match. This is what turns a typed message into a directed DM; an
83
+ * unmatched `@foo` is treated as plain text and the message broadcasts.
84
+ *
85
+ * @param text - The message text to scan.
86
+ * @param knownNames - Canonical agent names to resolve against (case-insensitive).
87
+ * @returns The canonical name of the first matching mention, or `null`.
88
+ */
89
+ export declare function extractTarget(text: string, knownNames: string[]): string | null;
90
+ /**
91
+ * Sorted unique repo identifiers present across the given tasks.
92
+ *
93
+ * @param tasks - Items carrying a `repo` (a structural subset of {@link WorkspaceTaskT}).
94
+ * @returns The distinct repos, ascending.
95
+ */
96
+ export declare function distinctRepos(tasks: Pick<WorkspaceTaskT, "repo">[]): string[];
97
+ /**
98
+ * True if `item.repo` matches the selection; `null`/`"__all__"` = all repos.
99
+ *
100
+ * @param item - Anything carrying a `repo`.
101
+ * @param selected - The selected repo, or `null`/`"__all__"` for all.
102
+ * @returns Whether the item belongs to the current board filter.
103
+ */
104
+ export declare function matchesRepo(item: {
105
+ repo: string;
106
+ }, selected: string | null): boolean;
107
+ /**
108
+ * Live agents addressable by @mention under the current board filter: only those
109
+ * whose (most-recent) session sits in the selected repo, so a message can't be
110
+ * directed at someone who isn't in the repo you're viewing. Mirrors the crew the
111
+ * board already shows for that repo; in All-repos mode everyone live is
112
+ * addressable. Returns de-duplicated canonical names, sorted.
113
+ *
114
+ * @param agents - Agents carrying `name`, `presence`, and `repo` (a structural
115
+ * subset of {@link WorkspaceAgentT}).
116
+ * @param selectedRepo - The selected repo, or `null`/`"__all__"` for all.
117
+ * @returns Sorted, de-duplicated names of mentionable live agents.
118
+ */
119
+ export declare function mentionableAgents(agents: {
120
+ name: string;
121
+ presence: string;
122
+ repo: string;
123
+ }[], selectedRepo: string | null): string[];
124
+ /**
125
+ * First-load default repo: newest active task's repo, else newest task's, else
126
+ * null.
127
+ *
128
+ * @param tasks - Tasks carrying `repo` and `status` (a structural subset of
129
+ * {@link WorkspaceTaskT}); assumed newest-first.
130
+ * @returns The repo to select on first load, or `null` when there are no tasks.
131
+ */
132
+ export declare function defaultRepo(tasks: Pick<WorkspaceTaskT, "repo" | "status">[]): string | null;
133
+ /**
134
+ * Human label for a history task's status.
135
+ *
136
+ * @param status - The task status.
137
+ * @returns `"dropped"` for a dropped task, otherwise `"done"`.
138
+ */
139
+ export declare function statusLabel(status: string): string;
140
+ /**
141
+ * "active Nm/Nh" for the created->ended span; "" when the task hasn't ended.
142
+ *
143
+ * @param createdIso - The task's creation instant (ISO 8601 string).
144
+ * @param endedIso - The task's end instant, or `null` while still active.
145
+ * @returns A duration label, or the empty string for an unfinished task.
146
+ */
147
+ export declare function formatActiveDuration(createdIso: string, endedIso: string | null): string;
148
+ /**
149
+ * Local-day bucket label: "Today" / "Yesterday" / "Mon D".
150
+ *
151
+ * @param iso - The instant to bucket (ISO 8601 string).
152
+ * @param nowMs - The reference "now" in epoch milliseconds.
153
+ * @returns `"Today"`, `"Yesterday"`, or a short month/day label.
154
+ */
155
+ export declare function dayBucket(iso: string, nowMs: number): string;
156
+ /**
157
+ * Does glob set `outer` fully cover glob set `inner` — every path `inner` could
158
+ * touch already inside `outer`? Conservative: unsure -> false, so a caller only
159
+ * folds a claim it's certain is contained (a false negative just shows both
160
+ * claims, which is safe). An empty `inner` is trivially covered.
161
+ *
162
+ * @param outer - The candidate broader glob set.
163
+ * @param inner - The candidate narrower glob set.
164
+ * @returns Whether every glob in `inner` is covered by some glob in `outer`.
165
+ */
166
+ export declare function globsCover(outer: string[], inner: string[]): boolean;
167
+ /**
168
+ * The fields a claim must carry to be grouped: its agent, glob territory, and
169
+ * creation order, plus the header fields surfaced on the group. A structural
170
+ * subset of {@link WorkspaceTaskT}, so callers may pass partial task shapes.
171
+ */
172
+ export type ClaimLike = Pick<WorkspaceTaskT, "agentName" | "pathGlobs" | "createdAt" | "model" | "program" | "repo">;
173
+ /**
174
+ * One agent's grouped active claims for the board. `primaries` are the visible
175
+ * claims; `narrower` are those strictly covered by a broader sibling and folded
176
+ * away. Header fields (model/program/repo) come from the group's newest claim.
177
+ *
178
+ * @typeParam T - The concrete claim shape passed in, preserved on the members.
179
+ */
180
+ export interface ClaimGroup<T extends ClaimLike = ClaimLike> {
181
+ /** The agent these claims belong to. */
182
+ agentName: string;
183
+ /** Representative model from the group's newest claim. */
184
+ model: T["model"];
185
+ /** Representative program from the group's newest claim. */
186
+ program: T["program"];
187
+ /** Representative repo from the group's newest claim. */
188
+ repo: T["repo"];
189
+ /** Visible claims, newest-first. */
190
+ primaries: T[];
191
+ /** Claims folded under a broader sibling, newest-first. */
192
+ narrower: T[];
193
+ }
194
+ /**
195
+ * Group active claims by agent for the board. Within an agent's claims, one that
196
+ * is STRICTLY covered by a broader sibling (the sibling covers it but not vice
197
+ * versa, so identical claims don't cannibalise each other) folds into `narrower`;
198
+ * everything else stays a visible `primary`. Distinct or merely-overlapping
199
+ * claims all remain primaries. Pure — no DOM, no clock; `createdAt` drives order.
200
+ *
201
+ * Groups are newest-claim-first; `primaries`/`narrower` are each newest-first;
202
+ * header fields (model/program/repo) come from the group's newest claim.
203
+ *
204
+ * @param tasks - The active claims to group (a structural subset of
205
+ * {@link WorkspaceTaskT}).
206
+ * @returns One {@link ClaimGroup} per agent, newest-claim-first.
207
+ */
208
+ export declare function groupActiveClaims<T extends ClaimLike>(tasks: T[]): ClaimGroup<T>[];
package/dist/logic.js ADDED
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Pure, auth-agnostic page logic for the Shepherd wallboard.
3
+ *
4
+ * Ported verbatim from packages/hub/public/app.js (the original plain-JS single
5
+ * static page), adding TypeScript types only — behavior is byte-for-byte
6
+ * identical, as the characterization suite in test/logic.test.ts pins. These are
7
+ * the page's "Pure helpers" plus the "Glob containment + active-claim grouping"
8
+ * section; the browser-only DOM-wiring half of app.js is NOT ported here.
9
+ *
10
+ * Times are computed against the server's clock (`serverTime` in the payload),
11
+ * not the browser's, so countdowns and "last seen" stay correct under skew —
12
+ * hence every time helper takes an explicit `nowMs`/`endedIso` rather than
13
+ * reading `Date.now()`.
14
+ *
15
+ * Note: this module intentionally keeps its OWN browser-subset glob matcher
16
+ * (normalizeGlob/segmentCovers/patternCovers below); it does not import the
17
+ * server-side packages/hub/src/globs.ts, which is a deliberately-separate
18
+ * duplicate.
19
+ */
20
+ /**
21
+ * Human "N ago" for a past ISO timestamp, relative to `nowMs` (epoch ms).
22
+ *
23
+ * @param iso - A past instant as an ISO 8601 string.
24
+ * @param nowMs - The reference "now" in epoch milliseconds (the server clock).
25
+ * @returns A short relative label, e.g. `"just now"`, `"30s ago"`, `"2h ago"`.
26
+ */
27
+ export function formatRelative(iso, nowMs) {
28
+ const secs = Math.floor((nowMs - Date.parse(iso)) / 1000);
29
+ if (secs < 5)
30
+ return "just now";
31
+ if (secs < 60)
32
+ return `${secs}s ago`;
33
+ const mins = Math.floor(secs / 60);
34
+ if (mins < 60)
35
+ return `${mins}m ago`;
36
+ const hrs = Math.floor(mins / 60);
37
+ if (hrs < 24)
38
+ return `${hrs}h ago`;
39
+ return `${Math.floor(hrs / 24)}d ago`;
40
+ }
41
+ /**
42
+ * Human "expires in N" / "expired" for a future ISO timestamp, relative to
43
+ * `nowMs`.
44
+ *
45
+ * @param iso - The expiry instant as an ISO 8601 string.
46
+ * @param nowMs - The reference "now" in epoch milliseconds (the server clock).
47
+ * @returns `"expired"` once at/past the instant, else e.g. `"expires in 4m"`.
48
+ */
49
+ export function formatCountdown(iso, nowMs) {
50
+ const secs = Math.floor((Date.parse(iso) - nowMs) / 1000);
51
+ if (secs <= 0)
52
+ return "expired";
53
+ if (secs < 60)
54
+ return `expires in ${secs}s`;
55
+ const mins = Math.floor(secs / 60);
56
+ if (mins < 60)
57
+ return `expires in ${mins}m`;
58
+ return `expires in ${Math.floor(mins / 60)}h`;
59
+ }
60
+ /**
61
+ * Deterministic chat color for an agent name — same name always maps to the
62
+ * same hue, so each speaker reads consistently across the announcement thread.
63
+ * Saturation/lightness are tuned to stay legible on the dark background.
64
+ *
65
+ * @param name - The agent name (may be empty, which yields hue 0 — never throws).
66
+ * @returns An `hsl(...)` CSS color string.
67
+ */
68
+ export function colorForName(name) {
69
+ let h = 0;
70
+ for (let i = 0; i < name.length; i++)
71
+ h = (h * 31 + name.charCodeAt(i)) % 360;
72
+ return `hsl(${h}, 38%, 42%)`;
73
+ }
74
+ /**
75
+ * Up to two initials for an avatar. Prefers the capital letters of a CamelCase
76
+ * agent name (RedDragon → RD); otherwise the first two letters (alice → AL).
77
+ *
78
+ * @param name - The agent name; empty yields the `"?"` placeholder.
79
+ * @returns A 1–2 character uppercase label.
80
+ */
81
+ export function initialsFor(name) {
82
+ if (!name)
83
+ return "?";
84
+ const caps = name.match(/[A-Z]/g);
85
+ if (caps && caps.length >= 2)
86
+ return caps[0] + caps[1];
87
+ return name.slice(0, 2).toUpperCase();
88
+ }
89
+ /**
90
+ * Characters allowed in an agent-name mention token. Agent names are CamelCase
91
+ * (`RedDragon`) or `handle-ordinal` (`daichi-6`), so letters, digits, hyphen,
92
+ * and underscore. Used by both the autocomplete and target extraction.
93
+ */
94
+ const MENTION_CHARS = "A-Za-z0-9_-";
95
+ /**
96
+ * The active `@mention` immediately to the LEFT of the caret, or null when the
97
+ * caret isn't in one. Drives the autocomplete: the `@` must start the text or
98
+ * follow whitespace (so email addresses like `a@b` don't trigger it), and only
99
+ * mention-name characters may sit between it and the caret.
100
+ *
101
+ * @param text - The full input text.
102
+ * @param caretIndex - The caret position within `text`.
103
+ * @returns The mention slice and query, or `null` when not in a mention.
104
+ */
105
+ export function parseMention(text, caretIndex) {
106
+ const before = text.slice(0, caretIndex);
107
+ const m = before.match(new RegExp(`(?:^|\\s)@([${MENTION_CHARS}]*)$`));
108
+ if (!m)
109
+ return null;
110
+ const query = m[1];
111
+ return { start: caretIndex - query.length - 1, end: caretIndex, query };
112
+ }
113
+ /**
114
+ * The first `@mention` in `text` that matches a known agent name, returned in
115
+ * the name's canonical casing (so `@reddragon` resolves to `RedDragon`), or null
116
+ * when none match. This is what turns a typed message into a directed DM; an
117
+ * unmatched `@foo` is treated as plain text and the message broadcasts.
118
+ *
119
+ * @param text - The message text to scan.
120
+ * @param knownNames - Canonical agent names to resolve against (case-insensitive).
121
+ * @returns The canonical name of the first matching mention, or `null`.
122
+ */
123
+ export function extractTarget(text, knownNames) {
124
+ const re = new RegExp(`(?:^|\\s)@([${MENTION_CHARS}]+)`, "g");
125
+ let m;
126
+ while ((m = re.exec(text)) !== null) {
127
+ const typed = m[1].toLowerCase();
128
+ const hit = knownNames.find((n) => n.toLowerCase() === typed);
129
+ if (hit)
130
+ return hit;
131
+ }
132
+ return null;
133
+ }
134
+ /**
135
+ * Sorted unique repo identifiers present across the given tasks.
136
+ *
137
+ * @param tasks - Items carrying a `repo` (a structural subset of {@link WorkspaceTaskT}).
138
+ * @returns The distinct repos, ascending.
139
+ */
140
+ export function distinctRepos(tasks) {
141
+ return [...new Set(tasks.map((t) => t.repo))].sort();
142
+ }
143
+ /**
144
+ * True if `item.repo` matches the selection; `null`/`"__all__"` = all repos.
145
+ *
146
+ * @param item - Anything carrying a `repo`.
147
+ * @param selected - The selected repo, or `null`/`"__all__"` for all.
148
+ * @returns Whether the item belongs to the current board filter.
149
+ */
150
+ export function matchesRepo(item, selected) {
151
+ return selected === null || selected === "__all__" || item.repo === selected;
152
+ }
153
+ /**
154
+ * Live agents addressable by @mention under the current board filter: only those
155
+ * whose (most-recent) session sits in the selected repo, so a message can't be
156
+ * directed at someone who isn't in the repo you're viewing. Mirrors the crew the
157
+ * board already shows for that repo; in All-repos mode everyone live is
158
+ * addressable. Returns de-duplicated canonical names, sorted.
159
+ *
160
+ * @param agents - Agents carrying `name`, `presence`, and `repo` (a structural
161
+ * subset of {@link WorkspaceAgentT}).
162
+ * @param selectedRepo - The selected repo, or `null`/`"__all__"` for all.
163
+ * @returns Sorted, de-duplicated names of mentionable live agents.
164
+ */
165
+ export function mentionableAgents(agents, selectedRepo) {
166
+ return [
167
+ ...new Set(agents
168
+ .filter((a) => a.presence === "live" && matchesRepo(a, selectedRepo))
169
+ .map((a) => a.name)),
170
+ ].sort();
171
+ }
172
+ /**
173
+ * First-load default repo: newest active task's repo, else newest task's, else
174
+ * null.
175
+ *
176
+ * @param tasks - Tasks carrying `repo` and `status` (a structural subset of
177
+ * {@link WorkspaceTaskT}); assumed newest-first.
178
+ * @returns The repo to select on first load, or `null` when there are no tasks.
179
+ */
180
+ export function defaultRepo(tasks) {
181
+ const active = tasks.find((t) => t.status === "active");
182
+ if (active)
183
+ return active.repo;
184
+ return tasks.length ? tasks[0].repo : null;
185
+ }
186
+ /**
187
+ * Human label for a history task's status.
188
+ *
189
+ * @param status - The task status.
190
+ * @returns `"dropped"` for a dropped task, otherwise `"done"`.
191
+ */
192
+ export function statusLabel(status) {
193
+ return status === "dropped" ? "dropped" : "done";
194
+ }
195
+ /**
196
+ * "active Nm/Nh" for the created->ended span; "" when the task hasn't ended.
197
+ *
198
+ * @param createdIso - The task's creation instant (ISO 8601 string).
199
+ * @param endedIso - The task's end instant, or `null` while still active.
200
+ * @returns A duration label, or the empty string for an unfinished task.
201
+ */
202
+ export function formatActiveDuration(createdIso, endedIso) {
203
+ if (!endedIso)
204
+ return "";
205
+ const secs = Math.floor((Date.parse(endedIso) - Date.parse(createdIso)) / 1000);
206
+ const mins = Math.floor(secs / 60);
207
+ if (mins < 60)
208
+ return `active ${mins}m`;
209
+ return `active ${Math.floor(mins / 60)}h`;
210
+ }
211
+ /**
212
+ * Local-day bucket label: "Today" / "Yesterday" / "Mon D".
213
+ *
214
+ * @param iso - The instant to bucket (ISO 8601 string).
215
+ * @param nowMs - The reference "now" in epoch milliseconds.
216
+ * @returns `"Today"`, `"Yesterday"`, or a short month/day label.
217
+ */
218
+ export function dayBucket(iso, nowMs) {
219
+ const d = new Date(Date.parse(iso));
220
+ const now = new Date(nowMs);
221
+ const startOf = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
222
+ const dayDiff = Math.round((startOf(now) - startOf(d)) / 86400000);
223
+ if (dayDiff <= 0)
224
+ return "Today";
225
+ if (dayDiff === 1)
226
+ return "Yesterday";
227
+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
228
+ }
229
+ // ---------------------------------------------------------------------------
230
+ // Glob containment + active-claim grouping
231
+ // ---------------------------------------------------------------------------
232
+ /**
233
+ * Normalize a glob into a segment list. Mirrors the server's normalize
234
+ * (packages/hub/src/globs.ts) so the two agree on path shape: lowercase,
235
+ * backslashes -> '/', a trailing slash claims the subtree ('dir/' -> 'dir/**'),
236
+ * split on '/', and resolve '.'/'..'. Module-private.
237
+ */
238
+ function normalizeGlob(pattern) {
239
+ let p = pattern.toLowerCase().replace(/\\/g, "/");
240
+ if (p.endsWith("/"))
241
+ p = p.replace(/\/+$/, "") + "/**";
242
+ const out = [];
243
+ for (const seg of p.split("/")) {
244
+ if (seg === "" || seg === ".")
245
+ continue;
246
+ if (seg === "..") {
247
+ if (out.length)
248
+ out.pop();
249
+ continue;
250
+ }
251
+ out.push(seg);
252
+ }
253
+ return out;
254
+ }
255
+ const SEG_WILDCARD = /[*?{}[\]]/;
256
+ /**
257
+ * Does single-segment pattern `a` cover single-segment pattern `b`? Conservative:
258
+ * '*' covers any one segment; a literal covers only the identical literal; any
259
+ * other wildcard ('?', braces, brackets) covers only a byte-identical segment.
260
+ * Module-private.
261
+ */
262
+ function segmentCovers(a, b) {
263
+ if (a === "*")
264
+ return true;
265
+ if (SEG_WILDCARD.test(a))
266
+ return a === b;
267
+ return !SEG_WILDCARD.test(b) && a === b;
268
+ }
269
+ /**
270
+ * Does pattern A (segments) cover pattern B (segments) — i.e. every concrete path
271
+ * matching B also matches A? '**' in A consumes zero-or-more B segments; a bounded
272
+ * (non-'**') A segment can NEVER cover a B '**' (the safety gate that stops a
273
+ * shallow claim from swallowing a deep one). Memoized on the (i, j) suffix.
274
+ * Module-private.
275
+ */
276
+ function patternCovers(a, b) {
277
+ const memo = new Map();
278
+ function go(i, j) {
279
+ if (b.length - j === 0) {
280
+ // B exhausted: A covers iff its remainder matches the empty tail, i.e.
281
+ // every remaining A segment is '**' (which can consume zero segments).
282
+ for (let k = i; k < a.length; k++)
283
+ if (a[k] !== "**")
284
+ return false;
285
+ return true;
286
+ }
287
+ if (a.length - i === 0)
288
+ return false; // A exhausted, B still has depth
289
+ const key = i * (b.length + 1) + j;
290
+ const cached = memo.get(key);
291
+ if (cached !== undefined)
292
+ return cached;
293
+ const ah = a[i], bh = b[j];
294
+ let res;
295
+ if (ah === "**") {
296
+ res = go(i + 1, j) || go(i, j + 1); // consume zero, or one B segment
297
+ }
298
+ else if (bh === "**") {
299
+ res = false; // bounded A segment cannot cover an unbounded B '**'
300
+ }
301
+ else {
302
+ res = segmentCovers(ah, bh) && go(i + 1, j + 1);
303
+ }
304
+ memo.set(key, res);
305
+ return res;
306
+ }
307
+ return go(0, 0);
308
+ }
309
+ /**
310
+ * Does glob set `outer` fully cover glob set `inner` — every path `inner` could
311
+ * touch already inside `outer`? Conservative: unsure -> false, so a caller only
312
+ * folds a claim it's certain is contained (a false negative just shows both
313
+ * claims, which is safe). An empty `inner` is trivially covered.
314
+ *
315
+ * @param outer - The candidate broader glob set.
316
+ * @param inner - The candidate narrower glob set.
317
+ * @returns Whether every glob in `inner` is covered by some glob in `outer`.
318
+ */
319
+ export function globsCover(outer, inner) {
320
+ if (inner.length === 0)
321
+ return true;
322
+ const o = outer.map(normalizeGlob);
323
+ return inner.every((g) => {
324
+ const ng = normalizeGlob(g);
325
+ return o.some((op) => patternCovers(op, ng));
326
+ });
327
+ }
328
+ /**
329
+ * Group active claims by agent for the board. Within an agent's claims, one that
330
+ * is STRICTLY covered by a broader sibling (the sibling covers it but not vice
331
+ * versa, so identical claims don't cannibalise each other) folds into `narrower`;
332
+ * everything else stays a visible `primary`. Distinct or merely-overlapping
333
+ * claims all remain primaries. Pure — no DOM, no clock; `createdAt` drives order.
334
+ *
335
+ * Groups are newest-claim-first; `primaries`/`narrower` are each newest-first;
336
+ * header fields (model/program/repo) come from the group's newest claim.
337
+ *
338
+ * @param tasks - The active claims to group (a structural subset of
339
+ * {@link WorkspaceTaskT}).
340
+ * @returns One {@link ClaimGroup} per agent, newest-claim-first.
341
+ */
342
+ export function groupActiveClaims(tasks) {
343
+ const byAgent = new Map();
344
+ for (const t of tasks) {
345
+ if (!byAgent.has(t.agentName))
346
+ byAgent.set(t.agentName, []);
347
+ byAgent.get(t.agentName).push(t);
348
+ }
349
+ const newestFirst = (a, b) => b.createdAt.localeCompare(a.createdAt);
350
+ const groups = [];
351
+ for (const [agentName, claims] of byAgent) {
352
+ const sorted = [...claims].sort(newestFirst);
353
+ const isNarrower = sorted.map((c) => sorted.some((o) => o !== c &&
354
+ globsCover(o.pathGlobs, c.pathGlobs) &&
355
+ !globsCover(c.pathGlobs, o.pathGlobs)));
356
+ const head = sorted[0];
357
+ groups.push({
358
+ agentName,
359
+ model: head.model,
360
+ program: head.program,
361
+ repo: head.repo,
362
+ primaries: sorted.filter((_, i) => !isNarrower[i]),
363
+ narrower: sorted.filter((_, i) => isNarrower[i]),
364
+ });
365
+ }
366
+ groups.sort((g1, g2) => {
367
+ const t1 = g1.primaries.concat(g1.narrower)[0].createdAt;
368
+ const t2 = g2.primaries.concat(g2.narrower)[0].createdAt;
369
+ return t2.localeCompare(t1);
370
+ });
371
+ return groups;
372
+ }
package/dist/main.d.ts ADDED
@@ -0,0 +1 @@
1
+ import "./styles.css";
package/dist/main.js ADDED
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { StrictMode } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import "./styles.css";
5
+ import { SelfHostApp } from "./selfhost.js";
6
+ /**
7
+ * SPA mount point for the self-host app build. Bundles the stylesheet (the
8
+ * standalone build ships its own CSS, unlike the `.` lib export which leaves
9
+ * styling opt-in) and mounts the token-gated {@link SelfHostApp} at #root.
10
+ */
11
+ createRoot(document.getElementById("root")).render(_jsx(StrictMode, { children: _jsx(SelfHostApp, {}) }));
@@ -1 +1 @@
1
- :root{--bg:#ece0c6;--ink:#322c20;--ink2:#6f6450;--ink3:#9c917a;--line:#d7c9ac;--line2:#e0d4b9;--live:#3d8b58;--drop:#bd4f3a;--chip:#e3d6ba;--terr:#7a6a48;--card:#f5ecd9}*{box-sizing:border-box}html,body{height:100%}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;-webkit-font-smoothing:antialiased}.wrap{max-width:1100px;margin:0 auto;padding:40px 44px}header{display:flex;align-items:center;gap:12px;padding-bottom:18px;border-bottom:1px solid var(--line)}#board:has(#panel-chat:not([hidden])) header{border-bottom:0}.brand{font-size:17px;font-weight:600}.vitals{color:var(--ink3);font-size:12.5px}.grow{flex:1}.status{font-size:12px;color:var(--ink3)}.status--ok{color:var(--live)}.status--warn{color:var(--terr)}.status--error{color:var(--drop)}.freshness{font-size:12px;color:var(--ink3)}.repo{position:relative}.repo-trig{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-family:inherit;color:var(--ink);-webkit-appearance:none;-moz-appearance:none;appearance:none;border:1px solid var(--line);border-radius:8px;padding:4px 10px;background:#fffaee66;cursor:pointer}.repo-trig .slash{color:var(--ink3)}.repo-menu{position:absolute;top:36px;left:0;min-width:200px;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:5px;box-shadow:0 8px 24px #32281424;z-index:5}.repo-mi{display:flex;justify-content:space-between;gap:14px;padding:7px 9px;width:100%;text-align:left;border:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;font-family:inherit;color:inherit;background:transparent;border-radius:7px;font-size:13px;cursor:pointer}.repo-mi:hover{background:var(--chip)}.repo-mi.on{background:var(--chip);font-weight:600}.repo-mi .ct{font-size:11px;color:var(--ink3)}.repo-mi.all{border-top:1px solid var(--line2);margin-top:4px;padding-top:9px;color:var(--ink2)}.tabs{display:flex;gap:4px;margin-left:auto}.tab{font-size:13px;color:var(--ink2);padding:5px 11px;border:0;border-radius:7px;background:transparent;cursor:pointer;font:inherit}.tab--active{background:var(--ink);color:var(--bg)}.crew{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:26px 0 32px}.person{display:inline-flex;align-items:center;gap:9px;padding:5px 13px 5px 5px;border:1px solid var(--line);border-radius:999px;background:var(--card);font-size:13px}.person--idle{opacity:.55}.avatar{width:24px;height:24px;border-radius:50%;display:grid;place-items:center;font-size:10px;font-weight:700;color:#fff;flex:none}.person__name{font-weight:600}.person__doing{color:var(--ink3);font-size:12px}.board{display:grid;grid-template-columns:1fr 1px 1fr;gap:0}.board__rule{background:var(--line)}.col{padding:0 36px}.col:first-child{padding-left:0}.col:last-child{padding-right:0}.colhead{display:flex;align-items:center;gap:8px;margin-bottom:6px}.colhead h2{font-size:12px;font-weight:600;letter-spacing:.03em;margin:0;text-transform:uppercase;color:var(--ink2)}.colhead .n{font-size:11px;color:var(--ink3);background:var(--chip);border-radius:999px;padding:1px 8px}.task{padding:16px 0;border-bottom:1px solid var(--line2)}.task__r1{display:flex;align-items:center;gap:8px}.task__who{font-size:13.5px;font-weight:600}.task__tag{font-size:11px;color:var(--ink3)}.task__repo{font-size:11px;color:var(--terr);background:#7a6a481a;border-radius:4px;padding:0 6px}.task__stat{margin-left:auto;font-size:11px;font-weight:500;color:var(--ink3)}.task__stat--drop{color:var(--drop)}.livedot{width:7px;height:7px;border-radius:50%;background:var(--live);margin-left:auto}.task__intent{font-size:13.5px;margin:5px 0 0;line-height:1.45}.terr{margin-top:8px;display:flex;flex-wrap:wrap;gap:6px;align-items:center}.terr__lbl{font-size:9.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--ink3)}.glob{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--terr);background:#7a6a481f;border:1px solid rgba(122,106,72,.22);border-radius:5px;padding:1px 7px}.task__meta{font-size:11.5px;color:var(--ink3);margin-top:6px}.terrx{margin-top:8px}.terrx summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:6px;flex-wrap:wrap;-webkit-user-select:none;user-select:none}.terrx summary::-webkit-details-marker{display:none}.terrx .terr--full{margin-top:8px}.glob--more{cursor:pointer;color:var(--ink2);background:var(--chip);border:1px solid var(--line)}.glob--more .tw{color:var(--ink3);display:inline-block;transition:transform .15s}.terrx[open] summary .glob--more .tw{transform:rotate(90deg)}.grp{padding:16px 0;border-bottom:1px solid var(--line2)}.grp__head{display:flex;align-items:center;gap:8px}.grp__who{font-size:13.5px;font-weight:600}.grp__tag,.grp__count{font-size:11px;color:var(--ink3)}.grp__dot{width:7px;height:7px;border-radius:50%;background:var(--live);margin-left:auto}.claims{margin:10px 0 0 6px;padding-left:14px;border-left:1px solid var(--line)}.claim{padding:6px 0}.claim+.claim{border-top:1px solid var(--line2);margin-top:6px}.fold{margin-top:10px}.fold summary{list-style:none;cursor:pointer;font-size:12px;color:var(--ink2);display:inline-flex;align-items:center;gap:6px;-webkit-user-select:none;user-select:none}.fold summary::-webkit-details-marker{display:none}.fold summary .tw{display:inline-block;color:var(--ink3);transition:transform .15s}.fold[open] summary .tw{transform:rotate(90deg)}.fold__body{margin:8px 0 0 6px;padding-left:14px;border-left:1px dashed var(--line)}.covered{font-size:10.5px;color:var(--ink3);font-style:italic;margin-top:4px}.day{font-size:11px;color:var(--ink3);padding:18px 0 2px;font-weight:600}.more{margin-top:20px;font-size:12.5px;color:var(--ink2);padding:7px 13px;border:1px solid var(--line);border-radius:8px;background:#fffaee59;cursor:pointer}.empty{color:var(--ink3);font-style:italic;padding:14px 0}.chat-wrap{background:var(--card);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;overflow:hidden;height:calc(100vh - 150px);min-height:320px}.chat{flex:1;overflow-y:auto;padding:16px}.chat{scrollbar-width:thin;scrollbar-color:var(--line) transparent}.chat::-webkit-scrollbar{width:10px}.chat::-webkit-scrollbar-track{background:transparent}.chat::-webkit-scrollbar-thumb{background:var(--line);border-radius:999px;border:3px solid var(--card);background-clip:padding-box}.chat::-webkit-scrollbar-thumb:hover{background:var(--ink3);background-clip:padding-box}.chat__note{padding:9px 16px;font-size:11.5px;color:var(--ink3)}.msg{display:flex;gap:11px;padding:9px 0}.msg__avatar{flex:none;width:32px;height:32px;border-radius:50%;display:grid;place-items:center;font-size:12px;font-weight:700;color:#fff}.msg__body{min-width:0;flex:1}.msg__head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}.msg__who{font-weight:600}.msg__human{font-size:12px;color:var(--ink3)}.msg__to{font-size:11.5px;color:var(--terr)}.msg__time{font-size:11.5px;color:var(--ink3);margin-left:auto}.msg__text{margin-top:3px;padding:8px 12px;border-radius:4px 12px 12px;background:var(--chip);white-space:pre-wrap;word-break:break-word}.msg--targeted .msg__text{box-shadow:inset 3px 0 0 var(--terr)}.msg--me,.msg--me .msg__head{flex-direction:row-reverse}.msg--me .msg__time{margin-left:0;margin-right:auto}.msg--me .msg__body{display:flex;flex-direction:column;align-items:flex-end}.msg--me .msg__text{border-radius:12px 4px 12px 12px;background:#d4e7d2}.msg--me.msg--targeted .msg__text{box-shadow:inset -3px 0 0 var(--terr)}.composer{position:relative;border-top:1px solid var(--line);padding:10px 12px}.chat-form{display:flex;gap:8px}.chat-input{flex:1;padding:9px 12px;border-radius:8px;border:1px solid var(--line);background:var(--bg);color:var(--ink);font:inherit}.chat-input:focus{outline:none;border-color:var(--terr)}.chat-send{padding:9px 16px;border-radius:8px;border:1px solid var(--ink);background:var(--ink);color:var(--bg);font-weight:600;cursor:pointer;font:inherit}.chat-send:disabled{opacity:.5;cursor:default}.mention-pop{position:absolute;left:12px;right:12px;bottom:56px;z-index:6;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:5px;box-shadow:0 8px 24px #32281429;max-height:200px;overflow-y:auto}.mention-mi{display:flex;align-items:center;gap:9px;width:100%;text-align:left;border:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;font-family:inherit;color:inherit;background:transparent;border-radius:7px;padding:6px 8px;font-size:13px;cursor:pointer}.mention-mi:hover,.mention-mi.on{background:var(--chip)}.mention-mi .ma{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;font-size:9px;font-weight:700;color:#fff;flex:none}.gate{max-width:420px;margin:12vh auto 0;text-align:center}.gate h1{font-size:19px;margin:0 0 6px}.gate p{color:var(--ink2);margin:0 0 20px}.gate form{display:flex;gap:8px}.gate input{flex:1;padding:10px 12px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--ink)}.gate button{padding:10px 16px;border-radius:8px;border:1px solid var(--ink);background:var(--ink);color:var(--bg);font-weight:600;cursor:pointer}
1
+ :root{--bg:#ece0c6;--ink:#322c20;--ink2:#6f6450;--ink3:#9c917a;--line:#d7c9ac;--line2:#e0d4b9;--live:#3d8b58;--drop:#bd4f3a;--chip:#e3d6ba;--terr:#7a6a48;--card:#f5ecd9}*{box-sizing:border-box}html,body{height:100%}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;-webkit-font-smoothing:antialiased}.wrap{max-width:1100px;margin:0 auto;padding:40px 44px}header{display:flex;align-items:center;gap:12px;padding-bottom:18px;border-bottom:1px solid var(--line)}#board:has(#panel-chat:not([hidden])) header{border-bottom:0}.brand{font-size:17px;font-weight:600}.vitals{color:var(--ink3);font-size:12.5px}.grow{flex:1}.status{font-size:12px;color:var(--ink3)}.status--ok{color:var(--live)}.status--warn{color:var(--terr)}.status--error{color:var(--drop)}.freshness{font-size:12px;color:var(--ink3)}.repo{position:relative}.repo-trig{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-family:inherit;color:var(--ink);-webkit-appearance:none;-moz-appearance:none;appearance:none;border:1px solid var(--line);border-radius:8px;padding:4px 10px;background:#fffaee66;cursor:pointer}.repo-trig .slash{color:var(--ink3)}.repo-menu{position:absolute;top:36px;left:0;min-width:200px;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:5px;box-shadow:0 8px 24px #32281424;z-index:5}.repo-mi{display:flex;justify-content:space-between;gap:14px;padding:7px 9px;width:100%;text-align:left;border:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;font-family:inherit;color:inherit;background:transparent;border-radius:7px;font-size:13px;cursor:pointer}.repo-mi:hover{background:var(--chip)}.repo-mi.on{background:var(--chip);font-weight:600}.repo-mi .ct{font-size:11px;color:var(--ink3)}.repo-mi.all{border-top:1px solid var(--line2);margin-top:4px;padding-top:9px;color:var(--ink2)}.tabs{display:flex;gap:4px;margin-left:auto}.tab{font-size:13px;color:var(--ink2);padding:5px 11px;border:0;border-radius:7px;background:transparent;cursor:pointer;font:inherit}.tab--active{background:var(--ink);color:var(--bg)}.crew{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:26px 0 32px}.person{display:inline-flex;align-items:center;gap:9px;padding:5px 13px 5px 5px;border:1px solid var(--line);border-radius:999px;background:var(--card);font-size:13px}.person--idle{opacity:.55}.avatar{width:24px;height:24px;border-radius:50%;display:grid;place-items:center;font-size:10px;font-weight:700;color:#fff;flex:none}.person__name{font-weight:600}.person__doing{color:var(--ink3);font-size:12px}.board{display:grid;grid-template-columns:1fr 1px 1fr;gap:0}.board__rule{background:var(--line)}.col{padding:0 36px}.col:first-child{padding-left:0}.col:last-child{padding-right:0}.colhead{display:flex;align-items:center;gap:8px;margin-bottom:6px}.colhead h2{font-size:12px;font-weight:600;letter-spacing:.03em;margin:0;text-transform:uppercase;color:var(--ink2)}.colhead .n{font-size:11px;color:var(--ink3);background:var(--chip);border-radius:999px;padding:1px 8px}.task{padding:16px 0;border-bottom:1px solid var(--line2)}.task__r1{display:flex;align-items:center;gap:8px}.task__who{font-size:13.5px;font-weight:600}.task__tag{font-size:11px;color:var(--ink3)}.task__repo{font-size:11px;color:var(--terr);background:#7a6a481a;border-radius:4px;padding:0 6px}.task__stat{margin-left:auto;font-size:11px;font-weight:500;color:var(--ink3)}.task__stat--drop{color:var(--drop)}.livedot{width:7px;height:7px;border-radius:50%;background:var(--live);margin-left:auto}.task__intent{font-size:13.5px;margin:5px 0 0;line-height:1.45}.terr{margin-top:8px;display:flex;flex-wrap:wrap;gap:6px;align-items:center}.terr__lbl{font-size:9.5px;text-transform:uppercase;letter-spacing:.05em;color:var(--ink3)}.glob{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--terr);background:#7a6a481f;border:1px solid rgba(122,106,72,.22);border-radius:5px;padding:1px 7px}.task__meta{font-size:11.5px;color:var(--ink3);margin-top:6px}.terrx{margin-top:8px}.terrx summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:6px;flex-wrap:wrap;-webkit-user-select:none;user-select:none}.terrx summary::-webkit-details-marker{display:none}.terrx .terr--full{margin-top:8px}.glob--more{cursor:pointer;color:var(--ink2);background:var(--chip);border:1px solid var(--line)}.glob--more .tw{color:var(--ink3);display:inline-block;transition:transform .15s}.terrx[open] summary .glob--more .tw{transform:rotate(90deg)}.grp{padding:16px 0;border-bottom:1px solid var(--line2)}.grp__head{display:flex;align-items:center;gap:8px}.grp__who{font-size:13.5px;font-weight:600}.grp__tag,.grp__count{font-size:11px;color:var(--ink3)}.grp__dot{width:7px;height:7px;border-radius:50%;background:var(--live);margin-left:auto}.claims{margin:10px 0 0 6px;padding-left:14px;border-left:1px solid var(--line)}.claim{padding:6px 0}.claim+.claim{border-top:1px solid var(--line2);margin-top:6px}.fold{margin-top:10px}.fold summary{list-style:none;cursor:pointer;font-size:12px;color:var(--ink2);display:inline-flex;align-items:center;gap:6px;-webkit-user-select:none;user-select:none}.fold summary::-webkit-details-marker{display:none}.fold summary .tw{display:inline-block;color:var(--ink3);transition:transform .15s}.fold[open] summary .tw{transform:rotate(90deg)}.fold__body{margin:8px 0 0 6px;padding-left:14px;border-left:1px dashed var(--line)}.covered{font-size:10.5px;color:var(--ink3);font-style:italic;margin-top:4px}.day{font-size:11px;color:var(--ink3);padding:18px 0 2px;font-weight:600}.more{margin-top:20px;font-size:12.5px;color:var(--ink2);padding:7px 13px;border:1px solid var(--line);border-radius:8px;background:#fffaee59;cursor:pointer}.empty{color:var(--ink3);font-style:italic;padding:14px 0}.chat-wrap{background:var(--card);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;overflow:hidden;height:calc(100vh - 150px);min-height:320px}.chat{flex:1;overflow-y:auto;padding:16px}.chat{scrollbar-width:thin;scrollbar-color:var(--line) transparent}.chat::-webkit-scrollbar{width:10px}.chat::-webkit-scrollbar-track{background:transparent}.chat::-webkit-scrollbar-thumb{background:var(--line);border-radius:999px;border:3px solid var(--card);background-clip:padding-box}.chat::-webkit-scrollbar-thumb:hover{background:var(--ink3);background-clip:padding-box}.chat__note{padding:9px 16px;font-size:11.5px;color:var(--ink3)}.msg{display:flex;gap:11px;padding:9px 0}.msg__avatar{flex:none;width:32px;height:32px;border-radius:50%;display:grid;place-items:center;font-size:12px;font-weight:700;color:#fff}.msg__body{min-width:0;flex:1}.msg__head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}.msg__who{font-weight:600}.msg__human{font-size:12px;color:var(--ink3)}.msg__to{font-size:11.5px;color:var(--terr)}.msg__time{font-size:11.5px;color:var(--ink3);margin-left:auto}.msg__text{margin-top:3px;padding:8px 12px;border-radius:4px 12px 12px;background:var(--chip);white-space:pre-wrap;word-break:break-word}.msg--targeted .msg__text{box-shadow:inset 3px 0 0 var(--terr)}.msg--me,.msg--me .msg__head{flex-direction:row-reverse}.msg--me .msg__time{margin-left:0;margin-right:auto}.msg--me .msg__body{display:flex;flex-direction:column;align-items:flex-end}.msg--me .msg__text{border-radius:12px 4px 12px 12px;background:#d4e7d2}.msg--me.msg--targeted .msg__text{box-shadow:inset -3px 0 0 var(--terr)}.composer{position:relative;border-top:1px solid var(--line);padding:10px 12px}.chat-form{display:flex;gap:8px}.chat-input{flex:1;padding:9px 12px;border-radius:8px;border:1px solid var(--line);background:var(--bg);color:var(--ink);font:inherit}.chat-input:focus{outline:none;border-color:var(--terr)}.chat-send{padding:9px 16px;border-radius:8px;border:1px solid var(--ink);background:var(--ink);color:var(--bg);font-weight:600;cursor:pointer;font:inherit}.chat-send:disabled{opacity:.5;cursor:default}.mention-pop{position:absolute;left:12px;right:12px;bottom:56px;z-index:6;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:5px;box-shadow:0 8px 24px #32281429;max-height:200px;overflow-y:auto}.mention-mi{display:flex;align-items:center;gap:9px;width:100%;text-align:left;border:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;font-family:inherit;color:inherit;background:transparent;border-radius:7px;padding:6px 8px;font-size:13px;cursor:pointer}.mention-mi:hover,.mention-mi.on{background:var(--chip)}.mention-mi .ma{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;font-size:9px;font-weight:700;color:#fff;flex:none}.gate{max-width:420px;margin:12vh auto 0;text-align:center}.gate h1{font-size:19px;margin:0 0 6px}.gate p{color:var(--ink2);margin:0 0 20px}.gate form{display:flex;gap:8px}.gate input{flex:1;padding:10px 12px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--ink)}.gate button{padding:10px 16px;border-radius:8px;border:1px solid var(--ink);background:var(--ink);color:var(--bg);font-weight:600;cursor:pointer}.shepherd-root{max-width:1100px;margin:0 auto;padding:40px 44px}.shepherd-root>main{padding-top:18px}.shepherd-config{display:flex;flex-direction:column;gap:24px}.shepherd-config h2{font-size:16px;margin:0 0 4px}.shepherd-workspaces,.shepherd-members,.shepherd-connect-agent{display:flex;flex-direction:column;gap:10px;padding:16px;border:1px solid var(--line);border-radius:10px;background:var(--card)}.shepherd-workspaces .switcher{display:flex;flex-wrap:wrap;gap:8px}.shepherd-workspaces .role{color:var(--ink3);font-size:12px}.shepherd-workspaces .admin,.shepherd-workspaces .invites{display:flex;flex-wrap:wrap;gap:8px;align-items:center}.shepherd-config input,.shepherd-config select{padding:8px 10px;border-radius:8px;border:1px solid var(--line);background:var(--bg);color:var(--ink)}.shepherd-config button{padding:8px 14px;border-radius:8px;border:1px solid var(--ink);background:var(--ink);color:var(--bg);font-weight:600;cursor:pointer}.shepherd-config button:disabled{opacity:.55;cursor:default}.shepherd-config .invite-result,.shepherd-connect-agent .token-once{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;word-break:break-all;padding:8px 10px;border-radius:8px;background:var(--chip);border:1px solid var(--line2)}.shepherd-config .revoked{color:var(--ink3);text-decoration:line-through}.shepherd-empty-state{text-align:center;color:var(--ink2);padding:48px 24px}.shepherd-empty-state button{margin-top:14px}