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; }