diff --git a/client/mentions.test.ts b/client/mentions.test.ts
new file mode 100644
index 0000000..da7f567
--- /dev/null
+++ b/client/mentions.test.ts
@@ -0,0 +1,52 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { TASK_KEY, matchTasks } from "./mentions.ts";
+import type { Task, TaskStatus } from "./types.ts";
+
+const task = (key: string, title: string, status: TaskStatus, updated: number) =>
+ ({
+ id: `id-${key}`,
+ key,
+ title,
+ outcome: "",
+ status,
+ discussion_channel_id: "c1",
+ created_by: "u1",
+ created_at: 0,
+ updated_at: updated,
+ position: 0,
+ }) as Task;
+
+const tasks = [
+ task("PW-1", "Ship the relay", "done", 300),
+ task("PW-42", "Allow mentioning tasks", "planned", 100),
+ task("ACME-7", "Cache the pricing endpoint", "running", 200),
+];
+
+test("open work is offered before finished work, newest first", () => {
+ assert.deepEqual(
+ matchTasks(tasks, "").map((found) => found.key),
+ ["ACME-7", "PW-42", "PW-1"],
+ );
+});
+
+test("a key or a word from the title finds the task", () => {
+ assert.deepEqual(
+ matchTasks(tasks, "pw-4").map((found) => found.key),
+ ["PW-42"],
+ );
+ assert.deepEqual(
+ matchTasks(tasks, "pricing").map((found) => found.key),
+ ["ACME-7"],
+ );
+ assert.deepEqual(matchTasks(tasks, "nothing here"), []);
+});
+
+test("a key is recognised in prose but not inside a longer token", () => {
+ const find = (text: string) => text.match(new RegExp(TASK_KEY.source))?.[1];
+ assert.equal(find("landed in PW-42, see above"), "PW-42");
+ assert.equal(find("ACME-7"), "ACME-7");
+ assert.equal(find("v2-1024-beta"), undefined);
+ assert.equal(find("plain words"), undefined);
+});
diff --git a/client/mentions.ts b/client/mentions.ts
new file mode 100644
index 0000000..0806abd
--- /dev/null
+++ b/client/mentions.ts
@@ -0,0 +1,29 @@
+// Mentioning a task is just writing its key. `PW-42` in a message is the same
+// reference everywhere — typed by a person, printed by an agent, or copied out
+// of the CLI — so the composer only has to help find the key, and the renderer
+// only has to recognise the keys that name a real task.
+
+import type { Task } from "./types";
+
+/// The shape of a key: the workspace prefix is configurable, so this matches
+/// `ACME-7` as readily as `PW-42` and the caller decides which keys are real.
+export const TASK_KEY = /(? task.status === "done" || task.status === "canceled";
+
+/// Tasks worth offering for what has been typed after `#`, open work first and
+/// most recently touched before the rest.
+export function matchTasks(tasks: Task[], query: string, limit = 6): Task[] {
+ const needle = query.trim().toLowerCase();
+ return tasks
+ .filter(
+ (task) =>
+ task.key.toLowerCase().includes(needle) ||
+ task.title.toLowerCase().includes(needle),
+ )
+ .sort(
+ (a, b) =>
+ Number(finished(a)) - Number(finished(b)) || b.updated_at - a.updated_at,
+ )
+ .slice(0, limit);
+}
diff --git a/desktop/src/components/Chat.tsx b/desktop/src/components/Chat.tsx
index b60c942..000313c 100644
--- a/desktop/src/components/Chat.tsx
+++ b/desktop/src/components/Chat.tsx
@@ -7,7 +7,8 @@ import {
useState,
} from "react";
import { store, useApi, useAppSelector } from "../lib/store";
-import { bytes, dayLabel, duration, timeOfDay } from "../lib/format";
+import { bytes, dayLabel, duration, statusLabel, timeOfDay } from "../lib/format";
+import { matchTasks } from "@client/mentions";
import { useVirtualWindow } from "../lib/virtual";
import { useBottomAnchor } from "../lib/scroll";
import { useFileUrl } from "../lib/file";
@@ -27,6 +28,7 @@ import {
RunIcon,
SendIcon,
Spinner,
+ TasksIcon,
ThreadIcon,
} from "./icons";
import { Menu, type MenuItem } from "./ui";
@@ -257,7 +259,8 @@ export function Timeline({
{messages.length === 0 && (
- Nothing here yet. Say something, or bring an agent in with @.
+ Nothing here yet. Say something, bring an agent in with @, or point
+ at a task with #.