Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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
149 changes: 149 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,149 @@
# Workspace status presets β€” design

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

## Problem

The `Ctrl+S` status menu (`internal/ui/presencemenu`) only sets presence
(Active/Away) and DND snooze. Slack's actual custom status (free text +
emoji + expiration, `users.profile.set`) is never exposed from `slk`,
even though the app already reads and caches *other* users' custom
status (`status_emoji`/`status_text` in `internal/cache/users.go`) to
show peer status in the sidebar and DM headers. There is no way to set
something like "Working from home" or "On vacation" without switching
to the Slack desktop/web client.

Custom status in Slack is scoped **per workspace** β€” your profile,
including status, belongs to each workspace membership independently.
Presence/DND are already handled this way in `slk`
(`presenceController.byTeam` in `internal/ui/presence.go`, keyed by
team ID); the new feature must follow the same scoping.

## Decisions (from issue discussion + first prototype, now reverted)

1. **Presets, not a free-text editor.** Two starter rows: "Working from
home" (🏠) and "On vacation" (🌴). Free-text/custom-emoji entry (like
Slack's own status editor) is explicitly out of scope for this pass.
2. **Expiration:** WFH auto-clears at end of day (local time, 23:59:59).
Vacation has no expiration β€” cleared manually. This was a judgment
call, not from the issue; flag it in review if a reviewer wants
different defaults.
3. **Presence and custom status are independent**, matching real Slack.
This was *not* the first instinct β€” the first prototype cleared the
custom status whenever Active/Away was picked, which is wrong and
doesn't match Slack's actual behavior. Picking Active/Away must only
touch `Presence`, never `StatusText`/`StatusEmoji`.
4. **An explicit "Clear status" row**, shown only once a status is
actually set (same conditional-row pattern already used for "End
snooze / DND" when DND is active).
5. **Visible feedback is required** β€” today nothing in the UI shows
what status is currently set. Two surfaces:
- A statusbar segment (e.g. `🏠 Working from home`), hidden when no
status is set.
- A `βœ“` checkmark on the matching preset row when the menu is
reopened (same `current` mechanism `buildItems` already uses for
Active/Away).

## Where the pieces live (prior pass's touch points β€” useful as a map,
re-derive line-by-line from current `main` rather than trusting exact
line numbers here)

- `internal/slack/client.go` β€” `SlackAPI` interface is a hand-picked
subset of the real `slack-go` client (see its own comment block for
why β€” deliberately excludes whole-workspace enumeration calls).
`SetUserCustomStatusContext(ctx, statusText, statusEmoji string,
statusExpiration int64) error` already exists on the underlying
`slack-go` client but isn't in the interface β€” add it, then add a
`Client.SetCustomStatus(ctx, text, emoji string, expiration int64)
error` wrapper mirroring the existing `SetUserPresence`/`SetSnooze`
wrappers just below `GetUserPresence`.
- `internal/slack/client_test.go` β€” the interface is satisfied by a
hand-written `mockSlackAPI` with one `xxxFn` field + method per
interface method (see `setUserPresenceContextFn` /
`SetUserPresenceContext` for the pattern). Adding to the interface
requires adding a matching field + method here or the package stops
compiling.
- `internal/core/types.go` β€” `PresenceAction` enum (`iota`-based, not
serialized anywhere β€” safe to append new values at the end). Needs
new actions for WFH, Vacation, and Clear-status.
- `internal/ui/presencemenu/model.go` β€” `buildItems(presence string,
dndActive bool) []item` composes the menu rows; it will need a third
parameter for "what custom status is currently active" so it can mark
the right row `current: true` and conditionally append "Clear
status". `OpenWith` is the single call site. Existing rows are
Active/Away/seven snooze options/custom-snooze; End-DND is appended
conditionally already β€” follow that exact pattern for Clear-status.
- `internal/ui/presence.go` β€” `presenceController.byTeam` holds a
`workspaceStatus` struct per team (`Presence`, `DNDEnabled`,
`DNDEndTS` today). Add `StatusText`/`StatusEmoji` fields here, and
extend the `Apply` switch. Needs a `CustomStatus(teamID) (text, emoji
string)` accessor alongside the existing `Status(teamID)`.
- `internal/ui/mode_normal.go` β€” the `Ctrl+S` key handler
(`a.keys.PresenceMenu` case) is the one production call site for
`presenceMenu.OpenWith`; it needs to also look up and pass through the
current custom status before opening.
- `internal/ui/presence_action.go` β€” `applyPresenceAction` is the single
chokepoint both the menu and the `/away`-family slash commands
(`internal/ui/slash_builtins.go`) go through; it applies to local
state optimistically, then hands off to `presenceSvc.SetStatus`. The
statusbar update call lives here.
- `internal/ui/statusbar/model.go` β€” `Model.SetStatus(presence,
dndEnabled, dndEndTS)` + `View()` is the rendering side; needs a
parallel `SetCustomStatus(text, emoji string)` plus a new segment in
`View()`, following the existing Presence/DND segment's style
pattern (`lipgloss.NewStyle().Foreground(...).Background(styles.
SurfaceDark)`).
- `cmd/slk/main.go` β€” the `setStatus` closure (installed via
`core.NewPresenceService`) is where `core.PresenceAction` actually
turns into a Slack API call, on `router.Active().Client`. This is
where the WFH/Vacation/Clear-status `Client.SetCustomStatus` calls go,
and where the end-of-day expiration gets computed.

## Known test-fragility traps hit during the first pass

These aren't test *requirements* β€” just things that broke last time and
will break again if not written defensively:

- `internal/ui/mode_presence_menu_test.go` hardcodes the unfiltered row
count in a constant (`presenceMenuAllRows`) and walks the cursor with
a fixed number of `j`/down presses in several cases β€” both need
updating for however many new rows land, and the exact count should be
computed from `len(buildItems(...))` or similar rather than
re-guessed.
- The same file's "a printable key filters the menu" case filters by the
literal rune `"a"` and asserts an exact row count β€” "On vacation"
substring-matches "a" via "vacation", so adding that row changes the
expected count there too (filter matches prefix first, then
substring β€” see `(m *Model) filter()`).
- `internal/ui/presencemenu/model_test.go` has a couple of tests that
walk the cursor down by a hardcoded number of `j` presses to reach a
specific row ("Snooze for 20 minutes" at a fixed offset, "Snooze
custom..." after a fixed number of steps) β€” same fragility.
- `internal/ui/golden_dialogs_test.go` / `testdata/golden/
dialog_presence_menu.ansi` is a golden snapshot of the rendered menu
box; it must be regenerated with `go test ./internal/ui/ -run
TestGolden_Dialogs -update` once the row count changes, and the diff
should be reviewed (only that one golden file, nothing else) before
committing.
- No Go toolchain was preinstalled in this environment; `go`,
`golangci-lint` were installed via `brew install go golangci-lint`
to actually build/test rather than guessing.

## Out of scope

- Free-text / custom-emoji status entry.
- Auto-clearing vacation status after N days.
- Syncing status across workspaces (each workspace's status is
independent, intentionally).

## Next step

Fresh branch off `main`, TDD per `superpowers:test-driven-development`:
write the failing tests for each piece above first (client wiring,
`presenceController.Apply`/`CustomStatus`, `buildItems` current-row +
conditional Clear-status row, statusbar segment, the Active/Away-must-
not-clear-status invariant specifically, since that was the actual bug
caught in review last time), then implement to green. Pre-PR checklist
per `AGENTS.md`: `go build ./...`, `go vet ./...`, `go test ./...
-race`, `gofmt -l .` empty, `golangci-lint run` clean.
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