Skip to content

Commit 805d4b3

Browse files
committed
fix(slack): defer parked-tab keeper to pinned workspace instead of forcing reopen (t098)
1 parent bb5197b commit 805d4b3

8 files changed

Lines changed: 208 additions & 12 deletions

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

CONTEXT.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ The server-side, authoritative Slack capture modality (ADR-0011). The web server
6161
_Avoid_: scraper, poller, backfill.
6262

6363
**Workspace Registry**:
64-
The server-side persistence (`slack-workspaces.json`) mapping each Slack `teamId``{ url, name, enterpriseId, lastSeen }`, populated the first time a workspace tab is seen live (ADR-0011). Persisting `enterpriseId` lets a cold start resolve each workspace's **Grid Group** without live creds. It drives two things: re-extraction targets for stale creds, and the **Parked Tab** keep-alive loop, which ensures exactly one tab per registered workspace exists on the remote browser (recreated via `/json/new` if closed or after a browser restart) so creds self-refresh and the hijack stays armed. Distinct from ADR-0010 **Workspaces** (multi-CDP-host UI), though both stamp entries with a workspace key.
64+
The server-side persistence (`slack-workspaces.json`) mapping each Slack `teamId``{ url, name, enterpriseId, lastSeen }`, populated the first time a workspace tab is seen live (ADR-0011). Persisting `enterpriseId` lets a cold start resolve each workspace's **Grid Group** without live creds. It drives two things: re-extraction targets for stale creds, and the **Parked Tab** keep-alive loop. The keeper ensures at least one Slack tab is live so creds self-refresh and the hijack stays armed — but it **defers to a Pin** (t098): a registered workspace whose URL is pinned is owned by its Pin and the keeper never spawns an anonymous duplicate for it (closing its tab no longer resurrects a stray). When no Slack tab is live at all, a single cred lifeline opens one tab, preferring a pinned URL, so shared creds remain available to the sweep. Per-workspace anonymous tabs are kept only for unpinned workspaces. Distinct from ADR-0010 **Workspaces** (multi-CDP-host UI), though both stamp entries with a workspace key.
6565
_Avoid_: account list, team store, tenant table.
6666

6767
**Grid Group**:
@@ -108,7 +108,7 @@ _Avoid_: native tab, page view, webview tab.
108108
- **Viewport Transform** maps canvas coordinates to **Remote Page** coordinates for both drawing **Screencast Frames** and hit-testing **Input Forwarding**.
109109
- **Adaptive Viewport** (when enabled) resizes the **Remote Page** to the canvas so **Screencast Frames** fill it without letterbox bars.
110110
- A **Notification Side-Channel** attaches to a background **Tab**'s target and uses a **Notification Adapter** to run **Notification Capture** — independent of the **Active Tab**'s screencast socket. Clicking the result activates the owning Tab and, if the entry carries an `activate` intent, the activation registry maps it to a **Remote Page** deep-open intention.
111-
- For Slack, the **Slack Content Sweep** is the authoritative **Notification Capture** writer; the in-page hijack provides only an instant foreground toast. The sweep reads creds from a live **Tab**, persists workspaces in the **Workspace Registry**, keeps a **Parked Tab** alive per registered workspace, and respects the **Channel Exclude** list.
111+
- For Slack, the **Slack Content Sweep** is the authoritative **Notification Capture** writer; the in-page hijack provides only an instant foreground toast. The sweep reads creds from a live **Tab**, persists workspaces in the **Workspace Registry**, uses the **Parked Tab** keeper (pin-deferred since t098 — a pinned workspace is owned by its Pin), and respects the **Channel Exclude** list.
112112
- A **Local Tab** renders a real local web page (in-DOM `<webview>`) alongside CDP Tabs — it does not use **Screencast Frames** or **Input Forwarding**; it gets direct device access instead.
113113

114114
## Example dialogue

core/slack-workspaces.js

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,37 @@ function liveTeamIds(targets) {
4444
}
4545

