# Message Pin 与 Top Notice 实现设计

> Status: implementation-design
> Canonical path: `specs/message/pin-design.md`
> Product spec: [Message Pin 与置顶消息规范](pin.md)
> Scope: 在现有 AgentTeam codebase 中落地 channel / reply thread / DM 的 shared Pins tab/list、独立 top notice banner、row marker、realtime/cache 同步与测试边界
> Non-goals: message edit/delete/recall 实现、task discussion pin、parent channel/DM 汇总 child thread top notice、personal saved/later、外部 IM 平台 pin 桥接、push/unread/agent wake

## 1. Implementation principles

1. **Pin 和 top notice 是不同 sidecar**：
   - Pins tab/list 写 `scope_message_pins`，对 target 成员共享；
   - top banner 写 `scope_message_top_notices`，每个 target 最多一条；
   - viewer-local banner hide 写 `scope_message_top_notice_hides`。
2. **不复用命名**：`pin` 只表示 Pins tab/list；顶部 banner 使用 `top_notice` / `top-notice` / `TopNotice`，不要使用 `top_pin`。
3. **Pin list scope 与 target 一致**：channel、DM、reply thread 的 Pin row 与 top notice 都使用当前 target scope；parent target 不汇总 child-thread state。
4. **sidecar 不写 timeline**：不创建 system message、不推进 unread/read cursor、不写 agent activity 或 fanout outbox。
5. **read model 复用现有 message authority**：Pins list、top notice 和 row marker 不复制 private/public/grant ACL SQL；必须复用 `internal/store/message` 的 visible-row/read-check 能力。
6. **不引入 root store 新业务逻辑**：业务读写进入 `internal/store/message`；不要向 root `internal/store` 增加新的业务方法或生产调用方。
7. **UI 模型分离**：Pins 是 `Chat / Tasks` 同级 tab/list；top notice 是聊天区顶部 notice/banner。

## 2. Existing owners

- API owner：`internal/api/im`，新增/扩展 `PinsHandler`、`PinStore` port、DTO/converter。
- Store owner：`internal/store/message`，在 message domain 中实现 high-level authorization/hydration/idempotency 和 low-level repositories。
- Access owner：`internal/store/access`，通过 `ScopeTargetOperationMessagePinWrite` 区分 shared Pin/top 写权限。
- UI owner：`web/src/agentteam/workspace/conversation`，`ThreadMessages` 接 top notice 与 row actions，channel route 增加 Pins tab。
- Cache owner：`web/src/agentteam/workspace/shared/queryCache.ts` 与 `workspaceQueryKeys`。

## 3. Schema

### 3.1 `scope_message_pins`

```sql
CREATE TABLE IF NOT EXISTS `scope_message_pins` (
  `id` varchar(255) NOT NULL,
  `workspace_id` varchar(255) NOT NULL,
  `scope_id` varchar(255) NOT NULL,
  `message_id` varchar(255) NOT NULL,
  `pinned_by_member_id` varchar(255) NOT NULL,
  `pinned_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`) /*T![clustered_index] CLUSTERED */,
  UNIQUE KEY `scope_message_pins_scope_message_key` (`scope_id`,`message_id`),
  KEY `scope_message_pins_scope_list_idx` (`workspace_id`,`scope_id`,`pinned_at`),
  KEY `scope_message_pins_message_idx` (`workspace_id`,`message_id`),
  KEY `scope_message_pins_pinner_idx` (`workspace_id`,`pinned_by_member_id`,`pinned_at`),
  CONSTRAINT `fk_scope_message_pins_workspace` FOREIGN KEY (`workspace_id`) REFERENCES `workspaces` (`id`) ON DELETE CASCADE,
  CONSTRAINT `fk_scope_message_pins_scope` FOREIGN KEY (`scope_id`) REFERENCES `scopes` (`id`) ON DELETE CASCADE,
  CONSTRAINT `fk_scope_message_pins_message` FOREIGN KEY (`message_id`) REFERENCES `scope_messages` (`id`) ON DELETE CASCADE,
  CONSTRAINT `fk_scope_message_pins_pinner` FOREIGN KEY (`pinned_by_member_id`) REFERENCES `members` (`id`)
);
```

