golem-kit 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +8 -5
- package/docs/agents.md +64 -0
- package/docs/app-backend.md +259 -0
- package/docs/architecture.md +93 -0
- package/docs/builder.md +15 -0
- package/docs/knowledge.md +35 -0
- package/docs/local-cli.md +19 -12
- package/docs/source-development.md +31 -0
- package/index.html +9 -0
- package/package.json +24 -5
- package/src/backend/accounts.ts +287 -0
- package/src/backend/app.ts +269 -0
- package/src/backend/files.ts +68 -0
- package/src/backend/http.ts +276 -0
- package/src/backend/index.ts +10 -0
- package/src/backend/jobs.ts +302 -0
- package/src/backend/jsonl.ts +87 -0
- package/src/backend/knowledge.ts +264 -0
- package/src/backend/model.ts +129 -0
- package/src/backend/rules.ts +53 -0
- package/src/backend/sqlite.ts +73 -0
- package/src/backend/views.ts +216 -0
- package/src/brain.ts +94 -0
- package/src/browser/adapters.ts +229 -53
- package/src/browser/ansi.ts +104 -0
- package/src/browser/app.d.ts +5 -2
- package/src/browser/app.tsx +167 -39
- package/src/browser/groups.tsx +29 -0
- package/src/browser/main.tsx +1 -0
- package/src/browser/panekeys.ts +34 -0
- package/src/browser/sources.tsx +113 -0
- package/src/browser/styles.css +36 -0
- package/src/browser/terminal.tsx +89 -0
- package/src/browser-build.ts +20 -7
- package/src/chat.ts +74 -0
- package/src/cli.ts +85 -13
- package/src/client.ts +205 -0
- package/src/config.ts +139 -5
- package/src/dev-server.ts +336 -39
- package/src/entry.mjs +19 -0
- package/src/eslint.mjs +55 -0
- package/src/operations.ts +169 -0
- package/src/runtime/assistant.ts +141 -0
- package/src/runtime/discovery.ts +13 -7
- package/src/runtime/harness/agent-status.js +388 -0
- package/src/runtime/harness/claude-tmux.js +573 -0
- package/src/runtime/harness/codex-notify.js +95 -0
- package/src/runtime/harness/codex-tmux.js +292 -0
- package/src/runtime/harness/fake.js +430 -0
- package/src/runtime/harness/package.json +1 -0
- package/src/runtime/harness/port.js +208 -0
- package/src/runtime/harness/tmux-session.js +556 -0
- package/src/runtime/harness/tmux.js +285 -0
- package/src/runtime/harness/turnend-hook.js +105 -0
- package/src/runtime/session.ts +171 -34
- package/src/runtime/tmux.ts +173 -0
- package/src/runtime/tool-names.ts +19 -0
- package/src/source-mode.ts +56 -0
- package/vite.config.ts +2 -4
- package/src/runtime/codex.ts +0 -119
package/src/browser/app.tsx
CHANGED
|
@@ -1,63 +1,191 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react'
|
|
2
|
-
import
|
|
3
|
-
import
|
|
1
|
+
import { useEffect, useRef, useState, type ComponentProps, type ComponentType, type ReactNode } from 'react'
|
|
2
|
+
import * as GolemUI from 'golem-ui'
|
|
3
|
+
import { Auth, Chat, Shell } from 'golem-ui'
|
|
4
|
+
import * as AppModule from '@golem/app'
|
|
4
5
|
import projectConfig from '@golem/config'
|
|
5
|
-
import {
|
|
6
|
+
import { currentSession, identity, type Me } from '../client'
|
|
7
|
+
import { anonymousIdentity, brain, chat, currentBrowserBackend, leaveBrowserSession, forgetBrowserSession, navigation, restoreBrowserSession, startBrowserSession, subscribeBrowserSession, subscribeBrowserStatus, type SessionKind } from './adapters'
|
|
8
|
+
import { Groups } from './groups'
|
|
9
|
+
import { SourcePanel, SourceReturn, useSourceView, type OpenSource } from './sources'
|
|
10
|
+
import { Terminal } from './terminal'
|
|
6
11
|
|
|
7
|
-
const shellAdapters = { identity: anonymousIdentity, navigation }
|
|
8
12
|
const chatAdapters = { chat }
|
|
13
|
+
// `Brain` is in golem-ui after 0.1.1; with 0.1.1 installed the panel says so instead of rendering it.
|
|
14
|
+
const Brain = (GolemUI as unknown as { Brain?: (props: { config: { title: string; openLocation?: string }; adapters: { brain: typeof brain } }) => ReactNode }).Brain
|
|
15
|
+
// The Shell chrome after golem-ui 29acbd5: a menu row, a settings dropdown, the chat behind a toggle. 0.1.1's
|
|
16
|
+
// strict schema would reject the keys, so they go in only when `Shell.Setting` (same vintage) is there.
|
|
17
|
+
type ShellSetting = (props: { icon: ReactNode; label: string; on?: boolean; onClick: () => void }) => ReactNode
|
|
18
|
+
type ShellIcon = (props: { name: 'user' | 'wrench' }) => ReactNode
|
|
19
|
+
const { Setting: ShellSetting, Icon: ShellIcon } = Shell as unknown as { Setting?: ShellSetting; Icon?: ShellIcon }
|
|
20
|
+
const ShellFrame = Shell as unknown as (props: Omit<ComponentProps<typeof Shell>, 'config'> & {
|
|
21
|
+
config: ComponentProps<typeof Shell>['config'] & { menu?: { id: string; label: string; icon?: string }[]; activeId?: string; chatOpen?: boolean }
|
|
22
|
+
settings?: ReactNode
|
|
23
|
+
onSelect?: (id: string) => void
|
|
24
|
+
}) => ReactNode
|
|
25
|
+
// An app with more than one screen lists them (`export const screens`); each becomes an item in the
|
|
26
|
+
// menu row, and the chosen one's id goes to the app as `screen`. Read through a cast, like the other
|
|
27
|
+
// optional things here, because an app that lists none is the single-screen app it always was.
|
|
28
|
+
const UserApp = AppModule.default as ComponentType<{ screen?: string }>
|
|
29
|
+
type AppScreen = { id: string; label: string; icon?: string }
|
|
30
|
+
const appScreens = (AppModule as { screens?: AppScreen[] }).screens ?? []
|
|
31
|
+
/** The `?screen=` route param: which of the app's own screens is showing. */
|
|
32
|
+
const screenParam = () => new URLSearchParams(window.location.search).get('screen') ?? undefined
|
|
33
|
+
const screenUrl = (id?: string) => (id ? `${window.location.pathname}?screen=${encodeURIComponent(id)}` : window.location.pathname)
|
|
34
|
+
/** The `?brain=` route param: the location the Brain panel shows, or undefined when the app is showing. */
|
|
35
|
+
const brainParam = () => new URLSearchParams(window.location.search).get('brain') ?? undefined
|
|
36
|
+
const brainUrl = (location: string) => `${window.location.pathname}?brain=${encodeURIComponent(location)}`
|
|
37
|
+
// What the reader opens on its own goes into the URL, so a reload (a Builder rebuild, a sign-in
|
|
38
|
+
// change) lands on the same document instead of the root index.
|
|
39
|
+
const brainAdapters = { brain: { ...brain, open: (location: string) => navigation.go(brainUrl(location)) } }
|
|
40
|
+
const authAdapters = { identity, navigation }
|
|
41
|
+
const agentNames: Record<string, string> = { codex: 'Codex', claude: 'Claude Code' }
|
|
42
|
+
type Discovery = { agent: string; status: string; runnable?: boolean; detail?: string }
|
|
9
43
|
|
|
10
44
|
export function App() {
|
|
11
|
-
|
|
45
|
+
// Builder mode is app state, kept on the server (`.golem/builder.json`) so a reload comes back in it.
|
|
46
|
+
const [builder, setBuilder] = useState<boolean>()
|
|
12
47
|
const [session, setSession] = useState<string>()
|
|
13
48
|
const [sessionStatus, setSessionStatus] = useState('starting')
|
|
14
|
-
const [
|
|
15
|
-
const enteringBuildModeRef = useRef(false)
|
|
16
|
-
const [runtime, setRuntime] = useState<{ codex: boolean; claude: boolean }>({ codex: false, claude: false })
|
|
49
|
+
const [sessionBackend, setSessionBackend] = useState<string>()
|
|
17
50
|
const [error, setError] = useState<string>()
|
|
51
|
+
const [chatInfo, setChatInfo] = useState<{ provider: 'tmux' | 'anthropic' | null; agent?: string; available: boolean; detail?: string; views?: boolean }>()
|
|
52
|
+
const [source, setSource] = useState<OpenSource>()
|
|
53
|
+
const [sourceShown, setSourceShown] = useState(false)
|
|
54
|
+
const [me, setMe] = useState<Me>()
|
|
55
|
+
// The Admin screen is app state; any navigation (the title, a Brain item, a chip) leaves it.
|
|
56
|
+
const [view, setView] = useState<'app' | 'account'>('app')
|
|
57
|
+
const [brainAt, setBrainAt] = useState(brainParam)
|
|
58
|
+
const [screenAt, setScreenAt] = useState(screenParam)
|
|
59
|
+
useEffect(() => navigation.subscribe(() => { setBrainAt(brainParam()); setScreenAt(screenParam()); setView('app') }), [])
|
|
60
|
+
const showBrain = (projectConfig as { brain?: boolean }).brain === true && brainAt !== undefined
|
|
61
|
+
// An unknown `?screen=` is the app's first screen, not an error.
|
|
62
|
+
const screen = appScreens.some((one) => one.id === screenAt) ? screenAt : undefined
|
|
63
|
+
const [terminal, setTerminal] = useState(false)
|
|
64
|
+
const signedInAs = useRef<string | null>(null)
|
|
18
65
|
useEffect(() => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
66
|
+
currentSession().then((next) => { signedInAs.current = next.user?.id ?? null; setMe(next) }, () => setError('Account service unavailable.'))
|
|
67
|
+
// Another person on this browser starts clean: their own build conversation, their own view.
|
|
68
|
+
return identity.subscribe(() => void currentSession().then((next) => {
|
|
69
|
+
if (!next.accounts) return
|
|
70
|
+
if ((next.user?.id ?? null) !== signedInAs.current) {
|
|
71
|
+
// A used invite link must not reopen sign-up on the next load.
|
|
72
|
+
const url = new URL(window.location.href)
|
|
73
|
+
url.searchParams.delete('invite')
|
|
74
|
+
forgetBrowserSession()
|
|
75
|
+
window.location.replace(url)
|
|
76
|
+
}
|
|
77
|
+
else setMe(next)
|
|
78
|
+
}))
|
|
23
79
|
}, [])
|
|
80
|
+
const canBuild = me?.canBuild === true
|
|
81
|
+
const chatting = sessionBackend === 'anthropic'
|
|
82
|
+
const offers = useSourceView(!builder && chatting && chatInfo?.views ? session : undefined, (next) => { setSource(next); setSourceShown(true) })
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!me) return
|
|
85
|
+
fetch('/api/chat').then(async (response) => { if (response.ok) setChatInfo(await response.json()) }).catch(() => {})
|
|
86
|
+
fetch('/api/builder').then(async (response) => setBuilder(response.ok ? Boolean((await response.json()).builder) : false)).catch(() => setBuilder(false))
|
|
87
|
+
}, [me])
|
|
88
|
+
useEffect(() => subscribeBrowserStatus(setSessionStatus), [])
|
|
89
|
+
useEffect(() => subscribeBrowserSession(setSession), [])
|
|
90
|
+
// What the chat column shows: the builder agent, or whatever the app defines for normal mode (maybe nothing).
|
|
91
|
+
const kind: SessionKind | undefined = builder === undefined || !chatInfo ? undefined : builder && canBuild ? 'builder' : chatInfo.provider === 'tmux' ? 'chat' : chatInfo.provider === 'anthropic' ? 'anthropic' : undefined
|
|
24
92
|
useEffect(() => {
|
|
25
|
-
const unsubscribe = subscribeBrowserStatus(setSessionStatus)
|
|
26
|
-
restoreBrowserSession().then((restored) => {
|
|
27
|
-
if (restored) { setSession(window.sessionStorage.getItem('golem.browser.session') ?? undefined); setMode(true) }
|
|
28
|
-
}).catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))
|
|
29
|
-
return unsubscribe
|
|
30
|
-
}, [])
|
|
31
|
-
const enterBuildMode = async () => {
|
|
32
|
-
if (enteringBuildModeRef.current) return
|
|
33
|
-
enteringBuildModeRef.current = true
|
|
34
|
-
setEnteringBuildMode(true)
|
|
35
93
|
setError(undefined)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
94
|
+
setTerminal(false)
|
|
95
|
+
if (!kind) { leaveBrowserSession(); setSession(undefined); setSessionBackend(undefined); return }
|
|
96
|
+
let cancelled = false
|
|
97
|
+
const open = async () => {
|
|
98
|
+
// A terminal agent: the configured one, else what this computer has (the server picks per intent).
|
|
99
|
+
const runtime = kind === 'anthropic' ? undefined : await fetch('/api/runtime').then((response) => response.json()) as { discoveries: Discovery[]; builder?: string } | undefined
|
|
100
|
+
const ready = runtime?.discoveries?.filter((item) => item.status === 'available' && item.runnable).map((item) => item.agent) ?? []
|
|
101
|
+
const agent = kind === 'anthropic' ? undefined : [kind === 'chat' ? chatInfo?.agent : undefined, runtime?.builder, 'codex', 'claude'].find((one) => one && ready.includes(one))
|
|
102
|
+
if (await restoreBrowserSession(kind)) return
|
|
103
|
+
if (kind !== 'anthropic' && !agent) throw new Error(`No agent to chat with: ${runtime?.discoveries?.map((item) => `${agentNames[item.agent] ?? item.agent} ${item.status === 'missing' ? 'not installed' : item.detail ?? item.status}`).join(', ') || 'checking agents…'}`)
|
|
104
|
+
if (kind === 'anthropic' && !chatInfo?.available) throw new Error(chatInfo?.detail ?? 'Chat is not available.')
|
|
105
|
+
await startBrowserSession(agent, kind)
|
|
106
|
+
}
|
|
107
|
+
open().then(() => { if (!cancelled) { setSessionBackend(currentBrowserBackend()) } }, (cause) => { if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause)) })
|
|
108
|
+
return () => { cancelled = true }
|
|
109
|
+
}, [kind])
|
|
110
|
+
const toggleBuilder = async (on: boolean) => {
|
|
111
|
+
setBuilder(on)
|
|
112
|
+
const response = await fetch('/api/builder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ builder: on }) })
|
|
113
|
+
if (!response.ok) { setError((await response.json()).error ?? 'Unable to switch mode'); setBuilder(!on) }
|
|
39
114
|
}
|
|
40
|
-
const
|
|
41
|
-
|
|
115
|
+
const accounts = me?.accounts
|
|
116
|
+
const authConfig = accounts && { workspaceName: projectConfig.title, mode: 'password' as const, allowSignUp: accounts.allowSignUp, roles: accounts.roles }
|
|
117
|
+
const manages = Boolean(me?.user?.roles.some((role) => accounts?.roles.some((one) => one.id === role && one.manages)))
|
|
118
|
+
const invited = new URLSearchParams(window.location.search).has('invite')
|
|
119
|
+
const shellAdapters = { identity: accounts ? identity : anonymousIdentity, navigation }
|
|
120
|
+
const hasBrain = (projectConfig as { brain?: boolean }).brain === true
|
|
121
|
+
// The bottom menu bar: the app's own screens first, then Brain, then Admin for managers.
|
|
122
|
+
const menu = [
|
|
123
|
+
{ id: 'app', label: projectConfig.title, icon: '🏠' },
|
|
124
|
+
...appScreens.map((one) => ({ id: `screen:${one.id}`, label: one.label, ...(one.icon ? { icon: one.icon } : {}) })),
|
|
125
|
+
...(hasBrain ? [{ id: 'brain', label: 'Brain', icon: '🧠' }] : []),
|
|
126
|
+
...(manages ? [{ id: 'admin', label: 'Admin', icon: '🛠️' }] : []),
|
|
127
|
+
]
|
|
128
|
+
// An app item leaves `?brain=` and the Admin view and carries its own `?screen=`; Brain opens its own.
|
|
129
|
+
const select = (id: string) => {
|
|
130
|
+
if (id === 'brain') { if (!showBrain) navigation.go(brainUrl('index.md')); return }
|
|
131
|
+
const target = id.startsWith('screen:') ? id.slice('screen:'.length) : undefined
|
|
132
|
+
if (showBrain || screenAt !== target) navigation.go(screenUrl(target))
|
|
133
|
+
setView(id === 'admin' ? 'account' : 'app')
|
|
42
134
|
}
|
|
135
|
+
const barButton = 'golem-browser-bar-button flex size-8 items-center justify-center rounded-lg border'
|
|
43
136
|
return (
|
|
44
|
-
<
|
|
45
|
-
config={{ title: projectConfig.title, chatSide: 'left', breakpoint: 768
|
|
137
|
+
<ShellFrame
|
|
138
|
+
config={{ title: projectConfig.title, chatSide: 'left', breakpoint: 768,
|
|
139
|
+
// Builder on raises the chat; a mode that is off leaves the chat where the reader left it.
|
|
140
|
+
...(ShellSetting ? { menu, activeId: showBrain ? 'brain' : view === 'account' ? 'admin' : screen ? `screen:${screen}` : 'app', chatOpen: builder === true } : {}) }}
|
|
46
141
|
adapters={shellAdapters}
|
|
47
|
-
|
|
142
|
+
onSelect={select}
|
|
143
|
+
settings={ShellSetting && canBuild && builder !== undefined && (
|
|
144
|
+
<ShellSetting icon={ShellIcon ? <ShellIcon name="wrench" /> : '🔧'} label="Builder" on={builder} onClick={() => void toggleBuilder(!builder)} />
|
|
145
|
+
)}
|
|
146
|
+
chat={!kind ? null : (
|
|
48
147
|
<div className="flex h-full min-h-0 flex-col">
|
|
49
|
-
<div className="flex items-center
|
|
50
|
-
<span className="font-medium">{
|
|
51
|
-
<span className={
|
|
52
|
-
|
|
148
|
+
<div className="golem-browser-header flex items-center gap-1 whitespace-nowrap border-b border-neutral-200 bg-white px-2 py-2 text-xs">
|
|
149
|
+
<span className="font-medium">{kind === 'builder' ? 'Builder' : 'Chat'}</span>
|
|
150
|
+
<span className={`flex min-w-0 items-center ${session ? 'golem-browser-status-connected text-green-700' : 'golem-browser-status-disconnected text-neutral-500'}`} title={session}>
|
|
151
|
+
<span className="golem-browser-status-dot" aria-hidden="true" />
|
|
152
|
+
<span className="truncate">{session ? <>{sessionStatus} <span className="opacity-60">{session.slice(0, 8)}</span></> : error ? 'Not connected' : 'Connecting…'}</span>
|
|
153
|
+
</span>
|
|
154
|
+
{session && !chatting && <button type="button" className="golem-browser-terminal ml-auto shrink-0 rounded-full border border-neutral-300 px-2 py-1" title="Agent terminal" aria-label="Agent terminal" onClick={() => setTerminal(true)}>🖥️</button>}
|
|
53
155
|
</div>
|
|
54
|
-
{
|
|
156
|
+
{terminal && session && !chatting && <Terminal session={session} onClose={() => setTerminal(false)} />}
|
|
157
|
+
{error && <p className="golem-browser-error px-4 pt-3 text-sm text-red-700">{error}</p>}
|
|
158
|
+
{session && (chatting
|
|
159
|
+
? <><div className="min-h-0 flex-1"><Chat key={session} config={{ agentName: 'Assistant', emptyState: 'Ask about or update what you can see in this app.' }} adapters={chatAdapters} /></div>{offers}{source && !sourceShown && <SourceReturn source={source} onShow={() => setSourceShown(true)} />}</>
|
|
160
|
+
: <Chat key={kind} config={{ agentName: `Golem ${agentNames[sessionBackend ?? 'codex'] ?? sessionBackend}`, emptyState: kind === 'builder' ? `Ask ${agentNames[sessionBackend ?? 'codex'] ?? sessionBackend} to build or change this app.` : 'Ask about this app.' }} adapters={chatAdapters} />)}
|
|
55
161
|
</div>
|
|
56
|
-
}
|
|
162
|
+
)}
|
|
57
163
|
canvas={
|
|
58
|
-
|
|
164
|
+
!me ? null : <>
|
|
165
|
+
{/* Mounted from the first accepted source on, so closing or switching never drops an edit. */}
|
|
166
|
+
{source && <SourcePanel source={source} shown={sourceShown} onClose={() => setSourceShown(false)} />}
|
|
167
|
+
{showBrain && (
|
|
168
|
+
<div className="golem-browser-brain h-full overflow-hidden">
|
|
169
|
+
{Brain ? <Brain config={{ title: projectConfig.title, openLocation: brainAt === 'index.md' ? undefined : brainAt }} adapters={brainAdapters} /> : <p className="p-4 text-sm text-neutral-600">The Brain reader needs golem-ui after 0.1.1 (see docs/source-development.md).</p>}
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
<div className="h-full" style={(source && sourceShown) || showBrain ? { display: 'none' } : undefined}>{
|
|
173
|
+
!authConfig ? <UserApp screen={screen} />
|
|
174
|
+
: view === 'account' || (invited && !me.user)
|
|
175
|
+
? <div className="golem-browser-admin flex h-full flex-col overflow-auto">
|
|
176
|
+
<Auth config={authConfig} adapters={authAdapters} />
|
|
177
|
+
{manages && <Groups />}
|
|
178
|
+
</div>
|
|
179
|
+
: accounts.guests ? <UserApp screen={screen} />
|
|
180
|
+
: <Auth.Guard config={authConfig} adapters={authAdapters}><UserApp screen={screen} /></Auth.Guard>
|
|
181
|
+
}</div>
|
|
182
|
+
</>
|
|
183
|
+
}
|
|
184
|
+
account={
|
|
185
|
+
!authConfig ? <span className="golem-browser-guest text-sm text-neutral-500">Guest</span>
|
|
186
|
+
: me?.user ? <Auth.AccountMenu config={authConfig} adapters={authAdapters} />
|
|
187
|
+
: <button type="button" className={`golem-browser-sign-in ${barButton}`} title="Sign in" aria-label="Sign in" onClick={() => setView('account')}>{ShellIcon ? <ShellIcon name="user" /> : '👤'}</button>
|
|
59
188
|
}
|
|
60
|
-
account={<span className="shrink-0 text-sm text-neutral-500">Guest</span>}
|
|
61
189
|
/>
|
|
62
190
|
)
|
|
63
191
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { identity, setGroups, type Member } from '../client'
|
|
3
|
+
|
|
4
|
+
/** Group membership beside golem-ui's member list, which edits roles but not groups. */
|
|
5
|
+
export function Groups() {
|
|
6
|
+
const [members, setMembers] = useState<Member[]>([])
|
|
7
|
+
const [error, setError] = useState<string>()
|
|
8
|
+
const load = () => identity.listMembers().then((list) => setMembers(list as Member[]), (cause) => setError(String(cause?.message ?? cause)))
|
|
9
|
+
useEffect(() => { void load() }, [])
|
|
10
|
+
const save = async (member: Member, text: string) => {
|
|
11
|
+
setError(undefined)
|
|
12
|
+
try { await setGroups(member.id, text.split(',').map((group) => group.trim()).filter(Boolean)); await load() }
|
|
13
|
+
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) }
|
|
14
|
+
}
|
|
15
|
+
return (
|
|
16
|
+
<section className="golem-browser-groups mx-auto w-full max-w-sm p-4 text-sm">
|
|
17
|
+
<h2 className="font-semibold">Groups</h2>
|
|
18
|
+
<p className="mt-1 text-neutral-500">Comma-separated. App rules can check these.</p>
|
|
19
|
+
{members.map((member) => (
|
|
20
|
+
<form key={`${member.id}:${member.groups.join(',')}`} className="mt-3 flex items-center gap-2" onSubmit={(event) => { event.preventDefault(); void save(member, String(new FormData(event.currentTarget).get('groups') ?? '')) }}>
|
|
21
|
+
<label className="w-28 truncate" htmlFor={`groups-${member.id}`}>{member.name}</label>
|
|
22
|
+
<input id={`groups-${member.id}`} name="groups" defaultValue={member.groups.join(', ')} className="min-w-0 flex-1 rounded border border-neutral-300 px-2 py-1" />
|
|
23
|
+
<button type="submit" className="rounded border border-neutral-300 px-2 py-1">Save</button>
|
|
24
|
+
</form>
|
|
25
|
+
))}
|
|
26
|
+
{error && <p className="golem-browser-error mt-3 text-red-700">{error}</p>}
|
|
27
|
+
</section>
|
|
28
|
+
)
|
|
29
|
+
}
|
package/src/browser/main.tsx
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Browser keydown → the pane input route's payload. Pure (no DOM at import) so a unit test can import it.
|
|
2
|
+
// Port of Bridge Commander's ui/js/panekeys.js.
|
|
3
|
+
// { key: '<tmux key name>' } — Enter, BSpace, Up, BTab, C-c, …
|
|
4
|
+
// { text: '<literal>' } — one printable character, typed as-is
|
|
5
|
+
// null — not ours: the browser keeps the event (Ctrl-V, ⌘W, F5, …)
|
|
6
|
+
export type PaneInput = { key: string } | { text: string }
|
|
7
|
+
|
|
8
|
+
// tmux spells several DOM keys differently (BSpace, DC), which is the whole point of the table.
|
|
9
|
+
export const NAMED: Record<string, string> = {
|
|
10
|
+
Enter: 'Enter', Backspace: 'BSpace', Tab: 'Tab', Escape: 'Escape',
|
|
11
|
+
ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right',
|
|
12
|
+
Home: 'Home', End: 'End', PageUp: 'PageUp', PageDown: 'PageDown', Delete: 'DC', Insert: 'IC',
|
|
13
|
+
}
|
|
14
|
+
// Ctrl-<x> combos tmux understands: letters plus the punctuation controls (C-[ Escape, C-\ quit, C-_ undo).
|
|
15
|
+
export const CTRL_KEYS = 'abcdefghijklmnopqrstuvwxyz[\\]^_'
|
|
16
|
+
|
|
17
|
+
export function keyForEvent(e: { key: string; altKey?: boolean; metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean }): PaneInput | null {
|
|
18
|
+
// Alt/Meta chords belong to the browser and the OS (⌘W, Alt-Tab): a swallowed ⌘W is worse than a missing M-b.
|
|
19
|
+
if (e.altKey || e.metaKey) return null
|
|
20
|
+
if (e.key === 'Tab') return { key: e.shiftKey ? 'BTab' : 'Tab' }
|
|
21
|
+
const named = NAMED[e.key]
|
|
22
|
+
if (named) return { key: named }
|
|
23
|
+
if (e.ctrlKey) {
|
|
24
|
+
if (e.key.length !== 1) return null
|
|
25
|
+
const c = e.key.toLowerCase()
|
|
26
|
+
// Ctrl-V stays with the browser: preventDefaulting it would kill the `paste` event, and paste (bracketed,
|
|
27
|
+
// multi-line) beats a bare C-v. Ctrl-C is NOT excluded: interrupting the agent is the point of typing here.
|
|
28
|
+
if (c === 'v') return null
|
|
29
|
+
return CTRL_KEYS.includes(c) ? { key: `C-${c}` } : null
|
|
30
|
+
}
|
|
31
|
+
// Any single printable character rides as literal text; no per-character table keeps up with real keyboards.
|
|
32
|
+
if (e.key.length === 1) return { text: e.key }
|
|
33
|
+
return null // F-keys, bare modifiers, dead keys, media keys
|
|
34
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type ComponentProps } from 'react'
|
|
2
|
+
import { Editor } from 'golem-ui'
|
|
3
|
+
import { knowledge, type ViewOffer } from '../client'
|
|
4
|
+
import { answerOffer, subscribeChatView } from './adapters'
|
|
5
|
+
|
|
6
|
+
/** An accepted passage: `line`–`endLine` hold `text` in file `version`. */
|
|
7
|
+
export type OpenSource = ViewOffer['input'] & { key: string; version: number; text: string }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* This tab's view of the open chat: the assistant's offers to open a source, answered here. Only an
|
|
11
|
+
* offer the person accepts in this tab opens, and only here.
|
|
12
|
+
*/
|
|
13
|
+
export function useSourceView(conversation: string | undefined, onOpen: (source: OpenSource) => void) {
|
|
14
|
+
const [offers, setOffers] = useState<ViewOffer[]>([])
|
|
15
|
+
const [error, setError] = useState<string>()
|
|
16
|
+
const open = useRef(onOpen)
|
|
17
|
+
open.current = onOpen
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
setOffers([])
|
|
20
|
+
if (!conversation) return
|
|
21
|
+
return subscribeChatView((event) => {
|
|
22
|
+
if (event.type === 'offer' && event.offer.conversation === conversation) setOffers((list) => [...list.filter((one) => one.id !== event.offer.id), event.offer])
|
|
23
|
+
if (event.type === 'withdrawn') setOffers((list) => list.filter((one) => one.id !== event.id))
|
|
24
|
+
if (event.type === 'apply') open.current({ ...event.offer.input, key: event.offer.id, version: event.version, text: event.text })
|
|
25
|
+
})
|
|
26
|
+
}, [conversation])
|
|
27
|
+
const answer = async (offer: ViewOffer, accept: boolean) => {
|
|
28
|
+
setError(undefined)
|
|
29
|
+
setOffers((list) => list.filter((one) => one.id !== offer.id))
|
|
30
|
+
try { await answerOffer(offer.id, accept) } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) }
|
|
31
|
+
}
|
|
32
|
+
const panel = (offers.length > 0 || error) && (
|
|
33
|
+
<div className="golem-browser-offers golem-browser-header border-t border-neutral-200 px-4 py-2 text-sm">
|
|
34
|
+
{offers.map((offer) => (
|
|
35
|
+
<div key={offer.id} className="golem-browser-offer flex flex-wrap items-center gap-2 py-1">
|
|
36
|
+
<span className="golem-browser-runtime min-w-0 flex-1 break-words">Open {offer.input.path}, lines {offer.input.line}–{offer.input.endLine}?</span>
|
|
37
|
+
<button className="golem-browser-enter rounded bg-neutral-900 px-2 py-1 text-white" onClick={() => void answer(offer, true)}>Open</button>
|
|
38
|
+
<button className="golem-browser-new rounded border border-neutral-300 px-2 py-1" onClick={() => void answer(offer, false)}>Dismiss</button>
|
|
39
|
+
</div>
|
|
40
|
+
))}
|
|
41
|
+
{error && <p className="golem-browser-error text-red-700">{error}</p>}
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
return panel || null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const clock = { now: () => new Date(), timeZone: () => Intl.DateTimeFormat().resolvedOptions().timeZone }
|
|
48
|
+
// `focus` is in golem-ui's Editor after 0.1.1; `version` and `text` let it find the passage in what it
|
|
49
|
+
// shows, waiting for that file version and marking nothing it cannot match exactly once. With 0.1.1
|
|
50
|
+
// installed the prop is ignored: the file opens at the top and the header's line numbers are the pointer.
|
|
51
|
+
type Focus = { line: number; endLine?: number; key?: string; version?: number; text?: string }
|
|
52
|
+
type FocusedEditor = (props: ComponentProps<typeof Editor> & { focus?: Focus }) => ReturnType<typeof Editor>
|
|
53
|
+
const SourceEditor = Editor as unknown as FocusedEditor
|
|
54
|
+
const adapters = { records: knowledge, clock }
|
|
55
|
+
// Editor statuses whose text is not yet in the file: an edit waiting to save, a save in flight, a
|
|
56
|
+
// refused save or a conflict waiting for the person.
|
|
57
|
+
const unsavedStatus = new Set(['dirty', 'saving', 'error', 'conflict'])
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The accepted sources in one golem-ui Editor, which marks the offered passage where it finds it. It
|
|
61
|
+
* stays mounted for the page's life: moving to another source parks this one's draft or conflict in the
|
|
62
|
+
* Editor, and closing only hides it, so neither loses an edit. Leaving with an edit unsaved asks first.
|
|
63
|
+
*/
|
|
64
|
+
export function SourcePanel({ source, shown, onClose }: { source: OpenSource; shown: boolean; onClose(): void }) {
|
|
65
|
+
const root = useRef<HTMLDivElement>(null)
|
|
66
|
+
const record = `${source.root}/${source.path}`
|
|
67
|
+
const current = useRef(record)
|
|
68
|
+
current.current = record
|
|
69
|
+
const [statuses, setStatuses] = useState<Record<string, string>>({})
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const element = root.current
|
|
72
|
+
if (!element) return
|
|
73
|
+
// The last status each source showed; a source moved away from keeps it, since the Editor parks it as it was.
|
|
74
|
+
const read = () => {
|
|
75
|
+
const status = element.querySelector('[data-golem-status]')?.getAttribute('data-golem-status')
|
|
76
|
+
if (status) setStatuses((all) => all[current.current] === status ? all : { ...all, [current.current]: status })
|
|
77
|
+
}
|
|
78
|
+
const observer = new MutationObserver(read)
|
|
79
|
+
observer.observe(element, { subtree: true, childList: true, attributes: true, attributeFilter: ['data-golem-status'] })
|
|
80
|
+
read()
|
|
81
|
+
return () => observer.disconnect()
|
|
82
|
+
}, [])
|
|
83
|
+
const unsaved = Object.keys(statuses).filter((key) => unsavedStatus.has(statuses[key]!))
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!unsaved.length) return
|
|
86
|
+
const ask = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
|
|
87
|
+
window.addEventListener('beforeunload', ask)
|
|
88
|
+
return () => window.removeEventListener('beforeunload', ask)
|
|
89
|
+
}, [unsaved.length])
|
|
90
|
+
const focus = useMemo<Focus>(() => ({ line: source.line, endLine: source.endLine, key: source.key, version: source.version, text: source.text }), [source.key])
|
|
91
|
+
const config = useMemo(() => ({ collection: source.root, id: source.path, preview: 'toggle' as const }), [source.root, source.path])
|
|
92
|
+
return (
|
|
93
|
+
<div ref={root} className="golem-browser-source flex h-full flex-col overflow-hidden" style={shown ? undefined : { display: 'none' }}>
|
|
94
|
+
<div className="golem-browser-header flex items-center justify-between gap-2 border-b border-neutral-200 px-4 py-2 text-sm">
|
|
95
|
+
<span className="golem-browser-runtime min-w-0 truncate"><span className="font-medium">{source.path}</span>, saved lines {source.line}–{source.endLine}</span>
|
|
96
|
+
{unsaved.some((key) => key !== record) && <span className="golem-browser-error shrink-0 text-red-700">Unsaved: {unsaved.filter((key) => key !== record).map((key) => key.slice(key.indexOf('/') + 1)).join(', ')}</span>}
|
|
97
|
+
<button type="button" className="golem-browser-new shrink-0 rounded border border-neutral-300 px-2 py-1" onClick={onClose}>Back to app</button>
|
|
98
|
+
</div>
|
|
99
|
+
<div className="min-h-0 flex-1">
|
|
100
|
+
<SourceEditor config={config} adapters={adapters} focus={focus} />
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Returns to the open source after Back to app, as the Editor left it. */
|
|
107
|
+
export function SourceReturn({ source, onShow }: { source: OpenSource; onShow(): void }) {
|
|
108
|
+
return (
|
|
109
|
+
<div className="golem-browser-header border-t border-neutral-200 px-4 py-2 text-sm">
|
|
110
|
+
<button type="button" className="golem-browser-new rounded border border-neutral-300 px-2 py-1" onClick={onShow}>Show {source.path}</button>
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[data-golem-theme] .golem-browser-status-connected { color: oklch(52.7% .154 150.069); }
|
|
2
|
+
[data-golem-theme='dark'] .golem-browser-header { border-color: oklch(26.9% 0 0); background-color: oklch(14.5% 0 0); color: oklch(87% 0 0); }
|
|
3
|
+
[data-golem-theme='dark'] .golem-browser-status-connected { color: oklch(79.2% .209 151.711); }
|
|
4
|
+
[data-golem-theme='dark'] .golem-browser-status-disconnected, [data-golem-theme='dark'] .golem-browser-guest { color: oklch(70.8% 0 0); }
|
|
5
|
+
[data-golem-theme='dark'] .golem-browser-runtime { color: oklch(87% 0 0); }
|
|
6
|
+
[data-golem-theme='dark'] .golem-browser-enter { background-color: oklch(87% 0 0); color: oklch(14.5% 0 0); }
|
|
7
|
+
[data-golem-theme='dark'] .golem-browser-error { color: oklch(70.4% .191 22.216); }
|
|
8
|
+
[data-golem-theme='dark'] .golem-browser-backend { border-color: oklch(37.1% 0 0); background-color: oklch(20.5% 0 0); color: oklch(98.5% 0 0); }
|
|
9
|
+
[data-golem-theme='dark'] .golem-browser-new { border-color: oklch(37.1% 0 0); color: oklch(87% 0 0); }
|
|
10
|
+
/* The sign-in button matches the Shell's own bar buttons (Chat's tokens). */
|
|
11
|
+
.golem-browser-bar-button { border-color: var(--chat-line, #dce3eb); color: var(--chat-dim, #5f7186); }
|
|
12
|
+
.golem-browser-bar-button:hover { border-color: var(--chat-accent, #2f83c2); color: var(--chat-accent, #2f83c2); }
|
|
13
|
+
.golem-browser-header { line-height: 1.25; }
|
|
14
|
+
.golem-browser-header button { line-height: 1.25; }
|
|
15
|
+
.golem-browser-status-dot { display: inline-block; width: .45em; height: .45em; margin-right: .35em; border-radius: 9999px; background-color: currentColor; flex-shrink: 0; }
|
|
16
|
+
[data-golem-theme='dark'] .golem-browser-status-disconnected .golem-browser-status-dot { opacity: .6; }
|
|
17
|
+
/* Terminal popup: Bridge Commander's pane overlay one for one. A dark surface in both themes (the ANSI palette is tuned for one). */
|
|
18
|
+
.golem-terminal-overlay { background-color: rgb(4 7 11 / .6); backdrop-filter: blur(2px); }
|
|
19
|
+
.golem-terminal { max-height: min(660px, 86vh); border-color: oklch(37.1% 0 0); background-color: #0a0c11; color: #c9d4e0; }
|
|
20
|
+
.golem-terminal-head { border-color: oklch(26.9% 0 0); background-color: oklch(14.5% 0 0); }
|
|
21
|
+
.golem-terminal-title { font-size: 12.5px; }
|
|
22
|
+
.golem-terminal-live { width: 8px; height: 8px; border-radius: 50%; background: oklch(45% 0 0); flex: none; transition: background .2s; }
|
|
23
|
+
.golem-terminal-live.on { background: oklch(79.2% .209 151.711); box-shadow: 0 0 7px oklch(79.2% .209 151.711); }
|
|
24
|
+
/* the hint doubles as the focus indicator and as the only place the way out is written down */
|
|
25
|
+
.golem-terminal-hint { flex: none; color: oklch(55.6% 0 0); font-size: 10.5px; }
|
|
26
|
+
.golem-terminal-hint.on { color: oklch(70.7% .165 254.624); }
|
|
27
|
+
.golem-terminal-hint.flash { color: oklch(70.4% .191 22.216); }
|
|
28
|
+
.golem-terminal-close { color: oklch(70.8% 0 0); font-size: 15px; line-height: 1; }
|
|
29
|
+
.golem-terminal-close:hover { color: #fff; }
|
|
30
|
+
.golem-terminal-screen {
|
|
31
|
+
color: #c9d4e0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11.5px; line-height: 1.4;
|
|
32
|
+
white-space: pre; user-select: text; cursor: text; overscroll-behavior: contain; border-top: 2px solid transparent;
|
|
33
|
+
}
|
|
34
|
+
/* focused == typing: unmistakable, because a pane that silently swallows keystrokes is worse than a read-only one */
|
|
35
|
+
.golem-terminal-screen.typing { border-top-color: oklch(70.7% .165 254.624); background-color: #0c1016; }
|
|
36
|
+
.golem-terminal-msg { color: oklch(70.8% 0 0); }
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { ansiToHtml } from './ansi'
|
|
3
|
+
import { keyForEvent } from './panekeys'
|
|
4
|
+
|
|
5
|
+
// Interrupts skip the send queue: Ctrl-C exists to arrive when the pane is not keeping up.
|
|
6
|
+
const JUMPS_QUEUE = new Set(['C-c', 'C-d', 'C-z', 'C-\\'])
|
|
7
|
+
|
|
8
|
+
// The Terminal popup, a port of Bridge Commander's ui/js/pane.js: the agent's live tmux screen (whole-screen
|
|
9
|
+
// frames over SSE, replaced not appended) that is itself focusable. Keys go straight to the pane, paste is a
|
|
10
|
+
// literal paste, text stays selectable. No terminal emulator: keys go out, frames come back.
|
|
11
|
+
export function Terminal({ session, onClose }: { session: string; onClose: () => void }) {
|
|
12
|
+
const preRef = useRef<HTMLPreElement>(null)
|
|
13
|
+
const [message, setMessage] = useState<string>()
|
|
14
|
+
const [live, setLive] = useState(false)
|
|
15
|
+
const [typing, setTyping] = useState(false)
|
|
16
|
+
const [flash, setFlash] = useState<string>()
|
|
17
|
+
const base = `/api/sessions/${encodeURIComponent(session)}/pane/`
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const es = new EventSource(`${base}stream`)
|
|
20
|
+
const fail = (text: string) => { es.close(); setLive(false); setMessage(text) }
|
|
21
|
+
es.addEventListener('frame', (event) => {
|
|
22
|
+
const pre = preRef.current
|
|
23
|
+
if (!pre) return
|
|
24
|
+
// Stick to the bottom only when the user was already there: a scroll-up must survive the next frame.
|
|
25
|
+
const stick = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 12
|
|
26
|
+
pre.innerHTML = ansiToHtml(String(JSON.parse((event as MessageEvent).data)))
|
|
27
|
+
if (stick) pre.scrollTop = pre.scrollHeight
|
|
28
|
+
setLive(true)
|
|
29
|
+
})
|
|
30
|
+
es.addEventListener('unsupported', () => fail('this agent has no live terminal'))
|
|
31
|
+
es.addEventListener('busy', () => fail('too many terminals open; close one and try again'))
|
|
32
|
+
es.addEventListener('no-pane', (event) => {
|
|
33
|
+
let reason = ''
|
|
34
|
+
try { reason = JSON.parse((event as MessageEvent).data).reason ?? '' } catch { /* plain message */ }
|
|
35
|
+
fail(`no live terminal${reason ? `: ${reason}` : ''}`)
|
|
36
|
+
})
|
|
37
|
+
es.onerror = () => setLive(false)
|
|
38
|
+
return () => es.close()
|
|
39
|
+
}, [base])
|
|
40
|
+
// Escape closes the overlay only while the screen is NOT focused; focused, it belongs to the agent
|
|
41
|
+
// (the pane's own keydown stops propagation before it gets here).
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose() }
|
|
44
|
+
window.addEventListener('keydown', onKey)
|
|
45
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
46
|
+
}, [onClose])
|
|
47
|
+
// One POST per keystroke, chained: same-origin fetches can complete out of order, and out-of-order
|
|
48
|
+
// keystrokes scramble typed text ("abc" → "acb"). Each hop is bounded by a timeout so one stall cannot wedge the rest.
|
|
49
|
+
const chain = useRef(Promise.resolve())
|
|
50
|
+
const flashTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
51
|
+
const post = (payload: { text?: string; key?: string }) => fetch(`${base}input`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.timeout(5000) })
|
|
52
|
+
.then(async (response) => { if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error ?? `HTTP ${response.status}`) })
|
|
53
|
+
// A rejected keystroke was preventDefaulted away from the browser: it must not vanish in silence.
|
|
54
|
+
.catch((error: unknown) => { setFlash(error instanceof Error ? error.message : String(error)); clearTimeout(flashTimer.current); flashTimer.current = setTimeout(() => setFlash(undefined), 4000) })
|
|
55
|
+
const send = (payload: { text?: string; key?: string }) => {
|
|
56
|
+
if (payload.key && JUMPS_QUEUE.has(payload.key)) { void post(payload); return }
|
|
57
|
+
chain.current = chain.current.then(() => post(payload))
|
|
58
|
+
}
|
|
59
|
+
const onKeyDown = (event: React.KeyboardEvent) => {
|
|
60
|
+
const payload = keyForEvent(event)
|
|
61
|
+
if (!payload) return // browser/OS chord: leave it alone (Ctrl-V, ⌘W, F5…)
|
|
62
|
+
event.preventDefault()
|
|
63
|
+
event.stopPropagation()
|
|
64
|
+
send(payload)
|
|
65
|
+
}
|
|
66
|
+
// Paste rides the literal path: the harness switches to a bracketed paste for multi-line text.
|
|
67
|
+
const onPaste = (event: React.ClipboardEvent) => {
|
|
68
|
+
event.preventDefault()
|
|
69
|
+
const text = event.clipboardData.getData('text')
|
|
70
|
+
if (text) send({ text })
|
|
71
|
+
}
|
|
72
|
+
const hint = flash ? `⚠ ${flash}` : typing ? 'typing — keys go to the pane · Esc too · ✕ or click outside to close' : 'click the screen to type'
|
|
73
|
+
return (
|
|
74
|
+
<div className="golem-terminal-overlay fixed inset-0 z-50 flex items-center justify-center p-4" onClick={(event) => { if (event.target === event.currentTarget) onClose() }}>
|
|
75
|
+
<div className="golem-terminal flex h-full w-full max-w-5xl flex-col overflow-hidden rounded-xl border shadow-xl" role="dialog" aria-label="Agent terminal">
|
|
76
|
+
<div className="golem-terminal-head flex items-center gap-2 border-b px-3.5 py-2.5 text-xs">
|
|
77
|
+
<span className={`golem-terminal-live ${live ? 'on' : ''}`} title={live ? 'live' : 'not streaming'} aria-hidden="true" />
|
|
78
|
+
<span className="golem-terminal-title min-w-0 flex-1 truncate font-mono font-semibold">{session}</span>
|
|
79
|
+
{!message && <span className={`golem-terminal-hint whitespace-nowrap ${flash ? 'flash' : typing ? 'on' : ''}`}>{hint}</span>}
|
|
80
|
+
<button type="button" className="golem-terminal-close px-1.5" title="Close" onClick={onClose}>✕</button>
|
|
81
|
+
</div>
|
|
82
|
+
{message
|
|
83
|
+
? <div className="golem-terminal-msg flex flex-1 items-center justify-center p-6 text-sm">{message}</div>
|
|
84
|
+
: <pre ref={preRef} tabIndex={0} className={`golem-terminal-screen m-0 min-h-0 flex-1 overflow-auto px-3.5 py-3 outline-none ${typing ? 'typing' : ''}`}
|
|
85
|
+
onFocus={() => setTyping(true)} onBlur={() => setTyping(false)} onKeyDown={onKeyDown} onPaste={onPaste}>connecting…</pre>}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
}
|
package/src/browser-build.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { resolve } from 'node:path';
|
|
5
6
|
import { build } from 'vite';
|
|
6
7
|
import { resolveUiSource } from '../vite.config.ts';
|
|
8
|
+
import { sourcePaths } from './source-mode.ts';
|
|
7
9
|
|
|
8
10
|
const frameworkRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
9
11
|
|
|
@@ -28,12 +30,23 @@ export async function buildBrowser(): Promise<void> {
|
|
|
28
30
|
cwd: process.cwd(),
|
|
29
31
|
stdio: 'inherit',
|
|
30
32
|
});
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
// A generated config rather than bare flags: `paths` is the only way to tell tsc that the app's
|
|
34
|
+
// own `golem-kit`/`golem-ui` imports come from the source checkouts, and it has no CLI flag. It
|
|
35
|
+
// lives in a temp dir so source mode never writes into the app folder.
|
|
36
|
+
const appTsconfig = resolve(mkdtempSync(resolve(tmpdir(), 'golem-typecheck-')), 'tsconfig.json');
|
|
37
|
+
writeFileSync(appTsconfig, JSON.stringify({
|
|
38
|
+
compilerOptions: {
|
|
39
|
+
noEmit: true, jsx: 'react-jsx', module: 'ESNext', moduleResolution: 'Bundler',
|
|
40
|
+
skipLibCheck: true, allowImportingTsExtensions: true,
|
|
41
|
+
types: ['node', 'react', 'react-dom'], typeRoots: [typeRoots],
|
|
42
|
+
paths: sourcePaths(),
|
|
43
|
+
},
|
|
44
|
+
files: [
|
|
45
|
+
resolve(process.cwd(), 'src/app.tsx'), resolve(process.cwd(), 'golem.config.ts'),
|
|
46
|
+
...[resolve(process.cwd(), 'src/server/index.ts')].filter(existsSync),
|
|
47
|
+
],
|
|
48
|
+
}));
|
|
49
|
+
execFileSync(tsc, ['-p', appTsconfig], { cwd: process.cwd(), stdio: 'inherit' });
|
|
37
50
|
await build({ configFile: resolve(frameworkRoot, 'vite.config.ts') });
|
|
38
51
|
}
|
|
39
52
|
|