4646
// Which registered workspaces need a parked tab created: those with no live tab and not
47-
// created within the cooldown. `createdAt` maps teamId → last create timestamp. Pure.
48-
function planParkedTabs(registry, live, createdAt, now) {
47+
// created within the cooldown. `createdAt` maps teamId → last create timestamp.
48+
//
49+
// `pinUrlByTeam` (t098) maps a pinned workspace's teamId → its pin URL. A pinned workspace
50+
// is considered OWNED BY ITS PIN: the keeper never spawns an anonymous duplicate for it
51+
// (closing its tab no longer resurrects a stray). Capture is unaffected because one live
52+
// Slack tab refreshes creds for ALL workspaces and the sweep polls each over the web API
53+
// regardless of which tab is live. So per-workspace tabs aren't needed — only one live tab
54+
// is. When NO Slack tab is live and nothing else would open one, a single cred lifeline
55+
// plan keeps one alive, preferring a pinned URL (so it adopts into the pin on next reload).
56+
// Omitting `pinUrlByTeam` preserves the prior per-workspace behavior. Pure.
57+
function planParkedTabs(registry, live, createdAt, now, pinUrlByTeam = {}) {
58+
const offCooldown = (teamId) => {
59+
const last = createdAt[teamId]
60+
return !(last && now - last < CREATE_COOLDOWN_MS)
61+
}
4962
const plans = []
5063
for (const teamId of Object.keys(registry)) {
5164
if (live.has(teamId)) continue
52-
const last = createdAt[teamId]
53-
if (last && now - last < CREATE_COOLDOWN_MS) continue
65+
if (pinUrlByTeam[teamId]) continue // pin owns it — don't reopen
66+
if (!offCooldown(teamId)) continue
5467
plans.push({ teamId, url: registry[teamId].url })
5568
}
69+
// Cred lifeline: nothing live and nothing else planned → keep exactly one Slack tab alive
70+
// via a pinned workspace, so shared creds keep refreshing. Cooldown-gated; one is enough.
71+
if (live.size === 0 && plans.length === 0) {
72+
for (const teamId of Object.keys(pinUrlByTeam)) {
73+
if (!offCooldown(teamId)) continue
74+
plans.push({ teamId, url: pinUrlByTeam[teamId] })
75+
break
76+
}
77+
}
5678
return plans
5779
}
5880

core/slack-workspaces.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,61 @@ describe("planParkedTabs — recreate registered workspaces with no live tab", (
8383
const plans = planParkedTabs(reg, new Set(["T1"]), { T2: 4000 }, 40000)
8484
expect(plans).toEqual([{ teamId: "T2", url: CLIENT("T2") }])
8585
})
86+
87+
it("omitting pinUrlByTeam is byte-identical to the prior behavior", () => {
88+
expect(planParkedTabs(reg, new Set(["T1"]), {}, 5000)).toEqual([
89+
{ teamId: "T2", url: CLIENT("T2") },
90+
])
91+
})
92+
})
93+
94+
describe("planParkedTabs — defers to a pinned workspace (t098)", () => {
95+
const reg = {
96+
T1: { teamId: "T1", url: CLIENT("T1"), name: "A", lastSeen: 1 },
97+
T2: { teamId: "T2", url: CLIENT("T2"), name: "B", lastSeen: 1 },
98+
}
99+
const PIN = (team: string) => `https://app.slack.com/client/${team}/CPIN`
100+
101+
it("skips a registered workspace that has a pin — the pin owns it", () => {
102+
// T1 live, T2 not live but pinned → no reopen for T2.
103+
const plans = planParkedTabs(reg, new Set(["T1"]), {}, 5000, { T2: PIN("T2") })
104+
expect(plans).toEqual([])
105+
})
106+
107+
it("still plans an unpinned workspace alongside a pinned one", () => {
108+
// T1 pinned (skip), T2 unpinned + not live → only T2 reopens.
109+
const plans = planParkedTabs(reg, new Set(), {}, 5000, { T1: PIN("T1") })
110+
expect(plans).toEqual([{ teamId: "T2", url: CLIENT("T2") }])
111+
})
112+
113+
it("cred lifeline: opens one pinned workspace (at the pin URL) when nothing is live and nothing else is planned", () => {
114+
// Both pinned, neither live → no normal plan, but the lifeline opens exactly one at its pin URL.
115+
const plans = planParkedTabs(reg, new Set(), {}, 5000, { T1: PIN("T1"), T2: PIN("T2") })
116+
expect(plans).toHaveLength(1)
117+
expect(plans[0]).toEqual({ teamId: "T1", url: PIN("T1") })
118+
})
119+
120+
it("no lifeline when a Slack tab is already live", () => {
121+
const plans = planParkedTabs(reg, new Set(["T1"]), {}, 5000, { T1: PIN("T1"), T2: PIN("T2") })
122+
expect(plans).toEqual([])
123+
})
124+
125+
it("no lifeline when an unpinned plan already keeps a tab alive", () => {
126+
// T1 pinned, T2 unpinned + not live → T2's normal plan covers cred-refresh; no extra lifeline.
127+
const plans = planParkedTabs(reg, new Set(), {}, 5000, { T1: PIN("T1") })
128+
expect(plans).toEqual([{ teamId: "T2", url: CLIENT("T2") }])
129+
})
130+
131+
it("cred lifeline respects the create cooldown", () => {
132+
// Only T1 registered + pinned, not live, but just created at t=4000 (cooldown) → no lifeline.
133+
const oneReg = { T1: reg.T1 }
134+
const plans = planParkedTabs(oneReg, new Set(), { T1: 4000 }, 5000, { T1: PIN("T1") })
135+
expect(plans).toEqual([])
136+
})
137+
138+
it("cred lifeline bootstraps from a pin even when the workspace is not yet registered", () => {
139+
// Fresh start: empty registry, no live tab, a pin exists → open it to seed creds.
140+
const plans = planParkedTabs({}, new Set(), {}, 5000, { T9: PIN("T9") })
141+
expect(plans).toEqual([{ teamId: "T9", url: PIN("T9") }])
142+
})
86143
})