Notes:

- `scope_id` is the current target scope and must equal the pinned message's scope.
- Repeated Pin hits `(scope_id, message_id)` and returns `changed=false` without refreshing `pinned_at`.
- Shared remove deletes only the active row in the current target scope.
- Row marker reads only from `scope_message_pins`; top notice does not imply row marker.

### 3.2 `scope_message_top_notices`

```sql
CREATE TABLE IF NOT EXISTS `scope_message_top_notices` (
  `id` varchar(255) NOT NULL,
  `workspace_id` varchar(255) NOT NULL,
  `scope_id` varchar(255) NOT NULL,
  `message_id` varchar(255) NOT NULL,
  `set_by_member_id` varchar(255) NOT NULL,
  `set_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`) /*T![clustered_index] CLUSTERED */,
  UNIQUE KEY `scope_message_top_notices_scope_key` (`scope_id`),
  UNIQUE KEY `scope_message_top_notices_scope_message_key` (`scope_id`,`message_id`),
  KEY `scope_message_top_notices_message_idx` (`workspace_id`,`message_id`),
  KEY `scope_message_top_notices_setter_idx` (`workspace_id`,`set_by_member_id`,`set_at`),
  CONSTRAINT `fk_scope_message_top_notices_workspace` FOREIGN KEY (`workspace_id`) REFERENCES `workspaces` (`id`) ON DELETE CASCADE,
  CONSTRAINT `fk_scope_message_top_notices_scope` FOREIGN KEY (`scope_id`) REFERENCES `scopes` (`id`) ON DELETE CASCADE,
  CONSTRAINT `fk_scope_message_top_notices_message` FOREIGN KEY (`message_id`) REFERENCES `scope_messages` (`id`) ON DELETE CASCADE,
  CONSTRAINT `fk_scope_message_top_notices_setter` FOREIGN KEY (`set_by_member_id`) REFERENCES `members` (`id`)
);
```

Notes:

- `scope_id` unique enforces one active top notice per target.
- Setting a different message deletes/replaces the previous top notice.
- Repeated setting of the current message returns `changed=false` without refreshing `set_at`.

### 3.3 `scope_message_top_notice_hides`

```sql
CREATE TABLE IF NOT EXISTS `scope_message_top_notice_hides` (
  `id` varchar(255) NOT NULL,
  `workspace_id` varchar(255) NOT NULL,
  `scope_id` varchar(255) NOT NULL,
  `top_notice_id` varchar(255) NOT NULL,
  `message_id` varchar(255) NOT NULL,
  `hidden_by_member_id` varchar(255) NOT NULL,
  `hidden_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`) /*T![clustered_index] CLUSTERED */,
  UNIQUE KEY `scope_message_top_notice_hides_instance_key` (`top_notice_id`,`hidden_by_member_id`),
  KEY `scope_message_top_notice_hides_viewer_scope_idx` (`workspace_id`,`scope_id`,`hidden_by_member_id`,`hidden_at`),
  KEY `scope_message_top_notice_hides_message_idx` (`workspace_id`,`message_id`,`hidden_by_member_id`),
  CONSTRAINT `fk_scope_message_top_notice_hides_notice` FOREIGN KEY (`top_notice_id`) REFERENCES `scope_message_top_notices` (`id`) ON DELETE CASCADE,
  CONSTRAINT `fk_scope_message_top_notice_hides_message` FOREIGN KEY (`message_id`) REFERENCES `scope_messages` (`id`) ON DELETE CASCADE,
  CONSTRAINT `fk_scope_message_top_notice_hides_member` FOREIGN KEY (`hidden_by_member_id`) REFERENCES `members` (`id`) ON DELETE CASCADE
);
```

Notes:

