Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ scrollbars, date formatting, case folding, or ID formatting: it already exists.
| Selected message or reply in the focused pane (channel, TS, text, author, panel) | `App.selectedMessageContext()` (`internal/ui/app.go`) |
| Switch the active workspace from the UI | `App.switchWorkspace(teamID)` (`internal/ui/app.go`). Always use it: it saves a theme a cycle left pending, so a switch back to that workspace reads the new theme |
| Save a per-workspace theme | `settings.SaveTheme(teamID, name, scope)` with the workspace on screen (`a.activeTeamID`), never the backend's active one: a switch moves that first |
| Set the user's own presence or DND from the UI (optimistic cache + status bar, then the service) | `(*App).applyPresenceAction(action, snoozeMinutes)` (`internal/ui/presence_action.go`); the presence menu, custom snooze and the `/away` family use it |
| Set the user's own presence, DND, or custom status from the UI (presence/DND optimistic; custom status forwards to the service and waits for the real event β€” see `presenceController.CustomStatus`) | `(*App).applyPresenceAction(action, snoozeMinutes)` (`internal/ui/presence_action.go`); the presence menu, custom snooze and the `/away` family use it |
| Switch to a channel, joining it first if the user is not a member | `(*App).openChannel(channelfinder.ChannelResult)` (`internal/ui/open_channel.go`); the channel finder and `/open` use it |
| Resolve a command's channel argument (`#name` or a `<#C…\|name>` mention) to a channel, joined or not | `(*App).findChannelByID` (finder rows, by ID) and `matchChannelByName` (case- and accent-insensitive, channels and private channels only) in `internal/ui/slash_builtins.go`; a name the finder does not hold goes to the server through `ChannelService.SearchRemote` and comes back as `channelLookupMsg` (reduced in `reducer_slash_command.go`), because the finder's rows are only the user's own conversations. `/open` and `/join` use it |
| Open (or find) a DM / group DM and switch to it, optionally pre-filling its draft | `(*App).openConversation(userIDs, msgPrefill)` (`internal/ui/open_conversation.go`); the new-message picker and `/msg` use it. Request IDs come from a counter that never resets; a `/msg` pre-fill whose open fails, is cancelled or is superseded is given back by `abandonMsgPrefill`, and `/msg` refuses while `openingConversation()` |
Expand Down
14 changes: 14 additions & 0 deletions cmd/slk/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,14 @@ func run() error {
}
app.SetSettingsService(core.NewSettingsService(saveTheme, saveSidebarWidth))

// endOfDayUnix returns 23:59:59 local time on now's date, as a Unix
// timestamp, so a WFH status auto-clears at the end of the day
// instead of lingering indefinitely.
endOfDayUnix := func(now time.Time) int64 {
y, m, d := now.Date()
return time.Date(y, m, d, 23, 59, 59, 0, now.Location()).Unix()
}

// Wire presence/DND status setter. Resolves the active team ID
// through the router at invocation so the closure always targets
// the currently-active workspace context.
Expand All @@ -681,6 +689,12 @@ func run() error {
err = wctx.Client.SetUserPresence(ctx, "away")
case presencemenu.ActionSnooze:
_, err = wctx.Client.SetSnooze(ctx, snoozeMinutes)
case presencemenu.ActionSetWFH:
err = wctx.Client.SetCustomStatus(ctx, "Working from home", ":house:", endOfDayUnix(time.Now()))
case presencemenu.ActionSetVacation:
err = wctx.Client.SetCustomStatus(ctx, "On vacation", ":palm_tree:", 0)
case presencemenu.ActionClearStatus:
err = wctx.Client.SetCustomStatus(ctx, "", "", 0)
case presencemenu.ActionEndDND:
// End any active manual snooze AND any active scheduled
// DND session. Either may be a no-op depending on the
Expand Down
157 changes: 157 additions & 0 deletions docs/superpowers/specs/2026-10-22-workspace-status-presets-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Workspace status presets β€” design

Issue: https://github.com/gammons/slk/issues/356 (approved)
PR: https://github.com/gammons/slk/pull/360
Author: SieuJS

## Problem

The `Ctrl+S` status menu (`internal/ui/presencemenu`) only set presence
(Active/Away) and DND snooze. Slack's actual custom status (free text +
emoji + expiration, `users.profile.set`) was never exposed from `slk`.

