Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions client/mentions.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
29 changes: 29 additions & 0 deletions client/mentions.ts
Original file line number Diff line number Diff line change
@@ -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 = /(?<![\w-])([A-Za-z][A-Za-z0-9]*-\d+)(?![\w-])/;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept valid digit-leading task prefixes

Workspaces can validly configure a task prefix beginning with a digit because update_workspace retains any nonempty ASCII-alphanumeric prefix, producing keys such as 2FA-1 or 123-4. This pattern requires the first character to be a letter, so those real tasks can be selected by the new composer menu but remain plain text in desktop messages. Match an alphanumeric leading character or tighten the server-side prefix validation consistently.

Useful? React with 👍 / 👎.


const finished = (task: Task) => 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);
}
83 changes: 52 additions & 31 deletions desktop/src/components/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -27,6 +28,7 @@ import {
RunIcon,
SendIcon,
Spinner,
TasksIcon,
ThreadIcon,
} from "./icons";
import { Menu, type MenuItem } from "./ui";
Expand Down Expand Up @@ -257,7 +259,8 @@ export function Timeline({
<div className="timeline-inner" ref={contentRef}>
{messages.length === 0 && (
<div className="empty">
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 #.
</div>
)}
{window_.padTop > 0 && <div style={{ height: window_.padTop }} />}
Expand Down Expand Up @@ -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<string | null>(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<HTMLTextAreaElement>(null);
const wrap = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -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);
};
Expand Down Expand Up @@ -1047,20 +1067,21 @@ function ComposerBox({

{candidates.length > 0 && (
<div className="mention-menu">
{candidates.map((member, index) => (
{candidates.map((candidate, index) => (
<button
key={member.id}
key={candidate.id}
className={`row${index === mentionIndex ? " active" : ""}`}
onMouseEnter={() => setMentionIndex(index)}
onClick={() => applyMention(member.handle)}
onClick={() => applyMention(candidate.insert)}
>
<Avatar member={member} size={22} />
{candidate.member ? (
<Avatar member={candidate.member} size={22} />
) : (
<TasksIcon size={16} />
)}
<span className="grow">
<span className="name">{member.display_name}</span>
<span className="sub">
@{member.handle}
{member.kind === "agent" ? " · agent" : ""}
</span>
<span className="name">{candidate.name}</span>
<span className="sub">{candidate.sub}</span>
</span>
</button>
))}
Expand Down Expand Up @@ -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;
}
}
Expand Down
15 changes: 14 additions & 1 deletion desktop/src/components/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
<div className={`md${compact ? " compact" : ""}${className ? ` ${className}` : ""}`}>
Expand Down
26 changes: 26 additions & 0 deletions desktop/src/lib/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 -----------------------------------------------------------------

Expand Down Expand Up @@ -346,6 +347,9 @@ export interface InlineOptions {
/// Handles that resolve to a real member, so `@nobody` stays plain text.
handles?: Set<string>;
onMention?: (handle: string) => void;
/// Uppercased task keys to task ids, so `PW-999` stays plain text.
tasks?: Map<string, string>;
onTask?: (taskId: string) => void;
}

/// Only schemes that can't execute anything. A `javascript:` href in an agent's
Expand All @@ -371,6 +375,7 @@ const INLINE = new RegExp(
"<(https?://[^>\\s]+)>", // autolink
"(?<![\\w/])(https?://[^\\s<>()\\[\\]]+[^\\s<>()\\[\\].,;:!?'\"])", // bare url
"@([a-z0-9][\\w-]*)", // mention
TASK_KEY.source, // task mention: PW-42
].join("|"),
"gi",
);
Expand Down Expand Up @@ -423,6 +428,7 @@ export function renderInline(
autolink,
bareUrl,
mention,
taskKey,
] = match;

if (code !== undefined) {
Expand Down Expand Up @@ -484,6 +490,26 @@ export function renderInline(
<span key={k}>@{mention}</span>
),
);
} 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 ? (
<span
key={k}
className="mention task"
onClick={(event) => {
event.stopPropagation();
options.onTask?.(taskId);
Comment on lines +502 to +504

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cancel the enclosing link when opening a task

When a known task key is used as an explicit Markdown link label, such as [PW-42](https://example.com), renderInline nests this clickable span inside the generated anchor. stopPropagation() does not cancel the anchor's default action, so clicking the key both navigates the app to the task and opens the external URL in a new tab. Prevent the default action or avoid task-link behavior inside explicit link labels.

Useful? React with 👍 / 👎.

}}
>
{taskKey.toUpperCase()}
</span>
) : (
<span key={k}>{taskKey}</span>
),
);
}
}

Expand Down
3 changes: 3 additions & 0 deletions desktop/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,9 @@ body.resizing {
color: var(--accent);
font-weight: 550;
}
.mention.task {
cursor: pointer;
}

/* --- composer ------------------------------------------------------------ */

Expand Down
2 changes: 1 addition & 1 deletion mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading