diff --git a/packages/views/issues/components/issue-detail.tsx b/packages/views/issues/components/issue-detail.tsx index 9b27f795e..474887acc 100644 --- a/packages/views/issues/components/issue-detail.tsx +++ b/packages/views/issues/components/issue-detail.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useMemo, useRef, Fragment } from "react"; -import { Virtuoso } from "react-virtuoso"; +import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; import { useDefaultLayout, usePanelRef } from "react-resizable-panels"; import { AppLink } from "../../navigation"; import { useNavigation } from "../../navigation"; @@ -58,6 +58,7 @@ import { LocalDirectoryHint } from "../../projects/components/local-directory-hi import { CommentCard } from "./comment-card"; import { CommentInput } from "./comment-input"; import { ResolvedThreadBar } from "./resolved-thread-bar"; +import { ThreadMinimap, type ThreadMinimapThread } from "./thread-minimap"; import { collectThreadReplies, deriveThreadResolution } from "./thread-utils"; import { IssueAgentHeaderChip } from "./issue-agent-header-chip"; import { ExecutionLogSection } from "./execution-log-section"; @@ -1079,6 +1080,59 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr return items.findIndex((it) => it.id === rootId); }, [items, highlightCommentId, replyToRoot]); + // Quick-jump minimap rail: one tick per comment thread (folded resolved + // bars included), activity groups skipped. Derived from the same flat + // `items` array Virtuoso renders so tick order always matches the page. + const minimapThreads = useMemo( + () => + items.flatMap((it) => + it.kind === "comment" || it.kind === "resolved-bar" + ? [{ id: it.id, entry: it.entry }] + : [], + ), + [items], + ); + + // When the timeline renders flat (deep-link or in-page find), there is no + // Virtuoso instance — minimap jumps drive the scroll container directly. + const isFlatTimeline = !!highlightCommentId || find.open; + const virtuosoRef = useRef(null); + const jumpFlashTimerRef = useRef(null); + useEffect( + () => () => { + if (jumpFlashTimerRef.current !== null) window.clearTimeout(jumpFlashTimerRef.current); + }, + [], + ); + const jumpToThread = useCallback( + (threadId: string) => { + if (isFlatTimeline) { + // Flat mode mounts every row, so the anchor is always in the DOM. + // Drive the container's scrollTop directly — never native + // scrollIntoView, which also scrolls the desktop shell (#3929). + const el = document.getElementById(`comment-${threadId}`); + const container = scrollContainerEl; + if (!el || !container) return; + const c = container.getBoundingClientRect(); + const e = el.getBoundingClientRect(); + container.scrollTop = Math.max(0, container.scrollTop + (e.top - c.top) - 16); + } else { + // Virtualized mode: the target row may not be mounted, so scroll by + // index and let Virtuoso mount it. Offset leaves a small top gap. + const index = items.findIndex((it) => it.id === threadId); + if (index < 0) return; + virtuosoRef.current?.scrollToIndex({ index, align: "start", offset: -16 }); + } + // Flash the landed thread the same way inbox deep-links do, so the eye + // has an anchor after the instant jump. (Folded resolved bars don't + // take the highlight prop — the scroll itself is the feedback there.) + setHighlightedId(threadId); + if (jumpFlashTimerRef.current !== null) window.clearTimeout(jumpFlashTimerRef.current); + jumpFlashTimerRef.current = window.setTimeout(() => setHighlightedId(null), 2000); + }, + [isFlatTimeline, items, scrollContainerEl], + ); + const { reactions: issueReactions, toggleReaction: handleToggleIssueReaction, @@ -2196,6 +2250,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
+ + {/* Thread quick-jump rail — overlays the scroll container's left + gutter (inside the px-8 content padding, so it never covers + text). Hover previews a thread, click jumps to it. Hidden on + mobile: no hover, and the gutter is too tight. */} + {!isMobile && ( + + )} ); diff --git a/packages/views/issues/components/thread-minimap.test.tsx b/packages/views/issues/components/thread-minimap.test.tsx new file mode 100644 index 000000000..2c495f0eb --- /dev/null +++ b/packages/views/issues/components/thread-minimap.test.tsx @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from "vitest"; +import { act, fireEvent, screen } from "@testing-library/react"; +import type { TimelineEntry } from "@multica/core/types"; +import { renderWithI18n } from "../../test/i18n"; +import { ThreadMinimap, commentPreview, waveScale } from "./thread-minimap"; + +vi.mock("@multica/core/workspace/hooks", () => ({ + useActorName: () => ({ + getActorName: (type: string, id: string) => `${type}:${id}`, + }), +})); + +function comment(id: string, content: string): TimelineEntry { + return { + type: "comment", + id, + actor_type: "member", + actor_id: `author-${id}`, + created_at: "2026-07-10T10:00:00Z", + content, + }; +} + +describe("commentPreview", () => { + it("splits the first line into the title and joins the rest into the body", () => { + const { title, body } = commentPreview( + "## Rollout plan\n\nShip the flag first.\nThen watch the dashboards.", + ); + expect(title).toBe("Rollout plan"); + expect(body).toBe("Ship the flag first. Then watch the dashboards."); + }); + + it("flattens markdown decorations to plain text", () => { + const { title, body } = commentPreview( + "**Bold** start with [a link](https://example.com) and [@Walt](mention://agent/a-1)\n" + + "- first item\n" + + "1. numbered ![diagram](https://example.com/x.png)\n" + + "```ts\nconst hidden = true;\n```\n" + + "> quoted tail", + ); + expect(title).toBe("Bold start with a link and @Walt"); + expect(body).toBe("first item numbered diagram quoted tail"); + }); + + it("returns empty strings for content that flattens to nothing", () => { + expect(commentPreview("![](https://example.com/only-image.png)")).toEqual({ + title: "", + body: "", + }); + }); + + it("caps runaway titles and bodies", () => { + const { title, body } = commentPreview(`${"t".repeat(500)}\n${"b".repeat(900)}`); + expect(title).toHaveLength(200); + expect(body).toHaveLength(300); + }); +}); + +describe("waveScale", () => { + it("peaks under the cursor and settles to 1 at the radius", () => { + expect(waveScale(0)).toBeCloseTo(1.7, 5); + expect(waveScale(56)).toBe(1); + expect(waveScale(200)).toBe(1); + }); + + it("tapers monotonically and symmetrically", () => { + const profile = [0, 14, 28, 42, 56].map(waveScale); + for (let i = 1; i < profile.length; i++) { + expect(profile[i]!).toBeLessThan(profile[i - 1]!); + } + expect(waveScale(-14)).toBeCloseTo(waveScale(14), 10); + }); +}); + +describe("ThreadMinimap", () => { + const threads = [ + { id: "c1", entry: comment("c1", "First thread opener\nwith details") }, + { id: "c2", entry: comment("c2", "Second thread opener") }, + { id: "c3", entry: comment("c3", "") }, + ]; + + it("renders nothing below the thread threshold", () => { + const { container } = renderWithI18n( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders one labelled tick per thread, falling back to the author for empty content", () => { + renderWithI18n( + , + ); + + const nav = screen.getByRole("navigation", { name: "Jump to comment thread" }); + expect(nav).toBeInTheDocument(); + expect(screen.getAllByRole("button")).toHaveLength(3); + expect(screen.getByRole("button", { name: "First thread opener" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Second thread opener" })).toBeInTheDocument(); + // Attachment-only comment: accessible name falls back to the actor. + expect(screen.getByRole("button", { name: "member:author-c3" })).toBeInTheDocument(); + }); + + it("opens the shared preview card after the intent delay and closes after the leave grace", () => { + vi.useFakeTimers({ + toFake: ["setTimeout", "clearTimeout", "requestAnimationFrame", "cancelAnimationFrame"], + }); + try { + renderWithI18n( + , + ); + const nav = screen.getByRole("navigation", { name: "Jump to comment thread" }); + + // jsdom rects are all zero → the nearest tick resolves to index 0. + fireEvent.pointerMove(nav, { clientY: 0 }); + act(() => vi.advanceTimersByTime(30)); // rAF flush — arms the intent timer + expect(screen.queryByText("with details")).not.toBeInTheDocument(); + + act(() => vi.advanceTimersByTime(150)); // intent delay elapses → card opens + expect(screen.getByText("with details")).toBeInTheDocument(); + + fireEvent.pointerLeave(nav); + act(() => vi.advanceTimersByTime(30)); // wave-clear frame + expect(screen.getByText("with details")).toBeInTheDocument(); // grace keeps it up + act(() => vi.advanceTimersByTime(150)); // grace elapses → card closes + expect(screen.queryByText("with details")).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + it("jumps to the clicked thread", () => { + const onJump = vi.fn(); + renderWithI18n( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Second thread opener" })); + expect(onJump).toHaveBeenCalledTimes(1); + expect(onJump).toHaveBeenCalledWith("c2"); + }); +}); diff --git a/packages/views/issues/components/thread-minimap.tsx b/packages/views/issues/components/thread-minimap.tsx new file mode 100644 index 000000000..e9e15c7ff --- /dev/null +++ b/packages/views/issues/components/thread-minimap.tsx @@ -0,0 +1,450 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { TimelineEntry } from "@multica/core/types"; +import { useActorName } from "@multica/core/workspace/hooks"; +import { cn } from "@multica/ui/lib/utils"; +import { useT } from "../../i18n"; + +// --------------------------------------------------------------------------- +// ThreadMinimap — Linear-style quick-jump rail for comment threads +// --------------------------------------------------------------------------- +// +// A vertical column of tick marks overlaid on the left edge of the issue +// detail scroll area, one tick per top-level comment thread (folded resolved +// bars included — they are jump targets too). Ticks whose thread is currently +// inside the scroll viewport render darker, so the rail doubles as a "you are +// here" minimap. Hovering magnifies ticks in a Dock-style wave around the +// cursor (the hovered tick peaks, neighbours taper off) and shows a preview +// card (bold first line + muted body excerpt); clicking jumps the timeline +// to that thread. +// +// The preview is ONE card owned by the rail, not a popover per tick: the +// open-intent delay is paid once when the pointer enters the rail, and while +// the pointer glides across ticks the card swaps content instantly and +// slides to the hovered tick. Per-tick popovers re-paid the open delay and +// exit/enter animations on every tick crossed, which read as lag when +// scanning the rail continuously. +// +// The rail deliberately skips activity groups: they are timeline noise, not +// navigation destinations. + +/** Minimum number of threads before the rail is worth its pixels. */ +const MIN_THREADS = 2; + +/** Intent delay before the card first appears; gliding afterwards is instant. */ +const PREVIEW_OPEN_DELAY_MS = 150; +/** Grace period on leave — long enough to travel from rail onto the card. */ +const PREVIEW_CLOSE_DELAY_MS = 150; + +// --------------------------------------------------------------------------- +// Hover wave — Dock-style proximity magnification +// --------------------------------------------------------------------------- +// +// While the pointer travels along the rail, every tick scales with a cosine +// falloff of its distance to the cursor, so the hovered tick peaks and its +// neighbours taper off like a wave. Driven per-pointermove with direct style +// writes (no React re-render), batched read-then-write inside one rAF, on the +// compositor-friendly native `scale` property; the 100ms ease-out transition +// on the tick smooths between pointer samples and settles the collapse on +// leave. Only the hovered tick darkens — neighbours grow but keep their color. + +/** Distance (px) at which a tick stops feeling the wave — ~4 tick pitches. */ +const WAVE_RADIUS_PX = 56; +/** Peak horizontal scale of the hovered tick (12px base → ~20px). */ +const WAVE_MAX_SCALE = 1.7; + +/** + * Horizontal scale for a tick whose center is `distancePx` from the pointer. + * Cosine-squared bell: smooth at the peak and at the radius edge (no kinks). + */ +export function waveScale(distancePx: number): number { + const d = Math.abs(distancePx); + if (d >= WAVE_RADIUS_PX) return 1; + const t = Math.cos(((d / WAVE_RADIUS_PX) * Math.PI) / 2); + return 1 + (WAVE_MAX_SCALE - 1) * t * t; +} + +/** + * Caps applied by `commentPreview`. The preview card clamps visually + * (`truncate` / `line-clamp-3`), but agent comments can be tens of KB of + * markdown — capping here keeps the flattened strings (and the aria-labels + * derived from them) small instead of shipping the whole comment into the DOM. + */ +const PREVIEW_TITLE_MAX = 200; +const PREVIEW_BODY_MAX = 300; + +/** + * Flatten comment markdown into a plain-text preview: `title` is the first + * non-empty line (bold in the card), `body` is the remaining lines joined + * into one muted excerpt. Mirrors the chat list's `toPreview` flattening + * (fences dropped, md tokens stripped) but keeps the first-line/body split + * the minimap card renders. + */ +export function commentPreview(markdown: string): { title: string; body: string } { + const lines = markdown + .replace(/```[\s\S]*?```/g, " ") + .split(/\r?\n/) + .map((line) => + line + .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/^\s*(?:[-+*]|\d+[.)])\s+/, "") + .replace(/[#*`>~]/g, "") + .replace(/\s+/g, " ") + .trim(), + ) + .filter(Boolean); + return { + title: (lines[0] ?? "").slice(0, PREVIEW_TITLE_MAX), + body: lines.slice(1).join(" ").slice(0, PREVIEW_BODY_MAX), + }; +} + +export interface ThreadMinimapThread { + /** Root comment id — also the `comment-${id}` DOM anchor of the rendered row. */ + id: string; + /** The thread's root comment entry (preview text + author fallback). */ + entry: TimelineEntry; +} + +interface ThreadMinimapProps { + threads: ThreadMinimapThread[]; + /** The issue detail scroll container; null until its callback ref populates. */ + scrollContainerEl: HTMLElement | null; + onJump: (threadId: string) => void; + /** Positioning within the page (e.g. `absolute left-2 top-12 bottom-0`) — owned by the caller, like FindBar. */ + className?: string; +} + +function sameIdSet(a: Set, b: Set): boolean { + if (a.size !== b.size) return false; + for (const v of a) if (!b.has(v)) return false; + return true; +} + +/** + * Which threads currently intersect the scroll viewport. Computed from DOM + * rects on scroll/resize instead of an IntersectionObserver because Virtuoso + * mounts/unmounts rows while scrolling — an observer would lose its targets. + * Unmounted rows are by definition outside the (overscanned) viewport, so + * "no element" correctly counts as not visible. + */ +function useVisibleThreadIds( + threads: ThreadMinimapThread[], + scrollContainerEl: HTMLElement | null, +): Set { + const [visibleIds, setVisibleIds] = useState>(() => new Set()); + + useEffect(() => { + const container = scrollContainerEl; + if (!container) return; + + let raf = 0; + const compute = () => { + raf = 0; + const rect = container.getBoundingClientRect(); + const next = new Set(); + for (const t of threads) { + const el = document.getElementById(`comment-${t.id}`); + if (!el) continue; + const r = el.getBoundingClientRect(); + if (r.bottom > rect.top && r.top < rect.bottom) next.add(t.id); + } + setVisibleIds((prev) => (sameIdSet(prev, next) ? prev : next)); + }; + const schedule = () => { + if (!raf) raf = requestAnimationFrame(compute); + }; + + compute(); + container.addEventListener("scroll", schedule, { passive: true }); + // Content height changes without scroll events: Virtuoso mounting rows + // after first paint, streamed agent replies growing, window resizes. + const ro = new ResizeObserver(schedule); + ro.observe(container); + if (container.firstElementChild) ro.observe(container.firstElementChild); + return () => { + container.removeEventListener("scroll", schedule); + ro.disconnect(); + if (raf) cancelAnimationFrame(raf); + }; + }, [threads, scrollContainerEl]); + + return visibleIds; +} + +/** The rail-owned preview card's target: which tick, anchored at which shim-relative Y. */ +interface PreviewAnchor { + index: number; + y: number; +} + +function MinimapTick({ + label, + inViewport, + isPreviewOpen, + onClick, +}: { + label: string; + inViewport: boolean; + /** This tick's preview is the open card — hold the grown state even when the pointer is on the card. */ + isPreviewOpen: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +export function ThreadMinimap({ threads, scrollContainerEl, onJump, className }: ThreadMinimapProps) { + const { t } = useT("issues"); + const { getActorName } = useActorName(); + const visibleIds = useVisibleThreadIds(threads, scrollContainerEl); + + // Flattened previews, cached per thread by content so an unrelated timeline + // update (reaction, new reply elsewhere) doesn't re-flatten every comment. + const prevPreviewsRef = useRef>(new Map()); + const previews = useMemo(() => { + const next = new Map(); + const arr = threads.map((th) => { + const cached = prevPreviewsRef.current.get(th.id); + const preview = + cached && cached.content === th.entry.content + ? cached.preview + : commentPreview(th.entry.content ?? ""); + next.set(th.id, { content: th.entry.content, preview }); + return preview; + }); + prevPreviewsRef.current = next; + return arr; + }, [threads]); + + const shimRef = useRef(null); + const navRef = useRef(null); + const cardRef = useRef(null); + + // Hover wave + preview targeting. Pointer position lives in refs and ticks + // are scaled with direct style writes so pointermove never re-renders the + // component; the rAF guard coalesces bursts to one batched read-then-write + // per frame. The same rect pass derives which tick the card should anchor + // to, so the card and the wave can never disagree about the hovered tick. + const waveRafRef = useRef(0); + const pointerYRef = useRef(null); + const reducedMotionRef = useRef(false); + + const [preview, setPreview] = useState(null); + const previewRef = useRef(null); + const pendingAnchorRef = useRef(null); + const openTimerRef = useRef(null); + const closeTimerRef = useRef(null); + + const showPreview = useCallback((anchor: PreviewAnchor | null) => { + previewRef.current = anchor; + setPreview((prev) => + prev?.index === anchor?.index && prev?.y === anchor?.y ? prev : anchor, + ); + }, []); + + useEffect(() => { + reducedMotionRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + return () => { + if (waveRafRef.current) cancelAnimationFrame(waveRafRef.current); + if (openTimerRef.current !== null) window.clearTimeout(openTimerRef.current); + if (closeTimerRef.current !== null) window.clearTimeout(closeTimerRef.current); + }; + }, []); + + const cancelClose = useCallback(() => { + if (closeTimerRef.current !== null) { + window.clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }, []); + const scheduleClose = useCallback(() => { + cancelClose(); + if (openTimerRef.current !== null) { + window.clearTimeout(openTimerRef.current); + openTimerRef.current = null; + } + closeTimerRef.current = window.setTimeout(() => { + closeTimerRef.current = null; + showPreview(null); + }, PREVIEW_CLOSE_DELAY_MS); + }, [cancelClose, showPreview]); + + const runWave = useCallback(() => { + waveRafRef.current = 0; + const nav = navRef.current; + const shim = shimRef.current; + if (!nav || !shim) return; + const y = pointerYRef.current; + const buttons = nav.querySelectorAll("button"); + // Read pass, then write pass — never interleaved, one reflow at most. + const shimTop = shim.getBoundingClientRect().top; + const shimHeight = shim.clientHeight; + const cardHalf = (cardRef.current?.offsetHeight ?? 96) / 2; + const scales: string[] = []; + let nearest: { index: number; centerY: number; dist: number } | null = null; + buttons.forEach((b, i) => { + if (y === null) { + scales.push(""); + return; + } + const r = b.getBoundingClientRect(); + const centerY = r.top + r.height / 2; + const dist = Math.abs(y - centerY); + const s = reducedMotionRef.current ? 1 : waveScale(y - centerY); + scales.push(s > 1.001 ? `${s.toFixed(3)} 1` : ""); + if (!nearest || dist < nearest.dist) nearest = { index: i, centerY, dist }; + }); + buttons.forEach((b, i) => { + const tick = b.firstElementChild as HTMLElement | null; + if (!tick) return; + const s = scales[i]!; + // Clearing the inline value hands control back to the CSS floor states + // (open-card tick / focus-visible / reduced-motion hover). + if (s) tick.style.setProperty("scale", s); + else tick.style.removeProperty("scale"); + }); + + // Preview targeting from the same pass. Clamp the anchor so the card + // never sticks out of the rail's column at the extremes. + if (y === null || !nearest) return; + const { index, centerY } = nearest as { index: number; centerY: number }; + const anchor: PreviewAnchor = { + index, + y: Math.min(Math.max(centerY - shimTop, cardHalf + 6), shimHeight - cardHalf - 6), + }; + pendingAnchorRef.current = anchor; + if (previewRef.current) { + // Already open: gliding retargets the card instantly — no re-delay. + showPreview(anchor); + } else if (openTimerRef.current === null) { + openTimerRef.current = window.setTimeout(() => { + openTimerRef.current = null; + if (pointerYRef.current !== null) showPreview(pendingAnchorRef.current); + }, PREVIEW_OPEN_DELAY_MS); + } + }, [showPreview]); + const scheduleWave = useCallback(() => { + if (!waveRafRef.current) waveRafRef.current = requestAnimationFrame(runWave); + }, [runWave]); + const handleWaveMove = useCallback( + (e: React.PointerEvent) => { + cancelClose(); + pointerYRef.current = e.clientY; + scheduleWave(); + }, + [cancelClose, scheduleWave], + ); + const handleWaveLeave = useCallback(() => { + pointerYRef.current = null; + scheduleWave(); + scheduleClose(); + }, [scheduleWave, scheduleClose]); + + // Keyboard parity: focusing a tick anchors the card to it immediately — + // there is no pointer, so there is no accidental-hover to debounce. + const handleFocus = useCallback( + (e: React.FocusEvent) => { + const nav = navRef.current; + const shim = shimRef.current; + const btn = (e.target as HTMLElement).closest("button"); + if (!nav || !shim || !btn) return; + cancelClose(); + const buttons = [...nav.querySelectorAll("button")]; + const index = buttons.indexOf(btn as HTMLButtonElement); + if (index < 0) return; + const r = btn.getBoundingClientRect(); + const cardHalf = (cardRef.current?.offsetHeight ?? 96) / 2; + const y = r.top + r.height / 2 - shim.getBoundingClientRect().top; + showPreview({ + index, + y: Math.min(Math.max(y, cardHalf + 6), shim.clientHeight - cardHalf - 6), + }); + }, + [cancelClose, showPreview], + ); + + if (threads.length < MIN_THREADS) return null; + + const activeThread = preview ? threads[preview.index] : undefined; + const activePreview = preview ? previews[preview.index] : undefined; + const activeTitle = activeThread && activePreview + ? activePreview.title || + getActorName(activeThread.entry.actor_type, activeThread.entry.actor_id) + : undefined; + + return ( + // Positioning shim; only the nav and the card take pointer events so the + // strip never blocks content clicks. +
+ + + {/* The rail's single preview card. Mounted without an enter animation + (the open-intent delay already gates accidental flashes; once the + user waited, showing content instantly is the responsive choice) + and slid between ticks with a short transform transition. Hovering + the card keeps it open so its text stays selectable. */} + {preview && activeThread && activePreview && ( +
+

{activeTitle}

+ {activePreview.body && ( +

{activePreview.body}

+ )} +
+ )} +
+ ); +} diff --git a/packages/views/locales/en/issues.json b/packages/views/locales/en/issues.json index a1b1a2f0a..3234e4696 100644 --- a/packages/views/locales/en/issues.json +++ b/packages/views/locales/en/issues.json @@ -227,6 +227,7 @@ "archive_tooltip": "Archive", "pin_tooltip": "Pin to sidebar", "unpin_tooltip": "Unpin from sidebar", + "thread_nav_label": "Jump to comment thread", "sidebar_tooltip": "Toggle sidebar", "find": { "placeholder": "Find in issue...", diff --git a/packages/views/locales/ja/issues.json b/packages/views/locales/ja/issues.json index 6ba19cdab..6926734ae 100644 --- a/packages/views/locales/ja/issues.json +++ b/packages/views/locales/ja/issues.json @@ -224,6 +224,7 @@ "archive_tooltip": "アーカイブ", "pin_tooltip": "サイドバーにピン留め", "unpin_tooltip": "サイドバーのピン留めを解除", + "thread_nav_label": "スレッドへ移動", "sidebar_tooltip": "サイドバーの開閉", "find": { "placeholder": "イシュー内を検索...", diff --git a/packages/views/locales/ko/issues.json b/packages/views/locales/ko/issues.json index 308b879ea..673a0e22a 100644 --- a/packages/views/locales/ko/issues.json +++ b/packages/views/locales/ko/issues.json @@ -224,6 +224,7 @@ "archive_tooltip": "보관", "pin_tooltip": "사이드바에 고정", "unpin_tooltip": "사이드바 고정 해제", + "thread_nav_label": "스레드로 이동", "sidebar_tooltip": "사이드바 열기/닫기", "find": { "placeholder": "이슈에서 찾기...", diff --git a/packages/views/locales/zh-Hans/issues.json b/packages/views/locales/zh-Hans/issues.json index 99fecd510..c20ab47bf 100644 --- a/packages/views/locales/zh-Hans/issues.json +++ b/packages/views/locales/zh-Hans/issues.json @@ -224,6 +224,7 @@ "archive_tooltip": "归档", "pin_tooltip": "固定到侧边栏", "unpin_tooltip": "从侧边栏取消固定", + "thread_nav_label": "快速跳转到讨论", "sidebar_tooltip": "切换侧边栏", "find": { "placeholder": "在 issue 中查找...",