From 547e494c74e4057cd83769a788ed5afd0107a9d7 Mon Sep 17 00:00:00 2001 From: Vincelwt Date: Mon, 10 Aug 2026 21:37:56 +0200 Subject: [PATCH] Mention tasks in any chat surface Typing # in a composer offers open tasks first and inserts the task key, the same reference every other surface already writes. Message rendering recognises a key that names a real task and makes it a live link to it, so a task an agent cited is as clickable as one a person typed. --- client/mentions.test.ts | 52 ++++++++++++++++++ client/mentions.ts | 29 ++++++++++ desktop/src/components/Chat.tsx | 83 ++++++++++++++++++----------- desktop/src/components/Markdown.tsx | 15 +++++- desktop/src/lib/markdown.tsx | 26 +++++++++ desktop/src/styles.css | 3 ++ mobile/package.json | 2 +- mobile/src/components/Composer.tsx | 37 +++++++++---- 8 files changed, 204 insertions(+), 43 deletions(-) create mode 100644 client/mentions.test.ts create mode 100644 client/mentions.ts 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 #.
)} {window_.padTop > 0 &&
} @@ -919,14 +922,16 @@ function ComposerBox({ onCancelReply, }: ComposerProps & { draftKey: string }) { const api = useApi(); - const { members, me } = useAppSelector((data) => ({ + const { members, me, tasks } = useAppSelector((data) => ({ members: data.members, me: data.me, + tasks: data.tasks, })); const [text, setText] = useState(() => localStorage.getItem(draftKey) ?? ""); const files = useAttachments({ incoming, onConsumed, taskId: channel.task_id }); const [busy, setBusy] = useState(false); - const [mentionQuery, setMentionQuery] = useState(null); + // `@` brings a teammate in, `#` points at a task. One menu, one keyboard. + const [mention, setMention] = useState<{ sigil: string; query: string } | null>(null); const [mentionIndex, setMentionIndex] = useState(0); const box = useRef(null); const wrap = useRef(null); @@ -983,27 +988,42 @@ function ComposerBox({ } }; - const candidates = - mentionQuery === null - ? [] - : members - .filter( - (member) => - member.handle.startsWith(mentionQuery.toLowerCase()) && - member.id !== me?.id, - ) - .slice(0, 6); - - const applyMention = (handle: string) => { - setText((current) => current.replace(/@[\w-]*$/, `@${handle} `)); - setMentionQuery(null); + const candidates = useMemo(() => { + if (!mention) return []; + const query = mention.query.toLowerCase(); + if (mention.sigil === "#") { + // The key is the mention: what lands in the message is what every other + // surface already writes, so the reference reads the same everywhere. + return matchTasks(tasks, query).map((task) => ({ + id: task.id, + insert: `${task.key} `, + name: task.title, + sub: `${task.key} · ${statusLabel(task.status)}`, + member: undefined as Member | undefined, + })); + } + return members + .filter((member) => member.handle.startsWith(query) && member.id !== me?.id) + .slice(0, 6) + .map((member) => ({ + id: member.id, + insert: `@${member.handle} `, + name: member.display_name, + sub: `@${member.handle}${member.kind === "agent" ? " · agent" : ""}`, + member, + })); + }, [mention, members, me?.id, tasks]); + + const applyMention = (insert: string) => { + setText((current) => current.replace(/[@#][\w-]*$/, insert)); + setMention(null); box.current?.focus(); }; const onChange = (value: string) => { setText(value); - const match = value.match(/@([\w-]*)$/); - setMentionQuery(match ? match[1] : null); + const match = value.match(/([@#])([\w-]*)$/); + setMention(match ? { sigil: match[1], query: match[2] } : null); setMentionIndex(0); store.typing(channel.id); }; @@ -1047,20 +1067,21 @@ function ComposerBox({ {candidates.length > 0 && (
- {candidates.map((member, index) => ( + {candidates.map((candidate, index) => ( ))} @@ -1114,11 +1135,11 @@ function ComposerBox({ } if (event.key === "Tab" || (event.key === "Enter" && !event.shiftKey)) { event.preventDefault(); - applyMention(candidates[mentionIndex].handle); + applyMention(candidates[mentionIndex].insert); return; } if (event.key === "Escape") { - setMentionQuery(null); + setMention(null); return; } } diff --git a/desktop/src/components/Markdown.tsx b/desktop/src/components/Markdown.tsx index eac0ce0..99948a1 100644 --- a/desktop/src/components/Markdown.tsx +++ b/desktop/src/components/Markdown.tsx @@ -9,6 +9,8 @@ import type { ReactNode } from "react"; import { parseBlocks, renderInline } from "../lib/markdown"; import type { InlineOptions } from "../lib/markdown"; import { CheckIcon, CopyIcon } from "./icons"; +import { useNavigation } from "./common"; +import { useAppSelector } from "../lib/store"; export const Markdown = memo(function Markdown({ body, @@ -23,7 +25,18 @@ export const Markdown = memo(function Markdown({ compact?: boolean; }) { const blocks = useMemo(() => parseBlocks(body), [body]); - const options: InlineOptions = useMemo(() => ({ handles }), [handles]); + const { go } = useNavigation(); + // Read here rather than handed down: a task key means the same task in every + // surface that renders a message, and the list only moves when a task does. + const allTasks = useAppSelector((data) => data.tasks); + const tasks = useMemo( + () => new Map(allTasks.map((task) => [task.key.toUpperCase(), task.id])), + [allTasks], + ); + const options: InlineOptions = useMemo( + () => ({ handles, tasks, onTask: (id: string) => go({ kind: "task", id }) }), + [handles, tasks, go], + ); return (
diff --git a/desktop/src/lib/markdown.tsx b/desktop/src/lib/markdown.tsx index d6fa13f..03f729d 100644 --- a/desktop/src/lib/markdown.tsx +++ b/desktop/src/lib/markdown.tsx @@ -8,6 +8,7 @@ // table has to render as something calm rather than flickering into garbage. import type { ReactNode } from "react"; +import { TASK_KEY } from "@client/mentions"; // --- blocks ----------------------------------------------------------------- @@ -346,6 +347,9 @@ export interface InlineOptions { /// Handles that resolve to a real member, so `@nobody` stays plain text. handles?: Set; onMention?: (handle: string) => void; + /// Uppercased task keys to task ids, so `PW-999` stays plain text. + tasks?: Map; + onTask?: (taskId: string) => void; } /// Only schemes that can't execute anything. A `javascript:` href in an agent's @@ -371,6 +375,7 @@ const INLINE = new RegExp( "<(https?://[^>\\s]+)>", // autolink "(?()\\[\\]]+[^\\s<>()\\[\\].,;:!?'\"])", // bare url "@([a-z0-9][\\w-]*)", // mention + TASK_KEY.source, // task mention: PW-42 ].join("|"), "gi", ); @@ -423,6 +428,7 @@ export function renderInline( autolink, bareUrl, mention, + taskKey, ] = match; if (code !== undefined) { @@ -484,6 +490,26 @@ export function renderInline( @{mention} ), ); + } else if (taskKey !== undefined) { + // Every surface writes the key the same way, so recognising it here makes + // a task someone typed and a task an agent cited the same live reference. + const taskId = options.tasks?.get(taskKey.toUpperCase()); + push( + taskId ? ( + { + event.stopPropagation(); + options.onTask?.(taskId); + }} + > + {taskKey.toUpperCase()} + + ) : ( + {taskKey} + ), + ); } } diff --git a/desktop/src/styles.css b/desktop/src/styles.css index c7a434c..9feda87 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -1046,6 +1046,9 @@ body.resizing { color: var(--accent); font-weight: 550; } +.mention.task { + cursor: pointer; +} /* --- composer ------------------------------------------------------------ */ diff --git a/mobile/package.json b/mobile/package.json index 352c91d..5866356 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -8,7 +8,7 @@ "android": "expo run:android", "ios": "expo run:ios", "typecheck": "tsc --noEmit", - "test": "node --test --experimental-strip-types ../client/mobile-store-reducer.test.ts ../client/run-activity.test.ts ../client/scroll.test.ts src/lib/format.test.ts src/lib/workspace-cache.test.ts", + "test": "node --test --experimental-strip-types ../client/mentions.test.ts ../client/mobile-store-reducer.test.ts ../client/run-activity.test.ts ../client/scroll.test.ts src/lib/format.test.ts src/lib/workspace-cache.test.ts", "export": "expo export --platform all" }, "dependencies": { diff --git a/mobile/src/components/Composer.tsx b/mobile/src/components/Composer.tsx index 7a552c6..20b3f1d 100644 --- a/mobile/src/components/Composer.tsx +++ b/mobile/src/components/Composer.tsx @@ -10,6 +10,7 @@ import { View, } from "react-native"; +import { matchTasks } from "@client/mentions"; import type { Id, Message } from "@client/types"; import { useDictation } from "@/lib/dictation"; import { useWorkspace, useWorkspaceStore } from "@/lib/store"; @@ -42,14 +43,30 @@ export function Composer({ const typingAt = useRef(0); const input = useRef(null); const members = workspace.bootstrap?.members ?? []; + const tasks = workspace.bootstrap?.tasks ?? []; + // `@` brings a teammate in, `#` points at a task. A task mention is its key, + // the same reference every other surface writes. const mention = useMemo(() => { - const match = text.match(/(?:^|\s)@([\w-]*)$/); + const match = text.match(/(?:^|\s)([@#])([\w-]*)$/); if (!match) return []; - const query = match[1].toLowerCase(); + const query = match[2].toLowerCase(); + if (match[1] === "#") + return matchTasks(tasks, query, 5).map((task) => ({ + id: task.id, + insert: `${task.key} `, + name: task.title, + sub: task.key, + })); return members .filter((member) => member.handle.toLowerCase().startsWith(query)) - .slice(0, 5); - }, [members, text]); + .slice(0, 5) + .map((member) => ({ + id: member.id, + insert: `@${member.handle} `, + name: member.display_name, + sub: `@${member.handle}`, + })); + }, [members, tasks, text]); const dictation = useDictation((value) => store.setDraft(draftKey, value)); const offline = workspace.connection !== "live"; const empty = !text.trim() && images.pending.length === 0; @@ -67,8 +84,8 @@ export function Composer({ } }; - const insertMention = (handle: string) => { - change(text.replace(/@[\w-]*$/, `@${handle} `)); + const insertMention = (insert: string) => { + change(text.replace(/[@#][\w-]*$/, insert)); }; const send = async () => { @@ -103,10 +120,10 @@ export function Composer({ {mention.length ? ( - {mention.map((member) => ( - insertMention(member.handle)} style={styles.mentionRow}> - {member.display_name} - @{member.handle} + {mention.map((candidate) => ( + insertMention(candidate.insert)} style={styles.mentionRow}> + {candidate.name} + {candidate.sub} ))}