Custom status in Slack is scoped **per workspace** β€” your profile,
including status, belongs to each workspace membership independently,
same as presence/DND already are in `slk` (see `presenceController` in
`internal/ui/presence.go`).

## What shipped

Two preset rows β€” **"Working from home"** (🏠) and **"On vacation"**
(🌴) β€” plus a **"Clear status"** row (shown only once a status is set)
in the `Ctrl+S` menu. Presence (Active/Away) never clears an existing
custom status; they're independent, matching real Slack. The status
bar shows a segment (e.g. `🏠 Working from home`) when a status is
active, and the menu shows a `βœ“` on the matching row when reopened.

## Architecture: self-status reads from the peer-status store

This is the one decision worth recording, because it wasn't the first
draft β€” PR review (two independent passes) caught that the initial
approach was wrong.

**First draft:** a second, client-only cache β€” `workspaceStatus.
StatusText`/`StatusEmoji` in `presenceController`, written only when
`Apply()` ran a WFH/Vacation/Clear action from the menu.

**Why that was wrong**, per review: `slk` already has a complete store
for exactly this data β€” `presenceController.peers` (`peerstatus.
Status`), used today to show *other people's* custom status in the
sidebar, DM headers, and channel finder. It's seeded at connect/switch
from `cachedPeerStatuses`/`ListUserStatuses` (which has no self-filter
on the `users` cache table), kept live via `UserStatusChangeMsg` (fed
by `user_change`/`user_status_changed`, which also carry no self-filter
β€” `cmd/slk/peer_status.go`'s `OnUserStatusChange`), and already expired
on a schedule via `peerStatusTickMsg`. The authenticated user's own
status flows through this exact pipeline; the first draft just never
read it back out for `a.currentUserID`.

Keeping a second, write-only-by-`Apply` cache meant:
- The status vanished on restart (nothing re-read it from the peer
store or re-fetched it from Slack).
- An edit made from the real Slack client was never reflected.
- A failed `users.profile.set` call still displayed as if it had
succeeded, because the cache was written optimistically and nothing
ever corrected it (presence self-corrects via the next
`StatusChangeMsg`; the custom-status cache had no equivalent).
- WFH's end-of-day expiration was never locally honored β€” no deadline
field existed on the self-only cache at all.

**Final architecture:** `presenceController.CustomStatus(userID)` reads
`p.peers[userID]` directly (`HasStatus`/`StatusGlyph` on `peerstatus.
Status` already handle expiry and shortcode→glyph). `Apply()` is now a
no-op for `ActionSetWFH`/`ActionSetVacation`/`ActionClearStatus` β€” they
only forward to `presenceSvc.SetStatus`, which calls the real API
(`Client.SetCustomStatus`, wrapping `slack-go`'s
`SetUserCustomStatusContext`). The status bar and the menu's checkmark
update only once the real `UserStatusChangeMsg` echo arrives (via
`applyPeer`) β€” not optimistically. This fixes all four problems above
at once: restart persistence, external edits, failed-request display,
and local expiry, all for free, with less code than the first draft
had.

One ordering constraint this implies: whichever code seeds the active
workspace's custom-status display (workspace switch, `InitialActive`
activation) must call `a.presence.SetPeers(...)` (which seeds
`p.peers` for the new workspace) *before* calling `a.statusbar.
SetCustomStatus(a.presence.CustomStatus(a.currentUserID))` β€” reading
`CustomStatus` first would see the previous workspace's (or no)
peers.

## Decisions

