diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 25f519bb0885..11cab5973cf3 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -159,6 +159,8 @@ export const make = Effect.gen(function* () { threadPriority: true, // T3-CUSTOM(expbkt3): durable manual Linear tags. threadLinearIssue: true, + // T3-CUSTOM(expbkt3): durable Mattermost conversation link. + threadMattermostLink: true, threadExternalSessionAttach: true, // T3-CUSTOM(expbkt3): high-level durable bootstrap and defaults hierarchy. durableThreadBootstrap: true, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 66ac6e0438f5..6e4b26cb2810 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -762,6 +762,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti priority: event.payload.priority ?? null, // T3-CUSTOM(expbkt3): no manual Linear tag at thread creation. linearIssueUrl: null, + // T3-CUSTOM(expbkt3): the Mattermost link is bound after creation. + mattermostThreadUrl: null, // T3-CUSTOM(expbkt3): session lineage stamped at creation. parentThreadId: event.payload.parentThreadId ?? null, // T3-CUSTOM(expbkt3): BEGIN — no work summary until one is requested. @@ -1040,6 +1042,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.linearIssueUrl !== undefined ? { linearIssueUrl: event.payload.linearIssueUrl } : {}), + // T3-CUSTOM(expbkt3): durable Mattermost conversation link. + ...(event.payload.mattermostThreadUrl !== undefined + ? { mattermostThreadUrl: event.payload.mattermostThreadUrl } + : {}), // T3-CUSTOM(expbkt3): session lineage re-parent / detach. ...(event.payload.parentThreadId !== undefined ? { parentThreadId: event.payload.parentThreadId } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 60b37e833ec9..c65a69b89f17 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -349,6 +349,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { priority: null, // T3-CUSTOM(expbkt3): no manual Linear tag on this fixture. linearIssueUrl: null, + // T3-CUSTOM(expbkt3): no Mattermost conversation on this fixture. + mattermostThreadUrl: null, // T3-CUSTOM(expbkt3): session lineage. parentThreadId: null, // T3-CUSTOM(expbkt3): no work summary was ever requested here. @@ -501,6 +503,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { priority: null, // T3-CUSTOM(expbkt3): no manual Linear tag on this fixture. linearIssueUrl: null, + // T3-CUSTOM(expbkt3): no Mattermost conversation on this fixture. + mattermostThreadUrl: null, // T3-CUSTOM(expbkt3): session lineage. parentThreadId: null, // T3-CUSTOM(expbkt3): no work summary was ever requested here. diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index d4c0c61e72d2..8ae69dd64b49 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -592,6 +592,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", priority, linear_issue_url AS "linearIssueUrl", + mattermost_thread_url AS "mattermostThreadUrl", parent_thread_id AS "parentThreadId", -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON blob). work_summary AS "workSummary", @@ -680,6 +681,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", priority, linear_issue_url AS "linearIssueUrl", + mattermost_thread_url AS "mattermostThreadUrl", parent_thread_id AS "parentThreadId", -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON blob). work_summary AS "workSummary", @@ -731,6 +733,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", priority, linear_issue_url AS "linearIssueUrl", + mattermost_thread_url AS "mattermostThreadUrl", parent_thread_id AS "parentThreadId", -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON blob). work_summary AS "workSummary", @@ -1191,6 +1194,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", priority, linear_issue_url AS "linearIssueUrl", + mattermost_thread_url AS "mattermostThreadUrl", parent_thread_id AS "parentThreadId", -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON blob). work_summary AS "workSummary", @@ -1263,6 +1267,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", priority, linear_issue_url AS "linearIssueUrl", + mattermost_thread_url AS "mattermostThreadUrl", parent_thread_id AS "parentThreadId", -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON blob). work_summary AS "workSummary", @@ -2228,6 +2233,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedAt: row.snoozedAt, priority: row.priority, linearIssueUrl: row.linearIssueUrl ?? null, + mattermostThreadUrl: row.mattermostThreadUrl ?? null, parentThreadId: row.parentThreadId ?? null, // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. workSummary: mapWorkSummary(row.workSummary), @@ -2548,6 +2554,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedAt: row.snoozedAt, priority: row.priority, linearIssueUrl: row.linearIssueUrl ?? null, + mattermostThreadUrl: row.mattermostThreadUrl ?? null, parentThreadId: row.parentThreadId ?? null, // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. workSummary: mapWorkSummary(row.workSummary), @@ -2733,6 +2740,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedAt: row.snoozedAt, priority: row.priority, linearIssueUrl: row.linearIssueUrl ?? null, + mattermostThreadUrl: row.mattermostThreadUrl ?? null, parentThreadId: row.parentThreadId ?? null, // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. workSummary: mapWorkSummary(row.workSummary), @@ -2924,6 +2932,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedAt: row.snoozedAt, priority: row.priority, linearIssueUrl: row.linearIssueUrl ?? null, + mattermostThreadUrl: row.mattermostThreadUrl ?? null, parentThreadId: row.parentThreadId ?? null, // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. workSummary: mapWorkSummary(row.workSummary), @@ -3257,6 +3266,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedAt: threadRow.value.snoozedAt, priority: threadRow.value.priority, linearIssueUrl: threadRow.value.linearIssueUrl ?? null, + mattermostThreadUrl: threadRow.value.mattermostThreadUrl ?? null, parentThreadId: threadRow.value.parentThreadId ?? null, // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. workSummary: mapWorkSummary(threadRow.value.workSummary), @@ -3492,6 +3502,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedAt: threadRow.value.snoozedAt, priority: threadRow.value.priority, linearIssueUrl: threadRow.value.linearIssueUrl ?? null, + mattermostThreadUrl: threadRow.value.mattermostThreadUrl ?? null, parentThreadId: threadRow.value.parentThreadId ?? null, // T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary. workSummary: mapWorkSummary(threadRow.value.workSummary), diff --git a/apps/server/src/orchestration/decider.priority.test.ts b/apps/server/src/orchestration/decider.priority.test.ts index e37505e05ec3..b813ba983af7 100644 --- a/apps/server/src/orchestration/decider.priority.test.ts +++ b/apps/server/src/orchestration/decider.priority.test.ts @@ -214,4 +214,60 @@ it.layer(NodeServices.layer)("thread priority decider", (it) => { ).toBeNull(); }), ); + + // T3-CUSTOM(expbkt3): the Mattermost conversation a session is bound to. + it.effect("sets and clears a Mattermost link through thread metadata", () => + Effect.gen(function* () { + const linked = yield* decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-link-mattermost"), + threadId: ThreadId.make("thread-1"), + mattermostThreadUrl: "https://chat.example.com/beknown/pl/abc123", + }, + readModel: makeReadModel(), + }); + const linkedEvents = Array.isArray(linked) ? linked : [linked]; + expect( + linkedEvents[0]?.type === "thread.meta-updated" + ? linkedEvents[0].payload.mattermostThreadUrl + : undefined, + ).toBe("https://chat.example.com/beknown/pl/abc123"); + + const cleared = yield* decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-clear-mattermost"), + threadId: ThreadId.make("thread-1"), + mattermostThreadUrl: null, + }, + readModel: makeReadModel(), + }); + const clearedEvents = Array.isArray(cleared) ? cleared : [cleared]; + expect( + clearedEvents[0]?.type === "thread.meta-updated" + ? clearedEvents[0].payload.mattermostThreadUrl + : undefined, + ).toBeNull(); + }), + ); + + it.effect("leaves the Mattermost link untouched when the command omits it", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-rename-only-mattermost"), + threadId: ThreadId.make("thread-1"), + title: "Renamed", + }, + readModel: makeReadModel(), + }); + const events = Array.isArray(event) ? event : [event]; + if (events[0]?.type === "thread.meta-updated") { + // undefined, not null: an omitted field must not clear a live binding. + expect(events[0].payload.mattermostThreadUrl).toBe(undefined); + } + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 9c1aae1d04b4..12f7ec6be4b5 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -928,6 +928,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.linearIssueUrl !== undefined ? { linearIssueUrl: command.linearIssueUrl } : {}), + // T3-CUSTOM(expbkt3): undefined leaves the Mattermost link unchanged. + ...(command.mattermostThreadUrl !== undefined + ? { mattermostThreadUrl: command.mattermostThreadUrl } + : {}), // T3-CUSTOM(expbkt3): undefined leaves lineage unchanged; null detaches. ...(command.parentThreadId !== undefined ? { parentThreadId: command.parentThreadId } diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 309a40b882be..73189dbdf1f0 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -104,6 +104,8 @@ describe("orchestration projector", () => { priority: null, // T3-CUSTOM(expbkt3): no manual Linear tag on a new thread. linearIssueUrl: null, + // T3-CUSTOM(expbkt3): no Mattermost conversation bound to a new thread. + mattermostThreadUrl: null, // T3-CUSTOM(expbkt3): session lineage. parentThreadId: null, deletedAt: null, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index ae96cba9ecc5..1a77a8009df8 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -353,6 +353,8 @@ export function projectEvent( priority: payload.priority ?? null, // T3-CUSTOM(expbkt3): no manual Linear tag at thread creation. linearIssueUrl: null, + // T3-CUSTOM(expbkt3): the Mattermost link is bound after creation. + mattermostThreadUrl: null, // T3-CUSTOM(expbkt3): session lineage stamped at creation. parentThreadId: payload.parentThreadId ?? null, deletedAt: null, @@ -528,6 +530,10 @@ export function projectEvent( ...(payload.linearIssueUrl !== undefined ? { linearIssueUrl: payload.linearIssueUrl } : {}), + // T3-CUSTOM(expbkt3): durable Mattermost conversation link. + ...(payload.mattermostThreadUrl !== undefined + ? { mattermostThreadUrl: payload.mattermostThreadUrl } + : {}), // T3-CUSTOM(expbkt3): session lineage re-parent / detach. ...(payload.parentThreadId !== undefined ? { parentThreadId: payload.parentThreadId } diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 78915b987640..5055827be12f 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -54,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at, priority, linear_issue_url, + mattermost_thread_url, parent_thread_id, -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON). work_summary, @@ -94,6 +95,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.snoozedAt}, ${row.priority}, ${row.linearIssueUrl ?? null}, + ${row.mattermostThreadUrl ?? null}, ${row.parentThreadId ?? null}, -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON). ${row.workSummary ?? null}, @@ -134,6 +136,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at = excluded.snoozed_at, priority = excluded.priority, linear_issue_url = excluded.linear_issue_url, + mattermost_thread_url = excluded.mattermost_thread_url, parent_thread_id = excluded.parent_thread_id, -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON). work_summary = excluded.work_summary, @@ -181,6 +184,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at AS "snoozedAt", priority, linear_issue_url AS "linearIssueUrl", + mattermost_thread_url AS "mattermostThreadUrl", parent_thread_id AS "parentThreadId", -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON). work_summary AS "workSummary", @@ -230,6 +234,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at AS "snoozedAt", priority, linear_issue_url AS "linearIssueUrl", + mattermost_thread_url AS "mattermostThreadUrl", parent_thread_id AS "parentThreadId", -- T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (JSON). work_summary AS "workSummary", @@ -288,6 +293,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at AS "snoozedAt", priority, linear_issue_url AS "linearIssueUrl", + mattermost_thread_url AS "mattermostThreadUrl", parent_thread_id AS "parentThreadId", work_summary AS "workSummary", pinned_at AS "pinnedAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index d3b737f6d263..71d3008def98 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -67,6 +67,7 @@ import Migration1005 from "./Migrations/036_ProjectionThreadsPinned.ts"; // T3-CUSTOM(expbkt3): fork migrations, numbered 1000+. import Migration1000 from "./Migrations/1000_ProjectionThreadsPriority.ts"; import Migration1022 from "./Migrations/1022_AgentUiRenders.ts"; +import Migration1023 from "./Migrations/1023_ProjectionThreadsMattermostLink.ts"; import Migration1001 from "./Migrations/1001_SessionRecoveryState.ts"; import Migration1002 from "./Migrations/1002_ThreadBootstrapAndCreationDefaults.ts"; // T3-CUSTOM(expbkt3): exact durable work items and guarded recovery audit. @@ -226,6 +227,8 @@ const migrationEntries = [ [1021, "ProjectionThreadsUnsettledAt", Migration1021], // T3-CUSTOM(expbkt3): agent-rendered UI surfaces in chat. [1022, "AgentUiRenders", Migration1022], + // T3-CUSTOM(expbkt3): durable Mattermost conversation link on a thread. + [1023, "ProjectionThreadsMattermostLink", Migration1023], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/1023_ProjectionThreadsMattermostLink.test.ts b/apps/server/src/persistence/Migrations/1023_ProjectionThreadsMattermostLink.test.ts new file mode 100644 index 000000000000..7ab42a34bd30 --- /dev/null +++ b/apps/server/src/persistence/Migrations/1023_ProjectionThreadsMattermostLink.test.ts @@ -0,0 +1,25 @@ +// T3-CUSTOM(expbkt3): durable Mattermost conversation link migration coverage. +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()))( + "1023_ProjectionThreadsMattermostLink", + (it) => { + it.effect("adds the nullable Mattermost conversation URL", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 1023 }); + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + assert.isTrue(columns.some((column) => column.name === "mattermost_thread_url")); + }), + ); + }, +); diff --git a/apps/server/src/persistence/Migrations/1023_ProjectionThreadsMattermostLink.ts b/apps/server/src/persistence/Migrations/1023_ProjectionThreadsMattermostLink.ts new file mode 100644 index 000000000000..d55da215fe61 --- /dev/null +++ b/apps/server/src/persistence/Migrations/1023_ProjectionThreadsMattermostLink.ts @@ -0,0 +1,20 @@ +// T3-CUSTOM(expbkt3): durable Mattermost conversation permalink on a thread. +// Written by the Linear/Mattermost bridge when it binds a session to a +// Mattermost thread, so the sidebar can mark sessions a human is watching +// from chat. Nullable: most threads have no Mattermost conversation. +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "mattermost_thread_url")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN mattermost_thread_url TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index aeb4986d3b08..4b94203839ea 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -53,6 +53,8 @@ export const ProjectionThread = Schema.Struct({ priority: Schema.NullOr(ThreadPriority), // T3-CUSTOM(expbkt3): durable manual Linear issue URL. linearIssueUrl: Schema.optional(Schema.NullOr(Schema.String)), + // T3-CUSTOM(expbkt3): durable Mattermost conversation permalink. + mattermostThreadUrl: Schema.optional(Schema.NullOr(Schema.String)), // T3-CUSTOM(expbkt3): session lineage; null means this is a root session. parentThreadId: Schema.optional(Schema.NullOr(ThreadId)), // T3-CUSTOM(expbkt3): BEGIN — JSON-encoded ThreadWorkSummary for the bulk session diff --git a/apps/web/src/components/PhaseGroupedSidebar.tsx b/apps/web/src/components/PhaseGroupedSidebar.tsx index 5cfb47ace0f4..dc49f9e31b70 100644 --- a/apps/web/src/components/PhaseGroupedSidebar.tsx +++ b/apps/web/src/components/PhaseGroupedSidebar.tsx @@ -156,6 +156,7 @@ import { resolvePhaseSidebarDisplayPhase, resolvePhaseSidebarPhase, resolvePhaseSidebarLinearIssue, + resolvePhaseSidebarMattermostLink, resolvePhaseSidebarProviderCode, resolvePhaseSidebarTraversalTarget, resolvePhaseSidebarWorkBadge, @@ -236,6 +237,8 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { PhaseSidebarUnreadIndicator } from "./sidebar/PhaseSidebarUnreadIndicator"; import { LinearIssueTagDialog } from "./sidebar/LinearIssueTagDialog"; +import { MattermostLinkDialog } from "./sidebar/MattermostLinkDialog"; +import { MattermostThreadBadge } from "./sidebar/MattermostThreadBadge"; // T3-CUSTOM(expbkt3): Settled-tail paging — recent history is the common // lookup; the deep tail stays behind an explicit Show more. @@ -870,6 +873,7 @@ interface PhaseThreadRowProps { readonly onSetPriority: (row: PhaseSidebarRow, priority: 0 | 1 | 2 | 3 | 4 | null) => void; // T3-CUSTOM(expbkt3): null clears a manually attached Linear issue. readonly onSetLinearIssueUrl: (row: PhaseSidebarRow, url: string | null) => void; + readonly onSetMattermostThreadUrl: (row: PhaseSidebarRow, url: string | null) => void; // T3-CUSTOM(expbkt3): re-derive the title from the conversation. readonly onRegenerateTitle: (row: PhaseSidebarRow) => void; readonly linearIssueStatus: LinearIssueStatusSummary | null; @@ -955,6 +959,7 @@ const PhaseThreadRow = memo(function PhaseThreadRow(props: PhaseThreadRowProps) onUnsnooze, onSetPriority, onSetLinearIssueUrl, + onSetMattermostThreadUrl, onRegenerateTitle, linearIssueStatus, onCreateThread, @@ -1005,6 +1010,8 @@ const PhaseThreadRow = memo(function PhaseThreadRow(props: PhaseThreadRowProps) const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); const linearIssue = resolvePhaseSidebarLinearIssue(row.thread.branch, row.thread.linearIssueUrl); + // T3-CUSTOM(expbkt3): the Mattermost conversation following this session. + const mattermostLink = resolvePhaseSidebarMattermostLink(row.thread.mattermostThreadUrl); // T3-CUSTOM(expbkt3): the row's PR reads beside its Linear tag — colour-only // state, number as the label. const changeRequestBadge = resolvePhaseSidebarChangeRequestBadge(vcsStatus); @@ -1041,6 +1048,7 @@ const PhaseThreadRow = memo(function PhaseThreadRow(props: PhaseThreadRowProps) // the row, so the hover cluster has to stay pinned. const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false); const [linearTagDialogOpen, setLinearTagDialogOpen] = useState(false); + const [mattermostDialogOpen, setMattermostDialogOpen] = useState(false); // Snooze is offered only where it can succeed: capability-gated, and never // on a thread that is blocked on the user (hiding a pending request would // defeat it). @@ -1193,6 +1201,20 @@ const PhaseThreadRow = memo(function PhaseThreadRow(props: PhaseThreadRowProps) : []), ] : []; + // T3-CUSTOM(expbkt3): the Mattermost conversation following this session. + // "Open" comes first because the badge itself is not clickable - it lives + // inside the row's button, where an anchor would be invalid and would + // swallow row selection. + const mattermostItems = row.mattermostLinkSupported + ? [ + ...(mattermostLink ? [{ id: "open-mattermost", label: "Open in Mattermost" }] : []), + { + id: "link-mattermost", + label: mattermostLink ? "Change Mattermost link\u2026" : "Link Mattermost\u2026", + }, + ...(mattermostLink ? [{ id: "remove-mattermost", label: "Remove Mattermost link" }] : []), + ] + : []; // Session lineage. "Detach" is always offered when a parent exists — // nesting must never be a one-way door. const lineageItems = treeActions @@ -1249,6 +1271,8 @@ const PhaseThreadRow = memo(function PhaseThreadRow(props: PhaseThreadRowProps) { id: "mark-unread", label: "Mark unread" }, ...priorityItems, ...linearItems, + // T3-CUSTOM(expbkt3): Mattermost conversation link. + ...mattermostItems, // T3-CUSTOM(expbkt3): session lineage. ...lineageItems, ...settlementItems, @@ -1297,6 +1321,12 @@ const PhaseThreadRow = memo(function PhaseThreadRow(props: PhaseThreadRowProps) } if (action === "tag-linear") setLinearTagDialogOpen(true); if (action === "remove-linear") onSetLinearIssueUrl(row, null); + // T3-CUSTOM(expbkt3): Mattermost conversation link. + if (action === "open-mattermost" && mattermostLink) { + window.open(mattermostLink.url, "_blank", "noopener,noreferrer"); + } + if (action === "link-mattermost") setMattermostDialogOpen(true); + if (action === "remove-mattermost") onSetMattermostThreadUrl(row, null); if (action === "regenerate-title") onRegenerateTitle(row); // T3-CUSTOM(expbkt3): END if (action === "force-stop-agent") onForceStop(row); @@ -1754,6 +1784,10 @@ const PhaseThreadRow = memo(function PhaseThreadRow(props: PhaseThreadRowProps) {ownerAvatarUserId !== null ? ( ) : null} + {/* T3-CUSTOM(expbkt3): a human is following this session from chat. */} + {mattermostLink ? ( + + ) : null} {/* T3-CUSTOM(expbkt3): BEGIN — hover actions overlay the row instead of reflowing metadata. */} - + {section === "snoozed" ? ( row.snoozeSupported ? ( onSetLinearIssueUrl(row, url)} /> + {/* T3-CUSTOM(expbkt3): Mattermost conversation link editor. */} + onSetMattermostThreadUrl(row, url)} + /> ); }); @@ -2060,6 +2106,9 @@ export function PhaseGroupedSidebar() { snoozeSupported: serverConfig?.environment.capabilities.threadSnooze === true, prioritySupported: serverConfig?.environment.capabilities.threadPriority === true, linearIssueSupported: serverConfig?.environment.capabilities.threadLinearIssue === true, + // T3-CUSTOM(expbkt3): durable Mattermost conversation link. + mattermostLinkSupported: + serverConfig?.environment.capabilities.threadMattermostLink === true, titleRegenerationSupported: serverConfig?.environment.capabilities.threadTitleRegeneration === true, threadBootstrapSupported: @@ -2549,6 +2598,30 @@ export function PhaseGroupedSidebar() { }, [updateThreadMetadata], ); + // T3-CUSTOM(expbkt3): the Mattermost conversation a session is bound to. The + // bridge normally writes this when it attaches a session to a chat thread; + // the row menu is the manual path and the way to clear a stale link. + const setThreadMattermostThreadUrl = useCallback( + (row: PhaseSidebarRow, mattermostThreadUrl: string | null) => { + if ((row.thread.mattermostThreadUrl ?? null) === mattermostThreadUrl) return; + void updateThreadMetadata({ + environmentId: row.thread.environmentId, + input: { threadId: row.thread.id, mattermostThreadUrl }, + }).then((result) => { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to link Mattermost conversation", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }); + }, + [updateThreadMetadata], + ); const setThreadLinearIssueUrl = useCallback( (row: PhaseSidebarRow, linearIssueUrl: string | null) => { if ((row.thread.linearIssueUrl ?? null) === linearIssueUrl) return; @@ -2983,6 +3056,7 @@ export function PhaseGroupedSidebar() { onUnsnooze={attemptUnsnooze} onSetPriority={setThreadPriority} onSetLinearIssueUrl={setThreadLinearIssueUrl} + onSetMattermostThreadUrl={setThreadMattermostThreadUrl} onRegenerateTitle={regenerateThreadTitle} onCreateThread={createThreadFromRow} linearIssueStatus={(() => { diff --git a/apps/web/src/components/sidebar/MattermostLinkDialog.tsx b/apps/web/src/components/sidebar/MattermostLinkDialog.tsx new file mode 100644 index 000000000000..eab9a696acef --- /dev/null +++ b/apps/web/src/components/sidebar/MattermostLinkDialog.tsx @@ -0,0 +1,88 @@ +/** T3-CUSTOM(expbkt3): Mattermost conversation link editor opened from a thread row. */ +import { useEffect, useState, type FormEvent } from "react"; + +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { resolvePhaseSidebarMattermostLink } from "./PhaseGroupedSidebar.logic"; + +export function MattermostLinkDialog({ + open, + initialUrl, + threadTitle, + onOpenChange, + onSave, +}: { + readonly open: boolean; + readonly initialUrl: string; + readonly threadTitle: string; + readonly onOpenChange: (open: boolean) => void; + readonly onSave: (url: string) => void; +}) { + const [url, setUrl] = useState(initialUrl); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setUrl(initialUrl); + setError(null); + }, [initialUrl, open]); + + const submit = (event: FormEvent) => { + event.preventDefault(); + const link = resolvePhaseSidebarMattermostLink(url); + if (!link) { + setError("Paste a Mattermost link such as https://chat.example.com/team/pl/postid."); + return; + } + onSave(link.url); + onOpenChange(false); + }; + + return ( + + +
+ + Link Mattermost conversation + + Link the Mattermost thread that follows “{threadTitle}”. The Mattermost mark appears + on the row beside the provider icon. + + + + + { + setUrl(event.target.value); + setError(null); + }} + /> + {error ?

{error}

: null} +
+ + + + +
+
+
+ ); +} diff --git a/apps/web/src/components/sidebar/MattermostThreadBadge.test.tsx b/apps/web/src/components/sidebar/MattermostThreadBadge.test.tsx new file mode 100644 index 000000000000..4effc42be688 --- /dev/null +++ b/apps/web/src/components/sidebar/MattermostThreadBadge.test.tsx @@ -0,0 +1,53 @@ +/** T3-CUSTOM(expbkt3): the sidebar's Mattermost conversation badge. */ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { MattermostThreadBadge } from "./MattermostThreadBadge"; +import { resolvePhaseSidebarMattermostLink } from "./PhaseGroupedSidebar.logic"; + +describe("resolvePhaseSidebarMattermostLink", () => { + it("labels a channel link with the channel name", () => { + expect( + resolvePhaseSidebarMattermostLink("https://chat.example.com/beknown/channels/co-x-tech"), + ).toEqual({ + label: "Mattermost · #co-x-tech", + url: "https://chat.example.com/beknown/channels/co-x-tech", + }); + }); + + it("labels a DM link with the recipient", () => { + expect( + resolvePhaseSidebarMattermostLink("https://chat.example.com/beknown/messages/@tushar")?.label, + ).toBe("Mattermost · @tushar"); + }); + + it("falls back to the host for a permalink, which carries no readable name", () => { + expect( + resolvePhaseSidebarMattermostLink("https://chat.example.com/beknown/pl/abc123")?.label, + ).toBe("Mattermost · chat.example.com"); + }); + + it("returns null for an absent, blank, or unparseable link", () => { + expect(resolvePhaseSidebarMattermostLink(null)).toBeNull(); + expect(resolvePhaseSidebarMattermostLink(undefined)).toBeNull(); + expect(resolvePhaseSidebarMattermostLink(" ")).toBeNull(); + expect(resolvePhaseSidebarMattermostLink("not a url")).toBeNull(); + }); + + it("rejects a non-http scheme so the menu never opens javascript:", () => { + expect(resolvePhaseSidebarMattermostLink("javascript:alert(1)")).toBeNull(); + }); +}); + +describe("MattermostThreadBadge", () => { + it("labels the conversation and keeps the provider-lane sizing", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-testid="phase-thread-mattermost-thread-1"'); + expect(markup).toContain('aria-label="Mattermost · #co-x-tech"'); + expect(markup).toContain("size-3.5"); + expect(markup).toContain(", so it is a labelled span rather than an + * anchor - opening the conversation lives in the row's context menu, because + * an nested in a