feat(mobile): add issue MentionType + viewed-issues store

Extend MentionType with "issue" and serialize issue mentions without
the leading `@` in the link label, matching web's
mention-extension.ts:67-74. New in-memory LRU tracks recently viewed
issues per workspace so the chat composer can surface them next.

Issue detail screen pushes its id into the store on mount. Suggestion
bar UI lands in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-05-14 13:48:32 +08:00
parent 2ac2609809
commit ee1c040071
3 changed files with 83 additions and 8 deletions

View File

@@ -9,7 +9,7 @@
* `issue/[id]` Stack.Screen with title "Issue". We override that here once
* the data lands so the navigation bar shows `MUL-123` (Linear-style).
*/
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
KeyboardAvoidingView,
@@ -32,6 +32,7 @@ import {
import { useCreateComment } from "@/data/mutations/issues";
import { useIssueRealtime } from "@/data/realtime/use-issue-realtime";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useViewedIssuesStore } from "@/data/viewed-issues-store";
export default function IssueDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
@@ -57,6 +58,16 @@ export default function IssueDetail() {
// user isn't stranded on a 404 detail page.
useIssueRealtime(id, () => router.back());
// Track viewed issues so the chat composer's `@` suggestion bar can
// surface "Recent" — the user just looked at MUL-123, likely wants to
// ask the agent about it next. Workspace-scoped + in-memory; see
// data/viewed-issues-store.ts.
useEffect(() => {
if (wsId && id) {
useViewedIssuesStore.getState().push(wsId, id);
}
}, [wsId, id]);
// Lifted: long-press a comment → action sheet → "Reply" sets this; the
// composer reads it to render a "Replying to <name>" chip and sends the
// resulting comment with `parent_id`.

View File

@@ -0,0 +1,60 @@
/**
* In-memory LRU of recently viewed issue UUIDs, scoped per workspace.
* Powers the chat composer's `@` suggestion bar — the "Recent" section
* shows whatever the user opened in the issue detail view most recently.
*
* Why in-memory only (no SecureStore persist):
* - Recency is a session signal; cold-start clearing is fine UX
* - Avoids the cost of awaiting SecureStore on every issue open
* - Persistence can be a follow-up if users miss it
*
* Why per-workspace:
* - Viewing MUL-1 in workspace A shouldn't surface it in workspace B's
* chat — different agents, different context
*
* Capacity 10 per workspace: enough for "the last few things I looked
* at" without overwhelming the suggestion list. We render at most 5 in
* the chat suggestion bar; the extras buffer against the user opening a
* couple of issues they don't end up referencing.
*/
import { create } from "zustand";
const CAPACITY = 10;
interface State {
byWorkspace: Record<string, string[]>;
/** Promote `id` to the head of this workspace's list (most-recent
* first). De-dupes the prior occurrence. */
push: (wsId: string, id: string) => void;
/** Remove an id from a workspace's list — call when issue detail
* fetch returns 404, so a deleted issue stops appearing in `@`. */
remove: (wsId: string, id: string) => void;
}
export const useViewedIssuesStore = create<State>((set) => ({
byWorkspace: {},
push: (wsId, id) =>
set((s) => {
const prev = s.byWorkspace[wsId] ?? [];
const next = [id, ...prev.filter((x) => x !== id)].slice(0, CAPACITY);
return { byWorkspace: { ...s.byWorkspace, [wsId]: next } };
}),
remove: (wsId, id) =>
set((s) => {
const prev = s.byWorkspace[wsId];
if (!prev) return s;
return {
byWorkspace: {
...s.byWorkspace,
[wsId]: prev.filter((x) => x !== id),
},
};
}),
}));
/** Selector — current workspace's viewed-issue ids in most-recent-first
* order. Returns the same array reference between updates so consumers
* can pass it directly to memoised selectors without re-renders. */
export function selectViewedIssueIds(wsId: string | null) {
return (s: State): string[] => (wsId ? s.byWorkspace[wsId] ?? [] : []);
}

View File

@@ -10,7 +10,8 @@
* On send: scan for `@<word>` runs, zip with the ordered `markers`
* list to produce `[@<name>](mention://<type>/<id>)` markdown that the
* backend's `util.ParseMentions` regex (server/internal/util/mention.go:16)
* already accepts.
* already accepts. **Issues drop the `@` in the label** — they render as
* `[MUL-123](mention://issue/<uuid>)` to match web (mention-extension.ts).
*
* Sentinel mismatch (e.g. user copy-paste broke a marker) → serializer
* falls back to plain text with all sentinels stripped: never crash, never
@@ -19,13 +20,14 @@
const SENTINEL = "";
export type MentionType = "member" | "agent" | "all";
export type MentionType = "member" | "agent" | "all" | "issue";
export interface MentionMarker {
type: MentionType;
/** UUID for member/agent, the literal "all" for @all. */
/** UUID for member/agent/issue, the literal "all" for @all. */
id: string;
/** Display name without the leading `@`. May contain non-ASCII chars. */
/** Display name without the leading `@`. For issues this is the
* identifier (e.g. "MUL-123"). May contain non-ASCII chars. */
name: string;
}
@@ -156,9 +158,11 @@ export function serializeMentions(
break;
}
out.push(
`[@${marker.name}](mention://${marker.type}/${marker.id})`,
);
// Issues render without the leading `@` in the link label (mirrors
// web's mention-extension.ts:67-74). Members / agents / @all keep it.
const label =
marker.type === "issue" ? marker.name : `@${marker.name}`;
out.push(`[${label}](mention://${marker.type}/${marker.id})`);
markerIndex++;
cursor = wordEnd;
}