Skip to content

Add per-workspace custom status presets (WFH, vacation) to the Ctrl+S menu - #360

Open
SieuJS wants to merge 5 commits into
gammons:mainfrom
SieuJS:feature/356-workspace-status-presets
Open

SieuJS wants to merge 5 commits into
gammons:mainfrom
SieuJS:feature/356-workspace-status-presets

Conversation

@SieuJS

@SieuJS SieuJS commented Oct 23, 2026 •

Copy link
Copy Markdown

Summary

Closes #356.

  • Adds "Working from home" (🏠) and "On vacation" (🌴) presets, plus a "Clear status" row, to the Ctrl+S presence menu — independent of presence/DND, scoped per-workspace like presence/DND already are.
  • Picking Active/Away never clears an existing custom status (verified against real Slack's actual behavior; an earlier draft of this got it backwards).
  • Clear status only appears once a status is actually set.
  • Status bar shows a new segment (e.g. 🏠 Working from home) when a status is active; the menu shows a ✓ checkmark on the matching preset when reopened.
  • WFH auto-expires server-side at end of day (status_expiration); vacation has no expiration and is cleared manually.

Bugs found and fixed during review (not present in the initial draft)

Two rounds of review (self + two independent read-only passes) surfaced three real staleness bugs in how the cached custom status survives events unrelated to the status itself, all fixed with regression tests:

  1. presenceController.Set() rebuilt workspaceStatus from a zero-value struct on every incoming StatusChangeMsg — which fires on every reconnect and on any presence/DND change — silently wiping the cached custom status even though Slack still had it set server-side.
  2. Switching workspaces refreshed the status bar's presence/DND segment but never its custom-status segment, leaving a stale status from the previous workspace on screen.
  3. The same bug as Enterprise workspaces don't work #2, but on the InitialActive workspace-activation path: workspaces connect concurrently, and a non-default workspace can finish connecting and be switched to (with a status set on it) before the slower, configured-default workspace's InitialActive message arrives and reclaims the active slot.

Explicitly out of scope

  • Free-text / custom-emoji status entry (like Slack's own status editor) — presets only for this pass.
  • Local expiry of your own status. The WFH preset's end-of-day expiration is set server-side via status_expiration, but slk never locally re-checks it — unlike peer statuses, which already have a peerStatusTick that expires them in the UI every minute. Your own status has no deadline field at all today. Concretely: leave slk running past midnight with WFH set, and Slack clears it server-side, but the menu checkmark and status-bar segment keep showing "Working from home" until something else happens to touch the cache. Fixing this would mean adding a deadline field to the self-status cache and a tick loop mirroring the peer one — a reasonable follow-up, not bundled into this PR.
  • Syncing status across workspaces — each workspace's status is independent, intentionally.

Test plan

  • go build ./...
  • go vet ./...
  • gofmt -l . (empty)
  • golangci-lint run (0 issues)
  • go test ./... -race (full suite green; one TestWriteMacOSClipboard flake confirmed environmental/pre-existing — passes standalone and on full-suite rerun, zero overlap with this diff)
  • TDD throughout: every new behavior (menu rows, checkmark/Clear-status visibility, presence-independence invariant, client wrapper, the three staleness-bug fixes) has a test written and watched to fail before the implementation
  • Not yet manually exercised against a live Slack workspace — only automated tests and a local build were run in this session. Worth doing before merge.

🤖 Generated with Claude Code

…Ctrl+S

Adds "Working from home" and "On vacation" presets plus a Clear status
row to the presence menu, independent of presence/DND (picking
Active/Away never clears an existing status, matching real Slack), with
a status-bar segment showing what's active. Fixes two related
staleness bugs found in review: the per-team cache was losing the
custom status on every incoming presence/DND event and on workspace
activation, and the status bar wasn't refreshed on workspace switch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge, with no new blocking issues found.

Summary

Adds per-workspace WFH, vacation, and clear-status choices to Ctrl+S, with the current status shown in the status bar.

  • The Ctrl+S menu offers two custom status presets and a way to clear them.
  • The status bar follows the current user's status in each workspace.
  • The status bar shows the active custom status beside its other indicators.

Reviews (3) · Last reviewed commit: "fix(status): correct truncation budget m..." · Reviewed by Greptile

Comment thread internal/ui/presence.go Outdated
Comment thread internal/ui/presence_action.go Outdated
Comment thread internal/ui/statusbar/model.go Outdated
Comment thread internal/ui/presencemenu/model.go
@gammons

gammons commented Oct 23, 2026

Copy link
Copy Markdown
Owner

🤖 Riley Reviewer (AI review for @gammons). Nothing here was run locally; checks come from CI, Greptile and reading the code.

Thanks @SieuJS, and welcome! This is your first PR here. The write-up is thorough: it lists what's out of scope and the three staleness bugs you caught yourself. The I/O stays in cmd/slk behind the existing presence service, and the Slack call goes through a new Client.SetCustomStatus wrapper, as AGENTS.md asks. Keeping Active/Away independent of the custom status is right. presenceController.Set keeping the cached fields on every StatusChangeMsg (internal/ui/presence.go:109-116) is a good catch, and it has a test.

Check Result Source
Fits AGENTS.md and the re-architecture plan Mostly. But it adds a second store for your own status next to the peer-status store that already has it (finding 1) reading
CI held: PR carries needs human review (run 39903088901) CI
Greptile 3/5. P1s: status lost after restart, failed request still shown as set. P2s: status bar overflow, menu taller than short terminals Greptile
Tests cover the new behaviour Yes for the menu rows, the statusbar segment, Set/Apply and the workspace-switch refresh. Nothing covers a status that slk didn't set itself reading

Findings

Fix before merge:

  1. slk only knows about a status it set itself. At 54ab905, StatusText/StatusEmoji are only written by Apply (internal/ui/presence.go:146-151). Nothing fills them at startup or when your profile changes. So:

    • Set "On vacation", quit slk, start it again: no status bar segment, no ✓, and no "Clear status" row (presencemenu/model.go:150-152 hides it when the text is empty). Slack still has the status, and slk gives you no direct way to clear it.
    • A status set or cleared in the Slack app never shows up in slk.
    • WFH's end-of-day expiry never clears the segment (you already list this as out of scope).

    I think the data is already there. cachedPeerStatuses (cmd/slk/main.go, from ListUserStatuses, internal/cache/users.go:163) loads every user in the workspace who has a status, and that should include you. user_change/user_status_changed has no self filter (internal/slack/events.go:654-660 on main). Both feed presenceController.peers (SetPeers, presence.go:313, and the UserStatusChangeMsg arm, :220). That store also has the expiry tick (peerStatusTickMsg). Suggestion: read your own status from p.peers[a.currentUserID] instead of adding StatusText/StatusEmoji to workspaceStatus. Restart, external changes and expiry then work with no new code. Please add a test that seeds a status through SetPeers or UserStatusChangeMsg and checks the ✓, the "Clear status" row and the segment. (Greptile's first P1 is the same point.)

  2. A failed request still shows as set. applyPresenceAction updates the cache and status bar first (internal/ui/presence_action.go:10-12). If users.profile.set then fails, cmd/slk/main.go:715 only shows a toast. The bar keeps "🏠 Working from home" even though Slack never set it. Or, after a failed clear, the "Clear status" row disappears while the status is still set. Presence gets corrected by Slack's next presence event. A failed status change never does, because no user_change arrives. If finding 1 makes user_change the source of truth, the simplest fix is to not update the display optimistically for these three actions (the event comes back within a second or so). Or put the old value back on failure.

Optional:

  1. Greptile P2: the status bar never shortens a segment. When left + right is wider than the screen, gap just clamps to 0 (internal/ui/statusbar/model.go, the gap < 0 check). An extra ~20-column segment makes overflow at narrow widths much more likely. Consider dropping or shortening the custom-status segment when it doesn't fit (emoji only, say), with a narrow-width test.
  2. Greptile P2: the menu can now be 21 rows tall (both presets, Clear status and End DND), and it doesn't scroll. That's an older limit of the menu, but this PR makes it easier to hit.
  3. The spec (docs/superpowers/specs/2026-10-22-workspace-status-presets-design.md) reads partly as a hand-off to whoever writes the code. "Known test-fragility traps", the brew install go note and "Next step: fresh branch off main" are about the session that wrote it, not the design. Consider cutting those sections and adding how your own status is read (finding 1).
  4. AGENTS.md's row for applyPresenceAction says "presence or DND". Since it now sets custom status too, the row could say so.

Verdict: changes requested. Finding 1 means the status display and "Clear status" only work for a status set in the current slk session. Fixing 1 and 2 unblocks it. Flagged for @gammons: a user-visible feature over 300 changed lines, and it adds a file under docs/superpowers/. The preset choices (two hard-coded presets, WFH expiring at 23:59 local time) are yours to confirm.

@gammons gammons added changes requested Blocking issues found in review needs human review Architecture or scope needs the maintainer labels Oct 23, 2026
Review (Greptile + a maintainer pass) found the first draft cached self
status in a second, client-only store written only by Apply(), which:
lost the status on restart, never reflected an edit from the real Slack
client, had no expiry, and displayed a failed API call as if it had
succeeded. Self-status now reads from presenceController.peers (the
same store already used for everyone else's custom status, seeded and
kept live with no self-filter), fixing all four at once with less code.
Apply() no longer writes custom status at all for the three preset
actions; the display only updates once the real UserStatusChangeMsg
echo arrives.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@SieuJS

SieuJS commented Oct 23, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough review, both — this surfaced a real architecture mistake, not just a bug.

Finding 1 (self-status architecture) — fixed. You're right that this should read from the existing peer-status store instead of a second cache. Reworked: presenceController.CustomStatus(userID) now reads p.peers[userID] directly — the same store already seeded by cachedPeerStatuses/SetPeers at connect/switch and kept live via UserStatusChangeMsg (confirmed OnUserStatusChange has no self-filter, so this already carried our own status through unused). Apply() no longer writes anything for WFH/Vacation/Clear. This fixes restart persistence, external edits from the real Slack client, and the WFH-expiry gap all at once — the expiry tick (peerStatusTickMsg) now covers self status for free, so that's no longer an open gap either.

Finding 2 (failed request shows as applied) — fixed as a side effect of the above. Since nothing is written optimistically anymore, the status bar/checkmark only ever reflect the real event. A failed users.profile.set call now simply... doesn't change the display, and the existing error toast still fires. No rollback logic needed.

Design doc — rewritten to describe the actual shipped architecture and why the first draft was wrong, with the session-specific handoff notes trimmed per your note.

AGENTS.md — updated the applyPresenceAction row to mention custom status.

Left as-is (flagging rather than silently skipping):

  • Status bar overflow and menu height/scrolling (Greptile P2s) — real but pre-existing/general-mechanism issues, not something this PR's two short fixed-length presets actually trigger. Happy to take a pass at either if you'd rather have them in this PR than as follow-ups.
  • Preset wording and WFH's 23:59-local expiry default are still yours to confirm per the earlier note.

All new/changed behavior is TDD'd (watched red before green), full suite green under -race, go vet/gofmt/golangci-lint clean.

Comment thread internal/ui/mode_normal.go Outdated
@gammons gammons removed the changes requested Blocking issues found in review label Oct 23, 2026
@gammons

gammons commented Oct 23, 2026

Copy link
Copy Markdown
Owner

🤖 Riley Reviewer (AI review for @gammons). Nothing here was run locally; checks come from CI, Greptile and reading the code.

Thanks @SieuJS, this is a clean rework. presenceController.CustomStatus(userID) now reads p.peers (internal/ui/presence.go:104-111), and Apply no longer writes anything for the three status actions. I checked the pieces it relies on. cachedPeerStatuses has no self filter (cmd/slk/peer_status.go:330). OnUserStatusChange writes the cache and sends UserStatusChangeMsg for any user, you included (cmd/slk/peer_status.go:395-403). Both workspace reducers call SetCurrentUserID and SetPeers before they read your status (internal/ui/reducer_workspace.go:231-244 and :393-417). So restart, edits made in Slack, expiry and failed requests now all behave. The new tests seed through UserStatusChangeMsg (TestSelfStatusChangeRefreshesStatusBar, TestPresenceMenuKeyOpensWithCurrentCustomStatus), as asked. The rewritten spec is much better too.

Check Result Source
Fits AGENTS.md and the re-architecture plan Yes. One store for everyone's status, I/O stays in cmd/slk, AGENTS.md row updated reading
CI held: PR carries needs human review (run 39951706021) CI
Greptile 5/5 at 3c1e633. New P2: emoji-only statuses stay hidden. The two older P2s (status bar overflow, menu height) are still open Greptile
Tests cover the new behaviour Yes for text statuses: seed, live change, expiry, workspace switch both ways, startup. Nothing covers an emoji-only status reading

Findings

Fix before merge (both small, and both new with this rework: slk now shows any status set in Slack, not just the two presets):

  1. An emoji-only status shows nothing, and you can't clear it from slk. Greptile's new P2 holds. CustomStatus does return the glyph for a status with no text. But handleNormalMode passes only the text to the menu (internal/ui/mode_normal.go:299-300), so the "Clear status" row stays hidden. The status bar only draws the segment when statusText != "" (internal/ui/statusbar/model.go:275). A plain 🤒 or 🍔 set in Slack is common. Suggestion: draw the segment when text or emoji is set, and give the menu a "has a status" flag rather than the text alone. Add a test that seeds UserStatusChangeMsg{Emoji: ":face_with_thermometer:"} and checks both.
  2. A long status from Slack can push the status bar off the screen. The spec says overflow can't happen with the two short presets, and that was true before. Now any status shows, and Slack allows 100 characters of text. View only clamps the gap to 0 (internal/ui/statusbar/model.go, the gap < 0 check), so a long status hides the connection indicator or wraps the bar. Suggestion: cut the segment to the room that's left (emoji only, or emoji plus truncated text), with a narrow-width test.

Optional:

  1. The menu height point (Greptile P2) is an older limit of the menu. Fine as a follow-up.
  2. The spec's "Review history" says the reviews were "an automated pass plus a maintainer review". Both were automated (Greptile and this bot). Grant hasn't reviewed it yet.

Verdict: needs minor changes. Fixing 1 and 2 unblocks it. Flagged for @gammons: a user-visible feature over 300 changed lines that also edits AGENTS.md and adds a file under docs/superpowers/. The preset choices (two hard-coded presets, WFH ending at 23:59 local time) are yours to confirm.

@gammons gammons added the needs minor changes Approved in principle; small fixes requested label Oct 23, 2026
SieuJS and others added 3 commits October 23, 2026 20:06
Review (Greptile) found that an emoji-only status (valid in Slack, no
status text) was dropped everywhere: mode_normal.go discarded the
emoji return value from CustomStatus entirely, so the status bar
segment and the menu's Clear-status row both gated visibility on
status text alone. SetCurrentCustomStatus/buildItems now take the
emoji too, and visibility checks text-or-emoji instead of text-only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A long status from the real Slack client (up to 100 chars) could push
the status bar over its exact-width budget or hide the connection
indicator, now that the self-status redesign shows any status, not
just this feature's own two short presets. The custom-status segment
now degrades to fit: full text, then emoji + truncated text + ellipsis,
then emoji alone, then dropped entirely, before every other segment is
measured. Also corrects the design doc's review-history section, which
mischaracterized two automated review passes as including a maintainer
review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Local codex-batch-review of the previous commit (not run before it was
written) found three real bugs in the status-bar truncation fix: the
separator budget double-counted when the segment landed between two
other segments instead of at an edge; the truncation loop hand-rolled
rune-by-rune width summation instead of reusing this codebase's
existing muesli/reflow/truncate.StringWithTail (used in
internal/ui/presencemenu and internal/ui/thread/breadcrumb.go, among
others) — the hand-rolled version can mismeasure combined grapheme
clusters and let a segment exceed its budget; and a budget that fit
the emoji but not one character of text produced a bare "emoji …"
instead of falling back to emoji alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@SieuJS

SieuJS commented Oct 23, 2026

Copy link
Copy Markdown
Author

Both findings from the latest review fixed, pushed at e261bf7:

Emoji-only status (Greptile P2 + the "needs minor changes" review). CustomStatus already returned the glyph correctly even with empty text, but mode_normal.go discarded it with _, and both the status-bar segment and the menu's Clear-status row gated visibility on text alone. SetCurrentCustomStatus/buildItems now take the emoji too, and visibility checks text-or-emoji everywhere.

Long status overflowing the status bar. Fair point — the redesign made this reachable (any status from the real Slack client, not just this feature's own two short presets, and Slack allows up to 100 chars). The custom-status segment now degrades to fit: full text → emoji + truncated text + ellipsis → emoji alone → dropped entirely, sized against whatever room is actually left after every other segment.

I also ran a local review pass on that fix before pushing and caught three more real issues in it before you'd see them: the truncation budget math double-counted a separator when the segment landed between two other segments instead of at an edge (always needs exactly one more separator than the other segments' joined width already has, not one per side); the truncation loop hand-rolled rune-by-rune width summation instead of reusing this codebase's own muesli/reflow/truncate.StringWithTail (already used in internal/ui/presencemenu and internal/ui/thread/breadcrumb.go) — the hand-rolled version can mismeasure combined grapheme clusters; and a budget that fit the emoji but not one character of text produced a bare "emoji …" instead of falling back to emoji alone. All fixed, TDD'd, in the same push.

Design doc's review-history section updated to record all of this. Full checklist green (go build/go vet/gofmt -l ./golangci-lint/go test ./... -race).

@gammons gammons removed the needs minor changes Approved in principle; small fixes requested label Oct 23, 2026
@gammons

gammons commented Oct 23, 2026

Copy link
Copy Markdown
Owner

🤖 Riley Reviewer (AI review for @gammons). Nothing here was run locally; checks come from CI, Greptile and reading the code.

Thanks @SieuJS. Both findings are fixed, with tests for each.

  • Emoji-only status: the menu gets the emoji as well as the text (internal/ui/mode_normal.go:299-300). "Clear status" and the status-bar segment now show when either one is set. TestModel_ClearStatusVisibleForEmojiOnlyStatus and TestStatusBar_CustomStatusSegment_EmojiOnly check this.
  • Long status: fitStatusSegment sizes the segment to the room left after the other segments. It first cuts the text, then shows the emoji alone, then drops the segment. The separator count is right wherever the segment is inserted, and the tests check the exact bar width at narrow sizes.

Catching the separator double-count yourself before pushing was a good catch.

Check Result Source
Fits AGENTS.md and the re-architecture plan yes: status is read from presenceController, and the bar stays at its exact width reading
CI held: the PR carries needs human review CI
Greptile 5/5 at e261bf7, no new findings Greptile
Tests cover the new behaviour yes: emoji-only (menu and bar), truncation, the middle step, dropping at narrow widths, and inserting between two segments reading

Findings (at e261bf7), all optional

  1. truncate.StringWithTail (internal/ui/statusbar/model.go:457) measures one rune at a time with go-runewidth, the same as a hand-rolled loop would. So the comment there and the spec overstate it: it doesn't handle grapheme clusters. In practice it errs short on ZWJ sequences. It can be 1 column too wide on a variation-selector emoji inside the status text, and View absorbs that by clamping the gap. Just reword the comment.
  2. The menu height point (Greptile's earlier P2) is still a fine follow-up.

Verdict: ready to merge. Flagged for @gammons: a user-visible feature over 300 changed lines that also edits AGENTS.md and adds a spec under docs/superpowers/. The two presets, and WFH ending at 23:59 local time, are yours to confirm.

@gammons gammons added the ready to merge Reviewed, approved, no blockers label Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs human review Architecture or scope needs the maintainer ready to merge Reviewed, approved, no blockers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add per-workspace custom status presets to the Ctrl+S status menu

3 participants