mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
Merge pull request #69750 from NousResearch/bb/desktop-fork-new-tab
fix(desktop): open branched chat in a new tab and switch to it
This commit is contained in:
commit
5c4d358a7e
8 changed files with 204 additions and 43 deletions
|
|
@ -36,6 +36,7 @@ import { sessionTitle } from '@/lib/chat-runtime'
|
|||
import { createComposerAttachmentScope } from '@/store/composer'
|
||||
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { $projectTree } from '@/store/projects'
|
||||
import { sessionAwaitingInput } from '@/store/prompts'
|
||||
import {
|
||||
$gatewayState,
|
||||
|
|
@ -54,6 +55,7 @@ import {
|
|||
type SessionTile,
|
||||
sessionTileDelegate
|
||||
} from '@/store/session-states'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import type { SessionDragPayload } from './composer/inline-refs'
|
||||
import { type ComposerScope, ComposerScopeProvider } from './composer/scope'
|
||||
|
|
@ -278,8 +280,25 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string }
|
|||
// Tile -> pane contribution sync (call once from the app root).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Resolve a tile's stored row: the recents list first, then the project
|
||||
* tree. A session opened as a tab from a project group is often older than
|
||||
* the paginated recents page, so it has no `$sessions` row at all until new
|
||||
* activity lands it there — resolving through the tree keeps its tab titled
|
||||
* and tinted instead of a grey "Session" placeholder. */
|
||||
export function tileStoredRow(storedSessionId: string): SessionInfo | undefined {
|
||||
const match = (s: SessionInfo) => sessionMatchesStoredId(s, storedSessionId)
|
||||
|
||||
return (
|
||||
$sessions.get().find(match) ??
|
||||
$projectTree
|
||||
.get()
|
||||
.flatMap(p => [...p.repos.flatMap(r => r.groups.flatMap(g => g.sessions)), ...(p.previewSessions ?? [])])
|
||||
.find(match)
|
||||
)
|
||||
}
|
||||
|
||||
function tileTitle(storedSessionId: string): string {
|
||||
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
const stored = tileStoredRow(storedSessionId)
|
||||
|
||||
return stored ? sessionTitle(stored) : 'Session'
|
||||
}
|
||||
|
|
@ -287,12 +306,12 @@ function tileTitle(storedSessionId: string): string {
|
|||
/** The tab's lead-dot color — the tile's session resolved through the SAME
|
||||
* shared map the sidebar reads, so a row and its tab always agree. */
|
||||
function tileAccent(storedSessionId: string): string | undefined {
|
||||
return sessionColorFor($sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)))
|
||||
return sessionColorFor(tileStoredRow(storedSessionId))
|
||||
}
|
||||
|
||||
/** The `@session` link payload for a tile tab drag — id + owning profile + title. */
|
||||
function tileDragPayload(storedSessionId: string): SessionDragPayload {
|
||||
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
const stored = tileStoredRow(storedSessionId)
|
||||
|
||||
return { id: storedSessionId, profile: stored?.profile ?? '', title: tileTitle(storedSessionId) }
|
||||
}
|
||||
|
|
@ -381,9 +400,12 @@ export function SessionTabMenu({
|
|||
/** Layout-tree pane id — powers the Close-others/right/all verbs. */
|
||||
tabPaneId: string
|
||||
}) {
|
||||
const sessions = useStore($sessions)
|
||||
// Subscribe for reactivity; the row is read imperatively via tileStoredRow
|
||||
// (which spans both sources), so the values themselves are unused here.
|
||||
useStore($sessions)
|
||||
useStore($projectTree)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const stored = sessions.find(s => sessionMatchesStoredId(s, storedSessionId))
|
||||
const stored = tileStoredRow(storedSessionId)
|
||||
const pinId = stored ? sessionPinId(stored) : storedSessionId
|
||||
const pinned = pinnedSessionIds.includes(pinId)
|
||||
|
||||
|
|
@ -440,7 +462,9 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement })
|
|||
* `$sessions`). Tiles dock against main on the chosen edge, flex width. */
|
||||
export const watchSessionTiles = paneMirror<SessionTile>({
|
||||
source: $sessionTiles,
|
||||
also: [$sessions, $sessionColorById],
|
||||
// $projectTree: a tile whose session is older than the recents page resolves
|
||||
// its title/accent through the tree, which loads after the tiles register.
|
||||
also: [$sessions, $sessionColorById, $projectTree],
|
||||
key: t => t.storedSessionId,
|
||||
prefix: 'session-tile',
|
||||
dir: t => t.dir,
|
||||
|
|
|
|||
|
|
@ -505,6 +505,24 @@ describe('liveSessionProjectId', () => {
|
|||
expect(id).toBe('p_app')
|
||||
})
|
||||
|
||||
it('places a cwd-outside-root session under an explicit project matching either path', () => {
|
||||
// A mid-session relocation (or a sibling worktree) leaves cwd outside the
|
||||
// recorded repo root. An explicit folder match is still authoritative —
|
||||
// only the auto-project (repo root) fallback needs cwd-under-root
|
||||
// confidence. Match via the repo root...
|
||||
expect(
|
||||
liveSessionProjectId(makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }), [
|
||||
makeProject('p_proj', ['/home/u/proj'])
|
||||
])
|
||||
).toBe('p_proj')
|
||||
// ...and via the cwd.
|
||||
expect(
|
||||
liveSessionProjectId(makeSession('/www/elsewhere/sub', { git_repo_root: '/home/u/proj' }), [
|
||||
makeProject('p_www', ['/www/elsewhere'])
|
||||
])
|
||||
).toBe('p_www')
|
||||
})
|
||||
|
||||
it('matches a mixed-case/separator Windows cwd to its explicit project in the live overlay', () => {
|
||||
// The bug: a fresh Windows session drops into the overlay before the next
|
||||
// backend refresh; case-sensitive matching missed its project until then.
|
||||
|
|
@ -553,6 +571,14 @@ describe('sessionProjectColor', () => {
|
|||
expect(sessionProjectColor(session, [colored('p_app', ['/www/app'], '#4a9eff')])).toBe('#4a9eff')
|
||||
})
|
||||
|
||||
it('colors a cwd-outside-root session when an explicit project folder matches', () => {
|
||||
// The backend tree groups such a row under the project; the client color
|
||||
// derivation must agree instead of leaving the row (and its tab) grey.
|
||||
const session = makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' })
|
||||
|
||||
expect(sessionProjectColor(session, [colored('p_proj', ['/home/u/proj'], '#4a9eff')])).toBe('#4a9eff')
|
||||
})
|
||||
|
||||
it('returns null for a session that only maps to an auto repo root (no explicit project)', () => {
|
||||
// liveSessionProjectId falls back to the repo root id, which is not a
|
||||
// project row and therefore carries no color.
|
||||
|
|
|
|||
|
|
@ -357,7 +357,11 @@ function isPathUnder(folder: string, target: string): boolean {
|
|||
* the overview at once instead of waiting for the next backend refresh. Returns
|
||||
* null only for sessions we genuinely can't place from the row alone: cwd-less,
|
||||
* kanban-task worktrees (they fold into the kanban bucket), or a worktree that
|
||||
* lives OUTSIDE the repo root (a sibling dir whose project can't be derived).
|
||||
* lives OUTSIDE the repo root (a sibling dir) AND under no explicit project
|
||||
* folder. An explicit-project folder match always places the row — even when
|
||||
* the row's cwd sits outside its recorded repo root (a mid-session relocation,
|
||||
* or a sibling worktree of a project repo), the folder match is authoritative;
|
||||
* only the repo-root AUTO-project fallback needs cwd-under-root confidence.
|
||||
*/
|
||||
export function liveSessionProjectId(session: SessionInfo, explicitProjects: ProjectInfo[]): null | string {
|
||||
const cwd = (session.cwd || '').trim()
|
||||
|
|
@ -372,13 +376,6 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro
|
|||
return null
|
||||
}
|
||||
|
||||
// With a cwd present it must sit under the repo root (a sibling worktree
|
||||
// outside the root can't be placed from the row alone); a root-only session
|
||||
// skips this — the root IS the anchor.
|
||||
if (cwd && !isPathUnder(repoRoot, cwd)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let projectId = ''
|
||||
let bestLen = -1
|
||||
|
||||
|
|
@ -399,7 +396,19 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro
|
|||
}
|
||||
}
|
||||
|
||||
return projectId || repoRoot
|
||||
if (projectId) {
|
||||
return projectId
|
||||
}
|
||||
|
||||
// AUTO-project fallback (the repo root itself): with a cwd present it must
|
||||
// sit under the repo root (a sibling worktree outside the root can't be
|
||||
// placed from the row alone); a root-only session skips this — the root IS
|
||||
// the anchor.
|
||||
if (cwd && !isPathUnder(repoRoot, cwd)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return repoRoot
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -273,7 +273,10 @@ function useSessionActions({
|
|||
const workItems: ItemSpec[] = [
|
||||
spec({
|
||||
disabled: !onBranch,
|
||||
icon: 'git-branch',
|
||||
// Fork glyph to match the inline message action's GitFork icon
|
||||
// (assistant-message.tsx). NB: this codicon font has no `git-fork`
|
||||
// glyph (only `git-fork-private`); `repo-forked` is the fork icon.
|
||||
icon: 'repo-forked',
|
||||
label: r.branchFrom,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
setSelectedStoredSessionId,
|
||||
setSessions
|
||||
} from '@/store/session'
|
||||
import { $sessionTiles } from '@/store/session-states'
|
||||
|
||||
import { sessionRoute } from '../../routes'
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
|
@ -975,9 +976,11 @@ describe('resumeSession failure recovery', () => {
|
|||
})
|
||||
|
||||
function BranchHarness({
|
||||
navigate = vi.fn(),
|
||||
onReady,
|
||||
requestGateway
|
||||
}: {
|
||||
navigate?: ReturnType<typeof vi.fn>
|
||||
onReady: (branchStoredSession: (storedSessionId: string, sessionProfile?: string | null) => Promise<boolean>) => void
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}) {
|
||||
|
|
@ -991,7 +994,7 @@ function BranchHarness({
|
|||
ensureSessionState: () => ({}) as ClientSessionState,
|
||||
getRouteToken: () => 'token',
|
||||
getRoutedStoredSessionId: () => null,
|
||||
navigate: vi.fn() as never,
|
||||
navigate: navigate as never,
|
||||
requestGateway,
|
||||
resetViewSync: vi.fn(),
|
||||
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
|
||||
|
|
@ -1013,9 +1016,48 @@ describe('branchStoredSession desktop source tagging', () => {
|
|||
afterEach(() => {
|
||||
cleanup()
|
||||
setSessions([])
|
||||
$sessionTiles.set([])
|
||||
setSelectedStoredSessionId(null)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('opens the branch as a new tab and leaves the parent chat selected', async () => {
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'session.create') {
|
||||
return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
// Parent is the currently-open (primary) chat.
|
||||
setSessions([storedSession({ id: 'stored-parent', message_count: 1 })])
|
||||
setSelectedStoredSessionId('stored-parent')
|
||||
vi.mocked(getSessionMessages).mockResolvedValue({
|
||||
messages: [{ content: 'branch me', role: 'user', timestamp: 1 }],
|
||||
session_id: 'stored-parent'
|
||||
} as never)
|
||||
|
||||
const navigate = vi.fn()
|
||||
let branchStoredSession: ((storedSessionId: string) => Promise<boolean>) | null = null
|
||||
render(
|
||||
<BranchHarness
|
||||
navigate={navigate}
|
||||
onReady={branch => (branchStoredSession = branch)}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
await waitFor(() => expect(branchStoredSession).not.toBeNull())
|
||||
|
||||
await expect(branchStoredSession!('stored-parent')).resolves.toBe(true)
|
||||
|
||||
// The branch opened as its own tab...
|
||||
expect($sessionTiles.get().some(tile => tile.storedSessionId === 'branch-stored')).toBe(true)
|
||||
// ...without stealing the primary selection or navigating away from the parent.
|
||||
expect($selectedStoredSessionId.get()).toBe('stored-parent')
|
||||
expect(navigate).not.toHaveBeenCalledWith(sessionRoute('branch-stored'))
|
||||
})
|
||||
|
||||
it('tags desktop branch sessions as desktop sessions', async () => {
|
||||
let createParams: Record<string, unknown> | undefined
|
||||
|
||||
|
|
|
|||
|
|
@ -1030,7 +1030,8 @@ export function useSessionActions({
|
|||
)
|
||||
|
||||
// Shared fork: create a child session seeded with `branchMessages`, linked to
|
||||
// `parentStoredId` so it nests under its parent, then make it the active chat.
|
||||
// `parentStoredId` so it nests under its parent, then open it as its own tab
|
||||
// and switch to it — the parent chat stays put (mirrors openNewSessionTile).
|
||||
const forkBranch = useCallback(
|
||||
async (branchMessages: BranchMessage[], parentStoredId: null | string, cwd?: string): Promise<boolean> => {
|
||||
creatingSessionRef.current = true
|
||||
|
|
@ -1067,8 +1068,6 @@ export function useSessionActions({
|
|||
parent ? parent.last_active || parent.started_at : undefined
|
||||
)
|
||||
ensureSessionState(branched.session_id, routedSessionId)
|
||||
setActiveSessionId(branched.session_id)
|
||||
activeSessionIdRef.current = branched.session_id
|
||||
updateSessionState(
|
||||
branched.session_id,
|
||||
state => ({
|
||||
|
|
@ -1079,9 +1078,6 @@ export function useSessionActions({
|
|||
}),
|
||||
routedSessionId
|
||||
)
|
||||
setSelectedStoredSessionId(routedSessionId)
|
||||
selectedStoredSessionIdRef.current = routedSessionId
|
||||
navigate(sessionRoute(routedSessionId))
|
||||
|
||||
const runtimeInfo = applyRuntimeInfo(branched.info)
|
||||
patchSessionWorkspace(routedSessionId, runtimeInfo?.cwd)
|
||||
|
|
@ -1090,6 +1086,15 @@ export function useSessionActions({
|
|||
updateSessionState(branched.session_id, state => ({ ...state, ...runtimeInfo }), routedSessionId)
|
||||
}
|
||||
|
||||
// Open the branch as its own tab and switch to it, leaving the parent
|
||||
// chat exactly where it is. Prime the tile with the create runtime so it
|
||||
// skips a redundant resume. Do NOT select it as the primary session
|
||||
// first — openSessionTile no-ops when the id is already primary.
|
||||
openSessionTile(routedSessionId, 'center')
|
||||
patchSessionTile(routedSessionId, { runtimeId: branched.session_id })
|
||||
revealTreePane(`session-tile:${routedSessionId}`)
|
||||
broadcastSessionsChanged()
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
notifyError(err, copy.branchFailed)
|
||||
|
|
@ -1101,16 +1106,7 @@ export function useSessionActions({
|
|||
}, 0)
|
||||
}
|
||||
},
|
||||
[
|
||||
activeSessionIdRef,
|
||||
copy,
|
||||
creatingSessionRef,
|
||||
ensureSessionState,
|
||||
navigate,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
updateSessionState
|
||||
]
|
||||
[copy, creatingSessionRef, ensureSessionState, requestGateway, updateSessionState]
|
||||
)
|
||||
|
||||
// Branch the open chat — optionally from a specific message — off its live transcript.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type CSSProperties, Fragment, type ReactNode, type RefObject, useRef, useState } from 'react'
|
||||
import { type CSSProperties, Fragment, type ReactNode, type RefObject, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
|
|
@ -178,6 +178,30 @@ export function TreeGroup({
|
|||
const active = paneFor(activeId)
|
||||
const isEmpty = node.panes.length === 0
|
||||
|
||||
// KEEP-ALIVE: every pane that has been ACTIVE in this zone stays mounted —
|
||||
// an inactive tab merely hides (visibility), it does not unmount. Remounting
|
||||
// on every tab switch re-measured and re-scrolled the content from scratch
|
||||
// (the thread visibly layout-shifted each time a session tab was revisited).
|
||||
// Lazy on purpose: a pane first mounts when first activated, so a
|
||||
// boot-restored tab stack doesn't resume every session up front.
|
||||
const everActivePanesRef = useRef<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
if (!node.minimized && !isEmpty) {
|
||||
everActivePanesRef.current.add(activeId)
|
||||
}
|
||||
|
||||
// Prune panes that left the zone (closed / moved to another group), so a
|
||||
// long-lived zone doesn't pin stale ids forever.
|
||||
for (const id of everActivePanesRef.current) {
|
||||
if (!node.panes.includes(id)) {
|
||||
everActivePanesRef.current.delete(id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const keptPanes = shown.filter(id => id === activeId || everActivePanesRef.current.has(id))
|
||||
|
||||
// ONE header style: the app's compact pane-header. DEFAULT is contextual —
|
||||
// a single pane isn't a "tab", so its header auto-hides; a stack shows its
|
||||
// chips. EXCEPTIONS force a lone pane to keep its header (tab + close X):
|
||||
|
|
@ -478,18 +502,40 @@ export function TreeGroup({
|
|||
</ZoneMenu>
|
||||
)}
|
||||
|
||||
{/* Body: the active pane's contributed content, or the empty zone. */}
|
||||
{/* Body: the zone's pane content — every kept (ever-active) pane stays
|
||||
mounted in an absolute layer; only the active one is visible.
|
||||
`visibility` (not display) keeps the hidden pane's layout box, so
|
||||
scroll positions and measurements survive the round-trip. */}
|
||||
{!node.minimized && (
|
||||
<div className="relative min-h-0 min-w-0 flex-1 overflow-auto">
|
||||
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||
{isEmpty ? (
|
||||
<div className="grid h-full place-items-center">
|
||||
{/* Same decode primitive as the CONNECTING boot overlay. */}
|
||||
<DecodeText className="text-(--ui-text-quaternary)" cursor prefix={1} text="HERMES" />
|
||||
</div>
|
||||
) : active?.render ? (
|
||||
<ContribBoundary id={active.id}>{active.render()}</ContribBoundary>
|
||||
) : (
|
||||
<div className="p-3 font-mono text-[11px] text-(--ui-text-quaternary)">{t.zones.missingPane(activeId)}</div>
|
||||
keptPanes.map(paneId => {
|
||||
const pane = paneFor(paneId)
|
||||
const isActive = paneId === activeId
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!isActive || undefined}
|
||||
className={cn('absolute inset-0 overflow-auto', !isActive && 'pointer-events-none invisible')}
|
||||
key={paneId}
|
||||
>
|
||||
{pane?.render ? (
|
||||
<ContribBoundary id={pane.id}>{pane.render()}</ContribBoundary>
|
||||
) : (
|
||||
isActive && (
|
||||
<div className="p-3 font-mono text-[11px] text-(--ui-text-quaternary)">
|
||||
{t.zones.missingPane(paneId)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { sessionProjectColor } from '@/app/chat/sidebar/projects/workspace-group
|
|||
import { Codecs, persistentAtom } from '@/lib/persisted'
|
||||
import { $projects } from '@/store/projects'
|
||||
import { $sessions, sessionPinId } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
import type { ProjectInfo, SessionInfo } from '@/types/hermes'
|
||||
|
||||
// Per-session color OVERRIDES — a user-picked color that wins over the inherited
|
||||
// project color (#66565 layer 2). Desktop-local like pins, keyed by the DURABLE
|
||||
|
|
@ -39,13 +39,21 @@ export function setSessionColorOverride(durableId: string, color: null | string)
|
|||
//
|
||||
// Precedence in one place: an explicit per-session override wins over the
|
||||
// inherited project color. Agent-set color (#66565 layer 3) slots in here too.
|
||||
function resolveSessionColor(
|
||||
session: SessionInfo,
|
||||
projects: ProjectInfo[],
|
||||
overrides: Record<string, string>
|
||||
): string | undefined {
|
||||
return overrides[sessionPinId(session)] ?? sessionProjectColor(session, projects) ?? undefined
|
||||
}
|
||||
|
||||
export const $sessionColorById = computed(
|
||||
[$sessions, $projects, $sessionColorOverrides],
|
||||
(sessions, projects, overrides) => {
|
||||
const map: Record<string, string> = {}
|
||||
|
||||
for (const session of sessions) {
|
||||
const color = overrides[sessionPinId(session)] ?? sessionProjectColor(session, projects)
|
||||
const color = resolveSessionColor(session, projects, overrides)
|
||||
|
||||
if (color) {
|
||||
map[session.id] = color
|
||||
|
|
@ -57,7 +65,14 @@ export const $sessionColorById = computed(
|
|||
)
|
||||
|
||||
// The color for a single session object (the tabs already hold the SessionInfo
|
||||
// they render, so they resolve through the same map the sidebar reads).
|
||||
// they render, so they resolve through the same map the sidebar reads). A row
|
||||
// that isn't in `$sessions` — e.g. a project-tree session older than the
|
||||
// paginated recents page, opened as a tab — misses the map, so fall back to the
|
||||
// same resolver the map is built from.
|
||||
export function sessionColorFor(session: null | SessionInfo | undefined): string | undefined {
|
||||
return session ? $sessionColorById.get()[session.id] : undefined
|
||||
if (!session) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return $sessionColorById.get()[session.id] ?? resolveSessionColor(session, $projects.get(), $sessionColorOverrides.get())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue