Shows every file and page Claude makes for you as a small clickable bubble above the chat box.

A Claude Code plugin that shows every file and page Claude makes for you as a small bubble above the chat box, so you never have to scroll back to find them.
Made for you: ╭ 📄 notes.md ╮ ╭ 🌐 VPS Report ╮ ╭ 📄 setup.sh ╮ +3 more Clear Hide
<title> and opens the page.Left out on purpose: Claude's scratchpad and temp files, everything under ~/.claude (memory, settings), and any write or publish that failed or was refused.
In a Claude Code terminal session:
/plugin install made-bubbles --marketplace riaanjutte/made-bubbles
Answer y to add the marketplace, then pick a scope (user scope loads it in every session, including the desktop app's Code tab).
To try it from a local copy without installing:
claude --plugin-dir path/to/made-bubbles
claude plugin validate .
claude plugin test .
The bubbles are drawn by a ui.render hook on the AbovePrompt band in hooks/register.tsx; the tests in tests/ cover the terminal and desktop surfaces.
hooks/register.tsx 163 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import type { Made } from '../types'
5
6const items = atom({ plugin: 'made-bubbles', key: 'items' } as const, [] as Made[])
7const isHidden = atom({ plugin: 'made-bubbles', key: 'isHidden' } as const, false)
8
9/** Bubbles drawn at once; older ones fold into "+N more". */
10const MAX_SHOWN = 8
11
12/**
13 * Claude's own working files, not things made for the person: the scratchpad
14 * and other temp files, and everything under ~/.claude (memory, settings,
15 * this mod itself).
16 */
17const INTERNAL = /\/(appdata\/local\/temp|tmp|\.claude)\//i
18
19const normalize = (path: string) => path.replace(/\\/g, '/')
20
21const basename = (path: string) => normalize(path).split('/').filter(Boolean).pop() ?? path
22
23const fileHref = (path: string) => {
24 const p = normalize(path)
25 return encodeURI(p.startsWith('/') ? `file://${p}` : `file:///${p}`)
26}
27
28/** Adds one item, moving it to the end if it was already there. */
29const remember = async ($: EngineInterface, made: Made) => {
30 await update($, items, list => [...list.filter(m => m.href !== made.href), made])
31 await update($, isHidden, () => false)
32}
33
34const succeeded = (ran: ToolCallResult) => ran.deny === undefined && ran.isError !== true
35
36const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", apos: "'" }
37
38/**
39 * The `<title>` of a published HTML page, read from its source file (the same
40 * first 8 KB the publish scans); undefined when there is none or it can't be read.
41 */
42async function pageTitle($: EngineInterface, path: string) {
43 if (!/\.html?$/i.test(path)) return undefined
44 try {
45 const html = await $.fs.read(path)
46 if (typeof html !== 'string') return undefined
47 const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html.slice(0, 8192))
48 const title = match?.[1]
49 ?.replace(/&(amp|lt|gt|quot|#39|apos);/g, (_, name: string) => ENTITIES[name] ?? '')
50 .replace(/\s+/g, ' ')
51 .trim()
52 return title || undefined
53 } catch {
54 return undefined
55 }
56}
57
58/** Records a file a tool call wrote, unless the call failed or it is Claude's own. */
59async function onFile($: EngineInterface, path: unknown, ran: ToolCallResult) {
60 if (typeof path !== 'string' || !path || !succeeded(ran)) return
61 if (INTERNAL.test(normalize(path))) return
62 await remember($, { kind: 'file', label: basename(path), href: fileHref(path), path })
63}
64
65export const register: Register = on => {
66 // Each tool hook only watches: if its bookkeeping throws, `.catch` hands the
67 // call through untouched, so a bubble bug can never break a real write.
68 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
69 const ran = await next(e)
70 await onFile($, e.file_path, ran)
71 return ran
72 }).catch(($, e, next) => next(e))
73
74 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
75 const ran = await next(e)
76 await onFile($, e.file_path, ran)
77 return ran
78 }).catch(($, e, next) => next(e))
79
80 on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
81 const ran = await next(e)
82 await onFile($, e.notebook_path, ran)
83 return ran
84 }).catch(($, e, next) => next(e))
85
86 on('tool.call', { tool: 'Artifact' }, async ($, e, next) => {
87 const ran = await next(e)
88 const isPagePublish = (e.action === undefined || e.action === 'publish') && e.asset !== true
89 if (!isPagePublish || !succeeded(ran)) return ran
90
91 const result = ran.result as { url?: unknown; title?: unknown } | undefined
92 const url = typeof result?.url === 'string' ? result.url : undefined
93 if (!url) return ran
94
95 const filePath = typeof e.file_path === 'string' ? e.file_path : undefined
96 const label =
97 (typeof result?.title === 'string' && result.title) ||
98 (filePath && (await pageTitle($, filePath))) ||
99 (typeof e.title === 'string' && e.title) ||
100 (filePath && basename(filePath)) ||
101 'Page'
102 await remember($, { kind: 'page', label, href: url })
103 return ran
104 }).catch(($, e, next) => next(e))
105
106 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
107 const list = await read($, items)
108 if (e.props.hasSurvey || list.length === 0 || (await read($, isHidden))) {
109 return next(e)
110 }
111
112 const { Box, Button, Link, Text } = $.ui.resolve(e)
113 const shown = list.slice(-MAX_SHOWN)
114 const older = list.length - shown.length
115 const isTerminal = e.surface === 'terminal'
116
117 return (
118 <Box key="made" flexDirection="row" flexWrap="wrap" columnGap={1} alignItems="center">
119 <Text dimColor>Made for you:</Text>
120 {shown.map((m, i) => (
121 <Box key={`made-${i}`} borderStyle="round" borderDimColor paddingX={1}>
122 {m.kind === 'page' || isTerminal ? (
123 // Pages open everywhere; a terminal also opens file:/// links.
124 <Text>
125 {m.kind === 'page' ? '🌐 ' : '📄 '}
126 <Link href={m.href} label={m.label} />
127 </Text>
128 ) : (
129 // The desktop app only links https:, so a file click copies its path.
130 <Button
131 key={`file-${i}`}
132 label={`📄 ${m.label}`}
133 plain
134 onPress={async () => {
135 await $.ui.copy({ text: m.path ?? m.label })
136 $.ui.toast(`Copied path: ${m.path ?? m.label}`)
137 }}
138 />
139 )}
140 </Box>
141 ))}
142 {older > 0 ? <Text dimColor>+{older} more</Text> : null}
143 <Button
144 key="clear"
145 label="Clear"
146 plain
147 dimColor
148 onPress={async () => {
149 await update($, items, () => [])
150 }}
151 />
152 <Button
153 key="hide"
154 label="Hide"
155 plain
156 dimColor
157 onPress={() => update($, isHidden, () => true)}
158 />
159 </Box>
160 )
161 })
162}
163types/index.d.ts 17 lines1/** One thing Claude made: a local file it wrote, or a page it published. */
2export type Made = {
3 kind: 'file' | 'page'
4 /** Short name shown in the bubble. */
5 label: string
6 /** What a click opens: a file:/// URL or the page's https URL. */
7 href: string
8 /** For a file, its full path as written (copied by a click on desktop). */
9 path?: string
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'made-bubbles': { items: Made[]; isHidden: boolean }
15 }
16}
17