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 (
+
+ );
+}
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("
+ {label}
+
+ );
+}
diff --git a/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.ts b/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.ts
index c67150b4916d..651ca4398388 100644
--- a/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.ts
+++ b/apps/web/src/components/sidebar/PhaseGroupedSidebar.logic.ts
@@ -305,6 +305,47 @@ export function resolvePhaseSidebarLinearIssue(
};
}
+/**
+ * T3-CUSTOM(expbkt3): the Mattermost conversation a session is bound to.
+ *
+ * Mattermost is self-hosted, so there is no canonical domain to validate
+ * against — anything that parses as an http(s) URL is accepted, and the label
+ * is derived only as far as the path reliably allows. Mattermost permalinks
+ * are `//pl/`, channels `//channels/`, and DMs
+ * `//messages/@user`; anything else degrades to the host, which is still
+ * more useful in a tooltip than the raw URL.
+ */
+export interface PhaseSidebarMattermostLink {
+ /** Tooltip text, e.g. "Mattermost · #co-x-tech". */
+ readonly label: string;
+ readonly url: string;
+}
+
+export function resolvePhaseSidebarMattermostLink(
+ manualUrl?: string | null,
+): PhaseSidebarMattermostLink | null {
+ const trimmed = manualUrl?.trim();
+ if (!trimmed) return null;
+ let parsed: URL;
+ try {
+ parsed = new URL(trimmed);
+ } catch {
+ return null;
+ }
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
+
+ const segments = parsed.pathname.split("/").filter((segment) => segment.length > 0);
+ const kind = segments[1];
+ const name = segments[2];
+ const detail =
+ kind === "channels" && name
+ ? `#${decodeURIComponent(name)}`
+ : kind === "messages" && name
+ ? decodeURIComponent(name)
+ : parsed.host;
+ return { label: `Mattermost · ${detail}`, url: parsed.toString() };
+}
+
/**
* T3-CUSTOM(expbkt3): The row's change request, rendered beside the Linear tag
* so the two trackers a session answers to read as one line: ticket, then PR.
@@ -460,6 +501,8 @@ export interface PhaseSidebarRow {
readonly prioritySupported: boolean;
/** Same version-skew contract for manual Linear tags on thread.meta.update. */
readonly linearIssueSupported?: boolean;
+ /** Same version-skew contract for the Mattermost link on thread.meta.update. */
+ readonly mattermostLinkSupported?: boolean;
/** Same version-skew contract for regenerateTitle on thread.meta.update,
which backs "Regenerate title" on the row's context menu. */
readonly titleRegenerationSupported?: boolean;
diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts
index 467cf6365dc9..eb2177ddd879 100644
--- a/packages/client-runtime/src/state/threadReducer.ts
+++ b/packages/client-runtime/src/state/threadReducer.ts
@@ -262,6 +262,10 @@ export function applyThreadDetailEvent(
...(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/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts
index 5a1be8fccb39..ebc9fa3d9e38 100644
--- a/packages/contracts/src/environment.ts
+++ b/packages/contracts/src/environment.ts
@@ -76,6 +76,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
threadPriority: Schema.optionalKey(Schema.Boolean),
/** T3-CUSTOM(expbkt3): durable manual Linear tags on thread metadata. */
threadLinearIssue: Schema.optionalKey(Schema.Boolean),
+ /** T3-CUSTOM(expbkt3): durable Mattermost conversation link on thread metadata. */
+ threadMattermostLink: Schema.optionalKey(Schema.Boolean),
/** T3-CUSTOM(expbkt3): server understands externalSession on thread.create
(attach a new thread to an existing provider session). */
threadExternalSessionAttach: Schema.optionalKey(Schema.Boolean),
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts
index 96cecbcb53a2..67fc8bb2f23e 100644
--- a/packages/contracts/src/orchestration.ts
+++ b/packages/contracts/src/orchestration.ts
@@ -797,6 +797,8 @@ export const OrchestrationThread = Schema.Struct({
priority: Schema.optional(Schema.NullOr(ThreadPriority)),
// T3-CUSTOM(expbkt3): optional so payloads from pre-manual-tag servers decode.
linearIssueUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
+ // T3-CUSTOM(expbkt3): optional so payloads from pre-Mattermost servers decode.
+ mattermostThreadUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
// T3-CUSTOM(expbkt3): session lineage. Optional so payloads from
// pre-lineage servers decode; null means this is a root session.
parentThreadId: Schema.optional(Schema.NullOr(ThreadId)),
@@ -892,6 +894,8 @@ export const OrchestrationThreadShell = Schema.Struct({
priority: Schema.optional(Schema.NullOr(ThreadPriority)),
// T3-CUSTOM(expbkt3): durable manual Linear issue URL.
linearIssueUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
+ // T3-CUSTOM(expbkt3): durable Mattermost conversation permalink.
+ mattermostThreadUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
// T3-CUSTOM(expbkt3): session lineage (see the ThreadPriority block above).
parentThreadId: Schema.optional(Schema.NullOr(ThreadId)),
// T3-CUSTOM(expbkt3): BEGIN — bulk session manager work summary (see ThreadWorkSummary).
@@ -1387,6 +1391,9 @@ const ThreadMetaUpdateCommand = Schema.Struct({
priority: Schema.optional(Schema.NullOr(ThreadPriority)),
// T3-CUSTOM(expbkt3): manual Linear tag. undefined = unchanged, null = clear.
linearIssueUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
+ // T3-CUSTOM(expbkt3): Mattermost conversation this session is bound to.
+ // undefined = unchanged, null = clear.
+ mattermostThreadUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
// T3-CUSTOM(expbkt3): session lineage. undefined = unchanged, null = detach
// to a root session. The decider rejects a value that would form a cycle.
parentThreadId: Schema.optional(Schema.NullOr(ThreadId)),
@@ -2129,6 +2136,9 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({
priority: Schema.optional(Schema.NullOr(ThreadPriority)),
// T3-CUSTOM(expbkt3): manual Linear tag. undefined = unchanged, null = clear.
linearIssueUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
+ // T3-CUSTOM(expbkt3): Mattermost conversation this session is bound to.
+ // undefined = unchanged, null = clear.
+ mattermostThreadUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
// T3-CUSTOM(expbkt3): session lineage. undefined = unchanged, null = detach.
parentThreadId: Schema.optional(Schema.NullOr(ThreadId)),
linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)),