Welcome.
Sign in with GitHub to join the guest room.
Any GitHub account can join.
diff --git a/CHANGELOG.md b/CHANGELOG.md index d32e6f07..83fd6630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Added bot-scoped durable agent commentary and tool activity, collapsed into per-turn preambles with independent visibility controls and configurable self-message alignment. Thanks @ragesaq. - Added persistent thread activity counts and row-click thread opening in channel and DM timelines, with live summary refreshes. Thanks @PollyBot13. - Added per-user DM closing with durable history, direct-link access, Undo, canonical one-to-one reopening, and automatic resurfacing on new messages. Thanks @PollyBot13. - Kept self-hosted landing-page App links on the instance-local `/app` route for Tailscale, LAN, and custom hostnames instead of redirecting to the hosted app. Thanks @iXandru for the report and @TurboTheTurtle for the fix. diff --git a/apps/api/internal/httpapi/agent_activity_test.go b/apps/api/internal/httpapi/agent_activity_test.go new file mode 100644 index 00000000..c4dae6c8 --- /dev/null +++ b/apps/api/internal/httpapi/agent_activity_test.go @@ -0,0 +1,228 @@ +package httpapi + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/openclaw/clickclack/apps/api/internal/realtime" + "github.com/openclaw/clickclack/apps/api/internal/store" + sqlitestore "github.com/openclaw/clickclack/apps/api/internal/store/sqlite" +) + +// TestAgentActivityMessageAuthz is the authorization gate for durable agent +// activity messages (kind = agent_commentary / agent_tool). It mirrors the +// authz matrix that previously guarded the ephemeral agent.progress frame, but +// now the activity rows are durable messages on the normal create path: +// +// - a human session can never create an activity-kind message (403), +// - an activity kind requires a BOT token carrying agent_activity:write; a +// bot:write token MUST NOT inherit it (403, no bundle inheritance), +// - an unknown kind is a 400, +// - a scoped bot can create both activity kinds (success), +// - an ordinary message is unaffected for any caller with messages:write. +func TestAgentActivityMessageAuthz(t *testing.T) { + t.Parallel() + ctx := context.Background() + dataDir := t.TempDir() + st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate(ctx); err != nil { + t.Fatal(err) + } + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + workspace := workspaces[0] + channels, err := st.ListChannels(ctx, workspace.ID, owner.ID) + if err != nil { + t.Fatal(err) + } + channel := channels[0] + + // A bridge bot WITH the explicit activity scope. + activityBot, activityToken, err := st.CreateBot(ctx, store.CreateBotInput{ + WorkspaceID: workspace.ID, + OwnerUserID: owner.ID, + DisplayName: "Activity Bot", + Scopes: []string{"bot:write", store.AgentActivityWriteScope}, + CreatedBy: owner.ID, + }) + if err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, workspace.ID, activityBot.ID, "bot"); err != nil { + t.Fatal(err) + } + + // A bridge bot WITHOUT the activity scope (bot:write only). Per the + // no-inheritance rule this must NOT gain agent_activity:write. + writeBot, writeToken, err := st.CreateBot(ctx, store.CreateBotInput{ + WorkspaceID: workspace.ID, + OwnerUserID: owner.ID, + DisplayName: "Write Bot", + Scopes: []string{"bot:write"}, + CreatedBy: owner.ID, + }) + if err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, workspace.ID, writeBot.ID, "bot"); err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(New(st, realtime.NewHub(), Options{UploadDir: filepath.Join(dataDir, "uploads")}).Handler()) + t.Cleanup(server.Close) + endpoint := server.URL + "/api/channels/" + channel.ID + "/messages" + + commentary := `{"body":"thinking about it","kind":"agent_commentary","turn_id":"t1"}` + toolFrame := `{"body":"ran bash","kind":"agent_tool","turn_id":"t1"}` + unknownKind := `{"body":"nope","kind":"agent_bogus"}` + ordinary := `{"body":"hello channel"}` + + // 1. Human (dev-auth) session cannot create an activity-kind message. + expectStatusAsUser(t, owner.ID, http.MethodPost, endpoint, strings.NewReader(commentary), http.StatusForbidden) + + // 2. bot:write WITHOUT agent_activity:write is rejected (no inheritance). + expectStatusWithBearer(t, writeToken.Token, http.MethodPost, endpoint, strings.NewReader(commentary), http.StatusForbidden) + + // 3. Unknown kind is a 400, regardless of scope. + expectStatusWithBearer(t, activityToken.Token, http.MethodPost, endpoint, strings.NewReader(unknownKind), http.StatusBadRequest) + + // 4. Scoped bot can create both activity kinds. + expectStatusWithBearer(t, activityToken.Token, http.MethodPost, endpoint, strings.NewReader(commentary), http.StatusCreated) + expectStatusWithBearer(t, activityToken.Token, http.MethodPost, endpoint, strings.NewReader(toolFrame), http.StatusCreated) + + // 5. Ordinary messages are unaffected for any messages:write caller. + expectStatusAsUser(t, owner.ID, http.MethodPost, endpoint, strings.NewReader(ordinary), http.StatusCreated) + expectStatusWithBearer(t, writeToken.Token, http.MethodPost, endpoint, strings.NewReader(ordinary), http.StatusCreated) +} + +// TestAgentActivityMessagePrivacy proves a durable activity message inherits the +// same membership privacy as any channel message: a user who is not a member of +// the workspace cannot read the channel that holds it (the activity row never +// leaks to a non-member). +func TestAgentActivityMessagePrivacy(t *testing.T) { + t.Parallel() + ctx := context.Background() + dataDir := t.TempDir() + st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate(ctx); err != nil { + t.Fatal(err) + } + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + workspace := workspaces[0] + channels, err := st.ListChannels(ctx, workspace.ID, owner.ID) + if err != nil { + t.Fatal(err) + } + channel := channels[0] + + // Seed a durable activity message in the channel via the store directly. + activity, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "private commentary", Kind: store.MessageKindAgentCommentary, TurnID: "t1"}) + if err != nil { + t.Fatal(err) + } + + // A user who is NOT a member of the workspace. + stranger, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Stranger", Email: "stranger@example.com"}) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(New(st, realtime.NewHub(), Options{UploadDir: filepath.Join(dataDir, "uploads")}).Handler()) + t.Cleanup(server.Close) + + // The non-member cannot list the channel's messages or fetch the activity + // row by id: the store's membership check denies access (surfaced as a 4xx), + // so the activity body never reaches a non-member. + expectStatusNot2xxAsUser(t, stranger.ID, http.MethodGet, server.URL+"/api/channels/"+channel.ID+"/messages") + expectStatusNot2xxAsUser(t, stranger.ID, http.MethodGet, server.URL+"/api/messages/"+activity.ID) + // The owner (a member) can read it. + expectStatusAsUser(t, owner.ID, http.MethodGet, server.URL+"/api/messages/"+activity.ID, nil, http.StatusOK) +} + +// expectStatusNot2xxAsUser asserts a dev-auth request as userID is rejected with +// a non-2xx status (the exact 400-vs-403 mapping is an internal detail; the +// privacy invariant is simply that the caller is denied). +func expectStatusNot2xxAsUser(t *testing.T, userID, method, endpoint string) { + t.Helper() + req, err := http.NewRequest(method, endpoint, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-ClickClack-User", userID) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode < 400 { + t.Fatalf("%s %s as %s: expected a denial (>=400), got %s", method, endpoint, userID, resp.Status) + } +} + +// TestAgentActivityScopeExcludedFromBotBundles asserts the new scope is NOT part +// of any bot:* bundle, so existing deployments that minted bot:read/write/admin +// tokens gain no new capability. +func TestAgentActivityScopeExcludedFromBotBundles(t *testing.T) { + t.Parallel() + ctx := context.Background() + dataDir := t.TempDir() + st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate(ctx); err != nil { + t.Fatal(err) + } + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + workspace := workspaces[0] + for _, bundle := range []string{"bot:read", "bot:write", "bot:admin"} { + _, token, err := st.CreateBot(ctx, store.CreateBotInput{ + WorkspaceID: workspace.ID, + OwnerUserID: owner.ID, + DisplayName: "Bundle Bot " + bundle, + Scopes: []string{bundle}, + CreatedBy: owner.ID, + }) + if err != nil { + t.Fatalf("create bot for %s: %v", bundle, err) + } + for _, scope := range token.Scopes { + if scope == store.AgentActivityWriteScope { + t.Fatalf("%s bundle unexpectedly granted %s", bundle, store.AgentActivityWriteScope) + } + } + } +} diff --git a/apps/api/internal/httpapi/features.go b/apps/api/internal/httpapi/features.go index 39cfcc46..5cd5dcc4 100644 --- a/apps/api/internal/httpapi/features.go +++ b/apps/api/internal/httpapi/features.go @@ -921,6 +921,8 @@ func (s *Server) createDirectMessage(w http.ResponseWriter, r *http.Request) { Body string `json:"body"` QuotedMessageID string `json:"quoted_message_id"` Nonce string `json:"nonce"` + Kind string `json:"kind"` + TurnID string `json:"turn_id"` } if err := readJSON(w, r, &body); err != nil { writeError(w, http.StatusBadRequest, err) @@ -930,13 +932,19 @@ func (s *Server) createDirectMessage(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusForbidden, err) return } + kind, turnID, ok := s.resolveMessageKind(w, act, body.Kind, body.TurnID) + if !ok { + return + } if !s.requireBotDirectWorkspace(w, r, act, chi.URLParam(r, "conversation_id")) { return } - message, event, err := s.store.CreateDirectMessage(r.Context(), store.CreateDirectMessageInput{ConversationID: chi.URLParam(r, "conversation_id"), AuthorID: act.user.ID, Body: body.Body, QuotedMessageID: optionalString(body.QuotedMessageID), Nonce: body.Nonce}) + message, event, err := s.store.CreateDirectMessage(r.Context(), store.CreateDirectMessageInput{ConversationID: chi.URLParam(r, "conversation_id"), AuthorID: act.user.ID, Body: body.Body, QuotedMessageID: optionalString(body.QuotedMessageID), Nonce: body.Nonce, Kind: kind, TurnID: turnID}) if err == nil && event.ID != "" { s.publishEvent(r.Context(), event) - s.notifyMessageCreated(r.Context(), message) + if !store.IsActivityMessageKind(message.Kind) { + s.notifyMessageCreated(r.Context(), message) + } } writeMessageCreateResult(w, message, event, err) } diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 1fbbcbad..cee2965e 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -609,22 +609,69 @@ func (s *Server) createMessage(w http.ResponseWriter, r *http.Request) { QuotedMessageID string `json:"quoted_message_id"` Nonce string `json:"nonce"` TopicID string `json:"topic_id"` + Kind string `json:"kind"` + TurnID string `json:"turn_id"` } if err := readJSON(w, r, &body); err != nil { writeError(w, http.StatusBadRequest, err) return } + kind, turnID, ok := s.resolveMessageKind(w, act, body.Kind, body.TurnID) + if !ok { + return + } if !s.requireBotChannelWorkspace(w, r, act, chi.URLParam(r, "channel_id")) { return } - message, event, err := s.store.CreateMessage(r.Context(), store.CreateMessageInput{ChannelID: chi.URLParam(r, "channel_id"), AuthorID: act.user.ID, Body: body.Body, QuotedMessageID: optionalString(body.QuotedMessageID), Nonce: body.Nonce, TopicID: body.TopicID}) + message, event, err := s.store.CreateMessage(r.Context(), store.CreateMessageInput{ChannelID: chi.URLParam(r, "channel_id"), AuthorID: act.user.ID, Body: body.Body, QuotedMessageID: optionalString(body.QuotedMessageID), Nonce: body.Nonce, TopicID: body.TopicID, Kind: kind, TurnID: turnID}) if err == nil && event.ID != "" { s.publishEvent(r.Context(), event) - s.notifyMessageCreated(r.Context(), message) + if !store.IsActivityMessageKind(message.Kind) { + s.notifyMessageCreated(r.Context(), message) + } } writeMessageCreateResult(w, message, event, err) } +// resolveMessageKind validates a caller-supplied message kind and turn_id and +// enforces the activity authorization contract: +// +// - an empty/'message' kind is always allowed and returned as 'message', +// - an unknown kind is a 400, +// - an ordinary 'message' MUST NOT carry a turn_id (400); turn_id correlates +// agent activity rows only, so a non-empty value on an ordinary message is +// a client contract violation and fails closed, +// - an activity kind (agent_commentary/agent_tool) requires a BOT token that +// carries agent_activity:write; a human session always gets 403 and a bot +// without the scope gets 403, and may carry a turn_id. +// +// It writes the error response itself and returns ok=false when the request +// must not proceed. On success it returns the normalized kind and the turn_id +// that should be persisted. +func (s *Server) resolveMessageKind(w http.ResponseWriter, act actor, rawKind, rawTurnID string) (string, string, bool) { + kind, err := store.NormalizeMessageKind(rawKind) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return "", "", false + } + if !store.IsActivityMessageKind(kind) { + if rawTurnID != "" { + writeError(w, http.StatusBadRequest, store.ErrTurnIDNotAllowed) + return "", "", false + } + return kind, "", true + } + if act.botTokenID == "" { + writeError(w, http.StatusForbidden, errors.New("agent activity messages require a bot token")) + return "", "", false + } + if err := act.requireScope(store.AgentActivityWriteScope); err != nil { + writeError(w, http.StatusForbidden, err) + return "", "", false + } + return kind, rawTurnID, true +} + func (s *Server) getMessage(w http.ResponseWriter, r *http.Request) { act, err := s.currentActor(r) if err != nil { diff --git a/apps/api/internal/store/postgres/bots.go b/apps/api/internal/store/postgres/bots.go index 1ada5928..cb41e8e0 100644 --- a/apps/api/internal/store/postgres/bots.go +++ b/apps/api/internal/store/postgres/bots.go @@ -66,6 +66,11 @@ var botAllowedScopes = []string{ "realtime:read", "uploads:write", "profile:read", + // agent_activity:write is grantable but intentionally NOT part of any + // bot:* bundle. Durable agent activity is a distinct authorization surface + // and must be granted explicitly, so existing bot:* deployments gain no new + // capability. + store.AgentActivityWriteScope, } func (s *Store) CreateBot(ctx context.Context, input store.CreateBotInput) (store.User, store.BotToken, error) { diff --git a/apps/api/internal/store/postgres/dms.go b/apps/api/internal/store/postgres/dms.go index b560c899..84ef7e7c 100644 --- a/apps/api/internal/store/postgres/dms.go +++ b/apps/api/internal/store/postgres/dms.go @@ -270,12 +270,16 @@ func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirec if err != nil { return store.Message{}, store.Event{}, err } + kind, err := store.NormalizeMessageKind(input.Kind) + if err != nil { + return store.Message{}, store.Event{}, err + } var quotedID, quotedAuthorID, quotedSnapshot string if input.QuotedMessageID != nil { quotedID = strings.TrimSpace(*input.QuotedMessageID) } if existing, err := getMessageByClientNonceTx(ctx, tx, input.AuthorID, nonce); err == nil { - if existing.DirectConversationID != input.ConversationID || existing.ChannelID != "" || existing.ParentMessageID != nil || existing.Body != body || !sameQuotedMessageID(existing, quotedID) { + if existing.DirectConversationID != input.ConversationID || existing.ChannelID != "" || existing.ParentMessageID != nil || existing.Body != body || existing.Kind != kind || existing.TurnID != input.TurnID || !sameQuotedMessageID(existing, quotedID) { return store.Message{}, store.Event{}, store.ErrClientNonceConflict } return existing, store.Event{}, nil @@ -303,9 +307,11 @@ func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirec QuotedBodySnapshot: quotedSnapshot, QuotedAuthorID: sqlOptionalText(quotedAuthorID), ClientNonce: nonce, + Kind: kind, + TurnID: sqlOptionalText(input.TurnID), }); err != nil { if existing, lookupErr := getMessageByClientNonceTx(ctx, tx, input.AuthorID, nonce); lookupErr == nil { - if existing.DirectConversationID == input.ConversationID && existing.ChannelID == "" && existing.ParentMessageID == nil && existing.Body == body && sameQuotedMessageID(existing, quotedID) { + if existing.DirectConversationID == input.ConversationID && existing.ChannelID == "" && existing.ParentMessageID == nil && existing.Body == body && existing.Kind == kind && existing.TurnID == input.TurnID && sameQuotedMessageID(existing, quotedID) { return existing, store.Event{}, nil } return store.Message{}, store.Event{}, store.ErrClientNonceConflict @@ -322,7 +328,14 @@ func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirec if err != nil { return store.Message{}, store.Event{}, err } - event, err := insertEventWithRecipients(ctx, tx, workspaceID, "", "message.created", &seq, eventPayload(map[string]string{"message_id": id, "direct_conversation_id": input.ConversationID, "author_id": input.AuthorID}, nonce), recipients) + dmEventFields := map[string]string{"message_id": id, "direct_conversation_id": input.ConversationID, "author_id": input.AuthorID} + if kind != store.MessageKindMessage { + dmEventFields["kind"] = kind + } + if input.TurnID != "" { + dmEventFields["turn_id"] = input.TurnID + } + event, err := insertEventWithRecipients(ctx, tx, workspaceID, "", "message.created", &seq, eventPayload(dmEventFields, nonce), recipients) if err != nil { return store.Message{}, store.Event{}, err } diff --git a/apps/api/internal/store/postgres/helpers.go b/apps/api/internal/store/postgres/helpers.go index 84578dc3..758cbf14 100644 --- a/apps/api/internal/store/postgres/helpers.go +++ b/apps/api/internal/store/postgres/helpers.go @@ -51,7 +51,7 @@ func messageSelect() string { u.id, u.kind, u.owner_user_id, u.display_name, u.handle, u.avatar_url, u.created_at, m.quoted_message_id, m.quoted_body_snapshot, m.quoted_author_id, qu.id, qu.kind, qu.owner_user_id, qu.display_name, qu.handle, qu.avatar_url, qu.created_at, - m.client_nonce + m.client_nonce, m.kind, COALESCE(m.turn_id, '') FROM messages m JOIN users u ON u.id = m.author_id LEFT JOIN users qu ON qu.id = m.quoted_author_id` @@ -72,7 +72,7 @@ func scanMessage(row scanner) (store.Message, error) { &author.ID, &author.Kind, &authorOwnerID, &author.DisplayName, &author.Handle, &author.AvatarURL, &author.CreatedAt, "edMessageID, &m.QuotedBodySnapshot, "edAuthorID, &quAuthorID, &quKind, &quOwnerID, &quDisplayName, &quHandle, &quAvatarURL, &quCreatedAt, - &nonce, + &nonce, &m.Kind, &m.TurnID, ) if err != nil { return store.Message{}, err diff --git a/apps/api/internal/store/postgres/migrations/0011_agent_activity.sql b/apps/api/internal/store/postgres/migrations/0011_agent_activity.sql new file mode 100644 index 00000000..db187ca9 --- /dev/null +++ b/apps/api/internal/store/postgres/migrations/0011_agent_activity.sql @@ -0,0 +1,19 @@ +-- Durable agent activity messages (postgres mirror of sqlite 0017). +-- +-- Adds a message kind discriminator and an optional turn correlation id. See +-- the sqlite migration for the full rationale. Activity rows +-- ('agent_commentary', 'agent_tool') are excluded from the full-text search +-- index and from unread/notification paths; 'message' is the default. + +ALTER TABLE messages ADD COLUMN kind TEXT NOT NULL DEFAULT 'message'; +ALTER TABLE messages ADD COLUMN turn_id TEXT; + +CREATE INDEX IF NOT EXISTS idx_messages_turn ON messages(turn_id); + +-- Rebuild the search GIN index so activity rows are never indexed. The search +-- query also filters on kind = 'message', but keeping the index partial avoids +-- wasting space on rows that can never match. +DROP INDEX IF EXISTS idx_messages_search_fts; +CREATE INDEX idx_messages_search_fts ON messages + USING GIN (to_tsvector('simple', body)) + WHERE direct_conversation_id IS NULL AND channel_id IS NOT NULL AND deleted_at IS NULL AND kind = 'message'; diff --git a/apps/api/internal/store/postgres/postgres.go b/apps/api/internal/store/postgres/postgres.go index ff7a5fd0..9bd41a15 100644 --- a/apps/api/internal/store/postgres/postgres.go +++ b/apps/api/internal/store/postgres/postgres.go @@ -509,12 +509,16 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu if err != nil { return store.Message{}, store.Event{}, err } + kind, err := store.NormalizeMessageKind(input.Kind) + if err != nil { + return store.Message{}, store.Event{}, err + } var quotedID, quotedAuthorID, quotedSnapshot string if input.QuotedMessageID != nil { quotedID = strings.TrimSpace(*input.QuotedMessageID) } if existing, err := getMessageByClientNonceTx(ctx, tx, input.AuthorID, nonce); err == nil { - if existing.ChannelID != input.ChannelID || existing.DirectConversationID != "" || existing.ParentMessageID != nil || existing.Body != body || existing.TopicID != input.TopicID || !sameQuotedMessageID(existing, quotedID) { + if existing.ChannelID != input.ChannelID || existing.DirectConversationID != "" || existing.ParentMessageID != nil || existing.Body != body || existing.TopicID != input.TopicID || existing.Kind != kind || existing.TurnID != input.TurnID || !sameQuotedMessageID(existing, quotedID) { return store.Message{}, store.Event{}, store.ErrClientNonceConflict } if err := requireMessageAccessTx(ctx, tx, existing, input.AuthorID); err != nil { @@ -549,9 +553,11 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu QuotedBodySnapshot: quotedSnapshot, QuotedAuthorID: sqlOptionalText(quotedAuthorID), ClientNonce: nonce, + Kind: kind, + TurnID: sqlOptionalText(input.TurnID), }); err != nil { if existing, lookupErr := getMessageByClientNonceTx(ctx, tx, input.AuthorID, nonce); lookupErr == nil { - if existing.ChannelID == input.ChannelID && existing.DirectConversationID == "" && existing.ParentMessageID == nil && existing.Body == body && existing.TopicID == input.TopicID && sameQuotedMessageID(existing, quotedID) { + if existing.ChannelID == input.ChannelID && existing.DirectConversationID == "" && existing.ParentMessageID == nil && existing.Body == body && existing.TopicID == input.TopicID && existing.Kind == kind && existing.TurnID == input.TurnID && sameQuotedMessageID(existing, quotedID) { if err := requireMessageAccessTx(ctx, tx, existing, input.AuthorID); err != nil { return store.Message{}, store.Event{}, err } @@ -568,6 +574,12 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu if input.TopicID != "" { eventFields["topic_id"] = input.TopicID } + if kind != store.MessageKindMessage { + eventFields["kind"] = kind + } + if input.TurnID != "" { + eventFields["turn_id"] = input.TurnID + } event, err := insertEvent(ctx, tx, workspaceID, input.ChannelID, "message.created", &seq, eventPayload(eventFields, nonce)) if err != nil { return store.Message{}, store.Event{}, err diff --git a/apps/api/internal/store/postgres/search.go b/apps/api/internal/store/postgres/search.go index 844882b0..e932e83d 100644 --- a/apps/api/internal/store/postgres/search.go +++ b/apps/api/internal/store/postgres/search.go @@ -58,6 +58,7 @@ func (s *Store) SearchMessages(ctx context.Context, workspaceID, channelID, user AND m.direct_conversation_id IS NULL AND m.channel_id IS NOT NULL AND m.deleted_at IS NULL + AND m.kind = 'message' `+guestWhere+` `+channelWhere+` ORDER BY rank DESC diff --git a/apps/api/internal/store/postgres/sqlc/queries.sql b/apps/api/internal/store/postgres/sqlc/queries.sql index 5ea3b361..d2ac8997 100644 --- a/apps/api/internal/store/postgres/sqlc/queries.sql +++ b/apps/api/internal/store/postgres/sqlc/queries.sql @@ -193,6 +193,7 @@ SELECT c.id, COALESCE(c.route_id, '') AS route_id, c.workspace_id, c.name, c.kin WHERE m.channel_id = c.id AND m.parent_message_id IS NULL AND m.author_id <> sqlc.arg(reader_user_id) + AND m.kind = 'message' AND m.channel_seq > COALESCE((SELECT cr2.last_read_seq FROM channel_reads cr2 WHERE cr2.channel_id = c.id AND cr2.user_id = sqlc.arg(reader_user_id)), 0) ), 0) AS BIGINT) AS unread_count FROM channels c @@ -458,6 +459,7 @@ SELECT dc.id, COALESCE(dc.route_id, '') AS route_id, dc.workspace_id, dc.created WHERE m.direct_conversation_id = dc.id AND m.parent_message_id IS NULL AND m.author_id <> sqlc.arg(reader_user_id) + AND m.kind = 'message' AND m.channel_seq > COALESCE((SELECT dr2.last_read_seq FROM direct_reads dr2 WHERE dr2.conversation_id = dc.id AND dr2.user_id = sqlc.arg(reader_user_id)), 0) ), 0) AS BIGINT) AS unread_count FROM direct_conversations dc @@ -482,6 +484,7 @@ SELECT dc.id, COALESCE(dc.route_id, '') AS route_id, dc.workspace_id, dc.created WHERE m.direct_conversation_id = dc.id AND m.parent_message_id IS NULL AND m.author_id <> sqlc.arg(reader_user_id) + AND m.kind = 'message' AND m.channel_seq > COALESCE((SELECT dr2.last_read_seq FROM direct_reads dr2 WHERE dr2.conversation_id = dc.id AND dr2.user_id = sqlc.arg(reader_user_id)), 0) ), 0) AS BIGINT) AS unread_count FROM direct_conversations dc @@ -565,12 +568,12 @@ WHERE dcm.conversation_id = sqlc.arg(conversation_id) ORDER BY u.display_name; -- name: InsertChannelMessage :exec -INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, topic_id, channel_seq, thread_seq, body, body_format, created_at, quoted_message_id, quoted_body_snapshot, quoted_author_id, client_nonce) -VALUES (sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(channel_id), NULL, sqlc.arg(author_id), NULL, sqlc.arg(thread_root_id), sqlc.arg(topic_id), sqlc.arg(channel_seq), NULL, sqlc.arg(body), 'markdown', sqlc.arg(created_at), sqlc.arg(quoted_message_id), sqlc.arg(quoted_body_snapshot), sqlc.arg(quoted_author_id), sqlc.arg(client_nonce)); +INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, topic_id, channel_seq, thread_seq, body, body_format, created_at, quoted_message_id, quoted_body_snapshot, quoted_author_id, client_nonce, kind, turn_id) +VALUES (sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(channel_id), NULL, sqlc.arg(author_id), NULL, sqlc.arg(thread_root_id), sqlc.arg(topic_id), sqlc.arg(channel_seq), NULL, sqlc.arg(body), 'markdown', sqlc.arg(created_at), sqlc.arg(quoted_message_id), sqlc.arg(quoted_body_snapshot), sqlc.arg(quoted_author_id), sqlc.arg(client_nonce), sqlc.arg(kind), sqlc.arg(turn_id)); -- name: InsertDirectMessage :exec -INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, channel_seq, thread_seq, body, body_format, created_at, quoted_message_id, quoted_body_snapshot, quoted_author_id, client_nonce) -VALUES (sqlc.arg(id), sqlc.arg(workspace_id), NULL, sqlc.arg(direct_conversation_id), sqlc.arg(author_id), NULL, sqlc.arg(thread_root_id), sqlc.arg(channel_seq), NULL, sqlc.arg(body), 'markdown', sqlc.arg(created_at), sqlc.arg(quoted_message_id), sqlc.arg(quoted_body_snapshot), sqlc.arg(quoted_author_id), sqlc.arg(client_nonce)); +INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, channel_seq, thread_seq, body, body_format, created_at, quoted_message_id, quoted_body_snapshot, quoted_author_id, client_nonce, kind, turn_id) +VALUES (sqlc.arg(id), sqlc.arg(workspace_id), NULL, sqlc.arg(direct_conversation_id), sqlc.arg(author_id), NULL, sqlc.arg(thread_root_id), sqlc.arg(channel_seq), NULL, sqlc.arg(body), 'markdown', sqlc.arg(created_at), sqlc.arg(quoted_message_id), sqlc.arg(quoted_body_snapshot), sqlc.arg(quoted_author_id), sqlc.arg(client_nonce), sqlc.arg(kind), sqlc.arg(turn_id)); -- name: InsertThreadState :exec INSERT INTO thread_state (root_message_id) diff --git a/apps/api/internal/store/postgres/sqlc/schema.sql b/apps/api/internal/store/postgres/sqlc/schema.sql index 6ed39960..cb7e8061 100644 --- a/apps/api/internal/store/postgres/sqlc/schema.sql +++ b/apps/api/internal/store/postgres/sqlc/schema.sql @@ -127,7 +127,9 @@ CREATE TABLE messages ( quoted_body_snapshot TEXT NOT NULL DEFAULT '', quoted_author_id TEXT REFERENCES users(id) ON DELETE SET NULL, client_nonce TEXT NOT NULL DEFAULT '', - route_id TEXT + route_id TEXT, + kind TEXT NOT NULL DEFAULT 'message', + turn_id TEXT ); CREATE INDEX idx_messages_channel_seq ON messages(channel_id, channel_seq); @@ -147,7 +149,7 @@ CREATE UNIQUE INDEX idx_messages_thread_unique_seq ON messages(thread_root_id, t WHERE parent_message_id IS NOT NULL AND thread_seq IS NOT NULL; CREATE INDEX idx_messages_search_fts ON messages USING GIN (to_tsvector('simple', body)) - WHERE direct_conversation_id IS NULL AND channel_id IS NOT NULL AND deleted_at IS NULL; + WHERE direct_conversation_id IS NULL AND channel_id IS NOT NULL AND deleted_at IS NULL AND kind = 'message'; CREATE TABLE thread_state ( root_message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, diff --git a/apps/api/internal/store/postgres/storedb/models.go b/apps/api/internal/store/postgres/storedb/models.go index ab8da222..9603ad52 100644 --- a/apps/api/internal/store/postgres/storedb/models.go +++ b/apps/api/internal/store/postgres/storedb/models.go @@ -196,6 +196,8 @@ type Message struct { QuotedAuthorID sql.NullString `json:"quoted_author_id"` ClientNonce string `json:"client_nonce"` RouteID sql.NullString `json:"route_id"` + Kind string `json:"kind"` + TurnID sql.NullString `json:"turn_id"` } type MessageAttachment struct { diff --git a/apps/api/internal/store/postgres/storedb/queries.sql.go b/apps/api/internal/store/postgres/storedb/queries.sql.go index c14241f2..ab349d1a 100644 --- a/apps/api/internal/store/postgres/storedb/queries.sql.go +++ b/apps/api/internal/store/postgres/storedb/queries.sql.go @@ -724,6 +724,7 @@ SELECT dc.id, COALESCE(dc.route_id, '') AS route_id, dc.workspace_id, dc.created WHERE m.direct_conversation_id = dc.id AND m.parent_message_id IS NULL AND m.author_id <> $1 + AND m.kind = 'message' AND m.channel_seq > COALESCE((SELECT dr2.last_read_seq FROM direct_reads dr2 WHERE dr2.conversation_id = dc.id AND dr2.user_id = $1), 0) ), 0) AS BIGINT) AS unread_count FROM direct_conversations dc @@ -1272,8 +1273,8 @@ func (q *Queries) InsertChannel(ctx context.Context, arg InsertChannelParams) er } const insertChannelMessage = `-- name: InsertChannelMessage :exec -INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, topic_id, channel_seq, thread_seq, body, body_format, created_at, quoted_message_id, quoted_body_snapshot, quoted_author_id, client_nonce) -VALUES ($1, $2, $3, NULL, $4, NULL, $5, $6, $7, NULL, $8, 'markdown', $9, $10, $11, $12, $13) +INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, topic_id, channel_seq, thread_seq, body, body_format, created_at, quoted_message_id, quoted_body_snapshot, quoted_author_id, client_nonce, kind, turn_id) +VALUES ($1, $2, $3, NULL, $4, NULL, $5, $6, $7, NULL, $8, 'markdown', $9, $10, $11, $12, $13, $14, $15) ` type InsertChannelMessageParams struct { @@ -1290,6 +1291,8 @@ type InsertChannelMessageParams struct { QuotedBodySnapshot string `json:"quoted_body_snapshot"` QuotedAuthorID sql.NullString `json:"quoted_author_id"` ClientNonce string `json:"client_nonce"` + Kind string `json:"kind"` + TurnID sql.NullString `json:"turn_id"` } func (q *Queries) InsertChannelMessage(ctx context.Context, arg InsertChannelMessageParams) error { @@ -1307,6 +1310,8 @@ func (q *Queries) InsertChannelMessage(ctx context.Context, arg InsertChannelMes arg.QuotedBodySnapshot, arg.QuotedAuthorID, arg.ClientNonce, + arg.Kind, + arg.TurnID, ) return err } @@ -1401,8 +1406,8 @@ func (q *Queries) InsertDirectConversationMember(ctx context.Context, arg Insert } const insertDirectMessage = `-- name: InsertDirectMessage :exec -INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, channel_seq, thread_seq, body, body_format, created_at, quoted_message_id, quoted_body_snapshot, quoted_author_id, client_nonce) -VALUES ($1, $2, NULL, $3, $4, NULL, $5, $6, NULL, $7, 'markdown', $8, $9, $10, $11, $12) +INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, channel_seq, thread_seq, body, body_format, created_at, quoted_message_id, quoted_body_snapshot, quoted_author_id, client_nonce, kind, turn_id) +VALUES ($1, $2, NULL, $3, $4, NULL, $5, $6, NULL, $7, 'markdown', $8, $9, $10, $11, $12, $13, $14) ` type InsertDirectMessageParams struct { @@ -1418,6 +1423,8 @@ type InsertDirectMessageParams struct { QuotedBodySnapshot string `json:"quoted_body_snapshot"` QuotedAuthorID sql.NullString `json:"quoted_author_id"` ClientNonce string `json:"client_nonce"` + Kind string `json:"kind"` + TurnID sql.NullString `json:"turn_id"` } func (q *Queries) InsertDirectMessage(ctx context.Context, arg InsertDirectMessageParams) error { @@ -1434,6 +1441,8 @@ func (q *Queries) InsertDirectMessage(ctx context.Context, arg InsertDirectMessa arg.QuotedBodySnapshot, arg.QuotedAuthorID, arg.ClientNonce, + arg.Kind, + arg.TurnID, ) return err } @@ -1782,6 +1791,7 @@ SELECT c.id, COALESCE(c.route_id, '') AS route_id, c.workspace_id, c.name, c.kin WHERE m.channel_id = c.id AND m.parent_message_id IS NULL AND m.author_id <> $1 + AND m.kind = 'message' AND m.channel_seq > COALESCE((SELECT cr2.last_read_seq FROM channel_reads cr2 WHERE cr2.channel_id = c.id AND cr2.user_id = $1), 0) ), 0) AS BIGINT) AS unread_count FROM channels c @@ -1851,6 +1861,7 @@ SELECT dc.id, COALESCE(dc.route_id, '') AS route_id, dc.workspace_id, dc.created WHERE m.direct_conversation_id = dc.id AND m.parent_message_id IS NULL AND m.author_id <> $1 + AND m.kind = 'message' AND m.channel_seq > COALESCE((SELECT dr2.last_read_seq FROM direct_reads dr2 WHERE dr2.conversation_id = dc.id AND dr2.user_id = $1), 0) ), 0) AS BIGINT) AS unread_count FROM direct_conversations dc diff --git a/apps/api/internal/store/sqlite/agent_activity_test.go b/apps/api/internal/store/sqlite/agent_activity_test.go new file mode 100644 index 00000000..2767fe71 --- /dev/null +++ b/apps/api/internal/store/sqlite/agent_activity_test.go @@ -0,0 +1,264 @@ +package sqlite + +import ( + "context" + "errors" + "testing" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +// TestAgentActivityMessageKindRoundTrip verifies that the kind + turn_id columns +// added by migration 0017 survive an insert/read cycle, default to 'message', +// and reject unknown kinds. +func TestAgentActivityMessageKindRoundTrip(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newTestStore(t) + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID) + if err != nil { + t.Fatal(err) + } + channel := channels[0] + + // Default kind is 'message' with no turn id. + plain, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "ordinary"}) + if err != nil { + t.Fatal(err) + } + if plain.Kind != store.MessageKindMessage { + t.Fatalf("expected default kind %q, got %q", store.MessageKindMessage, plain.Kind) + } + if plain.TurnID != "" { + t.Fatalf("expected empty turn id for ordinary message, got %q", plain.TurnID) + } + + // Activity kinds round-trip with their turn id. + for _, kind := range []string{store.MessageKindAgentCommentary, store.MessageKindAgentTool} { + created, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "activity " + kind, Kind: kind, TurnID: "turn-1"}) + if err != nil { + t.Fatalf("create %s: %v", kind, err) + } + if created.Kind != kind { + t.Fatalf("expected kind %q, got %q", kind, created.Kind) + } + if created.TurnID != "turn-1" { + t.Fatalf("expected turn id to round-trip, got %q", created.TurnID) + } + fetched, err := st.GetMessage(ctx, created.ID, owner.ID) + if err != nil { + t.Fatalf("get %s: %v", kind, err) + } + if fetched.Kind != kind || fetched.TurnID != "turn-1" { + t.Fatalf("re-read lost kind/turn: %#v", fetched) + } + } + + // Unknown kinds are rejected. + if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "bad", Kind: "bogus"}); err != store.ErrInvalidMessageKind { + t.Fatalf("expected ErrInvalidMessageKind for unknown kind, got %v", err) + } +} + +func TestAgentActivityNonceReplayIncludesKindAndTurn(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newTestStore(t) + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + workspace := workspaces[0] + channels, err := st.ListChannels(ctx, workspace.ID, owner.ID) + if err != nil { + t.Fatal(err) + } + + channelInput := store.CreateMessageInput{ + ChannelID: channels[0].ID, + AuthorID: owner.ID, + Body: "same body", + Nonce: "channel-activity-nonce", + Kind: store.MessageKindAgentCommentary, + TurnID: "turn-1", + } + created, _, err := st.CreateMessage(ctx, channelInput) + if err != nil { + t.Fatal(err) + } + replayed, event, err := st.CreateMessage(ctx, channelInput) + if err != nil || replayed.ID != created.ID || event.ID != "" { + t.Fatalf("expected exact channel replay, got message=%#v event=%#v err=%v", replayed, event, err) + } + changedKind := channelInput + changedKind.Kind = store.MessageKindAgentTool + if _, _, err := st.CreateMessage(ctx, changedKind); !errors.Is(err, store.ErrClientNonceConflict) { + t.Fatalf("expected channel kind mismatch conflict, got %v", err) + } + changedTurn := channelInput + changedTurn.TurnID = "turn-2" + if _, _, err := st.CreateMessage(ctx, changedTurn); !errors.Is(err, store.ErrClientNonceConflict) { + t.Fatalf("expected channel turn mismatch conflict, got %v", err) + } + + other, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Other", Email: "other@example.com"}) + if err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, workspace.ID, other.ID, "member"); err != nil { + t.Fatal(err) + } + dm, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{ + WorkspaceID: workspace.ID, + UserID: owner.ID, + MemberIDs: []string{other.ID}, + }) + if err != nil { + t.Fatal(err) + } + dmInput := store.CreateDirectMessageInput{ + ConversationID: dm.ID, + AuthorID: owner.ID, + Body: "same dm body", + Nonce: "dm-activity-nonce", + Kind: store.MessageKindAgentTool, + TurnID: "turn-dm-1", + } + dmCreated, _, err := st.CreateDirectMessage(ctx, dmInput) + if err != nil { + t.Fatal(err) + } + dmReplayed, dmEvent, err := st.CreateDirectMessage(ctx, dmInput) + if err != nil || dmReplayed.ID != dmCreated.ID || dmEvent.ID != "" { + t.Fatalf("expected exact DM replay, got message=%#v event=%#v err=%v", dmReplayed, dmEvent, err) + } + dmChangedKind := dmInput + dmChangedKind.Kind = store.MessageKindAgentCommentary + if _, _, err := st.CreateDirectMessage(ctx, dmChangedKind); !errors.Is(err, store.ErrClientNonceConflict) { + t.Fatalf("expected DM kind mismatch conflict, got %v", err) + } + dmChangedTurn := dmInput + dmChangedTurn.TurnID = "turn-dm-2" + if _, _, err := st.CreateDirectMessage(ctx, dmChangedTurn); !errors.Is(err, store.ErrClientNonceConflict) { + t.Fatalf("expected DM turn mismatch conflict, got %v", err) + } +} + +// TestAgentActivityMessagesExemptFromUnread verifies that durable agent activity +// rows authored by another member do not bump the reader's unread count, while +// an ordinary message from the same author does. +func TestAgentActivityMessagesExemptFromUnread(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newTestStore(t) + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + workspace := workspaces[0] + other, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Other", Email: "other@example.com"}) + if err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, workspace.ID, other.ID, "member"); err != nil { + t.Fatal(err) + } + channels, err := st.ListChannels(ctx, workspace.ID, owner.ID) + if err != nil { + t.Fatal(err) + } + channel := channels[0] + + unreadFor := func(userID string) int64 { + t.Helper() + list, err := st.ListChannels(ctx, workspace.ID, userID) + if err != nil { + t.Fatal(err) + } + for _, c := range list { + if c.ID == channel.ID { + return c.UnreadCount + } + } + t.Fatalf("channel %s not found for user %s", channel.ID, userID) + return 0 + } + + base := unreadFor(owner.ID) + + // An activity message from another member must NOT bump the owner's unread. + if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: other.ID, Body: "commentary line", Kind: store.MessageKindAgentCommentary, TurnID: "t1"}); err != nil { + t.Fatal(err) + } + if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: other.ID, Body: "tool line", Kind: store.MessageKindAgentTool, TurnID: "t1"}); err != nil { + t.Fatal(err) + } + if got := unreadFor(owner.ID); got != base { + t.Fatalf("activity messages bumped unread: expected %d, got %d", base, got) + } + + // An ordinary message from the same member DOES bump unread. + if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: other.ID, Body: "real message"}); err != nil { + t.Fatal(err) + } + if got := unreadFor(owner.ID); got != base+1 { + t.Fatalf("ordinary message did not bump unread by 1: expected %d, got %d", base+1, got) + } +} + +// TestAgentActivityMessagesExcludedFromSearch verifies the FTS trigger only +// indexes kind='message' rows, so activity bodies never surface in search. +func TestAgentActivityMessagesExcludedFromSearch(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newTestStore(t) + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + workspace := workspaces[0] + channels, err := st.ListChannels(ctx, workspace.ID, owner.ID) + if err != nil { + t.Fatal(err) + } + channel := channels[0] + + if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "zebrafish ordinary"}); err != nil { + t.Fatal(err) + } + if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "zebrafish commentary", Kind: store.MessageKindAgentCommentary, TurnID: "t1"}); err != nil { + t.Fatal(err) + } + + results, err := st.SearchMessages(ctx, workspace.ID, "", owner.ID, "zebrafish", 50) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 { + t.Fatalf("expected exactly one search hit (the ordinary message), got %d: %#v", len(results), results) + } + // The single hit must be the ordinary message, never the activity body. + if results[0].Message.Body != "zebrafish ordinary" { + t.Fatalf("search returned an unexpected (likely activity) row: %#v", results[0].Message) + } +} diff --git a/apps/api/internal/store/sqlite/bots.go b/apps/api/internal/store/sqlite/bots.go index 2b1b44ca..6a65d6e1 100644 --- a/apps/api/internal/store/sqlite/bots.go +++ b/apps/api/internal/store/sqlite/bots.go @@ -66,6 +66,11 @@ var botAllowedScopes = []string{ "realtime:read", "uploads:write", "profile:read", + // agent_activity:write is grantable but intentionally NOT part of any + // bot:* bundle. Durable agent activity is a distinct authorization surface + // and must be granted explicitly, so existing bot:* deployments gain no new + // capability. + store.AgentActivityWriteScope, } func (s *Store) CreateBot(ctx context.Context, input store.CreateBotInput) (store.User, store.BotToken, error) { diff --git a/apps/api/internal/store/sqlite/dms.go b/apps/api/internal/store/sqlite/dms.go index 7d7b847f..a208f924 100644 --- a/apps/api/internal/store/sqlite/dms.go +++ b/apps/api/internal/store/sqlite/dms.go @@ -270,12 +270,16 @@ func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirec if err != nil { return store.Message{}, store.Event{}, err } + kind, err := store.NormalizeMessageKind(input.Kind) + if err != nil { + return store.Message{}, store.Event{}, err + } var quotedID, quotedAuthorID, quotedSnapshot string if input.QuotedMessageID != nil { quotedID = strings.TrimSpace(*input.QuotedMessageID) } if existing, err := getMessageByClientNonceTx(ctx, tx, input.AuthorID, nonce); err == nil { - if existing.DirectConversationID != input.ConversationID || existing.ChannelID != "" || existing.ParentMessageID != nil || existing.Body != body || !sameQuotedMessageID(existing, quotedID) { + if existing.DirectConversationID != input.ConversationID || existing.ChannelID != "" || existing.ParentMessageID != nil || existing.Body != body || existing.Kind != kind || existing.TurnID != input.TurnID || !sameQuotedMessageID(existing, quotedID) { return store.Message{}, store.Event{}, store.ErrClientNonceConflict } return existing, store.Event{}, nil @@ -303,9 +307,11 @@ func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirec QuotedBodySnapshot: quotedSnapshot, QuotedAuthorID: sqlOptionalText(quotedAuthorID), ClientNonce: nonce, + Kind: kind, + TurnID: sqlOptionalText(input.TurnID), }); err != nil { if existing, lookupErr := getMessageByClientNonceTx(ctx, tx, input.AuthorID, nonce); lookupErr == nil { - if existing.DirectConversationID == input.ConversationID && existing.ChannelID == "" && existing.ParentMessageID == nil && existing.Body == body && sameQuotedMessageID(existing, quotedID) { + if existing.DirectConversationID == input.ConversationID && existing.ChannelID == "" && existing.ParentMessageID == nil && existing.Body == body && existing.Kind == kind && existing.TurnID == input.TurnID && sameQuotedMessageID(existing, quotedID) { return existing, store.Event{}, nil } return store.Message{}, store.Event{}, store.ErrClientNonceConflict @@ -322,7 +328,14 @@ func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirec if err != nil { return store.Message{}, store.Event{}, err } - event, err := insertEventWithRecipients(ctx, tx, workspaceID, "", "message.created", &seq, eventPayload(map[string]string{"message_id": id, "direct_conversation_id": input.ConversationID, "author_id": input.AuthorID}, nonce), recipients) + dmEventFields := map[string]string{"message_id": id, "direct_conversation_id": input.ConversationID, "author_id": input.AuthorID} + if kind != store.MessageKindMessage { + dmEventFields["kind"] = kind + } + if input.TurnID != "" { + dmEventFields["turn_id"] = input.TurnID + } + event, err := insertEventWithRecipients(ctx, tx, workspaceID, "", "message.created", &seq, eventPayload(dmEventFields, nonce), recipients) if err != nil { return store.Message{}, store.Event{}, err } diff --git a/apps/api/internal/store/sqlite/helpers.go b/apps/api/internal/store/sqlite/helpers.go index 87df89c2..1e122eb3 100644 --- a/apps/api/internal/store/sqlite/helpers.go +++ b/apps/api/internal/store/sqlite/helpers.go @@ -51,7 +51,7 @@ func messageSelect() string { u.id, u.kind, u.owner_user_id, u.display_name, u.handle, u.avatar_url, u.created_at, m.quoted_message_id, m.quoted_body_snapshot, m.quoted_author_id, qu.id, qu.kind, qu.owner_user_id, qu.display_name, qu.handle, qu.avatar_url, qu.created_at, - m.client_nonce + m.client_nonce, m.kind, COALESCE(m.turn_id, '') FROM messages m JOIN users u ON u.id = m.author_id LEFT JOIN users qu ON qu.id = m.quoted_author_id` @@ -72,7 +72,7 @@ func scanMessage(row scanner) (store.Message, error) { &author.ID, &author.Kind, &authorOwnerID, &author.DisplayName, &author.Handle, &author.AvatarURL, &author.CreatedAt, "edMessageID, &m.QuotedBodySnapshot, "edAuthorID, &quAuthorID, &quKind, &quOwnerID, &quDisplayName, &quHandle, &quAvatarURL, &quCreatedAt, - &nonce, + &nonce, &m.Kind, &m.TurnID, ) if err != nil { return store.Message{}, err diff --git a/apps/api/internal/store/sqlite/migrations/0017_agent_activity.sql b/apps/api/internal/store/sqlite/migrations/0017_agent_activity.sql new file mode 100644 index 00000000..04c94220 --- /dev/null +++ b/apps/api/internal/store/sqlite/migrations/0017_agent_activity.sql @@ -0,0 +1,31 @@ +-- Durable agent activity messages. +-- +-- Adds a message kind discriminator and an optional turn correlation id so a +-- bot bridge can persist agent commentary/tool rows in the normal message +-- stream. Activity rows ('agent_commentary', 'agent_tool') flow through the +-- ordinary message.created fan-out and inherit channel_seq for scrollback, but +-- they are excluded from full-text search and from unread/notification paths. +-- 'message' is the existing, default behaviour. + +ALTER TABLE messages ADD COLUMN kind TEXT NOT NULL DEFAULT 'message'; +ALTER TABLE messages ADD COLUMN turn_id TEXT; + +CREATE INDEX IF NOT EXISTS idx_messages_turn ON messages(turn_id); + +-- Recreate the FTS insert/update triggers so only kind='message' rows are +-- indexed. Activity rows are never user-authored prose and must not surface in +-- search. The delete trigger is unchanged (deleting a never-indexed row is a +-- no-op), but we keep all three definitions co-located for clarity. +DROP TRIGGER IF EXISTS messages_fts_ai; +DROP TRIGGER IF EXISTS messages_fts_au; + +CREATE TRIGGER messages_fts_ai AFTER INSERT ON messages +WHEN new.kind = 'message' BEGIN + INSERT INTO messages_fts(message_id, workspace_id, body) VALUES (new.id, new.workspace_id, new.body); +END; + +CREATE TRIGGER messages_fts_au AFTER UPDATE OF body ON messages +WHEN new.kind = 'message' BEGIN + DELETE FROM messages_fts WHERE message_id = old.id; + INSERT INTO messages_fts(message_id, workspace_id, body) VALUES (new.id, new.workspace_id, new.body); +END; diff --git a/apps/api/internal/store/sqlite/sqlc/queries.sql b/apps/api/internal/store/sqlite/sqlc/queries.sql index 5f383f4d..f061a2f4 100644 --- a/apps/api/internal/store/sqlite/sqlc/queries.sql +++ b/apps/api/internal/store/sqlite/sqlc/queries.sql @@ -191,6 +191,7 @@ SELECT c.id, COALESCE(c.route_id, '') AS route_id, c.workspace_id, c.name, c.kin WHERE m.channel_id = c.id AND m.parent_message_id IS NULL AND m.author_id <> sqlc.arg(reader_user_id) + AND m.kind = 'message' AND m.channel_seq > COALESCE((SELECT cr2.last_read_seq FROM channel_reads cr2 WHERE cr2.channel_id = c.id AND cr2.user_id = sqlc.arg(reader_user_id)), 0) ), 0) AS INTEGER) AS unread_count FROM channels c @@ -447,6 +448,7 @@ SELECT dc.id, COALESCE(dc.route_id, '') AS route_id, dc.workspace_id, dc.created WHERE m.direct_conversation_id = dc.id AND m.parent_message_id IS NULL AND m.author_id <> sqlc.arg(reader_user_id) + AND m.kind = 'message' AND m.channel_seq > COALESCE((SELECT dr2.last_read_seq FROM direct_reads dr2 WHERE dr2.conversation_id = dc.id AND dr2.user_id = sqlc.arg(reader_user_id)), 0) ), 0) AS INTEGER) AS unread_count FROM direct_conversations dc @@ -471,6 +473,7 @@ SELECT dc.id, COALESCE(dc.route_id, '') AS route_id, dc.workspace_id, dc.created WHERE m.direct_conversation_id = dc.id AND m.parent_message_id IS NULL AND m.author_id <> sqlc.arg(reader_user_id) + AND m.kind = 'message' AND m.channel_seq > COALESCE((SELECT dr2.last_read_seq FROM direct_reads dr2 WHERE dr2.conversation_id = dc.id AND dr2.user_id = sqlc.arg(reader_user_id)), 0) ), 0) AS INTEGER) AS unread_count FROM direct_conversations dc @@ -554,12 +557,12 @@ WHERE dcm.conversation_id = sqlc.arg(conversation_id) ORDER BY u.display_name; -- name: InsertChannelMessage :exec -INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, topic_id, channel_seq, thread_seq, body, body_format, created_at, quoted_message_id, quoted_body_snapshot, quoted_author_id, client_nonce) -VALUES (sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(channel_id), NULL, sqlc.arg(author_id), NULL, sqlc.arg(thread_root_id), sqlc.arg(topic_id), sqlc.arg(channel_seq), NULL, sqlc.arg(body), 'markdown', sqlc.arg(created_at), sqlc.arg(quoted_message_id), sqlc.arg(quoted_body_snapshot), sqlc.arg(quoted_author_id), sqlc.arg(client_nonce)); +INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, topic_id, channel_seq, thread_seq, body, body_format, created_at, quoted_message_id, quoted_body_snapshot, quoted_author_id, client_nonce, kind, turn_id) +VALUES (sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(channel_id), NULL, sqlc.arg(author_id), NULL, sqlc.arg(thread_root_id), sqlc.arg(topic_id), sqlc.arg(channel_seq), NULL, sqlc.arg(body), 'markdown', sqlc.arg(created_at), sqlc.arg(quoted_message_id), sqlc.arg(quoted_body_snapshot), sqlc.arg(quoted_author_id), sqlc.arg(client_nonce), sqlc.arg(kind), sqlc.arg(turn_id)); -- name: InsertDirectMessage :exec -INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, channel_seq, thread_seq, body, body_format, created_at, quoted_message_id, quoted_body_snapshot, quoted_author_id, client_nonce) -VALUES (sqlc.arg(id), sqlc.arg(workspace_id), NULL, sqlc.arg(direct_conversation_id), sqlc.arg(author_id), NULL, sqlc.arg(thread_root_id), sqlc.arg(channel_seq), NULL, sqlc.arg(body), 'markdown', sqlc.arg(created_at), sqlc.arg(quoted_message_id), sqlc.arg(quoted_body_snapshot), sqlc.arg(quoted_author_id), sqlc.arg(client_nonce)); +INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, channel_seq, thread_seq, body, body_format, created_at, quoted_message_id, quoted_body_snapshot, quoted_author_id, client_nonce, kind, turn_id) +VALUES (sqlc.arg(id), sqlc.arg(workspace_id), NULL, sqlc.arg(direct_conversation_id), sqlc.arg(author_id), NULL, sqlc.arg(thread_root_id), sqlc.arg(channel_seq), NULL, sqlc.arg(body), 'markdown', sqlc.arg(created_at), sqlc.arg(quoted_message_id), sqlc.arg(quoted_body_snapshot), sqlc.arg(quoted_author_id), sqlc.arg(client_nonce), sqlc.arg(kind), sqlc.arg(turn_id)); -- name: InsertThreadState :exec INSERT INTO thread_state (root_message_id) diff --git a/apps/api/internal/store/sqlite/sqlc/schema.sql b/apps/api/internal/store/sqlite/sqlc/schema.sql index b5f66b9c..12970e12 100644 --- a/apps/api/internal/store/sqlite/sqlc/schema.sql +++ b/apps/api/internal/store/sqlite/sqlc/schema.sql @@ -127,7 +127,9 @@ CREATE TABLE messages ( quoted_body_snapshot TEXT NOT NULL DEFAULT '', quoted_author_id TEXT REFERENCES users(id) ON DELETE SET NULL, client_nonce TEXT NOT NULL DEFAULT '', - route_id TEXT + route_id TEXT, + kind TEXT NOT NULL DEFAULT 'message', + turn_id TEXT ); CREATE INDEX idx_messages_channel_seq ON messages(channel_id, channel_seq); diff --git a/apps/api/internal/store/sqlite/sqlite.go b/apps/api/internal/store/sqlite/sqlite.go index cc073a90..b92b0a82 100644 --- a/apps/api/internal/store/sqlite/sqlite.go +++ b/apps/api/internal/store/sqlite/sqlite.go @@ -508,12 +508,16 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu if err != nil { return store.Message{}, store.Event{}, err } + kind, err := store.NormalizeMessageKind(input.Kind) + if err != nil { + return store.Message{}, store.Event{}, err + } var quotedID, quotedAuthorID, quotedSnapshot string if input.QuotedMessageID != nil { quotedID = strings.TrimSpace(*input.QuotedMessageID) } if existing, err := getMessageByClientNonceTx(ctx, tx, input.AuthorID, nonce); err == nil { - if existing.ChannelID != input.ChannelID || existing.DirectConversationID != "" || existing.ParentMessageID != nil || existing.Body != body || existing.TopicID != input.TopicID || !sameQuotedMessageID(existing, quotedID) { + if existing.ChannelID != input.ChannelID || existing.DirectConversationID != "" || existing.ParentMessageID != nil || existing.Body != body || existing.TopicID != input.TopicID || existing.Kind != kind || existing.TurnID != input.TurnID || !sameQuotedMessageID(existing, quotedID) { return store.Message{}, store.Event{}, store.ErrClientNonceConflict } if err := requireMessageAccessTx(ctx, tx, existing, input.AuthorID); err != nil { @@ -548,9 +552,11 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu QuotedBodySnapshot: quotedSnapshot, QuotedAuthorID: sqlOptionalText(quotedAuthorID), ClientNonce: nonce, + Kind: kind, + TurnID: sqlOptionalText(input.TurnID), }); err != nil { if existing, lookupErr := getMessageByClientNonceTx(ctx, tx, input.AuthorID, nonce); lookupErr == nil { - if existing.ChannelID == input.ChannelID && existing.DirectConversationID == "" && existing.ParentMessageID == nil && existing.Body == body && existing.TopicID == input.TopicID && sameQuotedMessageID(existing, quotedID) { + if existing.ChannelID == input.ChannelID && existing.DirectConversationID == "" && existing.ParentMessageID == nil && existing.Body == body && existing.TopicID == input.TopicID && existing.Kind == kind && existing.TurnID == input.TurnID && sameQuotedMessageID(existing, quotedID) { if err := requireMessageAccessTx(ctx, tx, existing, input.AuthorID); err != nil { return store.Message{}, store.Event{}, err } @@ -567,6 +573,12 @@ func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInpu if input.TopicID != "" { eventFields["topic_id"] = input.TopicID } + if kind != store.MessageKindMessage { + eventFields["kind"] = kind + } + if input.TurnID != "" { + eventFields["turn_id"] = input.TurnID + } event, err := insertEvent(ctx, tx, workspaceID, input.ChannelID, "message.created", &seq, eventPayload(eventFields, nonce)) if err != nil { return store.Message{}, store.Event{}, err diff --git a/apps/api/internal/store/sqlite/storedb/models.go b/apps/api/internal/store/sqlite/storedb/models.go index ab8da222..9603ad52 100644 --- a/apps/api/internal/store/sqlite/storedb/models.go +++ b/apps/api/internal/store/sqlite/storedb/models.go @@ -196,6 +196,8 @@ type Message struct { QuotedAuthorID sql.NullString `json:"quoted_author_id"` ClientNonce string `json:"client_nonce"` RouteID sql.NullString `json:"route_id"` + Kind string `json:"kind"` + TurnID sql.NullString `json:"turn_id"` } type MessageAttachment struct { diff --git a/apps/api/internal/store/sqlite/storedb/queries.sql.go b/apps/api/internal/store/sqlite/storedb/queries.sql.go index bd3da168..72392fff 100644 --- a/apps/api/internal/store/sqlite/storedb/queries.sql.go +++ b/apps/api/internal/store/sqlite/storedb/queries.sql.go @@ -721,6 +721,7 @@ SELECT dc.id, COALESCE(dc.route_id, '') AS route_id, dc.workspace_id, dc.created WHERE m.direct_conversation_id = dc.id AND m.parent_message_id IS NULL AND m.author_id <> ?1 + AND m.kind = 'message' AND m.channel_seq > COALESCE((SELECT dr2.last_read_seq FROM direct_reads dr2 WHERE dr2.conversation_id = dc.id AND dr2.user_id = ?1), 0) ), 0) AS INTEGER) AS unread_count FROM direct_conversations dc @@ -1269,8 +1270,8 @@ func (q *Queries) InsertChannel(ctx context.Context, arg InsertChannelParams) er } const insertChannelMessage = `-- name: InsertChannelMessage :exec -INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, topic_id, channel_seq, thread_seq, body, body_format, created_at, quoted_message_id, quoted_body_snapshot, quoted_author_id, client_nonce) -VALUES (?1, ?2, ?3, NULL, ?4, NULL, ?5, ?6, ?7, NULL, ?8, 'markdown', ?9, ?10, ?11, ?12, ?13) +INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, topic_id, channel_seq, thread_seq, body, body_format, created_at, quoted_message_id, quoted_body_snapshot, quoted_author_id, client_nonce, kind, turn_id) +VALUES (?1, ?2, ?3, NULL, ?4, NULL, ?5, ?6, ?7, NULL, ?8, 'markdown', ?9, ?10, ?11, ?12, ?13, ?14, ?15) ` type InsertChannelMessageParams struct { @@ -1287,6 +1288,8 @@ type InsertChannelMessageParams struct { QuotedBodySnapshot string `json:"quoted_body_snapshot"` QuotedAuthorID sql.NullString `json:"quoted_author_id"` ClientNonce string `json:"client_nonce"` + Kind string `json:"kind"` + TurnID sql.NullString `json:"turn_id"` } func (q *Queries) InsertChannelMessage(ctx context.Context, arg InsertChannelMessageParams) error { @@ -1304,6 +1307,8 @@ func (q *Queries) InsertChannelMessage(ctx context.Context, arg InsertChannelMes arg.QuotedBodySnapshot, arg.QuotedAuthorID, arg.ClientNonce, + arg.Kind, + arg.TurnID, ) return err } @@ -1397,8 +1402,8 @@ func (q *Queries) InsertDirectConversationMember(ctx context.Context, arg Insert } const insertDirectMessage = `-- name: InsertDirectMessage :exec -INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, channel_seq, thread_seq, body, body_format, created_at, quoted_message_id, quoted_body_snapshot, quoted_author_id, client_nonce) -VALUES (?1, ?2, NULL, ?3, ?4, NULL, ?5, ?6, NULL, ?7, 'markdown', ?8, ?9, ?10, ?11, ?12) +INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, channel_seq, thread_seq, body, body_format, created_at, quoted_message_id, quoted_body_snapshot, quoted_author_id, client_nonce, kind, turn_id) +VALUES (?1, ?2, NULL, ?3, ?4, NULL, ?5, ?6, NULL, ?7, 'markdown', ?8, ?9, ?10, ?11, ?12, ?13, ?14) ` type InsertDirectMessageParams struct { @@ -1414,6 +1419,8 @@ type InsertDirectMessageParams struct { QuotedBodySnapshot string `json:"quoted_body_snapshot"` QuotedAuthorID sql.NullString `json:"quoted_author_id"` ClientNonce string `json:"client_nonce"` + Kind string `json:"kind"` + TurnID sql.NullString `json:"turn_id"` } func (q *Queries) InsertDirectMessage(ctx context.Context, arg InsertDirectMessageParams) error { @@ -1430,6 +1437,8 @@ func (q *Queries) InsertDirectMessage(ctx context.Context, arg InsertDirectMessa arg.QuotedBodySnapshot, arg.QuotedAuthorID, arg.ClientNonce, + arg.Kind, + arg.TurnID, ) return err } @@ -1777,6 +1786,7 @@ SELECT c.id, COALESCE(c.route_id, '') AS route_id, c.workspace_id, c.name, c.kin WHERE m.channel_id = c.id AND m.parent_message_id IS NULL AND m.author_id <> ?1 + AND m.kind = 'message' AND m.channel_seq > COALESCE((SELECT cr2.last_read_seq FROM channel_reads cr2 WHERE cr2.channel_id = c.id AND cr2.user_id = ?1), 0) ), 0) AS INTEGER) AS unread_count FROM channels c @@ -1846,6 +1856,7 @@ SELECT dc.id, COALESCE(dc.route_id, '') AS route_id, dc.workspace_id, dc.created WHERE m.direct_conversation_id = dc.id AND m.parent_message_id IS NULL AND m.author_id <> ?1 + AND m.kind = 'message' AND m.channel_seq > COALESCE((SELECT dr2.last_read_seq FROM direct_reads dr2 WHERE dr2.conversation_id = dc.id AND dr2.user_id = ?1), 0) ), 0) AS INTEGER) AS unread_count FROM direct_conversations dc diff --git a/apps/api/internal/store/types.go b/apps/api/internal/store/types.go index 7ddd0e8a..a29892ca 100644 --- a/apps/api/internal/store/types.go +++ b/apps/api/internal/store/types.go @@ -34,6 +34,54 @@ var ErrPostRateLimited = errors.New("waiting room post limit reached") // budget in a workspace. var ErrUploadQuotaExceeded = errors.New("upload quota exceeded") +// ErrInvalidMessageKind is returned when a caller supplies a message kind that +// is not one of the recognised values. HTTP callers surface it as a 400. +var ErrInvalidMessageKind = errors.New("invalid message kind") + +// ErrTurnIDNotAllowed is returned when an ordinary ('message') row is created +// with a non-empty turn_id. turn_id correlates a sequence of agent activity +// rows belonging to one turn; an ordinary message carrying one contradicts the +// documented "must be empty for ordinary messages" contract. HTTP callers surface it as +// a 400 so a client bug fails closed instead of silently persisting a +// contradictory turn_id. +var ErrTurnIDNotAllowed = errors.New("turn_id is only valid for agent activity messages") + +// Message kinds. 'message' is an ordinary human/bot message and is the default +// for any row created before this column existed. The agent_* kinds are +// durable agent activity rows: they ride the normal message stream (channel +// sequence, message.created fan-out, scrollback) but are excluded from +// full-text search and from unread/notification accounting. +const ( + MessageKindMessage = "message" + MessageKindAgentCommentary = "agent_commentary" + MessageKindAgentTool = "agent_tool" +) + +// AgentActivityWriteScope is the dedicated, non-inherited bot scope required to +// create an agent activity message (kind != 'message'). It is deliberately +// EXCLUDED from the bot:* bundles so existing deployments' capability surface +// is unchanged: a bot must be granted it explicitly. +const AgentActivityWriteScope = "agent_activity:write" + +// IsActivityMessageKind reports whether kind is one of the durable agent +// activity kinds (anything other than the ordinary 'message'). +func IsActivityMessageKind(kind string) bool { + return kind == MessageKindAgentCommentary || kind == MessageKindAgentTool +} + +// NormalizeMessageKind validates a caller-supplied kind. An empty value +// defaults to 'message'. Unknown values return ErrInvalidMessageKind. +func NormalizeMessageKind(kind string) (string, error) { + switch kind { + case "", MessageKindMessage: + return MessageKindMessage, nil + case MessageKindAgentCommentary, MessageKindAgentTool: + return kind, nil + default: + return "", ErrInvalidMessageKind + } +} + const ( WorkspaceRoleOwner = "owner" WorkspaceRoleModerator = "moderator" @@ -99,29 +147,38 @@ type Channel struct { } type Message struct { - ID string `json:"id"` - RouteID string `json:"route_id,omitempty"` - WorkspaceID string `json:"workspace_id"` - ChannelID string `json:"channel_id,omitempty"` - DirectConversationID string `json:"direct_conversation_id,omitempty"` - AuthorID string `json:"author_id"` - ParentMessageID *string `json:"parent_message_id,omitempty"` - ThreadRootID string `json:"thread_root_id"` - TopicID string `json:"topic_id,omitempty"` - ChannelSeq *int64 `json:"channel_seq,omitempty"` - ThreadSeq *int64 `json:"thread_seq,omitempty"` - Body string `json:"body"` - BodyFormat string `json:"body_format"` - CreatedAt string `json:"created_at"` - EditedAt *string `json:"edited_at,omitempty"` - DeletedAt *string `json:"deleted_at,omitempty"` - Author *User `json:"author,omitempty"` - Attachments []Upload `json:"attachments,omitempty"` - QuotedMessageID *string `json:"quoted_message_id,omitempty"` - QuotedBodySnapshot string `json:"quoted_body_snapshot,omitempty"` - QuotedAuthorID *string `json:"quoted_author_id,omitempty"` - QuotedAuthor *User `json:"quoted_author,omitempty"` - ThreadState *ThreadState `json:"thread_state,omitempty"` + ID string `json:"id"` + RouteID string `json:"route_id,omitempty"` + WorkspaceID string `json:"workspace_id"` + ChannelID string `json:"channel_id,omitempty"` + DirectConversationID string `json:"direct_conversation_id,omitempty"` + AuthorID string `json:"author_id"` + ParentMessageID *string `json:"parent_message_id,omitempty"` + ThreadRootID string `json:"thread_root_id"` + TopicID string `json:"topic_id,omitempty"` + ChannelSeq *int64 `json:"channel_seq,omitempty"` + ThreadSeq *int64 `json:"thread_seq,omitempty"` + Body string `json:"body"` + BodyFormat string `json:"body_format"` + CreatedAt string `json:"created_at"` + EditedAt *string `json:"edited_at,omitempty"` + DeletedAt *string `json:"deleted_at,omitempty"` + // Kind discriminates ordinary messages from durable agent activity rows. + // Empty in JSON means the default 'message'. + Kind string `json:"kind,omitempty"` + // TurnID correlates a sequence of agent activity rows belonging to one + // agent turn. It must be empty for ordinary messages (kind="message"): the + // create path enforces this and rejects a non-empty turn_id on a 'message' + // kind with a 400 ErrTurnIDNotAllowed. It is optional for agent activity + // kinds (agent_commentary/agent_tool), which may carry one. + TurnID string `json:"turn_id,omitempty"` + Author *User `json:"author,omitempty"` + Attachments []Upload `json:"attachments,omitempty"` + QuotedMessageID *string `json:"quoted_message_id,omitempty"` + QuotedBodySnapshot string `json:"quoted_body_snapshot,omitempty"` + QuotedAuthorID *string `json:"quoted_author_id,omitempty"` + QuotedAuthor *User `json:"quoted_author,omitempty"` + ThreadState *ThreadState `json:"thread_state,omitempty"` // Nonce is a client-supplied idempotency key used by optimistic UIs to match // the server response to a pending placeholder and safely retry after a lost // response. @@ -422,6 +479,10 @@ type CreateMessageInput struct { QuotedMessageID *string Nonce string TopicID string + // Kind defaults to 'message' when empty. Activity kinds are gated at the + // API layer by AgentActivityWriteScope. + Kind string + TurnID string } type UpdateMessageInput struct { @@ -534,6 +595,8 @@ type CreateDirectMessageInput struct { Body string QuotedMessageID *string Nonce string + Kind string + TurnID string } type Topic struct { diff --git a/apps/api/internal/webassets/dist/200.html b/apps/api/internal/webassets/dist/200.html index 1d22f0dc..7031eded 100644 --- a/apps/api/internal/webassets/dist/200.html +++ b/apps/api/internal/webassets/dist/200.html @@ -9,15 +9,15 @@ - + - + - +
{m=!0,f=b=!1},T=()=>{m=!1,Wo()&&(f=!0)};return e.addEventListener("scroll",C),e.addEventListener("wheel",x,{passive:!0}),e.addEventListener("touchstart",E,{passive:!0}),e.addEventListener("touchend",T,{passive:!0}),{h:()=>{e.removeEventListener("scroll",C),e.removeEventListener("wheel",x),e.removeEventListener("touchstart",E),e.removeEventListener("touchend",T),w.p()},m:()=>{const[R,P]=n.$();R&&(i(R,P,b),b=!1,P&&n.$getViewportSize()>n.$getTotalSize()&&n.$update(1,r()))}}},af=(n,e,a)=>{let r;return[async(i,o)=>{if(!await e())return;r&&r();const c=()=>{const[d,u]=li();return r=()=>{u(!1)},n.$getViewportSize()&&yi(r,150),[d,n.$subscribe(2,()=>{u(!0)})]};if(o&&Yd())n.$update(8,i()),Wd(async()=>{for(;;){let d=!0;for(let[f,b]=n.$getRange();f<=b;f++)if(n.$isUnmeasuredItem(f)){d=!1;break}if(d)break;const[u,m]=c();try{if(!await u)return}finally{m()}}n.$update(7),a(i(),o)});else for(;;){const[d,u]=c();try{if(n.$update(7),a(i()),!await d)return}finally{u()}}},()=>{r&&r()}]},rf=(n,e)=>{let a,r,i=li(),o=!1;const c=e?"scrollLeft":"scrollTop",d=e?"overflowX":"overflowY",[u,m]=af(n,()=>i[0],(f,b)=>{f=Vr(f,o),b?a.scrollTo({[e?"left":"top"]:f,behavior:"smooth"}):a[c]=f});return{$observe(f,b){a=b,e&&(o=getComputedStyle(b).direction==="rtl"),r=nf(n,b,e,()=>Vr(b[c],o),(w,C,x)=>{if(x){const E=b.style,T=E[d];E[d]="hidden",yi(()=>{E[d]=T})}b[c]=Vr(n.$getScrollOffset()+w,o),C&&m()}),i[1](!0)},$dispose(){r&&r.h(),i[1](!1),i=li()},$isNegative:()=>o,$scrollTo(f){u(()=>f)},$scrollBy(f){f+=n.$getScrollOffset(),u(()=>f)},$scrollToIndex(f,{align:b,smooth:w,offset:C=0}={}){if(f=zo(f,0,n.$getItemsLength()-1),b==="nearest"){const x=n.$getItemOffset(f),E=n.$getScrollOffset();if(x ${this.parser.parseInline(n)} An error occurred: Channels Direct messages Blocked. Profile Browser notifications are not supported Browser notifications are blocked by this browser Account Thread Sign in with GitHub to join the guest room. Any GitHub account can join. ${this.parser.parseInline(n)} An error occurred: Channels Direct messages Blocked. Profile Browser notifications are not supported Browser notifications are blocked by this browser Account Thread Sign in with GitHub to join the guest room. Any GitHub account can join. Self-hostable chat. Serious tool. Mild brine. A single-binary chat app for teams, communities, bots, and agents:
+import{a as d,f as y,c as K}from"../chunks/BQbSjbnU.js";import{i as N,h as q,s,e as $,a as B,C as J}from"../chunks/C-yBSfLe.js";import{p as V,G as X,H as Y,$ as Z,o as t,v as a,q as e,t as G,a as aa,I as M,n as ea}from"../chunks/Dtg1jxvd.js";import{i as ta}from"../chunks/BT2tP07K.js";import{s as w}from"../chunks/D2t05ipP.js";const sa=!0,ua=Object.freeze(Object.defineProperty({__proto__:null,prerender:sa},Symbol.toStringTag,{value:"Module"})),ca=new Set(["clickclack.chat","www.clickclack.chat"]);function ra(i){return ca.has(i.toLowerCase())?"https://app.clickclack.chat":"/app"}var ia=y(' ',1),na=y(" What it is WebSocket is the pipe. The database is the truth. Every durable message,
thread reply, reaction, and channel update can be recovered over HTTP with
diff --git a/apps/api/internal/webassets/dist/_app/immutable/nodes/3.Beyl_vg6.js b/apps/api/internal/webassets/dist/_app/immutable/nodes/3.Beyl_vg6.js
new file mode 100644
index 00000000..81a7ad7d
--- /dev/null
+++ b/apps/api/internal/webassets/dist/_app/immutable/nodes/3.Beyl_vg6.js
@@ -0,0 +1 @@
+import"../chunks/BQbSjbnU.js";import{C as p}from"../chunks/C-yBSfLe.js";function m(o){p(o,{})}export{m as component};
diff --git a/apps/api/internal/webassets/dist/_app/immutable/nodes/3.DWWmaAg4.js b/apps/api/internal/webassets/dist/_app/immutable/nodes/3.DWWmaAg4.js
deleted file mode 100644
index 09e4e1be..00000000
--- a/apps/api/internal/webassets/dist/_app/immutable/nodes/3.DWWmaAg4.js
+++ /dev/null
@@ -1 +0,0 @@
-import"../chunks/BQbSjbnU.js";import{C as p}from"../chunks/D6tJMEHh.js";function m(o){p(o,{})}export{m as component};
diff --git a/apps/api/internal/webassets/dist/_app/immutable/nodes/4.BRQUM5yE.js b/apps/api/internal/webassets/dist/_app/immutable/nodes/4.DdHYRc9K.js
similarity index 71%
rename from apps/api/internal/webassets/dist/_app/immutable/nodes/4.BRQUM5yE.js
rename to apps/api/internal/webassets/dist/_app/immutable/nodes/4.DdHYRc9K.js
index 618432bc..b563bf92 100644
--- a/apps/api/internal/webassets/dist/_app/immutable/nodes/4.BRQUM5yE.js
+++ b/apps/api/internal/webassets/dist/_app/immutable/nodes/4.DdHYRc9K.js
@@ -1 +1 @@
-import"../chunks/BQbSjbnU.js";import{p as r,a}from"../chunks/Dtg1jxvd.js";import{C as t}from"../chunks/D6tJMEHh.js";function n(o,p){r(p,!0),t(o,{get routeWorkspaceID(){return p.params.workspaceID}}),a()}export{n as component};
+import"../chunks/BQbSjbnU.js";import{p as r,a}from"../chunks/Dtg1jxvd.js";import{C as t}from"../chunks/C-yBSfLe.js";function n(o,p){r(p,!0),t(o,{get routeWorkspaceID(){return p.params.workspaceID}}),a()}export{n as component};
diff --git a/apps/api/internal/webassets/dist/_app/immutable/nodes/5.CW1aX1ge.js b/apps/api/internal/webassets/dist/_app/immutable/nodes/5.Bjf09aR3.js
similarity index 76%
rename from apps/api/internal/webassets/dist/_app/immutable/nodes/5.CW1aX1ge.js
rename to apps/api/internal/webassets/dist/_app/immutable/nodes/5.Bjf09aR3.js
index 52c56a26..b1fee47a 100644
--- a/apps/api/internal/webassets/dist/_app/immutable/nodes/5.CW1aX1ge.js
+++ b/apps/api/internal/webassets/dist/_app/immutable/nodes/5.Bjf09aR3.js
@@ -1 +1 @@
-import"../chunks/BQbSjbnU.js";import{p as a,a as e}from"../chunks/Dtg1jxvd.js";import{C as o}from"../chunks/D6tJMEHh.js";function u(t,r){a(r,!0),o(t,{get routeWorkspaceID(){return r.params.workspaceID},get routeTargetID(){return r.params.targetID}}),e()}export{u as component};
+import"../chunks/BQbSjbnU.js";import{p as a,a as e}from"../chunks/Dtg1jxvd.js";import{C as o}from"../chunks/C-yBSfLe.js";function u(t,r){a(r,!0),o(t,{get routeWorkspaceID(){return r.params.workspaceID},get routeTargetID(){return r.params.targetID}}),e()}export{u as component};
diff --git a/apps/api/internal/webassets/dist/index.html b/apps/api/internal/webassets/dist/index.html
index 97e29a78..ebb3e1b2 100644
--- a/apps/api/internal/webassets/dist/index.html
+++ b/apps/api/internal/webassets/dist/index.html
@@ -9,17 +9,17 @@
-
+
-
+
-
-
+
+
-
+
@@ -35,7 +35,7 @@
Promise.all([
import("./_app/immutable/entry/start.DptDfE7f.js"),
- import("./_app/immutable/entry/app.BOItyeok.js")
+ import("./_app/immutable/entry/app.Nt0q6qqN.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
diff --git a/apps/api/internal/webassets/webassets.go b/apps/api/internal/webassets/webassets.go
index e16b941d..db886f33 100644
--- a/apps/api/internal/webassets/webassets.go
+++ b/apps/api/internal/webassets/webassets.go
@@ -4,5 +4,11 @@ import "embed"
// Dist is replaced by the root pnpm build script after the Svelte app builds.
//
-//go:embed dist/*
+// The all: prefix is required so files whose names begin with "_" or "."
+// are embedded too. SvelteKit/Rollup can emit chunk filenames with a leading
+// underscore (e.g. _MajmMwB.js); without all: those are silently dropped from
+// the binary and the server returns the SPA fallback HTML for them, which
+// breaks module loading with a MIME-type error and a blank screen.
+//
+//go:embed all:dist
var Dist embed.FS
diff --git a/apps/web/src/ChatApp.svelte b/apps/web/src/ChatApp.svelte
index a5753021..fa2b8d18 100644
--- a/apps/web/src/ChatApp.svelte
+++ b/apps/web/src/ChatApp.svelte
@@ -14,6 +14,7 @@
type MessageWindowDirection,
} from "./lib/chat/messageWindow";
import { collectRecentPeople, dmTitle } from "./lib/chat/people";
+ import { coalesceAgentActivity } from "./lib/chat/agent-activity";
import { redirectTypingToComposer, rememberTypeToFocusPointer } from "./lib/chat/typeToFocus";
import { connectRealtime, type RealtimeConnection } from "./lib/realtime.svelte";
import { notifyTyping, stopTyping } from "./lib/typing";
@@ -26,6 +27,7 @@
} from "./components/messages/MessageList.svelte";
import TypingIndicator, { TYPING_TTL_MS, type TypingEntry } from "./components/messages/TypingIndicator.svelte";
import AgentProgress, { AGENT_PROGRESS_TTL_MS, type AgentProgressTurn } from "./components/messages/AgentProgress.svelte";
+ import AgentResponding from "./components/messages/AgentResponding.svelte";
import CreateChannelModal from "./components/navigation/CreateChannelModal.svelte";
import CreateDirectModal from "./components/navigation/CreateDirectModal.svelte";
import GuildRail from "./components/navigation/GuildRail.svelte";
@@ -42,6 +44,10 @@
const LAST_CHANNEL_STORAGE_PREFIX = "clickclack:last-channel:v1:";
const BROWSER_NOTIFICATIONS_STORAGE_PREFIX = "clickclack:browser-notifications-enabled:v1:";
const MOBILE_NAV_MEDIA_QUERY = "(max-width: 820px)";
+ const SHOW_AGENT_ACTIVITY_STORAGE_KEY = "clickclack:show-agent-activity:v1";
+ const HIDE_COMMENTARY_STORAGE_KEY = "clickclack:hide-commentary:v1";
+ const HIDE_TOOL_CALLS_STORAGE_KEY = "clickclack:hide-tool-calls:v1";
+ const USER_ALIGN_STORAGE_KEY = "clickclack:user-align:v1";
const appSessionStartedAt = Date.now();
export let routeWorkspaceID = "";
@@ -86,6 +92,17 @@
let browserNotificationPermission: NotificationPermission | "unsupported" = "default";
let profileStatus = "";
let profileStatusError = false;
+ // Client-only preferences for agent activity. Consecutive same-turn
+ // agent_commentary/agent_tool rows are coalesced into one preamble block;
+ // these two independent flags drop the commentary prose and/or the tool-call
+ // sub-items from that block. When both are set the block is omitted entirely.
+ // Default: show both. Persisted in localStorage like other client prefs.
+ let hideCommentary = false;
+ let hideToolCalls = false;
+ // Self-message alignment: "left" (default, matches the legacy layout) or
+ // "right". Persisted client-side and applied as a root data attribute so the
+ // messages.css mirror rules can flip the self group without prop drilling.
+ let userAlign: "left" | "right" = "left";
let status = "loading";
let authRequired = false;
let connected = false;
@@ -120,6 +137,8 @@
let typingSweeper: number | undefined;
let agentProgressTurns: AgentProgressTurn[] = [];
let agentProgressSweeper: number | undefined;
+ let activityClock = Date.now();
+ let activityClockSweeper: number | undefined;
let appliedRouteKey = "";
let routeApplySerial = 0;
let hiddenDirectUndo: HiddenDirectUndo | null = null;
@@ -165,6 +184,20 @@
$: activeUnreadSince = activeUnreadCount > 0
? unreadSinceForKey(activeConversationKey, activeUnreadBoundarySeq, messageWindows)
: "";
+ // Coalesce consecutive same-turn agent activity rows into one preamble block
+ // per turn, applying the two visibility flags. Ordinary messages pass through
+ // untouched and keep their order.
+ $: visibleMessages = coalesceAgentActivity(
+ messages,
+ { hideCommentary, hideToolCalls },
+ activityClock,
+ );
+ // High-level "agent turn is live" signal: any tracked turn that still has an
+ // unfinalized line. Drives the compact AgentResponding status above the
+ // composer; clears as soon as every line finalizes or the turn is cleared.
+ $: agentResponding = agentProgressTurns.some((turn) =>
+ turn.lines.some((line) => !line.finalized),
+ );
$: sidePanelOpen = selectedThread !== null || selectedProfile !== null;
$: recentPeople = collectRecentPeople(messages, directConversations, user?.id || "");
$: mentionPeople = collectMentionPeople(user, recentPeople, moderationMembers, selectedDirect);
@@ -182,6 +215,10 @@
: [];
onMount(() => {
+ loadActivityPrefs();
+ activityClockSweeper = window.setInterval(() => {
+ activityClock = Date.now();
+ }, 30_000);
syncBrowserNotificationState();
void boot();
const mobileNavMedia = window.matchMedia(MOBILE_NAV_MEDIA_QUERY);
@@ -192,6 +229,59 @@
return () => mobileNavMedia.removeEventListener("change", handleMobileNavBreakpoint);
});
+ function loadActivityPrefs() {
+ try {
+ // New flags default off (both shown). Migrate the legacy single toggle:
+ // if the operator had previously hidden all activity, carry that forward
+ // as both flags hidden.
+ const legacyHidden = window.localStorage.getItem(SHOW_AGENT_ACTIVITY_STORAGE_KEY) === "0";
+ hideCommentary = window.localStorage.getItem(HIDE_COMMENTARY_STORAGE_KEY) === "1" || legacyHidden;
+ hideToolCalls = window.localStorage.getItem(HIDE_TOOL_CALLS_STORAGE_KEY) === "1" || legacyHidden;
+ userAlign = window.localStorage.getItem(USER_ALIGN_STORAGE_KEY) === "right" ? "right" : "left";
+ } catch {
+ hideCommentary = false;
+ hideToolCalls = false;
+ userAlign = "left";
+ }
+ applyUserAlign();
+ }
+
+ function applyUserAlign() {
+ try {
+ document.documentElement.setAttribute("data-user-align", userAlign);
+ } catch {
+ // Non-DOM context (SSR/tests); the in-memory pref still applies on mount.
+ }
+ }
+
+ function setUserAlign(value: "left" | "right") {
+ userAlign = value;
+ applyUserAlign();
+ try {
+ window.localStorage.setItem(USER_ALIGN_STORAGE_KEY, value);
+ } catch {
+ // Ignore unavailable storage; the in-memory pref still applies this session.
+ }
+ }
+
+ function setHideCommentary(value: boolean) {
+ hideCommentary = value;
+ try {
+ window.localStorage.setItem(HIDE_COMMENTARY_STORAGE_KEY, value ? "1" : "0");
+ } catch {
+ // Ignore unavailable storage; the in-memory pref still applies this session.
+ }
+ }
+
+ function setHideToolCalls(value: boolean) {
+ hideToolCalls = value;
+ try {
+ window.localStorage.setItem(HIDE_TOOL_CALLS_STORAGE_KEY, value ? "1" : "0");
+ } catch {
+ // Ignore unavailable storage; the in-memory pref still applies this session.
+ }
+ }
+
onDestroy(() => {
socket?.close();
socket = null;
@@ -199,6 +289,7 @@
stopTyping();
if (typingSweeper) window.clearInterval(typingSweeper);
if (agentProgressSweeper) window.clearInterval(agentProgressSweeper);
+ if (activityClockSweeper) window.clearInterval(activityClockSweeper);
if (hiddenDirectUndoTimer) clearTimeout(hiddenDirectUndoTimer);
});
@@ -1315,10 +1406,12 @@
function maybeShowBrowserNotification(event: RealtimeEvent, affectsActiveView: boolean) {
if (event.type !== "message.created") return;
+ const payload = event.payload as Record'),gf=H(''),pf=H("");function qn(n,e){ht(e,!0);let a=Fe(e,"class",3,"avatar"),r=Fe(e,"size",3,40),i=Fe(e,"loading",3,"lazy"),o=Fe(e,"fetchPriority",3,"low"),c=dt("");const d=oe(()=>hf(e.src)),u=oe(()=>t(d)!==""&&t(c)!==t(d)),m=oe(()=>Oo(e.id||e.name||t(d)||"avatar")),f=oe(()=>No(e.name));function b(){h(c,t(d),!0)}var w=bn(),C=Ft(w);{var x=T=>{var R=gf(),P=_(R);{var D=Y=>{var U=Fs();se(()=>{ne(U,"src",t(d)),ne(U,"width",r()),ne(U,"height",r()),ne(U,"loading",i()),ne(U,"fetchpriority",o())}),un("error",U,b),ks(U),O(Y,U)},W=Y=>{var U=Mn();se(()=>ee(U,t(f))),O(Y,U)};le(P,Y=>{t(u)?Y(D):Y(W,-1)})}p(R),se(()=>{Qe(R,1,yr(a())),oa(R,`--hue: ${t(m)??""}deg`),ne(R,"aria-label",e.buttonLabel)}),$("click",R,function(...Y){e.onclick?.apply(this,Y)}),O(T,R)},E=T=>{var R=pf(),P=_(R);{var D=Y=>{var U=Fs();se(()=>{ne(U,"src",t(d)),ne(U,"width",r()),ne(U,"height",r()),ne(U,"loading",i()),ne(U,"fetchpriority",o())}),un("error",U,b),ks(U),O(Y,U)},W=Y=>{var U=Mn();se(()=>ee(U,t(f))),O(Y,U)};le(P,Y=>{t(u)?Y(D):Y(W,-1)})}p(R),se(()=>{Qe(R,1,yr(a())),oa(R,`--hue: ${t(m)??""}deg`)}),O(T,R)};le(C,T=>{e.buttonLabel?T(x):T(E,-1)})}O(n,w),gt()}_t(["click"]);function Hs(n,e){(e==null||e>n.length)&&(e=n.length);for(var a=0,r=Array(e);a
+`:"'+(a?i:Dn(i,!0))+`
+`}blockquote({tokens:n}){return`"+(a?i:Dn(i,!0))+`
+${this.parser.parse(n)}
+`}html({text:n}){return n}def(n){return""}heading({tokens:n,depth:e}){return`
+`}list(n){let e=n.ordered,a=n.start,r="";for(let c=0;c
+
+`+e+`
+`+r+`
+`}tablerow({text:n}){return`
+${n}
+`}tablecell(n){let e=this.parser.parseInline(n.tokens),a=n.header?"th":"td";return(n.align?`<${a} align="${n.align}">`:`<${a}>`)+e+`${a}>
+`}strong({tokens:n}){return`${this.parser.parseInline(n)}`}em({tokens:n}){return`${this.parser.parseInline(n)}`}codespan({text:n}){return`${Dn(n,!0)}`}br(n){return"
"}del({tokens:n}){return`${this.parser.parseInline(n)}`}link({href:n,title:e,tokens:a}){let r=this.parser.parseInline(a),i=ro(n);if(i===null)return r;n=i;let o='"+r+"",o}image({href:n,title:e,text:a,tokens:r}){r&&(a=this.parser.parseInline(r,this.parser.textRenderer));let i=ro(n);if(i===null)return Dn(a);n=i;let o=`",o}text(n){return"tokens"in n&&n.tokens?this.parser.parseInline(n.tokens):"escaped"in n&&n.escaped?n.text:Dn(n.text)}},Ii=class{strong({text:n}){return n}em({text:n}){return n}codespan({text:n}){return n}del({text:n}){return n}html({text:n}){return n}text({text:n}){return n}link({text:n}){return""+n}image({text:n}){return""+n}br(){return""}checkbox({raw:n}){return n}},An=class hi{options;renderer;textRenderer;constructor(e){this.options=e||ca,this.options.renderer=this.options.renderer||new xr,this.renderer=this.options.renderer,this.renderer.options=this.options,this.renderer.parser=this,this.textRenderer=new Ii}static parse(e,a){return new hi(a).parse(e)}static parseInline(e,a){return new hi(a).parseInline(e)}parse(e){this.renderer.parser=this;let a="";for(let r=0;r
"+Dn(a.message+"",!0)+"
";return e?Promise.resolve(r):r}if(e)return Promise.reject(a);throw a}}},la=new Nh;function ft(n,e){return la.parse(n,e)}ft.options=ft.setOptions=function(n){return la.setOptions(n),ft.defaults=la.defaults,Vo(ft.defaults),ft};ft.getDefaults=wi;ft.defaults=ca;ft.use=function(...n){return la.use(...n),ft.defaults=la.defaults,Vo(ft.defaults),ft};ft.walkTokens=function(n,e){return la.walkTokens(n,e)};ft.parseInline=la.parseInline;ft.Parser=An;ft.parser=An.parse;ft.Renderer=xr;ft.TextRenderer=Ii;ft.Lexer=xn;ft.lexer=xn.lex;ft.Tokenizer=Sr;ft.Hooks=za;ft.parse=ft;ft.options;ft.setOptions;ft.use;ft.walkTokens;ft.parseInline;An.parse;xn.lex;function Va(n){return jf.sanitize(ft.parse(n,{async:!1,breaks:!0,gfm:!0}))}function Ya(n){return new Intl.DateTimeFormat(void 0,{hour:"2-digit",minute:"2-digit"}).format(new Date(n))}var Oh=H(''),Lh=H(''),Ph=H(' ',1),Uh=H('',2),qh=H(''),zh=H(''),Bh=H(''),Fh=H(' ');function gi(n,e){ht(e,!0);let a=Fe(e,"onOpenImage",3,()=>{});const r=360,i=120;let o=dt(null),c=dt(null),d=dt(!1),u=dt(!1),m=dt(!1),f=dt(""),b=oe(()=>t(f)||U(e.upload.duration_ms??0)),w=null,C=oe(()=>(e.upload.content_type||"").split(";")[0].trim().toLowerCase()),x=oe(()=>t(C).startsWith("image/")),E=oe(()=>t(C).startsWith("video/")),T=oe(()=>t(C).startsWith("audio/")),R=oe(()=>t(C)==="application/pdf"),P=oe(()=>t(C)==="text/plain"),D=oe(()=>t(R)||t(P)),W=oe(()=>t(R)?"PDF":"Text"),Y=oe(()=>{const S=e.upload.width??0,I=e.upload.height??0;if(S<=0||I<=0)return"";const N=t(x)?320:r,q=Math.min(N,Math.max(i,I));return`aspect-ratio: ${S} / ${I}; max-height: ${q}px;`});function U(S){if(!S||S<=0)return"";const I=Math.floor(S/1e3),N=Math.floor(I/60),q=I%60;return`${N}:${q.toString().padStart(2,"0")}`}function Z(){h(d,!0)}function K(){!t(o)||!isFinite(t(o).duration)||h(f,U(t(o).duration*1e3),!0)}function G(){t(o)&&(h(d,!0),t(o).play())}function ue(S){return S<1024?`${S} B`:S<1024*1024?`${Math.round(S/1024)} KB`:`${(S/(1024*1024)).toFixed(1)} MB`}en(()=>{if(!t(R)||!t(c)){h(u,!1),h(m,!1);return}w?.();let S=!1,I=null,N=null,q=null;return h(u,!1),h(m,!1),(async()=>{try{const[de,pe]=await Promise.all([Ts(()=>import("./B75MOh95.js"),[],import.meta.url),Ts(()=>import("./DGzl3AwC.js"),[],import.meta.url)]);de.GlobalWorkerOptions.workerSrc=pe.default,N=de.getDocument({url:e.url,withCredentials:!0}),q=await N.promise;const me=await q.getPage(1);if(S)return;const re=128/me.getViewport({scale:1}).width,ge=me.getViewport({scale:re}),ze=t(c),ve=ze?.getContext("2d");if(!ze||!ve)throw new Error("pdf thumbnail canvas unavailable");const ke=Math.min(window.devicePixelRatio||1,2);ze.width=Math.max(1,Math.floor(ge.width*ke)),ze.height=Math.max(1,Math.floor(ge.height*ke)),ve.setTransform(ke,0,0,ke,0,0),I=me.render({canvasContext:ve,viewport:ge}),await I.promise,S||h(u,!0)}catch(de){!S&&!(de instanceof Error&&de.name==="RenderingCancelledException")&&h(m,!0)}})(),w=()=>{S=!0,I?.cancel(),q?.destroy(),N?.destroy(),w=null},()=>w?.()}),Xa(()=>w?.());var ce=bn(),j=Ft(ce);{var V=S=>{var I=Oh(),N=_(I),q=_(N);p(N);var ye=y(N,2),de=_(ye),pe=_(de,!0);p(de);var me=y(de,2);p(ye),p(I),se(()=>{ne(N,"aria-label",`Open image ${e.upload.filename}`),ne(q,"src",e.url),ne(q,"alt",e.upload.filename),ne(q,"width",e.upload.width||void 0),ne(q,"height",e.upload.height||void 0),oa(q,t(Y)),ee(pe,e.upload.filename),ne(me,"href",e.url),ne(me,"download",e.upload.filename),ne(me,"aria-label",`Download ${e.upload.filename}`)}),$("click",N,()=>a()(e.url,e.upload.filename)),$("click",me,_e=>_e.stopPropagation()),O(S,I)},M=S=>{var I=Uh();let N;var q=_(I),ye=_(q);p(q),$n(q,ze=>h(o,ze),()=>t(o));var de=y(q,2);{var pe=ze=>{var ve=Ph(),ke=Ft(ve),Ce=y(ke,2);{var Ke=He=>{var we=Lh(),Pe=_(we,!0);p(we),se(()=>ee(Pe,t(b))),O(He,we)};le(Ce,He=>{t(b)&&He(Ke)})}se(()=>ne(ke,"aria-label",`Play ${e.upload.filename}`)),$("click",ke,G),O(ze,ve)};le(de,ze=>{t(d)||ze(pe)})}var me=y(de,2),_e=_(me),re=_(_e,!0);p(_e);var ge=y(_e,2);p(me),p(I),se(()=>{N=Qe(I,1,"media-tile media-tile--video",null,N,{"is-started":t(d)}),q.controls=t(d),ne(q,"aria-label",e.upload.filename),ne(q,"width",e.upload.width||void 0),ne(q,"height",e.upload.height||void 0),oa(q,t(Y)),ne(ye,"src",e.url),ne(ye,"type",t(C)),ee(re,e.upload.filename),ne(ge,"href",e.url),ne(ge,"download",e.upload.filename),ne(ge,"aria-label",`Download ${e.upload.filename}`)}),un("play",q,Z),un("loadedmetadata",q,K),$("click",ge,ze=>ze.stopPropagation()),O(S,I)},z=S=>{var I=qh(),N=_(I),q=y(_(N),2),ye=_(q),de=_(ye,!0);p(ye);var pe=y(ye,2),me=_(pe,!0);p(pe),p(q),p(N);var _e=y(N,2),re=_(_e),ge=_(re,!0);p(re),p(_e),p(I),se(ze=>{ee(de,e.upload.filename),ee(me,ze),ne(_e,"src",e.url),ne(re,"href",e.url),ee(ge,e.upload.filename)},[()=>ue(e.upload.byte_size)]),O(S,I)},B=S=>{var I=Bh(),N=_(I);let q;var ye=_(N);{var de=Ce=>{var Ke=zh();$n(Ke,He=>h(c,He),()=>t(c)),O(Ce,Ke)};le(ye,Ce=>{t(R)&&Ce(de)})}var pe=y(ye,2),me=_(pe,!0);p(pe),p(N);var _e=y(N,2),re=_(_e),ge=_(re,!0);p(re);var ze=y(re,2),ve=_(ze,!0);p(ze),p(_e);var ke=y(_e,2);p(I),se(Ce=>{q=Qe(N,1,"document-attachment__thumbnail",null,q,{"has-preview":t(u),"thumbnail-failed":t(m)}),ne(N,"href",e.url),ne(N,"aria-label",`Open ${e.upload.filename}`),ee(me,t(W)),ne(re,"href",e.url),ee(ge,e.upload.filename),ee(ve,Ce),ne(ke,"href",e.url),ne(ke,"download",e.upload.filename),ne(ke,"aria-label",`Download ${e.upload.filename}`)},[()=>ue(e.upload.byte_size)]),O(S,I)},J=S=>{var I=Fh(),N=y(_(I),2),q=_(N),ye=_(q,!0);p(q);var de=y(q,2),pe=_(de,!0);p(de),p(N),p(I),se(me=>{ne(I,"href",e.url),ee(ye,e.upload.filename),ee(pe,me)},[()=>ue(e.upload.byte_size)]),O(S,I)};le(j,S=>{t(x)?S(V):t(E)?S(M,1):t(T)?S(z,2):t(D)?S(B,3):S(J,-1)})}O(n,ce),gt()}_t(["click"]);var Hh=H(' '),Wh=H(' '),Gh=H('');function al(n,e){ht(e,!0);var a=bn(),r=Ft(a);{var i=o=>{var c=Gh();let d;var u=y(_(c),2),m=_(u),f=_(m,!0);p(m);var b=y(m,2);{var w=x=>{var E=Hh(),T=_(E,!0);p(E),se(R=>ee(T,R),[()=>oi(e.message.quoted_body_snapshot)]),O(x,E)},C=x=>{var E=Wh(),T=_(E);p(E),se(R=>ee(T,`[original deleted] ${R??""}`),[()=>oi(e.message.quoted_body_snapshot)]),O(x,E)};le(b,x=>{e.message.quoted_message_id?x(w):x(C,-1)})}p(u),p(c),se((x,E)=>{d=Qe(c,1,"quote-block",null,d,{dangling:!e.message.quoted_message_id}),c.disabled=!e.message.quoted_message_id,ne(c,"aria-label",x),ee(f,E)},[()=>e.message.quoted_message_id?`Jump to quoted message from ${Us(e.message)}`:"Original message was deleted",()=>Us(e.message)]),$("click",c,()=>e.onJump(e.message)),O(o,c)};le(r,o=>{(e.message.quoted_message_id||e.message.quoted_body_snapshot)&&o(i)})}O(n,a),gt()}_t(["click"]);const Kh={bash:{glyph:"🖥",action:"Ran shell command"},exec:{glyph:"🖥",action:"Ran shell command"},command:{glyph:"🖥",action:"Ran shell command"},bashsession:{glyph:"🖥",action:"Managed shell session"},sessions_send:{glyph:"🖥",action:"Managed shell session"},read:{glyph:"📖",action:"Read file"},write:{glyph:"📝",action:"Wrote file"},edit:{glyph:"✏",action:"Edited file"},apply_patch:{glyph:"🧩",action:"Applied patch"},applypatch:{glyph:"🧩",action:"Applied patch"},browsercontrol:{glyph:"🌐",action:"Controlled browser"},websearch:{glyph:"🔍",action:"Searched the web"},webfetch:{glyph:"🔗",action:"Fetched a page"},image:{glyph:"🖼",action:"Analyzed image"},imagecreate:{glyph:"🎨",action:"Generated image"},scheduler:{glyph:"⏰",action:"Scheduled a job"},sendmessage:{glyph:"📤",action:"Sent a message"},message:{glyph:"📤",action:"Sent a message"},systemctl:{glyph:"⚙",action:"Controlled the system"},process:{glyph:"⚙",action:"Managed a process"},devicecontrol:{glyph:"📱",action:"Controlled a device"},pdfparse:{glyph:"📄",action:"Parsed a PDF"},statuscheck:{glyph:"📊",action:"Checked status"}},jh=[{test:n=>n.includes("search"),glyph:"🔍",action:"Searched"},{test:n=>n.includes("fetch"),glyph:"🔗",action:"Fetched"},{test:n=>n.includes("browser"),glyph:"🌐",action:"Controlled browser"},{test:n=>n.includes("patch"),glyph:"🧩",action:"Applied patch"},{test:n=>n.includes("write"),glyph:"📝",action:"Wrote file"},{test:n=>n.includes("edit"),glyph:"✏",action:"Edited file"},{test:n=>n.includes("read"),glyph:"📖",action:"Read file"},{test:n=>n.includes("session"),glyph:"🖥",action:"Managed shell session"},{test:n=>n.includes("bash")||n.includes("shell")||n.includes("exec"),glyph:"🖥",action:"Ran command"},{test:n=>n.includes("image"),glyph:"🖼",action:"Worked with image"},{test:n=>n.includes("message")||n.includes("send"),glyph:"📤",action:"Sent a message"},{test:n=>n.includes("agent")||n.includes("subagent"),glyph:"🤖",action:"Ran a sub-agent"},{test:n=>n.includes("schedul")||n.includes("cron"),glyph:"⏰",action:"Scheduled a job"},{test:n=>n.includes("device"),glyph:"📱",action:"Controlled a device"},{test:n=>n.includes("file")||n.includes("dir"),glyph:"📁",action:"Worked with files"}];function Vh(n,e){const a=String(n||"").trim(),r=a.toLowerCase().replace(/[^a-z0-9_]/g,""),i=String(e||"").trim()||void 0,o=Kh[r];if(o)return{name:a,glyph:o.glyph,action:o.action,detail:i};for(const c of jh)if(c.test(r))return{name:a,glyph:c.glyph,action:c.action,detail:i};return{name:a||"tool",glyph:"🔧",action:"Used tool",detail:i}}var Yh=H(''),Zh=H(' '),Xh=H(' '),Jh=H('rt(L),`[data-message-id="${CSS.escape(L)}"]`,L),!0}function Kt(L=!0){if(!t(T)||mt()<0)return!1;I=!1;const he=e.viewKey,te=ke();return Gt(he,te,()=>mt(),"[data-unread-divider='true']",bt()).then(Ae=>{L&&!Ae&&Ce(he,te)&&e.onJumpToUnread?.()}),!0}function Lt(){if(!(t(J)&&Kt(!1))&&e.onJumpToUnread){e.onJumpToUnread();return}}function Nt(){if(!t(T))return null;if(we())return{atBottom:!0};const Q=t(T).getScrollOffset(),he=t(T).findItemIndex(Q),te=Math.max(0,Q-t(D));for(let Ae=Math.max(0,he);Ae
Create channel
Start a DM
Profile settings
'),Pp=H('
'),Up=H('ClickClack
'),qp=H(''),zp=H('Welcome.
{if(o){if(o.pending.delete(_),o.done.add(_),o.pending.size===0){var w=n.outrogroups;Kr(n,di(o.done)),w.delete(o),w.size===0&&(n.outrogroups=null)}}else c-=1},!1)}if(c===0){var u=r.length===0&&a!==null;if(u){var m=a,f=m.parentNode;$s(f),f.append(m),n.items.clear()}Kr(n,e,!u)}else o={pending:new Set(e),done:new Set},(n.outrogroups??=new Set).add(o)}function Kr(n,e,a=!0){var r;if(n.pending.size>0){r=new Set;for(const c of n.pending.values())for(const d of c)r.add(n.items.get(d).e)}for(var s=0;s=0;){var d=c+o;(c===0||vs.includes(r[c-1]))&&(d===r.length||vs.includes(r[d]))?r=(c===0?"":r.substring(0,c))+r.substring(d+1):c=d}}return r===""?null:r}function _s(n,e=!1){var a=e?" !important;":";",r="";for(var s of Object.keys(n)){var o=n[s];o!=null&&o!==""&&(r+=" "+s+": "+o+a)}return r}function Or(n){return n[0]!=="-"||n[1]!=="-"?n.toLowerCase():n}function iu(n,e){if(e){var a="",r,s;if(Array.isArray(e)?(r=e[0],s=e[1]):r=e,n){n=String(n).replaceAll(/\s*\/\*.*?\*\/\s*/g,"").trim();var o=!1,c=0,d=!1,u=[];r&&u.push(...Object.keys(r).map(Or)),s&&u.push(...Object.keys(s).map(Or));var m=0,f=-1;const C=n.length;for(var _=0;_'),od=V(''),ld=V(''),cd=V('');function ko(n,e){vt(e,!0);let a=Oe(e,"formClass",3,"composer"),r=Oe(e,"pendingUpload",3,null),s=Oe(e,"replyTarget",3,null),o=Oe(e,"showUpload",3,!1),c=Oe(e,"showToolbar",3,!1),d=Oe(e,"showGifPicker",3,!1),u=Oe(e,"gifQuery",3,""),m=Oe(e,"filteredGifs",19,()=>[]),f=Oe(e,"slashCommands",19,()=>[]),_=Oe(e,"mentionPeople",19,()=>[]),w=Oe(e,"onUploadFile",3,()=>{}),A=Oe(e,"onRemoveUpload",3,()=>{}),x=Oe(e,"onClearReply",3,()=>{}),C=Oe(e,"onApplyMarkdownWrap",3,()=>{}),T=Oe(e,"onAppendToComposer",3,()=>{}),E=Oe(e,"onToggleGif",3,()=>{}),M=Oe(e,"onGifQuery",3,()=>{}),U=Oe(e,"onPickGif",3,()=>{}),F=ht(null),Y=ht(0),O=ht(""),Z=ht(0);const N=ce(()=>G(e.value,t(Y))),L=ce(()=>!t(N)||K(t(N))===t(O)?[]:t(N).kind==="slash"?P(t(N)):ae(t(N)));nn(()=>(e.onInputRef(t(F)),()=>e.onInputRef(null))),nn(()=>{if(t(L).length===0){h(Z,0);return}t(Z)>=t(L).length&&h(Z,0)});function G(X,Ce){const Ie=Math.max(0,Math.min(Ce||X.length,X.length)),ge=X.slice(0,Ie),Je=/(^|\s)([/@][^\s]*)$/.exec(ge);if(!Je)return null;const qe=Je[2],ft=ge.length-qe.length;return qe.startsWith("/")&&ft!==0?null:{kind:qe.startsWith("/")?"slash":"mention",start:ft,end:Ie,query:qe.slice(1).toLowerCase(),raw:qe}}function K(X){return`${X.kind}:${X.start}:${X.raw}`}function q(X=t(F)){h(Y,X?.selectionStart??e.value.length,!0)}function D(X){return X.startsWith("/")?X:`/${X}`}function P(X){const Ce=X.query;return f().filter(Ie=>!Ie.revoked_at).map(Ie=>{const ge=D(Ie.command),Je=ge.slice(1).toLowerCase();return{id:Ie.id,kind:"slash",label:ge,detail:Ie.description||"Slash command",insertText:`${ge} `,sortText:Je}}).filter(Ie=>!Ce||Ie.sortText.includes(Ce)).sort((Ie,ge)=>+!Ie.sortText.startsWith(Ce)-+!ge.sortText.startsWith(Ce)||Ie.sortText.localeCompare(ge.sortText)).slice(0,6)}function W(X){return Ln(X.handle||X.display_name.replace(/\s+/g,""))}function ae(X){const Ce=X.query,Ie=new Set;return _().filter(ge=>!ge.id||Ie.has(ge.id)?!1:(Ie.add(ge.id),!0)).map(ge=>{const Je=W(ge),qe=`${ge.handle||""} ${ge.display_name}`.trim().toLowerCase();return{id:ge.id,kind:"mention",label:Je,detail:ge.kind==="bot"?`${ge.display_name} · bot`:ge.display_name,insertText:`${Je} `,sortText:qe}}).filter(ge=>!Ce||ge.sortText.includes(Ce)).sort((ge,Je)=>+!ge.sortText.startsWith(Ce)-+!Je.sortText.startsWith(Ce)||ge.sortText.localeCompare(Je.sortText)).slice(0,6)}function j(X){if(!t(N))return;const Ce=`${e.value.slice(0,t(N).start)}${X.insertText}${e.value.slice(t(N).end)}`,Ie=t(N).start+X.insertText.length;e.onValue(Ce),Wn().then(()=>{t(F)?.focus(),t(F)?.setSelectionRange(Ie,Ie),h(Y,Ie)})}function I(X){const Ce=X.currentTarget;e.onValue(Ce.value),q(Ce)}function H(){q(),e.onFocus()}function $(X){if(t(L).length>0){if(X.key==="ArrowDown"){X.preventDefault(),h(Z,(t(Z)+1)%t(L).length);return}if(X.key==="ArrowUp"){X.preventDefault(),h(Z,(t(Z)-1+t(L).length)%t(L).length);return}if(X.key==="Enter"||X.key==="Tab"){X.preventDefault(),j(t(L)[t(Z)]);return}if(X.key==="Escape"&&t(N)){X.preventDefault(),h(O,K(t(N)),!0);return}}e.onKeydown(X)}var ee=cd(),Se=b(ee);{var Te=X=>{Xu(X,{get gifs(){return m()},get query(){return u()},get onQuery(){return M()},get onPick(){return U()}})};ue(Se,X=>{d()&&X(Te)})}var Ne=k(Se,2);{var Ae=X=>{var Ce=id();Ft(Ce,23,()=>t(L),Ie=>Ie.id,(Ie,ge,Je)=>{var qe=rd();let ft;var Ke=b(qe),kt=b(Ke);{var Dt=Tt=>{var Qt=In("/");z(Tt,Qt)},Ht=Tt=>{var Qt=In();oe($t=>ie(Qt,$t),[()=>bo(t(ge).detail)]),z(Tt,Qt)};ue(kt,Tt=>{t(ge).kind==="slash"?Tt(Dt):Tt(Ht,-1)})}v(Ke);var St=k(Ke,2),it=b(St),He=b(it,!0);v(it);var bt=k(it,2),We=b(bt,!0);v(bt),v(St);var Qe=k(St,2),Mt=b(Qe,!0);v(Qe),v(qe),oe(()=>{re(qe,"aria-selected",t(Je)===t(Z)),ft=gt(qe,1,"",null,ft,{active:t(Je)===t(Z)}),ie(He,t(ge).label),ie(We,t(ge).detail),ie(Mt,t(ge).kind==="slash"?"command":"mention")}),Q("mousedown",qe,Tt=>Tt.preventDefault()),Q("click",qe,()=>j(t(ge))),z(Ie,qe)}),v(Ce),oe(()=>re(Ce,"aria-label",t(N)?.kind==="slash"?"Slash command suggestions":"Mention suggestions")),z(X,Ce)};ue(Ne,X=>{t(L).length>0&&X(Ae)})}var pe=k(Ne,2),Ue=b(pe);{var we=X=>{var Ce=od(),Ie=k(b(Ce),2);{var ge=kt=>{var Dt=sd();oe(Ht=>{re(Dt,"src",Ht),re(Dt,"alt",r().filename)},[()=>dr(r())]),z(kt,Dt)},Je=ce(()=>Gu(r()));ue(Ie,kt=>{t(Je)&&kt(ge)})}var qe=k(Ie,2),ft=b(qe);v(qe);var Ke=k(qe,2);v(Ce),oe(kt=>ie(ft,`${r().filename??""} · ${kt??""}`),[()=>Ku(r().byte_size)]),Q("click",Ke,function(...kt){A()?.apply(this,kt)}),z(X,Ce)};ue(Ue,X=>{r()&&X(we)})}var Fe=k(Ue,2);{var he=X=>{ad(X,{get target(){return s()},get onClear(){return x()}})};ue(Fe,X=>{s()&&X(he)})}var Re=k(Fe,2),Le=b(Re);{var et=X=>{var Ce=ld(),Ie=b(Ce);On(2),v(Ce),Q("change",Ie,function(...ge){w()?.apply(this,ge)}),z(X,Ce)};ue(Le,X=>{o()&&X(et)})}var Ge=k(Le,2);Zc(Ge),Xn(Ge,X=>h(F,X),()=>t(F)),qa(Ge,(X,Ce)=>Wu?.(X),()=>e.value);var me=k(Ge,2);v(Re);var Be=k(Re,2);{var pt=X=>{Vu(X,{get showGifPicker(){return d()},get onWrap(){return C()},get onAppend(){return T()},get onToggleGif(){return E()}})};ue(Be,X=>{c()&&X(pt)})}v(pe),v(ee),oe(X=>{gt(ee,1,ur(a())),Mn(Ge,e.value),re(Ge,"placeholder",e.placeholder),re(Ge,"aria-label",e.ariaLabel),re(me,"aria-label",e.submitLabel),me.disabled=X},[()=>!e.value.trim()]),an("submit",ee,X=>{X.preventDefault(),e.onSubmit()}),Q("input",Ge,I),an("focus",Ge,H),Q("keydown",Ge,$),Q("keyup",Ge,()=>q()),Q("mouseup",Ge,()=>q()),an("select",Ge,()=>q()),z(n,ee),_t()}wt(["mousedown","click","change","input","keydown","keyup","mouseup"]);var ud=V('');function dd(n,e){var a=ud(),r=b(a),s=k(r,2),o=b(s),c=b(o),d=b(c,!0);v(c);var u=k(c,2),m=b(u),f=k(m,2);v(u),v(o);var _=k(o,2),w=b(_);v(_),v(s),v(a),oe(()=>{ie(d,e.title),re(m,"href",e.url),re(w,"src",e.url),re(w,"alt",e.title)}),Q("click",r,function(...A){e.onClose?.apply(this,A)}),Q("click",f,function(...A){e.onClose?.apply(this,A)}),z(n,a)}wt(["click"]);const To=n=>Object.keys(n).reduce((e,a)=>{const r=n[a];return r==null?e:e+`${a}:${r};`},""),fd=(n,e)=>"_"+e,za=null,{min:ea,max:vn,abs:Es,floor:hd}=Math,So=(n,e,a)=>ea(a,vn(e,n)),xo=n=>[...n].sort((e,a)=>e-a),gd=typeof queueMicrotask=="function"?queueMicrotask:n=>{Promise.resolve().then(n)},ei=()=>{let n;return[new Promise(e=>{n=e}),n]},Eo=n=>{let e;return()=>(n&&(e=n(),n=void 0),e)},Pa=(n,e,a)=>{const r=a?"unshift":"push";for(let s=0;s
'),qd=V(''),zd=V("");function Pn(n,e){vt(e,!0);let a=Oe(e,"class",3,"avatar"),r=Oe(e,"size",3,40),s=Oe(e,"loading",3,"lazy"),o=Oe(e,"fetchPriority",3,"low"),c=ht("");const d=ce(()=>Ud(e.src)),u=ce(()=>t(d)!==""&&t(c)!==t(d)),m=ce(()=>yo(e.id||e.name||t(d)||"avatar")),f=ce(()=>bo(e.name));function _(){h(c,t(d),!0)}var w=yn(),A=Vt(w);{var x=T=>{var E=qd(),M=b(E);{var U=Y=>{var O=Is();oe(()=>{re(O,"src",t(d)),re(O,"width",r()),re(O,"height",r()),re(O,"loading",s()),re(O,"fetchpriority",o())}),an("error",O,_),fs(O),z(Y,O)},F=Y=>{var O=In();oe(()=>ie(O,t(f))),z(Y,O)};ue(M,Y=>{t(u)?Y(U):Y(F,-1)})}v(E),oe(()=>{gt(E,1,ur(a())),na(E,`--hue: ${t(m)??""}deg`),re(E,"aria-label",e.buttonLabel)}),Q("click",E,function(...Y){e.onclick?.apply(this,Y)}),z(T,E)},C=T=>{var E=zd(),M=b(E);{var U=Y=>{var O=Is();oe(()=>{re(O,"src",t(d)),re(O,"width",r()),re(O,"height",r()),re(O,"loading",s()),re(O,"fetchpriority",o())}),an("error",O,_),fs(O),z(Y,O)},F=Y=>{var O=In();oe(()=>ie(O,t(f))),z(Y,O)};ue(M,Y=>{t(u)?Y(U):Y(F,-1)})}v(E),oe(()=>{gt(E,1,ur(a())),na(E,`--hue: ${t(m)??""}deg`)}),z(T,E)};ue(A,T=>{e.buttonLabel?T(x):T(C,-1)})}z(n,w),_t()}wt(["click"]);function Rs(n,e){(e==null||e>n.length)&&(e=n.length);for(var a=0,r=Array(e);a
-`:"'+(a?s:Cn(s,!0))+`
-`}blockquote({tokens:n}){return`"+(a?s:Cn(s,!0))+`
-${this.parser.parse(n)}
-`}html({text:n}){return n}def(n){return""}heading({tokens:n,depth:e}){return`
-`}list(n){let e=n.ordered,a=n.start,r="";for(let c=0;c
-
-`+e+`
-`+r+`
-`}tablerow({text:n}){return`
-${n}
-`}tablecell(n){let e=this.parser.parseInline(n.tokens),a=n.header?"th":"td";return(n.align?`<${a} align="${n.align}">`:`<${a}>`)+e+`${a}>
-`}strong({tokens:n}){return`${this.parser.parseInline(n)}`}em({tokens:n}){return`${this.parser.parseInline(n)}`}codespan({text:n}){return`${Cn(n,!0)}`}br(n){return"
"}del({tokens:n}){return`${this.parser.parseInline(n)}`}link({href:n,title:e,tokens:a}){let r=this.parser.parseInline(a),s=js(n);if(s===null)return r;n=s;let o='"+r+"",o}image({href:n,title:e,text:a,tokens:r}){r&&(a=this.parser.parseInline(r,this.parser.textRenderer));let s=js(n);if(s===null)return Cn(a);n=s;let o=`",o}text(n){return"tokens"in n&&n.tokens?this.parser.parseInline(n.tokens):"escaped"in n&&n.escaped?n.text:Cn(n.text)}},ki=class{strong({text:n}){return n}em({text:n}){return n}codespan({text:n}){return n}del({text:n}){return n}html({text:n}){return n}text({text:n}){return n}link({text:n}){return""+n}image({text:n}){return""+n}br(){return""}checkbox({raw:n}){return n}},bn=class ii{options;renderer;textRenderer;constructor(e){this.options=e||ra,this.options.renderer=this.options.renderer||new gr,this.renderer=this.options.renderer,this.renderer.options=this.options,this.renderer.parser=this,this.textRenderer=new ki}static parse(e,a){return new ii(a).parse(e)}static parseInline(e,a){return new ii(a).parseInline(e)}parse(e){this.renderer.parser=this;let a="";for(let r=0;r
"+Cn(a.message+"",!0)+"
";return e?Promise.resolve(r):r}if(e)return Promise.reject(a);throw a}}},aa=new rh;function ut(n,e){return aa.parse(n,e)}ut.options=ut.setOptions=function(n){return aa.setOptions(n),ut.defaults=aa.defaults,Mo(ut.defaults),ut};ut.getDefaults=gi;ut.defaults=ra;ut.use=function(...n){return aa.use(...n),ut.defaults=aa.defaults,Mo(ut.defaults),ut};ut.walkTokens=function(n,e){return aa.walkTokens(n,e)};ut.parseInline=aa.parseInline;ut.Parser=bn;ut.parser=bn.parse;ut.Renderer=gr;ut.TextRenderer=ki;ut.Lexer=_n;ut.lexer=_n.lex;ut.Tokenizer=hr;ut.Hooks=Na;ut.parse=ut;ut.options;ut.setOptions;ut.use;ut.walkTokens;ut.parseInline;bn.parse;_n.lex;function si(n){return vf.sanitize(ut.parse(n,{async:!1,breaks:!0,gfm:!0}))}function Ba(n){return new Intl.DateTimeFormat(void 0,{hour:"2-digit",minute:"2-digit"}).format(new Date(n))}var ih=V(''),sh=V(''),oh=V(' ',1),lh=V('',2),ch=V(''),uh=V(''),dh=V(''),fh=V(' ');function oi(n,e){vt(e,!0);let a=Oe(e,"onOpenImage",3,()=>{});const r=360,s=120;let o=ht(null),c=ht(null),d=ht(!1),u=ht(!1),m=ht(!1),f=ht(""),_=ce(()=>t(f)||O(e.upload.duration_ms??0)),w=null,A=ce(()=>(e.upload.content_type||"").split(";")[0].trim().toLowerCase()),x=ce(()=>t(A).startsWith("image/")),C=ce(()=>t(A).startsWith("video/")),T=ce(()=>t(A).startsWith("audio/")),E=ce(()=>t(A)==="application/pdf"),M=ce(()=>t(A)==="text/plain"),U=ce(()=>t(E)||t(M)),F=ce(()=>t(E)?"PDF":"Text"),Y=ce(()=>{const I=e.upload.width??0,H=e.upload.height??0;if(I<=0||H<=0)return"";const $=t(x)?320:r,ee=Math.min($,Math.max(s,H));return`aspect-ratio: ${I} / ${H}; max-height: ${ee}px;`});function O(I){if(!I||I<=0)return"";const H=Math.floor(I/1e3),$=Math.floor(H/60),ee=H%60;return`${$}:${ee.toString().padStart(2,"0")}`}function Z(){h(d,!0)}function N(){!t(o)||!isFinite(t(o).duration)||h(f,O(t(o).duration*1e3),!0)}function L(){t(o)&&(h(d,!0),t(o).play())}function G(I){return I<1024?`${I} B`:I<1024*1024?`${Math.round(I/1024)} KB`:`${(I/(1024*1024)).toFixed(1)} MB`}nn(()=>{if(!t(E)||!t(c)){h(u,!1),h(m,!1);return}w?.();let I=!1,H=null,$=null,ee=null;return h(u,!1),h(m,!1),(async()=>{try{const[Te,Ne]=await Promise.all([hs(()=>import("./B75MOh95.js"),[],import.meta.url),hs(()=>import("./DGzl3AwC.js"),[],import.meta.url)]);Te.GlobalWorkerOptions.workerSrc=Ne.default,$=Te.getDocument({url:e.url,withCredentials:!0}),ee=await $.promise;const Ae=await ee.getPage(1);if(I)return;const Ue=128/Ae.getViewport({scale:1}).width,we=Ae.getViewport({scale:Ue}),Fe=t(c),he=Fe?.getContext("2d");if(!Fe||!he)throw new Error("pdf thumbnail canvas unavailable");const Re=Math.min(window.devicePixelRatio||1,2);Fe.width=Math.max(1,Math.floor(we.width*Re)),Fe.height=Math.max(1,Math.floor(we.height*Re)),he.setTransform(Re,0,0,Re,0,0),H=Ae.render({canvasContext:he,viewport:we}),await H.promise,I||h(u,!0)}catch(Te){!I&&!(Te instanceof Error&&Te.name==="RenderingCancelledException")&&h(m,!0)}})(),w=()=>{I=!0,H?.cancel(),ee?.destroy(),$?.destroy(),w=null},()=>w?.()}),Ha(()=>w?.());var K=yn(),q=Vt(K);{var D=I=>{var H=ih(),$=b(H),ee=b($);v($);var Se=k($,2),Te=b(Se),Ne=b(Te,!0);v(Te);var Ae=k(Te,2);v(Se),v(H),oe(()=>{re($,"aria-label",`Open image ${e.upload.filename}`),re(ee,"src",e.url),re(ee,"alt",e.upload.filename),re(ee,"width",e.upload.width||void 0),re(ee,"height",e.upload.height||void 0),na(ee,t(Y)),ie(Ne,e.upload.filename),re(Ae,"href",e.url),re(Ae,"download",e.upload.filename),re(Ae,"aria-label",`Download ${e.upload.filename}`)}),Q("click",$,()=>a()(e.url,e.upload.filename)),Q("click",Ae,pe=>pe.stopPropagation()),z(I,H)},P=I=>{var H=lh();let $;var ee=b(H),Se=b(ee);v(ee),Xn(ee,Fe=>h(o,Fe),()=>t(o));var Te=k(ee,2);{var Ne=Fe=>{var he=oh(),Re=Vt(he),Le=k(Re,2);{var et=Ge=>{var me=sh(),Be=b(me,!0);v(me),oe(()=>ie(Be,t(_))),z(Ge,me)};ue(Le,Ge=>{t(_)&&Ge(et)})}oe(()=>re(Re,"aria-label",`Play ${e.upload.filename}`)),Q("click",Re,L),z(Fe,he)};ue(Te,Fe=>{t(d)||Fe(Ne)})}var Ae=k(Te,2),pe=b(Ae),Ue=b(pe,!0);v(pe);var we=k(pe,2);v(Ae),v(H),oe(()=>{$=gt(H,1,"media-tile media-tile--video",null,$,{"is-started":t(d)}),ee.controls=t(d),re(ee,"aria-label",e.upload.filename),re(ee,"width",e.upload.width||void 0),re(ee,"height",e.upload.height||void 0),na(ee,t(Y)),re(Se,"src",e.url),re(Se,"type",t(A)),ie(Ue,e.upload.filename),re(we,"href",e.url),re(we,"download",e.upload.filename),re(we,"aria-label",`Download ${e.upload.filename}`)}),an("play",ee,Z),an("loadedmetadata",ee,N),Q("click",we,Fe=>Fe.stopPropagation()),z(I,H)},W=I=>{var H=ch(),$=b(H),ee=k(b($),2),Se=b(ee),Te=b(Se,!0);v(Se);var Ne=k(Se,2),Ae=b(Ne,!0);v(Ne),v(ee),v($);var pe=k($,2),Ue=b(pe),we=b(Ue,!0);v(Ue),v(pe),v(H),oe(Fe=>{ie(Te,e.upload.filename),ie(Ae,Fe),re(pe,"src",e.url),re(Ue,"href",e.url),ie(we,e.upload.filename)},[()=>G(e.upload.byte_size)]),z(I,H)},ae=I=>{var H=dh(),$=b(H);let ee;var Se=b($);{var Te=Le=>{var et=uh();Xn(et,Ge=>h(c,Ge),()=>t(c)),z(Le,et)};ue(Se,Le=>{t(E)&&Le(Te)})}var Ne=k(Se,2),Ae=b(Ne,!0);v(Ne),v($);var pe=k($,2),Ue=b(pe),we=b(Ue,!0);v(Ue);var Fe=k(Ue,2),he=b(Fe,!0);v(Fe),v(pe);var Re=k(pe,2);v(H),oe(Le=>{ee=gt($,1,"document-attachment__thumbnail",null,ee,{"has-preview":t(u),"thumbnail-failed":t(m)}),re($,"href",e.url),re($,"aria-label",`Open ${e.upload.filename}`),ie(Ae,t(F)),re(Ue,"href",e.url),ie(we,e.upload.filename),ie(he,Le),re(Re,"href",e.url),re(Re,"download",e.upload.filename),re(Re,"aria-label",`Download ${e.upload.filename}`)},[()=>G(e.upload.byte_size)]),z(I,H)},j=I=>{var H=fh(),$=k(b(H),2),ee=b($),Se=b(ee,!0);v(ee);var Te=k(ee,2),Ne=b(Te,!0);v(Te),v($),v(H),oe(Ae=>{re(H,"href",e.url),ie(Se,e.upload.filename),ie(Ne,Ae)},[()=>G(e.upload.byte_size)]),z(I,H)};ue(q,I=>{t(x)?I(D):t(C)?I(P,1):t(T)?I(W,2):t(U)?I(ae,3):I(j,-1)})}z(n,K),_t()}wt(["click"]);var hh=V(' '),gh=V(' '),ph=V('');function Ho(n,e){vt(e,!0);var a=yn(),r=Vt(a);{var s=o=>{var c=ph();let d;var u=k(b(c),2),m=b(u),f=b(m,!0);v(m);var _=k(m,2);{var w=x=>{var C=hh(),T=b(C,!0);v(C),oe(E=>ie(T,E),[()=>$r(e.message.quoted_body_snapshot)]),z(x,C)},A=x=>{var C=gh(),T=b(C);v(C),oe(E=>ie(T,`[original deleted] ${E??""}`),[()=>$r(e.message.quoted_body_snapshot)]),z(x,C)};ue(_,x=>{e.message.quoted_message_id?x(w):x(A,-1)})}v(u),v(c),oe((x,C)=>{d=gt(c,1,"quote-block",null,d,{dangling:!e.message.quoted_message_id}),c.disabled=!e.message.quoted_message_id,re(c,"aria-label",x),ie(f,C)},[()=>e.message.quoted_message_id?`Jump to quoted message from ${xs(e.message)}`:"Original message was deleted",()=>xs(e.message)]),Q("click",c,()=>e.onJump(e.message)),z(o,c)};ue(r,o=>{(e.message.quoted_message_id||e.message.quoted_body_snapshot)&&o(s)})}z(n,a),_t()}wt(["click"]);var mh=V(''),vh=V(''),_h=V(''),bh=V(``),yh=V(""),wh=V(" ",1),kh=V('Create channel
Start a DM
Profile settings
'),Gg=V('
'),Kg=V('ClickClack
'),jg=V(''),Vg=V('Welcome.
ClickClack
Chat infrastructure that stays boring when the socket drops.