- `top_notice_id` is the instance key; old hide rows cannot hide a future top notice.
- Repeated hide for the same viewer and instance returns `changed=false` and should not refresh ordering.
- Show deletes the matching hide row; reset deletes all rows for `(workspace_id, scope_id, hidden_by_member_id)`.

## 4. Store design

Recommended store-domain types in `internal/store/message`:

```go
type MessagePin struct {
    ID                 string
    WorkspaceID        string
    ScopeID            string
    MessageID          string
    PinnedByMemberID   string
    PinnedBy           AuthorProfile
    PinnedAt           time.Time
    Message            AppMessage
    ViewerState        MessagePinViewerState
    ViewerCapabilities MessagePinViewerCapabilities
}

type MessageTopNotice struct {
    ID                 string
    WorkspaceID        string
    ScopeID            string
    MessageID          string
    SetByMemberID      string
    SetBy              AuthorProfile
    SetAt              time.Time
    Message            AppMessage
    ViewerState        MessageTopNoticeViewerState
    ViewerCapabilities MessageTopNoticeViewerCapabilities
}
```

High-level methods should own authorization, idempotency, hydration and API semantics:

```go
func (store *Store) ListMessagePinsForUser(ctx context.Context, userID, workspaceID, scopeID string, cursor MessagePinCursor, limit int) (MessagePinList, error)
func (store *Store) PinMessageForUser(ctx context.Context, userID, workspaceID, scopeID, messageID string) (MessagePinMutationResult, error)
func (store *Store) UnpinMessageForEveryoneForUser(ctx context.Context, userID, workspaceID, scopeID, messageID string) (MessagePinMutationResult, error)
func (store *Store) GetMessageTopNoticeForUser(ctx context.Context, userID, workspaceID, scopeID string) (MessageTopNoticeState, error)
func (store *Store) TopMessageForUser(ctx context.Context, userID, workspaceID, scopeID, messageID string) (MessageTopNoticeMutationResult, error)
func (store *Store) UntopMessageForEveryoneForUser(ctx context.Context, userID, workspaceID, scopeID, messageID string) (MessageTopNoticeMutationResult, error)
func (store *Store) HideMessageTopNoticeBannerForUser(ctx context.Context, userID, workspaceID, scopeID, messageID, topNoticeID string) (MessageTopNoticeMutationResult, error)
func (store *Store) ShowMessageTopNoticeBannerForUser(ctx context.Context, userID, workspaceID, scopeID, messageID, topNoticeID string) (MessageTopNoticeMutationResult, error)
func (store *Store) ResetMessageTopNoticeHidesForUser(ctx context.Context, userID, workspaceID, scopeID string) (MessageTopNoticeMutationResult, error)
```

Low-level repository methods should stay storage-primitive only: insert/delete active Pin, list active Pins, list row pin states, upsert/delete active top notice, insert/delete/reset top notice hide. Active Pin list queries must structurally require both the Pin row and message to belong to the requested scope, then delegate per-message visibility to message sidecar authority.

## 5. Access and visibility

Shared Pin/top writes require active human member plus target write/member permission. Implement a target resolver that returns a typed access contract and uses `ScopeTargetOperationMessagePinWrite` for write operations. Pin and top-notice paths keep the requested target scope.

Rules:

- Shared Pin/top remove must require membership/write basis, not merely public read.
- Top notice hide/show only requires target read and message read; it does not require shared write capability.
- `pinned_by_member_id` / `set_by_member_id` must not participate in shared remove authorization.
- List/top notice read must not first scan all sidecar rows and then call per-row `CanReadMessage`; use bounded sidecar query plus `AuthorizeSidecarReadBatch` or single sidecar read check.
- Pin list reads must reject rows whose message scope differs from the requested Pin scope; do not copy private/public/grant ACL SQL into the Pin query.
- Use `SidecarKindPin` for message sidecar visibility.
- Storage migrations and bootstrap checks must preserve the invariant that every Pin row belongs to the same scope as its message and de-duplicate conflicts deterministically.

## 6. API and DTO

Routes under `/api/v1/workspaces/:workspaceId`:

