From 69718d12d2a2a2bde2777705c07c1288ba2bceb2 Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Mon, 10 Aug 2026 19:31:59 +0530 Subject: [PATCH] feat(console): autolink bare entity ids (tsk_/goal_/art_) everywhere they're displayed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents reference rows by id in prose ("tsk_dcde91f9e81af705 is in progress"), which leaves the reader with a token they can only follow by hand-searching a list. One shared resolver (web/src/lib/entity-links.ts) maps a prefixed id to its console route, and all three display paths use it: - remarkWikiLinks: bare ids in rendered markdown, plus an id written as an inline `code` span (wrapped, so it keeps its monospace look). Also added the plugin to the four chat/answer surfaces that only had remark-gfm. - InlineLinks: raw-text surfaces (Inbox cards, memory notes). - Xterm link provider: a new first matcher, so an id printed in the live pane is clickable like a URL. Only prefixes whose route detail IS the id are included; a session's route is keyed by its tmux name, so ses_ is deliberately absent. mdComponents links to in-app `#/…` targets now route in place instead of opening a new tab. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015ZUfffxY4hKv7M6wMCcaTz --- CHANGELOG.md | 12 +++++++++ package-lock.json | 4 +-- package.json | 2 +- web/src/App.tsx | 51 +++++++++++++++++++++++++++---------- web/src/Xterm.tsx | 8 ++++-- web/src/lib/entity-links.ts | 43 +++++++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 18 deletions(-) create mode 100644 web/src/lib/entity-links.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5605619b..738e76d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ new version heading in the same commit. ## [Unreleased] +## [0.334.0] — 2026-08-10 +### Added +- **A bare entity id is now a link wherever it is displayed.** Agents constantly reference a row by its + id in prose ("tsk_dcde91f9e81af705 is in progress with an engineer") — which left the reader holding a + token they could only follow by hand-searching a list. `tsk_…`, `goal_…` and `art_…` now render as + clickable links to that task / goal / deliverable in rendered markdown (including an id written in + `backticks`, which is how agents usually write one), in raw-text surfaces like Inbox cards and memory + notes, and **in the terminal itself** — the xterm linkifier gained an entity-id matcher alongside its + URL ones, so an id printed in an agent's narration is clickable in the live pane. In-app markdown links + (`#/…`) also stop opening a pointless new tab. Session ids are deliberately excluded: a session's route + is keyed by its tmux name, not `ses_…`. + ## [0.333.0] — 2026-08-10 ### Added - **A task room's Discussion is now a two-way channel to the agent actually working the task.** It used to diff --git a/package-lock.json b/package-lock.json index 674b987c..96cf0b00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.333.0", + "version": "0.334.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.333.0", + "version": "0.334.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index dfa6be41..ccfe1c9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.333.0", + "version": "0.334.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/web/src/App.tsx b/web/src/App.tsx index 37901845..28707fe4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -17,6 +17,7 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte import { api, EFFORTS, PERMISSION_MODES, type PermissionMode, type StateResp, type AgentInfo, type Session, type Msg, type Member, type Role, type TeamResp, type AgentAccess, type MemberIdentity, type IdentityProvider, IDENTITY_PROVIDERS, type Automation, type Task, type TaskEvent, type TaskAttachment, type TaskRun, type TaskTimelineEntry, type TaskDiscussionSummary, type TaskDiscussionDelivery, type TaskStatus, type AddTaskReq, type Goal, type GoalEvent, type GoalStatus, type GoalCounts, type GoalProgress, type AddGoalReq, type MemoryRecord, type MemoryHealth, type MemoryBackend, type MemorySettings, type MemorySettingsReq, type OllamaStatus, type KbPage, type KbRevision, type AgentRevision, type AgentStats, type AgentProposalTrust, type Recommendation, type DigestConfig, type DigestModel, type DreamingState, type Measurement, type Insights, type ImprovementTile, type MemoryCleanupPlan, type KbTidyPlan, type TaskReconcilePlan, type LibraryTidyPlan, type SessionTidyPlan, type StuckGoal, type TroubledAutomation, type PolicyDocument, type PolicyRule, type PolicyOutcome, type PolicyOp, type PolicyProposal, type PolicyRevision, type AutomationProposal, type AgentUpdateProposal, type GoalUpdateProposal, type DirListing, type FileEntry, type FileContent, type Artifact, type AppInfo, type AppFile, type AppCapabilities, type SkillSummary, type SkillsResp, type CatalogSkill, type CatalogAgent, type SkillSource, type RemoteSkill, type SkillshHit, type SkillRequest, type SecretRequest, type IntegrationsResp, type SlackStatus, type DiscordStatus, type TelegramStatus, type AuditEvent, type Effort, type RuntimeTuning, type RuntimeTuningPatch, type Verbosity, type VerbositySavings, type Concurrency, type RuntimeAccount, type RuntimeAccountKind, type RuntimeAccountsResp, type RuntimeLogin, type SecretMeta, type UpdateStatus, type UpdateApplyResult, type ActivityEvent, type ActivitySummaryRow, type SystemMetrics, type DepsReport, type DepStatus, type DepsInstallResult, type ChatTurn, type ChatArtifactRef, type ChatKbRef, type ChatAppRef, type RouterPreviewResp, type RouterCard, type SessionChain, type ChainNode, type ChainPending } from '@/lib/api' import { type Branding, type PublicBranding, type NotificationPrefs, DEFAULT_NOTIFICATION_PREFS, type PromptShortcut, type SessionMetrics, type Brief, type AutoApproval } from '@/lib/api' import { applyAccent, applyFavicon, faviconDataUri, readableOn } from '@/lib/branding' +import { ENTITY_ID_SRC, entityHref, isEntityId } from '@/lib/entity-links' import { ConnectorsPage, GithubMineCard } from '@/connectors' import { docPages } from '@/docs' import { Xterm } from './Xterm' @@ -4555,7 +4556,7 @@ function ChatBubble({ turn, agentIcon }: { turn: Extract{turn.text} ) : ( -
{turn.text}
+
{turn.text}
)} @@ -4725,11 +4726,11 @@ function CockpitPage({ sessions, onOpenChat, onOpenTerminal, nav }: { {preview.source === 'state' ? 'Answered from your workspace' : preview.run ? 'Concierge · looked it up for you' : 'Answered'} {preview.answer && ( -
{preview.answer}
+
{preview.answer}
)} {preview.run && ( runAnswer - ?
{runAnswer}
+ ?
{runAnswer}
:
Looking into it…
)}
@@ -4751,7 +4752,7 @@ function CockpitPage({ sessions, onOpenChat, onOpenTerminal, nav }: {
{runId ? ( runAnswer - ?
{runAnswer}
+ ?
{runAnswer}
@@ -5979,6 +5980,8 @@ const isTextMime = (m: string) => m.startsWith('text/') || m === 'application/js // the KB page; a bare `[[area]]` (library, tasks, …) → that nav route. Two renderers share the same // target resolver: `remarkWikiLinks` for full-markdown surfaces (KB/task/goal/artifact/docs) and // `InlineLinks` for raw-text surfaces (inbox cards, memory notes) that must stay pre-formatted. +// Both also autolink a bare ENTITY ID (`tsk_…`, `goal_…`, `art_…` — see `@/lib/entity-links`), which +// is what agents actually write when they reference a task or goal in prose. /** Known single-word wiki targets that map to a top-level route rather than a KB page. */ const WIKI_AREA: Record = { @@ -5996,18 +5999,28 @@ function wikiHref(target: string): string { return '#/kb/' + inner.split('/').map(encodeURIComponent).join('/') } -const WIKI_RE = /\[\[([^\]]+?)\]\]/g +// `[[wiki]]` or a bare entity id, in one pass so neither can split the other's match. +const WIKI_RE = new RegExp(String.raw`\[\[([^\]]+?)\]\]` + '|' + `(${ENTITY_ID_SRC})`, 'g') +const HAS_LINKABLE_RE = new RegExp(String.raw`\[\[` + '|' + ENTITY_ID_SRC) -/** remark plugin: rewrite `[[section/slug]]` / `[[section/slug|label]]` into real links, skipping code - * spans and existing links. Runs alongside remark-gfm (which already autolinks bare URLs). */ +/** remark plugin: rewrite `[[section/slug]]` / `[[section/slug|label]]` and bare entity ids + * (`tsk_…`) into real links, skipping fenced code and existing links. An entity id written as an + * inline `code` span IS linked — backticks are how agents usually write an id — by wrapping the + * span rather than unwrapping it, so it keeps its monospace look. Runs alongside remark-gfm + * (which already autolinks bare URLs). */ function remarkWikiLinks() { return (tree: any) => { const walk = (node: any) => { if (!node || !Array.isArray(node.children)) return const out: any[] = [] for (const child of node.children) { + if (child.type === 'inlineCode' && isEntityId(child.value)) { + const href = entityHref(child.value.trim()) + out.push(href ? { type: 'link', url: href, children: [child] } : child) + continue + } if (child.type === 'code' || child.type === 'inlineCode' || child.type === 'link') { out.push(child); continue } - if (child.type === 'text' && child.value.includes('[[')) { out.push(...splitWikiNodes(child.value)); continue } + if (child.type === 'text' && HAS_LINKABLE_RE.test(child.value)) { out.push(...splitWikiNodes(child.value)); continue } walk(child); out.push(child) } node.children = out @@ -6022,16 +6035,26 @@ function splitWikiNodes(value: string): any[] { let m: RegExpExecArray | null while ((m = WIKI_RE.exec(value))) { if (m.index > last) nodes.push({ type: 'text', value: value.slice(last, m.index) }) - const [target, label] = m[1].split('|') - nodes.push({ type: 'link', url: wikiHref(target), children: [{ type: 'text', value: (label ?? target).trim() }] }) + if (m[2] !== undefined) { + const href = entityHref(m[2]) + nodes.push(href ? { type: 'link', url: href, children: [{ type: 'text', value: m[2] }] } : { type: 'text', value: m[2] }) + } else { + const [target, label] = m[1].split('|') + nodes.push({ type: 'link', url: wikiHref(target), children: [{ type: 'text', value: (label ?? target).trim() }] }) + } last = m.index + m[0].length } if (last < value.length) nodes.push({ type: 'text', value: value.slice(last) }) return nodes.length ? nodes : [{ type: 'text', value }] } -// Markdown link · `[[wiki]]` · bare http(s) URL · in-app `#/route`, in priority order. -const INLINE_LINK_RE = /\[([^\]]+)\]\((https?:\/\/[^\s)]+|#\/[^\s)]+)\)|\[\[([^\]]+?)\]\]|(https?:\/\/[^\s<>()]+)|(#\/[A-Za-z0-9/_%-]+)/g +// Markdown link · `[[wiki]]` · bare http(s) URL · in-app `#/route` · bare entity id, in priority order. +const INLINE_LINK_RE = new RegExp( + String.raw`\[([^\]]+)\]\((https?:\/\/[^\s)]+|#\/[^\s)]+)\)` + + String.raw`|\[\[([^\]]+?)\]\]` + + String.raw`|(https?:\/\/[^\s<>()]+)` + + String.raw`|(#\/[A-Za-z0-9/_%-]+)` + + `|(${ENTITY_ID_SRC})`, 'g') /** Render a plain string with clickable links (markdown links, `[[wiki]]` refs, bare URLs, in-app * `#/route` paths) for surfaces that show raw text rather than full markdown — inbox cards, memory @@ -6049,6 +6072,7 @@ function InlineLinks({ text }: { text?: string | null }): ReactNode { else if (m[3] !== undefined) { const [t, l] = m[3].split('|'); href = wikiHref(t); label = (l ?? t).trim() } // [[wiki]] else if (m[4] !== undefined) { href = label = m[4] } // bare URL else if (m[5] !== undefined) { href = label = m[5] } // #/route + else if (m[6] !== undefined) { label = m[6]; href = entityHref(m[6]) ?? m[6] } // tsk_… / goal_… const external = /^https?:/.test(href) parts.push( e.stopPropagation()} @@ -6069,7 +6093,8 @@ const mdComponents = { ul: (p: any) =>