From ee1c040071517ede3dee456fa7d71b68e417fec8 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 14 May 2026 13:48:32 +0800 Subject: [PATCH] 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) --- .../app/(app)/[workspace]/issue/[id].tsx | 13 +++- apps/mobile/data/viewed-issues-store.ts | 60 +++++++++++++++++++ apps/mobile/lib/mention-serialize.ts | 18 +++--- 3 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/data/viewed-issues-store.ts diff --git a/apps/mobile/app/(app)/[workspace]/issue/[id].tsx b/apps/mobile/app/(app)/[workspace]/issue/[id].tsx index f6e001e196..0344c0632b 100644 --- a/apps/mobile/app/(app)/[workspace]/issue/[id].tsx +++ b/apps/mobile/app/(app)/[workspace]/issue/[id].tsx @@ -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 " chip and sends the // resulting comment with `parent_id`. diff --git a/apps/mobile/data/viewed-issues-store.ts b/apps/mobile/data/viewed-issues-store.ts new file mode 100644 index 0000000000..ebe7967fa8 --- /dev/null +++ b/apps/mobile/data/viewed-issues-store.ts @@ -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; + /** 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((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] ?? [] : []); +} diff --git a/apps/mobile/lib/mention-serialize.ts b/apps/mobile/lib/mention-serialize.ts index 3313c40380..74144aac82 100644 --- a/apps/mobile/lib/mention-serialize.ts +++ b/apps/mobile/lib/mention-serialize.ts @@ -10,7 +10,8 @@ * On send: scan for `⁣@` runs, zip with the ordered `markers` * list to produce `[@](mention:///)` 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/)` 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; }