1. **Presets, not a free-text editor.** Free-text/custom-emoji entry
is out of scope for this pass.
2. **Expiration:** WFH auto-clears server-side at end of day (local
time, 23:59:59, `cmd/slk/main.go`'s `endOfDayUnix`). Vacation has no
expiration β€” cleared manually. Both now also clear/display correctly
client-side once Slack's event reflects the change, per the
architecture above.
3. **Presence and custom status are independent**, matching real
Slack β€” picking Active/Away never touches the other.
4. **"Clear status"** only appears once a status is actually set.

## Known test-fragility traps (useful if this menu grows further)

- `internal/ui/mode_presence_menu_test.go` hardcodes the unfiltered row
count in `presenceMenuAllRows` and walks the cursor with a fixed
number of `j`/down presses in several cases. Both need updating for
any future row count change.
- The same file's "a printable key filters the menu" case filters by
the literal rune `"a"` β€” "On vacation" substring-matches via
"vacation", so any future row whose label contains "a" changes the
expected count there too.
- `internal/ui/golden_dialogs_test.go` / `testdata/golden/
dialog_presence_menu.ansi` needs regenerating with `go test
./internal/ui/ -run TestGolden_Dialogs -update` whenever the row
count changes.

## Out of scope

- Free-text / custom-emoji status entry.
- Syncing status across workspaces β€” each workspace's status is
independent, intentionally.

## Review history

Three rounds of automated review (Greptile, plus a bot posting as
@gammons β€” no human maintainer review yet as of this writing) on
PR #360 found:

- The self-status architecture issue above (fixed by this redesign).
- The optimistic-display-on-failure issue (fixed as a side effect of
the same redesign).
- Emoji-only statuses (no text) were dropped everywhere β€” the status
bar and the menu's Clear-status row both gated visibility on status
text alone, discarding the emoji. Fixed: visibility now checks
text-or-emoji.
- Status-bar width overflow for long statuses. The redesign above made
this reachable: `slk` now shows *any* status set from the real Slack
client, not just this feature's own two short fixed presets, and
Slack allows up to 100 characters. Fixed: the custom-status segment
now degrades (full text β†’ emoji + truncated text + ellipsis β†’ emoji
alone β†’ dropped) to stay within the status bar's exact-width budget,
rather than assuming a fixed short length.
- That the `Ctrl+S` menu can now exceed a short terminal's height with
no scrolling β€” a pre-existing limitation of the menu, not new to this
change, just easier to hit. Left as a follow-up, not fixed here.

A follow-up local review (`codex-batch-review`, scoped to just the
emoji-only/truncation fix commit) caught three more real issues in that
fix itself, all fixed with regression tests:

- The truncation budget math double-counted a separator when the
status segment landed between two other segments (e.g. an unread
badge before it, the connection indicator after it) β€” it always
needs exactly one *more* separator than the other segments' own
joined width already accounts for, not one per side.
- The truncation loop hand-rolled rune-by-rune width summation instead
of reusing `muesli/reflow/truncate.StringWithTail` β€” already the
established width-aware truncator elsewhere in this codebase
(`internal/ui/presencemenu`, `internal/ui/thread/breadcrumb.go`,
and a dozen other files). Rune-by-rune summation can mismeasure
combined grapheme clusters (variation selectors, ZWJ sequences) in
status text and let a segment exceed its budget.
- When the budget allowed the emoji but not even one character of
text, the result was a bare "emoji …" with nothing actually
truncated β€” now falls back to emoji alone instead.
3 changes: 3 additions & 0 deletions internal/core/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ const (
PresenceSnooze // SnoozeMinutes is set
PresenceCustomSnooze // opens the custom-snooze input; never reaches the engine
PresenceEndDND
PresenceSetWFH // custom status preset: "Working from home"
PresenceSetVacation // custom status preset: "On vacation"
PresenceClearStatus // clears the profile status text/emoji; leaves presence/DND alone
)

// ThemeScope identifies whether a theme selection should be saved to
Expand Down
10 changes: 10 additions & 0 deletions internal/slack/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type SlackAPI interface {
JoinConversation(channelID string) (*slack.Channel, string, []string, error)
SetUserPresenceContext(ctx context.Context, presence string) error
GetUserPresenceContext(ctx context.Context, user string) (*slack.UserPresence, error)
SetUserCustomStatusContext(ctx context.Context, statusText, statusEmoji string, statusExpiration int64) error
SetSnoozeContext(ctx context.Context, minutes int) (*slack.DNDStatus, error)
EndSnoozeContext(ctx context.Context) (*slack.DNDStatus, error)
EndDNDContext(ctx context.Context) error
Expand Down Expand Up @@ -1313,6 +1314,15 @@ func (c *Client) GetUserPresence(ctx context.Context, userID string) (*slack.Use
return p, nil
}

// SetCustomStatus sets the authenticated user's profile status text,
// emoji, and expiration (Unix seconds; 0 means it never auto-clears).
func (c *Client) SetCustomStatus(ctx context.Context, statusText, statusEmoji string, statusExpiration int64) error {
if err := c.api.SetUserCustomStatusContext(ctx, statusText, statusEmoji, statusExpiration); err != nil {
return fmt.Errorf("setting custom status: %w", err)
}
return nil
}

// SetSnooze enables Do-Not-Disturb for `minutes` minutes.
func (c *Client) SetSnooze(ctx context.Context, minutes int) (*slack.DNDStatus, error) {
st, err := c.api.SetSnoozeContext(ctx, minutes)
Expand Down
52 changes: 52 additions & 0 deletions internal/slack/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ type mockSlackAPI struct {
getEmojiFn func() (map[string]string, error)
getPermalinkContextFn func(ctx context.Context, params *slack.PermalinkParameters) (string, error)
setUserPresenceContextFn func(ctx context.Context, presence string) error
setUserCustomStatusContextFn func(ctx context.Context, statusText, statusEmoji string, statusExpiration int64) error
getUserPresenceContextFn func(ctx context.Context, user string) (*slack.UserPresence, error)
setSnoozeContextFn func(ctx context.Context, minutes int) (*slack.DNDStatus, error)
endSnoozeContextFn func(ctx context.Context) (*slack.DNDStatus, error)
Expand Down Expand Up @@ -250,6 +251,13 @@ func (m *mockSlackAPI) SetUserPresenceContext(ctx context.Context, presence stri
return nil
}

func (m *mockSlackAPI) SetUserCustomStatusContext(ctx context.Context, statusText, statusEmoji string, statusExpiration int64) error {
if m.setUserCustomStatusContextFn != nil {
return m.setUserCustomStatusContextFn(ctx, statusText, statusEmoji, statusExpiration)
}
return nil
}

func (m *mockSlackAPI) GetUserPresenceContext(ctx context.Context, user string) (*slack.UserPresence, error) {
if m.getUserPresenceContextFn != nil {
return m.getUserPresenceContextFn(ctx, user)
Expand Down Expand Up @@ -407,6 +415,50 @@ func TestClient_SetUserPresence(t *testing.T) {
}
}

func TestClient_SetCustomStatus(t *testing.T) {
var calls int
var gotText, gotEmoji string
var gotExpiration int64
mock := &mockSlackAPI{
setUserCustomStatusContextFn: func(ctx context.Context, statusText, statusEmoji string, statusExpiration int64) error {
calls++
gotText, gotEmoji, gotExpiration = statusText, statusEmoji, statusExpiration
return nil
},
}
c := &Client{api: mock}
if err := c.SetCustomStatus(context.Background(), "Working from home", ":house:", 12345); err != nil {
t.Fatalf("SetCustomStatus: %v", err)
}
if calls != 1 {
t.Errorf("expected 1 SetCustomStatus call, got %d", calls)
}
if gotText != "Working from home" || gotEmoji != ":house:" || gotExpiration != 12345 {
t.Errorf("got (%q, %q, %d), want (%q, %q, %d)",
gotText, gotEmoji, gotExpiration, "Working from home", ":house:", int64(12345))
}
}

func TestClient_SetCustomStatus_Error(t *testing.T) {
wantErr := errors.New("api boom")
mock := &mockSlackAPI{
setUserCustomStatusContextFn: func(ctx context.Context, statusText, statusEmoji string, statusExpiration int64) error {
return wantErr
},
}
c := &Client{api: mock}
err := c.SetCustomStatus(context.Background(), "On vacation", ":palm_tree:", 0)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, wantErr) {
t.Errorf("expected wrapped wantErr, got %v", err)
}
if !strings.Contains(err.Error(), "setting custom status") {
t.Errorf("expected 'setting custom status' prefix, got %q", err.Error())
}
}

func TestClient_SetUserPresence_Error(t *testing.T) {
wantErr := errors.New("api boom")
mock := &mockSlackAPI{
Expand Down
Loading