docs/adr/0012-phone-triage-surface-inbox-rooted-shell-conversati.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0012: Phone triage surface: inbox-rooted shell + conversation reader
22

3-
- **Status:** Proposed
3+
- **Status:** Accepted (all phases shipped t076–t081)
44
- **Date:** 2026-06-11
55

66
## Context

docs/conventions/docs-discipline.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ Any non-trivial design decision gets an ADR. Examples of "non-trivial":
8787

8888
ADRs are **append-only**. When a decision changes, write a new ADR that supersedes the old one. Update the old ADR's *Status* line to reference the superseder, but never edit its body. The history is the documentation.
8989

90-
Current ADRs: `docs/adr/0001-single-remote-page.md`, `0002-adaptive-viewport.md`, `0003-notifications-side-channel.md`, `0004-pin-live-tab-model.md`, `0005-local-tabs-base-window.md`, `0006-web-proxy-sse-transport.md`, `0007-web-websocket-transport.md`, `0008-defer-monorepo-shared-cjs-core.md`, `0009-touch-first-co-primary-input-surface.md`, `0010-multiple-workspaces-deferred-design.md`, `0011-slack-content-sweep-guaranteed-delivery.md`, `0012-phone-triage-surface-inbox-rooted-shell-conversati.md`, `0013-per-device-notification-delivery.md`, `0014-endpoint-reconciled-per-device-push-identity.md`.
90+
Current ADRs: `docs/adr/0001-single-remote-page.md`, `0002-adaptive-viewport.md`, `0003-notifications-side-channel.md`, `0004-pin-live-tab-model.md`, `0005-local-tabs-base-window.md`, `0006-web-proxy-sse-transport.md`, `0007-web-websocket-transport.md`, `0008-defer-monorepo-shared-cjs-core.md`, `0009-touch-first-co-primary-input-surface.md`, `0010-multiple-workspaces-deferred-design.md`, `0011-slack-content-sweep-guaranteed-delivery.md`, `0012-phone-triage-surface-inbox-rooted-shell-conversati.md`, `0013-per-device-notification-delivery.md`, `0014-endpoint-reconciled-per-device-push-identity.md`, `0015-prefer-thin-handlers-over-reducer-indirection.md`.
9191