```go
GET    /scopes/:scopeId/pins
PUT    /scopes/:scopeId/messages/:messageId/pin
DELETE /scopes/:scopeId/messages/:messageId/pin
GET    /scopes/:scopeId/top-notice
PUT    /scopes/:scopeId/messages/:messageId/top-notice
DELETE /scopes/:scopeId/messages/:messageId/top-notice
PUT    /scopes/:scopeId/messages/:messageId/top-notice/banner-hide
DELETE /scopes/:scopeId/messages/:messageId/top-notice/banner-hide
DELETE /scopes/:scopeId/top-notice-hides
```

DTO split:

- `MessagePin` / `MessagePinListResponse` / `MessagePinMutationResponse` for Pins tab/list.
- `MessageTopNotice` / `MessageTopNoticeResponse` / `MessageTopNoticeMutationResponse` for top banner.
- `AppMessage.pinState` remains row-level Pin state only.

## 7. Realtime/cache design

Events:

```go
imEventTypeMessagePinChanged           = "message.pin.changed"
imEventTypeMessageTopNoticeChanged     = "message.top_notice.changed"
imEventTypeMessageTopNoticeBannerState = "message.top_notice.banner_state_changed"
imEventTypeMessagePinsInvalidated      = "message.pins.invalidated"
```

Client cache behavior:

- `message.pin.changed`: patch row `pinState` and invalidate `messagePins(workspaceId, scopeId)` for the current target; reply-thread Pin changes notify only that reply thread.
- `message.top_notice.changed`: invalidate `messageTopNotice(workspaceId, scopeId)` for the requested target scope and refetch viewer-local state; because this event is scope-wide, it must not carry actor-local `state`/`topNotice.viewerState`/`viewerCapabilities`.
- `message.top_notice.banner_state_changed`: replace or invalidate only the current viewer's `messageTopNotice(workspaceId, scopeId)`.
- `message.pins.invalidated`: invalidate `messagePins`, `messageTopNotice`, and visible message pages for affected scope; this covers rewind tombstone/scrub or delete cleanup that may remove both Pins and top notices.

Do not create unread, push, fanout, or timeline events for these mutations.

## 8. Frontend design

- `ThreadMessages` owns message-row actions, row marker wiring, top notice banner, and fallback Pins drawer for views without route tabs.
- Channel and reply-thread routes add a `pins` tab after `tasks`; each tab renders `PinnedMessagesPanel` for its own target scope and preserves the chat/replies DOM while inactive.
- Pins list items should be message-like rows: author, preview, pinned-by/time metadata, row click for jump, destructive remove in hover/overflow.
- Top notice banner should be a real notice in the conversation surface, not header metadata and not a fake message row.
- `useMessagePins` owns Pins list query/mutations and must not own top-banner state.
- `useMessageTopNotice` owns top notice state/mutations and must not change Pins list ordering.
- UI copy must keep Pin and 置顶/top notice vocabulary separate in every supported locale.

## 9. Verification matrix

Backend:

- store tests for Pin idempotency, target-local reply-thread Pins, list ordering/pagination, top notice singleton replace/idempotency, hide/show/reset, permission and visibility filtering;
- API route/handler tests for DTO shape, paths and realtime event payloads, including reply-thread Pin events emitted only to the thread scope;
- rewind cleanup test covering `scope_message_pins`, `scope_message_top_notices`, and `scope_message_top_notice_hides`;
- TiDB migration/bootstrap verification for Pin/message scope equality, idempotence, and conflict de-duplication.

Frontend:

- API schema tests for split DTOs and paths;
- socket guard/cache tests for `message.pin.changed`, `message.top_notice.changed`, `message.top_notice.banner_state_changed`, and invalidation;
- row action/marker tests ensuring Pin/top actions are separate;
- Pins tab/list source tests for pagination and message-like rows;
- top notice banner source/tests for notice styling, markdown-stripped preview, hide/reset/remove controls;
- typecheck, workspace boundary/navigation guardrails, and focused component tests.
