@lightupai/polaris 0.0.4 → 0.0.6

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.
@@ -0,0 +1,399 @@
1
+ // --- Slack Bridge ---
2
+ // Connects a project's event stream to a Slack channel.
3
+ // Runs server-side, one bridge per org.
4
+ //
5
+ // Project → Slack: session events → formatted Slack posts
6
+ // Slack → Project: advisor messages → injected into target session
7
+
8
+ import { SocketModeClient } from "@slack/socket-mode";
9
+ import { WebClient } from "@slack/web-api";
10
+ import { createDb, getOrg, listProjects, getProjectEvents, getOrgEventsSince, getSession, createSession, pushEvent, setProjectSlackChannel, type Sql, type Org } from "../service/db";
11
+ import { formatEventForSlack, toMrkdwn, THREAD_THRESHOLD } from "./format";
12
+ import type { PolarisEvent } from "../types";
13
+
14
+ // --- Channel management ---
15
+
16
+ // In-memory cache: project name → Slack channel ID
17
+ const channelCache = new Map<string, string>();
18
+
19
+ async function getOrCreateChannel(web: WebClient, sql: Sql, orgId: string, projectName: string): Promise<string> {
20
+ // 1. Check in-memory cache
21
+ const cached = channelCache.get(projectName);
22
+ if (cached) return cached;
23
+
24
+ // 2. Check DB for stored channel ID (survives renames and restarts)
25
+ const projects = await listProjects(sql, orgId);
26
+ const project = projects.find((p) => p.name === projectName);
27
+ if (project?.slack_channel_id) {
28
+ channelCache.set(projectName, project.slack_channel_id);
29
+ // Ensure bot is in the channel (might have been removed)
30
+ try { await web.conversations.join({ channel: project.slack_channel_id }); } catch {}
31
+ return project.slack_channel_id;
32
+ }
33
+
34
+ // 3. Create or find channel by name
35
+ const channelName = projectName.toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 80);
36
+ let channelId: string | undefined;
37
+
38
+ try {
39
+ const result = await web.conversations.create({ name: channelName, is_private: false });
40
+ if (result.ok && result.channel?.id) {
41
+ channelId = result.channel.id;
42
+ await web.conversations.setTopic({
43
+ channel: channelId,
44
+ topic: `Polaris project: ${projectName}`,
45
+ });
46
+ }
47
+ } catch {
48
+ // name_taken — find and join
49
+ }
50
+
51
+ if (!channelId) {
52
+ let cursor: string | undefined;
53
+ do {
54
+ const list = await web.conversations.list({
55
+ types: "public_channel",
56
+ limit: 200,
57
+ exclude_archived: true,
58
+ cursor,
59
+ });
60
+ const found = list.channels?.find((c) => c.name === channelName);
61
+ if (found?.id) {
62
+ channelId = found.id;
63
+ await web.conversations.join({ channel: channelId });
64
+ break;
65
+ }
66
+ cursor = list.response_metadata?.next_cursor || undefined;
67
+ } while (cursor);
68
+ }
69
+
70
+ if (!channelId) {
71
+ throw new Error(`Could not create or find channel for project: ${projectName}`);
72
+ }
73
+
74
+ // 4. Persist the channel ID in DB (resilient to renames)
75
+ channelCache.set(projectName, channelId);
76
+ // Resolve and store channel name (for status line display)
77
+ let resolvedName: string | undefined;
78
+ try {
79
+ const info = await web.conversations.info({ channel: channelId });
80
+ resolvedName = info.channel?.name ?? undefined;
81
+ } catch {}
82
+ await setProjectSlackChannel(sql, orgId, projectName, channelId, resolvedName);
83
+ return channelId;
84
+ }
85
+
86
+ // --- Event → Slack posting ---
87
+
88
+ async function postEventToSlack(web: WebClient, sql: Sql, orgId: string, event: PolarisEvent): Promise<void> {
89
+ // Skip _system events (handled separately)
90
+ if (event.project === "_system") return;
91
+
92
+ // Skip events that originated from Slack (avoid re-posting)
93
+ if (event.sender.startsWith("slack:")) return;
94
+
95
+ const msg = formatEventForSlack(event);
96
+ if (!msg) return;
97
+
98
+ try {
99
+ const channelId = await getOrCreateChannel(web, sql, orgId, event.project);
100
+ const isLong = msg.text.length > THREAD_THRESHOLD;
101
+ // POLARIS_LONG_MSG controls how messages longer than THREAD_THRESHOLD are posted:
102
+ // "snippet" (default) — 500-char preview + full content as expandable text snippet
103
+ // "thread" — 500-char preview in channel, full content in a thread reply
104
+ // "inline" — post the full message directly in the channel (may be very long)
105
+ const longMode = process.env.POLARIS_LONG_MSG ?? "snippet";
106
+
107
+ if (!isLong || longMode === "inline") {
108
+ await web.chat.postMessage({
109
+ channel: channelId,
110
+ text: msg.text,
111
+ ...(msg.blocks ? { blocks: msg.blocks } : {}),
112
+ ...(msg.username ? { username: msg.username } : {}),
113
+ ...(msg.icon_emoji ? { icon_emoji: msg.icon_emoji } : {}),
114
+ });
115
+ } else if (longMode === "thread") {
116
+ const preview = msg.text.slice(0, 500).trimEnd();
117
+ const summaryText = `${preview}...\n\n_Full response in thread_ :thread:`;
118
+ const summary = await web.chat.postMessage({
119
+ channel: channelId,
120
+ text: summaryText,
121
+ blocks: [{ type: "section", text: { type: "mrkdwn", text: summaryText } }],
122
+ ...(msg.username ? { username: msg.username } : {}),
123
+ ...(msg.icon_emoji ? { icon_emoji: msg.icon_emoji } : {}),
124
+ });
125
+ if (summary.ok && summary.ts) {
126
+ await web.chat.postMessage({
127
+ channel: channelId,
128
+ thread_ts: summary.ts,
129
+ text: msg.text,
130
+ ...(msg.username ? { username: msg.username } : {}),
131
+ ...(msg.icon_emoji ? { icon_emoji: msg.icon_emoji } : {}),
132
+ });
133
+ }
134
+ } else {
135
+ // snippet mode — preview + expandable file attachment
136
+ // Requires files:write scope on the Slack bot token
137
+ const preview = msg.text.slice(0, 500).trimEnd();
138
+ const summaryText = `${preview}...`;
139
+ await web.chat.postMessage({
140
+ channel: channelId,
141
+ text: summaryText,
142
+ blocks: [{ type: "section", text: { type: "mrkdwn", text: summaryText } }],
143
+ ...(msg.username ? { username: msg.username } : {}),
144
+ ...(msg.icon_emoji ? { icon_emoji: msg.icon_emoji } : {}),
145
+ });
146
+ const sender = msg.username ?? "agent";
147
+ await web.filesUploadV2({
148
+ channel_id: channelId,
149
+ content: msg.text,
150
+ filename: `${sender.toLowerCase().replace(/[^a-z0-9-]/g, "-")}-${event.session}-${Date.now()}.md`,
151
+ title: `${sender} — full response`,
152
+ initial_comment: `_Expand to read the full response from ${sender}_`,
153
+ });
154
+ }
155
+ } catch (e) {
156
+ console.error(`[bridge] Failed to post to Slack for project ${event.project}:`, e);
157
+ }
158
+ }
159
+
160
+ // --- Slack → Project injection ---
161
+
162
+ async function handleSlackMessage(
163
+ web: WebClient,
164
+ sql: Sql,
165
+ orgId: string,
166
+ botUserId: string,
167
+ event: {
168
+ text: string;
169
+ user: string;
170
+ channel: string;
171
+ ts: string;
172
+ }
173
+ ): Promise<void> {
174
+ // Ignore bot's own messages
175
+ if (event.user === botUserId) return;
176
+
177
+ // Find which project this channel belongs to
178
+ let projectName: string | undefined;
179
+
180
+ // Check in-memory cache
181
+ for (const [proj, chanId] of channelCache) {
182
+ if (chanId === event.channel) {
183
+ projectName = proj;
184
+ break;
185
+ }
186
+ }
187
+
188
+ // Check DB
189
+ if (!projectName) {
190
+ const projects = await listProjects(sql, orgId);
191
+ for (const proj of projects) {
192
+ if (proj.slack_channel_id === event.channel) {
193
+ projectName = proj.name;
194
+ channelCache.set(proj.name, event.channel);
195
+ break;
196
+ }
197
+ }
198
+ }
199
+
200
+ // Fall back to channel name lookup
201
+ if (!projectName) {
202
+ try {
203
+ const info = await web.conversations.info({ channel: event.channel });
204
+ const chanName = info.channel?.name;
205
+ if (chanName) {
206
+ projectName = chanName;
207
+ channelCache.set(chanName, event.channel);
208
+ }
209
+ } catch {
210
+ return;
211
+ }
212
+ }
213
+
214
+ if (!projectName) {
215
+ return;
216
+ }
217
+
218
+ // Parse target session from message: @session-name or first word
219
+ // Format: "@session message" or "session: message"
220
+ const match = event.text.match(/^@(\S+)\s+(.+)$/s) || event.text.match(/^(\S+):\s+(.+)$/s);
221
+ if (!match) {
222
+ // No target specified — post a hint
223
+ try {
224
+ await web.chat.postMessage({
225
+ channel: event.channel,
226
+ thread_ts: event.ts,
227
+ text: `To send a message to a session, use: \`@session-name your message\``,
228
+ });
229
+ } catch { /* ignore */ }
230
+ return;
231
+ }
232
+
233
+ const targetSession = match[1];
234
+ const content = match[2];
235
+
236
+ // Look up Slack user to get display name
237
+ let senderName = `slack:${event.user}`;
238
+ try {
239
+ const userInfo = await web.users.info({ user: event.user });
240
+ if (userInfo.user?.profile?.display_name || userInfo.user?.real_name) {
241
+ const name = (userInfo.user.profile?.display_name || userInfo.user.real_name || "")
242
+ .toLowerCase().replace(/\s+/g, ".");
243
+ senderName = `slack:${name}`;
244
+ }
245
+ } catch { /* use ID */ }
246
+
247
+ // Inject directly into DB (bridge runs server-side with DB access)
248
+ try {
249
+ // Ensure session exists
250
+ let session = await getSession(sql, orgId, projectName, targetSession);
251
+ if (!session) {
252
+ try { session = await createSession(sql, orgId, projectName, targetSession, null); } catch { /* exists */ }
253
+ session = await getSession(sql, orgId, projectName, targetSession);
254
+ }
255
+ if (!session) {
256
+ console.error(`[bridge] Session ${projectName}/${targetSession} not found`);
257
+ await web.chat.postMessage({
258
+ channel: event.channel,
259
+ thread_ts: event.ts,
260
+ text: `Session \`${targetSession}\` not found in project \`${projectName}\`.`,
261
+ });
262
+ return;
263
+ }
264
+ const injectEvent = {
265
+ id: crypto.randomUUID(),
266
+ project: projectName,
267
+ session: targetSession,
268
+ timestamp: new Date().toISOString(),
269
+ source: "inject" as const,
270
+ sender: senderName as `${"user" | "agent" | "slack"}:${string}`,
271
+ payload: {
272
+ type: "inject" as const,
273
+ content,
274
+ sender: senderName as `${"user" | "agent" | "slack"}:${string}`,
275
+ target: targetSession,
276
+ },
277
+ };
278
+ await pushEvent(sql, orgId, injectEvent);
279
+ console.error(`[bridge] Injected into ${projectName}/${targetSession}: ${content.slice(0, 50)}`);
280
+ } catch (e) {
281
+ console.error(`[bridge] Failed to inject:`, e);
282
+ }
283
+ }
284
+
285
+ // --- Bridge startup ---
286
+
287
+ export async function startBridge(opts: {
288
+ databaseUrl?: string;
289
+ orgId: string;
290
+ apiBaseUrl?: string;
291
+ }): Promise<{ stop: () => void }> {
292
+ const sql = await createDb(opts.databaseUrl);
293
+ const org = await getOrg(sql, opts.orgId);
294
+ if (!org) throw new Error(`Org not found: ${opts.orgId}`);
295
+ if (!org.slack_bot_token) throw new Error(`Org ${opts.orgId} has no Slack bot token`);
296
+
297
+ const appToken = process.env.SLACK_APP_TOKEN;
298
+ if (!appToken) throw new Error("SLACK_APP_TOKEN required for Socket Mode");
299
+
300
+ const apiBaseUrl = opts.apiBaseUrl ?? process.env.POLARIS_API_URL ?? "http://localhost:4321";
301
+
302
+ const web = new WebClient(org.slack_bot_token);
303
+ const socketMode = new SocketModeClient({ appToken });
304
+
305
+ // Get bot user ID to filter own messages
306
+ let botUserId = "";
307
+ try {
308
+ const auth = await web.auth.test();
309
+ botUserId = auth.user_id as string;
310
+ console.error(`[bridge] Bot user ID: ${botUserId}`);
311
+ } catch (e) {
312
+ console.error("[bridge] Failed to get bot user ID:", e);
313
+ }
314
+
315
+ // Listen for Slack messages
316
+ socketMode.on("message", async ({ event, ack }: { event: Record<string, unknown>; ack: () => Promise<void> }) => {
317
+ try {
318
+ await ack();
319
+ const msg = event as { text?: string; user?: string; channel?: string; ts?: string; subtype?: string; name?: string };
320
+ console.error(`[bridge] message: user=${msg.user} channel=${msg.channel} subtype=${msg.subtype} text=${msg.text?.slice(0, 80)}`);
321
+
322
+ // Handle channel rename system messages
323
+ if (msg.subtype === "channel_name" && msg.channel && msg.name) {
324
+ console.error(`[bridge] channel renamed: ${msg.channel} → ${msg.name}`);
325
+ const projects = await listProjects(sql, opts.orgId);
326
+ for (const proj of projects) {
327
+ if (proj.slack_channel_id === msg.channel) {
328
+ channelCache.set(proj.name, msg.channel);
329
+ await setProjectSlackChannel(sql, opts.orgId, proj.name, msg.channel, msg.name);
330
+ // Notify local daemon so status line updates immediately
331
+ try {
332
+ await fetch(`http://127.0.0.1:${process.env.POLARIS_DAEMON_PORT ?? 4322}/channel-update`, {
333
+ method: "POST",
334
+ headers: { "Content-Type": "application/json" },
335
+ body: JSON.stringify({ project: proj.name, slackChannel: msg.name }),
336
+ });
337
+ } catch { /* daemon may not be running */ }
338
+ console.error(`[bridge] Updated channel name for project ${proj.name}: ${msg.name}`);
339
+ }
340
+ }
341
+ return;
342
+ }
343
+
344
+ if (msg.subtype || !msg.channel || !msg.text || !msg.user) return;
345
+ if (msg.user === botUserId) return;
346
+ await handleSlackMessage(web, sql, opts.orgId, botUserId, msg as { text: string; user: string; channel: string; ts: string });
347
+ } catch (e) {
348
+ console.error(`[bridge] message handler error:`, e);
349
+ }
350
+ });
351
+
352
+ // Poll for new events directly from DB (bridge runs server-side)
353
+ const postedEventIds = new Set<string>();
354
+ let lastPollTime = new Date().toISOString();
355
+
356
+ async function pollEvents() {
357
+ try {
358
+ const since = lastPollTime;
359
+ const events = await getOrgEventsSince(sql, opts.orgId, since);
360
+ const now = new Date().toISOString();
361
+
362
+ for (const event of events) {
363
+ if (event.project === "_system") continue;
364
+ if (postedEventIds.has(event.id)) continue;
365
+ postedEventIds.add(event.id);
366
+ await postEventToSlack(web, sql, opts.orgId, event);
367
+ }
368
+
369
+ lastPollTime = now;
370
+ } catch (e) {
371
+ console.error("[bridge] Poll error:", e);
372
+ }
373
+ }
374
+
375
+ const pollInterval = setInterval(pollEvents, 5000);
376
+
377
+ // Start Socket Mode
378
+ await socketMode.start();
379
+ console.error(`[bridge] Slack bridge started for org: ${org.name}`);
380
+ console.error(`[bridge] Watching for messages in project channels`);
381
+
382
+ return {
383
+ stop: () => {
384
+ clearInterval(pollInterval);
385
+ socketMode.disconnect();
386
+ sql.end();
387
+ },
388
+ };
389
+ }
390
+
391
+ // --- Run if executed directly ---
392
+ if (import.meta.main) {
393
+ const orgId = process.argv[2];
394
+ if (!orgId) {
395
+ console.error("Usage: bun run src/slack/bridge.ts <org-id>");
396
+ process.exit(1);
397
+ }
398
+ await startBridge({ orgId });
399
+ }
@@ -0,0 +1,115 @@
1
+ // --- Event → Slack message formatting ---
2
+
3
+ import type { PolarisEvent } from "../types";
4
+
5
+ // Messages longer than this get a summary in the channel + full content in a thread
6
+ export const THREAD_THRESHOLD = 700;
7
+
8
+ export interface SlackMessage {
9
+ text: string;
10
+ blocks?: Array<Record<string, unknown>>;
11
+ username?: string;
12
+ icon_emoji?: string;
13
+ }
14
+
15
+ // Derive a display name from a participant ID
16
+ function displayName(participantId: string): string {
17
+ const [type, name] = participantId.split(":", 2);
18
+ if (!name) return participantId;
19
+ const pretty = name.replace(/[._-]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
20
+ if (type === "agent") return `Agent: ${pretty}`;
21
+ return pretty;
22
+ }
23
+
24
+ function personaIcon(participantId: string): string {
25
+ if (participantId.startsWith("agent:")) return ":robot_face:";
26
+ if (participantId.startsWith("slack:")) return ":speech_balloon:";
27
+ return ":bust_in_silhouette:";
28
+ }
29
+
30
+ // Format a PolarisEvent into a Slack message.
31
+ // Returns null if the event should be skipped (e.g., tool calls).
32
+ export function formatEventForSlack(event: PolarisEvent): SlackMessage | null {
33
+ const payload = event.payload;
34
+
35
+ if ("hook_event_name" in payload) {
36
+ switch (payload.hook_event_name) {
37
+ case "UserPromptSubmit":
38
+ return formatUserPrompt(event.sender, event.session, payload.prompt);
39
+ case "Stop": {
40
+ const response = payload.stop_response || payload.last_assistant_message;
41
+ if (!response) return null;
42
+ return formatAgentResponse(event.session, response);
43
+ }
44
+ case "PreToolUse":
45
+ case "PostToolUse":
46
+ return null;
47
+ }
48
+ }
49
+
50
+ if ("type" in payload && payload.type === "inject") {
51
+ return formatAdvisorMessage(
52
+ event.sender,
53
+ (payload as { target: string }).target,
54
+ (payload as { content: string }).content,
55
+ );
56
+ }
57
+
58
+ if ("type" in payload && payload.type === "reply") {
59
+ const body = (payload as { content: string }).content;
60
+ if (!body) return null;
61
+ return {
62
+ text: toMrkdwn(body),
63
+ blocks: [{ type: "section", text: { type: "mrkdwn", text: toMrkdwn(body) } }],
64
+ username: displayName(event.sender),
65
+ icon_emoji: personaIcon(event.sender),
66
+ };
67
+ }
68
+
69
+ return null;
70
+ }
71
+
72
+ // --- User prompt ---
73
+
74
+ function formatUserPrompt(sender: string, session: string, prompt: string): SlackMessage | null {
75
+ if (!prompt) return null;
76
+ return {
77
+ text: toMrkdwn(prompt),
78
+ blocks: [{ type: "section", text: { type: "mrkdwn", text: toMrkdwn(prompt) } }],
79
+ username: `${displayName(sender)} (${session})`,
80
+ icon_emoji: personaIcon(sender),
81
+ };
82
+ }
83
+
84
+ // --- Agent response ---
85
+
86
+ function formatAgentResponse(session: string, response: string): SlackMessage | null {
87
+ if (!response) return null;
88
+ return {
89
+ text: toMrkdwn(response),
90
+ blocks: [{ type: "section", text: { type: "mrkdwn", text: toMrkdwn(response) } }],
91
+ username: `Agent (${session})`,
92
+ icon_emoji: ":robot_face:",
93
+ };
94
+ }
95
+
96
+ // --- Advisor message ---
97
+
98
+ function formatAdvisorMessage(sender: string, target: string, content: string): SlackMessage | null {
99
+ if (!content) return null;
100
+ return {
101
+ text: toMrkdwn(content),
102
+ blocks: [{ type: "section", text: { type: "mrkdwn", text: toMrkdwn(`→ _${target}_: ${content}`) } }],
103
+ username: displayName(sender),
104
+ icon_emoji: personaIcon(sender),
105
+ };
106
+ }
107
+
108
+ // --- Markdown → Slack mrkdwn ---
109
+
110
+ export function toMrkdwn(text: string): string {
111
+ return text
112
+ .replace(/```(\w*)\n([\s\S]*?)```/g, "```$2```")
113
+ .replace(/\*\*(.*?)\*\*/g, "*$1*")
114
+ .replace(/__(.*?)__/g, "*$1*");
115
+ }
package/src/types.ts CHANGED
@@ -27,7 +27,10 @@ export const UserPromptSubmitPayload = HookCommon.extend({
27
27
 
28
28
  export const StopPayload = HookCommon.extend({
29
29
  hook_event_name: z.literal("Stop"),
30
- stop_response: z.string(),
30
+ stop_response: z.string().optional(),
31
+ last_assistant_message: z.string().optional(),
32
+ stop_hook_active: z.boolean().optional(),
33
+ raw_turn: z.array(z.unknown()).optional(),
31
34
  });
32
35
 
33
36
  export const PreToolUsePayload = HookCommon.extend({
@@ -92,7 +95,10 @@ export type PolarisEvent = z.infer<typeof PolarisEvent>;
92
95
  // --- Project & Session Models ---
93
96
 
94
97
  export const Project = z.object({
98
+ id: z.string().uuid(),
95
99
  name: z.string().min(1),
100
+ slack_channel_id: z.string().nullable().optional(),
101
+ slack_channel_name: z.string().nullable().optional(),
96
102
  created_at: z.string().datetime(),
97
103
  });
98
104
 
package/src/web/app.ts CHANGED
@@ -10,6 +10,10 @@ import {
10
10
  upsertUser,
11
11
  setOrgSlack,
12
12
  getSessionEvents,
13
+ listProjects,
14
+ listSessions,
15
+ getSessionPromptCounts,
16
+ getProjectEvents,
13
17
  type Sql,
14
18
  } from "../service/db";
15
19
  import { layout, nav } from "./layout";
@@ -175,6 +179,34 @@ export function createApp(sql: Sql) {
175
179
  }
176
180
  } catch { /* _system project may not exist yet */ }
177
181
 
182
+ // Query real projects, sessions, and prompt counts
183
+ const projects = (await listProjects(sql, payload.org_id)).filter((p) => p.name !== "_system");
184
+ const allSessions = (await listSessions(sql, payload.org_id)).filter((s) => s.project !== "_system");
185
+ const promptCounts = await getSessionPromptCounts(sql, payload.org_id);
186
+
187
+ function buildSessionFixture(s: typeof allSessions[0]): import("./fixtures").SessionFixture {
188
+ return {
189
+ name: s.name,
190
+ project: s.project,
191
+ driver: s.driver ?? "",
192
+ role: (s.driver === payload.participant_id ? "driver" : "advisor") as "driver" | "advisor",
193
+ description: "",
194
+ participants: s.driver ? [{ id: s.driver, role: "driver" as const }] : [],
195
+ eventCount: promptCounts.get(`${s.project}/${s.name}`) ?? 0,
196
+ connectedSince: s.created_at,
197
+ };
198
+ }
199
+
200
+ const sessionFixtures = allSessions.map(buildSessionFixture);
201
+
202
+ const projectFixtures: import("./fixtures").ProjectFixture[] = projects.map((p) => ({
203
+ name: p.name,
204
+ slackChannel: p.slack_channel_name ? `#${p.slack_channel_name}` : "",
205
+ sessions: allSessions.filter((s) => s.project === p.name).map(buildSessionFixture),
206
+ }));
207
+
208
+ const hasConnectedSession = allSessions.length > 0;
209
+
178
210
  const ctx = {
179
211
  token,
180
212
  userName: payload.name,
@@ -183,14 +215,12 @@ export function createApp(sql: Sql) {
183
215
  email: payload.email,
184
216
  slackConnected: !!org.slack_team_id,
185
217
  cliInstalled,
186
- hasConnectedSession: false,
218
+ hasConnectedSession,
219
+ totalPrompts: Array.from(promptCounts.values()).reduce((a, b) => a + b, 0),
187
220
  };
188
221
 
189
- // TODO: query cloud service for active sessions for this user
190
- const activeSessions: unknown[] = [];
191
-
192
- if (activeSessions.length > 0) {
193
- return layout(renderActiveView(ctx, [], [], devices), "Polaris");
222
+ if (hasConnectedSession) {
223
+ return layout(renderActiveView(ctx, sessionFixtures, projectFixtures, devices), "Polaris");
194
224
  }
195
225
  return layout(renderSetupView(ctx, devices), "Polaris");
196
226
  });
@@ -227,10 +257,10 @@ export function createApp(sql: Sql) {
227
257
  const mockToken = "preview-token";
228
258
  const base = { token: mockToken, userName: mockUser.name, orgName: mockOrg.name, orgSlug: "lightup-data" as string | null, email: mockUser.email };
229
259
 
230
- const fresh = { ...base, orgSlug: null, slackConnected: false, cliInstalled: false, hasConnectedSession: false };
231
- const slackDone = { ...base, slackConnected: true, cliInstalled: false, hasConnectedSession: false };
232
- const cliDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: false };
233
- const allDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: true };
260
+ const fresh = { ...base, orgSlug: null, slackConnected: false, cliInstalled: false, hasConnectedSession: false, totalPrompts: 0 };
261
+ const slackDone = { ...base, slackConnected: true, cliInstalled: false, hasConnectedSession: false, totalPrompts: 0 };
262
+ const cliDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: false, totalPrompts: 0 };
263
+ const allDone = { ...base, slackConnected: true, cliInstalled: true, hasConnectedSession: true, totalPrompts: 127 };
234
264
 
235
265
  return layout(`
236
266
  <div class="max-w-5xl mx-auto px-6 py-12">
package/src/web/layout.ts CHANGED
@@ -27,8 +27,22 @@ tailwind.config = {
27
27
  }
28
28
  </script>
29
29
  </head>
30
- <body class="bg-gray-50 text-gray-900 antialiased">${body}</body></html>`,
31
- { headers: { "Content-Type": "text/html" } }
30
+ <body class="bg-gray-50 text-gray-900 antialiased">${body}
31
+ <script>
32
+ document.addEventListener('click', function(e) {
33
+ const btn = e.target.closest('.polaris-copy');
34
+ if (!btn) return;
35
+ const id = btn.dataset.copy;
36
+ const el = document.getElementById(id);
37
+ if (!el) return;
38
+ navigator.clipboard.writeText(el.textContent);
39
+ const orig = btn.innerHTML;
40
+ btn.innerHTML = '<svg class="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>';
41
+ setTimeout(function() { btn.innerHTML = orig; }, 1500);
42
+ });
43
+ </script>
44
+ </body></html>`,
45
+ { headers: { "Content-Type": "text/html", "Cache-Control": "no-store" } }
32
46
  );
33
47
  }
34
48