9292
---
9393

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# 098 — slack keeper defers to pinned workspace tab instead of forcing reopen
2+
3+
- **Status:** in-progress
4+
- **Mode:** HITL
5+
- **Estimate:** 0.5d
6+
- **Depends on:** none
7+
- **Blocks:** none
8+
9+
## Goal
10+
11+
The Slack parked-tab keeper (t070, ADR-0011) reopens an anonymous background tab
12+
for every registered workspace whose tab is not currently live — **even when the
13+
user has that workspace pinned**. Closing the workspace's tab immediately spawns a
14+
stray duplicate in the Tabs list, which is annoying. After this task, the keeper
15+
**defers to a pin**: a workspace that has a pin is considered covered by its pin and
16+
the keeper never spawns a duplicate for it. Capture is unaffected because **one live
17+
Slack tab refreshes creds for all workspaces** (shared `d` cookie + `localConfig_v2`
18+
holds every team's token) and the sweep polls every workspace over the web API
19+
regardless of which tab is live. A cred lifeline keeps exactly one Slack tab alive
20+
(preferring a pinned URL) only when no Slack tab is live and nothing else would open
21+
one.
22+
23+
## Why now
24+
25+
Daily-driver annoyance on the priority web/PWA surface: the user pins their Slack
26+
workspaces and the keeper keeps resurrecting closed tabs against their intent. The
27+
fix is small and server-only, and it tightens guaranteed delivery (ADR-0011) rather
28+
than weakening it — capture no longer depends on a per-workspace tab.
29+
30+
## Acceptance criteria
31+
32+
- [ ] A registered workspace that has a pin gets **no** anonymous parked tab from
33+
the keeper (it is not re-created on close).
34+
- [ ] A registered workspace with **no** pin keeps today's anonymous parked-tab
35+
behavior (unchanged).
36+
- [ ] Cred lifeline: when **no** Slack tab is live and no unpinned workspace would
37+
open one, the keeper opens exactly **one** tab, preferring a pinned URL, so
38+
creds keep refreshing.
39+
- [ ] The cred lifeline respects the existing create-cooldown (no spam).
40+
- [ ] Omitting the pin map preserves byte-identical prior behavior (back-compat).
41+
42+
## Test plan
43+
44+
### Layer 1 — Pure logic (TDD)
45+
46+
- [ ] `core/slack-workspaces.js` `planParkedTabs(registry, live, createdAt, now, pinUrlByTeam)`
47+
— skips a pinned registered workspace; still plans an unpinned one alongside it.
48+
- [ ] cred lifeline opens one pinned workspace when `live` is empty and no unpinned
49+
plan exists; uses the pin URL; respects cooldown; does nothing when a tab is
50+
live or an unpinned plan already keeps one alive.
51+
- [ ] back-compat: omitting `pinUrlByTeam` equals the prior result.
52+
53+
### Layer 2 — Manual smoke (CDP/IPC)
54+
55+
Web build (`pnpm web`) against a live Remote Browser with ≥1 Slack workspace pinned:
56+
57+
- [ ] Pin a Slack workspace, open it, then close its tab → keeper does **not** reopen
58+
a stray; the workspace still receives notifications (sweep continues).
59+
- [ ] Close **every** Slack tab → keeper opens exactly one (a pinned workspace's URL);
60+
creds recover and sweeps resume.
61+
- [ ] An unpinned workspace closed → still auto-reopens (unchanged).
62+
63+
### Layer 3 — Visual review
64+
65+
n/a — no renderer UI is touched (server-side keeper only).
66+
67+
## Design notes
68+
69+
- **Contracts changed:** `planParkedTabs` gains an optional 5th arg
70+
`pinUrlByTeam: { [teamId]: url }`. A workspace whose `teamId` is a key is skipped
71+
(pin owns it). When `live` is empty and the normal plan list is empty, the planner
72+
appends **one** lifeline plan from `pinUrlByTeam` (cooldown-gated) so a single
73+
Slack tab stays alive for shared-cred refresh. Absent/empty map → prior behavior.
74+
- The server (`web/server.mjs` `keepSlackTabsAlive`) derives `pinUrlByTeam` from
75+
`settings.getPins()` via `teamIdOf(pin.url)` and passes it in. No renderer change.
76+
- **New modules:** none.
77+
- **New ADR needed?** No — this is a tuning of the t070 keeper within ADR-0011.
78+
Document in CLAUDE.md (keeper bullet + `slack-workspaces.js` note) and the code.
79+
80+
## Out of scope
81+
82+
- Renderer pin-adoption of a keeper-opened tab mid-session (ADR-0004 keeps
83+
URL-adoption startup-only; the lifeline tab adopts on next reload). Not needed
84+
for the chosen "don't reopen — pin owns it" behavior.
85+
- Reducing the unpinned per-workspace keeper to one-tab-total (kept as-is per the
86+
decision).
87+
88+
## Definition of Done
89+
90+
- [ ] Layer 1 tests written and green
91+
- [ ] Layer 2 smoke checklist completed with a live Remote Browser (web build)
92+
- [ ] `pnpm check:changed` clean
93+
- [ ] `pnpm typecheck` clean
94+
- [ ] `pnpm test` green
95+
- [ ] CLAUDE.md updated (keeper bullet + `slack-workspaces.js` note)
96+
- [ ] No commented-out code, no `console.log` debris, no AI attribution
97+
- [ ] Task closed: status → done, file moved to `docs/tasks/done/`, t098 in commit
98+
99+
## Notes
100+
101+
Decided with the user via two grills: (1) "reuse the pin" over "hands-off"; then,
102+
after surfacing the code fact that one live Slack tab refreshes all workspaces'
103+
creds, (2) "don't reopen — pin owns it" with a single-tab cred lifeline. The keeper
104+
was over-aggressive: per-workspace tabs are not needed for the sweep — only one live
105+
Slack tab is.
106+
107+
---
108+
109+
_When task status flips to `done`, move this file to `done/`._

web/server.mjs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -634,9 +634,17 @@ async function keepSlackTabsAlive(targets) {
634634
)
635635
if (!before || before.enterpriseId !== enterpriseId) changed = true
636636
}
637-
// Recreate a tab for any registered workspace that has no live tab.
637+
// Recreate a tab for any registered workspace that has no live tab — UNLESS the user has
638+
// it pinned (t098): a pinned workspace is owned by its pin, so the keeper never spawns a
639+
// stray duplicate for it. One live Slack tab refreshes every workspace's creds, so capture
640+
// is unaffected; a cred lifeline (inside planParkedTabs) keeps one tab alive when none is.
638641
const live = slackLiveTeamIds(targets)
639-
const plans = slackPlanParkedTabs(slackRegistry, live, slackCreatedAt, Date.now())
642+
const pinUrlByTeam = {}
643+
for (const pin of settings.getPins()) {
644+
const tid = slackTeamIdOf(pin.url || "")
645+
if (tid && !pinUrlByTeam[tid]) pinUrlByTeam[tid] = pin.url
646+
}
647+
const plans = slackPlanParkedTabs(slackRegistry, live, slackCreatedAt, Date.now(), pinUrlByTeam)
640648
for (const plan of plans) {
641649
slackCreatedAt[plan.teamId] = Date.now()
642650
try {

0 commit comments

Comments
 (0)