heyquilt 0.3.116 → 0.3.119

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/src/ui/home.js CHANGED
@@ -14,7 +14,7 @@ import { bindSandboxKeys } from './sandbox-keys.js'
14
14
  import { agentsTableHtml, agentDetailsHtml } from './agents-table.js'
15
15
  import { SOUND_EVENTS, playSound } from './sounds.js'
16
16
  import { gitControl, gitSegHtml, thisSessionGitHtml } from './git-rule.js'
17
- import { cloudAiCardHtml, bindCloudAi } from './cloud-ai.js'
17
+ import { cloudAiCardHtml, bindCloudAi, cloudSetupOn, cloudSetupHtml, cloudSetupStep } from './cloud-ai.js'
18
18
  import { feedbackDockHtml, bindFeedback } from './feedback.js'
19
19
 
20
20
  export const tildify = (p) => state.defaults.home && String(p).startsWith(state.defaults.home) ? `~${String(p).slice(state.defaults.home.length)}` : p
@@ -809,7 +809,13 @@ function settingsPageHtml () {
809
809
  <p class="settings-scope" data-settings-scope="account">Your defaults for new sessions. A session already running keeps its own, in its Session settings.</p>
810
810
  </header>
811
811
  <nav class="settings-tabs page-tabs" role="tablist" aria-label="Account settings">${settingsTabs().map(([id, label, icon]) => `<button type="button" role="tab" class="tab ${tab === id ? 'on' : ''}" data-settings-tab="${id}" aria-selected="${tab === id}">${I[icon] || ''}<span>${label}</span></button>`).join('')}</nav>
812
- <div role="tabpanel">${settingsHtml()}</div>`
812
+ <div role="tabpanel">${tab === 'agents' && cloudSetupOn() ? cloudSetupPageHtml() : settingsHtml()}</div>`
813
+ }
814
+
815
+ /** Setting up your cloud AI on the Settings page: the whole tab panel, header to footer. */
816
+ function cloudSetupPageHtml () {
817
+ const f = cloudSetupHtml()
818
+ return `<div class="ai-page-flow"><div class="ss-head">${f.head}</div>${f.body}${f.foot}</div>`
813
819
  }
814
820
 
815
821
  /**
@@ -833,13 +839,8 @@ function settingsHtml (session = null) {
833
839
  </section>` : ''}
834
840
 
835
841
  ${show('your-ai') ? `<form class="card settings-sec" id="your-ai-sec" autocomplete="off">
836
- <div class="sec-intro"><h2>Your AI</h2><p>The AI tool you code with, in every session you are in.</p></div>
842
+ <div class="sec-intro"><h2>The AI on this computer</h2><p>The AI tool you code with. In a session it works as you, on what you ask it: what people write to you is yours.</p></div>
837
843
  <div class="sec-body">
838
- <div class="field">
839
- <label for="s-aitasks">Let my AI pick up tasks by itself</label>
840
- <select class="input" id="s-aitasks" name="aiTasks">${[['off', 'Off'], ['mine', 'Tasks assigned to it'], ['any', 'Assigned to it, then unassigned ones']].map(([v, l]) => `<option value="${v}" ${p.aiTasks === v ? 'selected' : ''}>${l}</option>`).join('')}</select>
841
- <span class="hint">When your AI finishes a piece of work, Quilt hands it the next To do task from the board, in whichever AI tool you use. Applies at once.</span>
842
- </div>
843
844
  ${toggle('shareAgent', p.shareAgent, 'Share my AI chat', 'Partners see your prompts, the replies and which files it touches. You can pause it inside any session.')}
844
845
  ${toggle('summarize', p.summarize, 'Summarize my chats', 'Your prompts and your AI’s replies are shortened to a sentence or two on this computer before they’re shared. Uses your claude CLI (a few Haiku tokens each); if it isn’t available, the text is just shortened.')}
845
846
  <div class="sec-actions"><span></span><button class="btn primary" type="submit">Save</button></div>
@@ -1162,10 +1163,10 @@ function bindSettings (root, refresh) {
1162
1163
  if (sess) saveForm(sess, (f) => ({ joinDir: f.get('joinDir'), preferLocal: !!f.get('preferLocal'), pushLocalCommits: !!f.get('pushLocalCommits'), report: !!f.get('report') }))
1163
1164
 
1164
1165
  const ai = $('#your-ai-sec', root)
1165
- if (ai) saveForm(ai, (f) => ({ shareAgent: !!f.get('shareAgent'), summarize: !!f.get('summarize'), aiTasks: f.get('aiTasks') }))
1166
+ if (ai) saveForm(ai, (f) => ({ shareAgent: !!f.get('shareAgent'), summarize: !!f.get('summarize') }))
1166
1167
 
1167
- const cloud = $('#cloud-ai-sec', root)
1168
- if (cloud) bindCloudAi(cloud)
1168
+ // Your cloud AI: its card, or its setup in place of the tab (in the pop-up, its header and footer too).
1169
+ if ($('#cloud-ai-sec', root) || $('.ai-flow-body', root)) bindCloudAi(root.closest('.settings-modal') || root, refresh)
1169
1170
 
1170
1171
  const agit = $('#agents-git-sec', root)
1171
1172
  if (agit) bindAgentsGit(agit)
@@ -1247,14 +1248,26 @@ export function openSettings (opts = {}) {
1247
1248
  document.body.appendChild(back)
1248
1249
  const body = $('#settings-body', back)
1249
1250
  const nav = $('.settings-tabs-slot', back)
1251
+ const main = $('.ss-main', back)
1252
+ const head = $('.ss-head', back)
1253
+ const plainHead = head.innerHTML
1254
+ const closeBtn = head.querySelector('[data-settings-close]').outerHTML
1250
1255
  let shown = ''
1251
1256
  const draw = () => {
1252
1257
  const tab = settingsTab()
1253
- const scroll = tab === shown ? body.scrollTop : 0
1254
- shown = tab
1258
+ // Setting up your cloud AI takes the whole right side: its own header, steps and footer.
1259
+ const flow = tab === 'agents' && cloudSetupOn() ? cloudSetupHtml() : null
1260
+ // The same tab (or step of the setup) keeps where it was scrolled to; another starts at the top.
1261
+ const key = flow ? `setup:${cloudSetupStep()}` : tab
1262
+ const scroll = key === shown ? body.scrollTop : 0
1263
+ shown = key
1255
1264
  nav.innerHTML = settingsTabsHtml(tab)
1256
- $('.ss-name', back).textContent = settingsTabLabel(tab)
1257
- body.innerHTML = settingsHtml(session)
1265
+ back.querySelector('.ai-flow-foot')?.remove()
1266
+ main.classList.toggle('ai-flow', !!flow)
1267
+ head.innerHTML = flow ? flow.head + closeBtn : plainHead
1268
+ if (!flow) $('.ss-name', back).textContent = settingsTabLabel(tab)
1269
+ body.innerHTML = flow ? flow.body : settingsHtml(session)
1270
+ if (flow) main.insertAdjacentHTML('beforeend', flow.foot)
1258
1271
  bindSettings(body, draw)
1259
1272
  body.scrollTop = scroll
1260
1273
  // The sidebar's profile card, if it is showing, follows the saved profile.
@@ -1283,7 +1296,7 @@ export function openSettings (opts = {}) {
1283
1296
  const close = () => back.remove()
1284
1297
  back.addEventListener('mousedown', (e) => { if (e.target === back) close() })
1285
1298
  back.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
1286
- back.querySelector('[data-settings-close]').onclick = close
1299
+ back.addEventListener('click', (e) => { if (e.target.closest('[data-settings-close]')) close() })
1287
1300
  body.addEventListener('click', (e) => {
1288
1301
  if (!session || !e.target.closest('[data-open-session-vc]')) return
1289
1302
  close()
package/src/ui/session.js CHANGED
@@ -3242,6 +3242,15 @@ function renderTyping () {
3242
3242
  if (nearBottom) list.scrollTop = list.scrollHeight
3243
3243
  }
3244
3244
 
3245
+ // Cloud AIs seen in this app by their own names: name -> their person (cloud-ai.js setup).
3246
+ const cloudAiOf = new Map()
3247
+ /** The "<person>'s AI" tag after a cloud AI's own name ("Juniper"); none for "<person>'s AI" itself. */
3248
+ function aiTag (by, me) {
3249
+ const of = cloudAiOf.get(by)
3250
+ if (!of || by === `${of}'s AI`) return ''
3251
+ return `<span class="ai-tag">${esc(of === me ? 'Your AI' : `${of}'s AI`)}</span>`
3252
+ }
3253
+
3245
3254
  function renderMessages (incoming = false, force = false) {
3246
3255
  const el = $('#messages')
3247
3256
  const s = sum()
@@ -3254,6 +3263,8 @@ function renderMessages (incoming = false, force = false) {
3254
3263
  const colors = new Map(s.status.peers.map((p) => [p.name, p.color]))
3255
3264
  colors.set(name, s.status.me.color)
3256
3265
  const names = mentionNames(s)
3266
+ // A person's cloud AI with a name of its own ("Juniper") is shown as theirs, also while it sleeps.
3267
+ for (const p of s.status.peers) if (p && p.cloud && typeof p.of === 'string') cloudAiOf.set(p.name, p.of)
3257
3268
  // Finished calls sit in the chat at when they began (voice.js).
3258
3269
  const calls = callCards(current)
3259
3270
  el.innerHTML = list.length || calls.length
@@ -3269,7 +3280,7 @@ function renderMessages (incoming = false, force = false) {
3269
3280
  // The sender's face sits in the name line above the bubble, so the bubble has the pane's width.
3270
3281
  const face = mine ? '' : relay ? `<span class="relay-q avatar">${I.globe}</span>` : avatar(by, colors.get(by) || colors.get(m.by))
3271
3282
  return `<div class="msg${mine ? ' mine' : ''}">
3272
- <div style="min-width:0"><div class="head">${face}<b${by !== m.by ? ` title="${esc(m.by)}"` : ''}>${mine ? 'You' : esc(by === `${name}'s AI` ? 'Your AI' : by)}</b>${dm}<span>${esc(clock(m.ts))}</span></div>
3283
+ <div style="min-width:0"><div class="head">${face}<b${by !== m.by ? ` title="${esc(m.by)}"` : ''}>${mine ? 'You' : esc(by === `${name}'s AI` ? 'Your AI' : by)}</b>${aiTag(by, name)}${dm}<span>${esc(clock(m.ts))}</span></div>
3273
3284
  <div class="bubble">${m.text ? `<div class="text">${textHtml(m.text, names, name, { meAgent: s.status.me.kind === 'agent' })}</div>` : ''}${file}</div></div></div>`
3274
3285
  })() })), ...calls].sort((a, b) => a.ts - b.ts).map((x) => x.html).join('')
3275
3286
  : '<div class="day-empty"><div><b>Say hi.</b></div><div class="hint">Messages, direct messages and files you share appear here. Drop a file on this panel to send it.</div></div>'
package/src/ui-server.js CHANGED
@@ -21,6 +21,7 @@ import { installedEditors, openIn, copyToClipboard } from './editors.js'
21
21
  import { autowork, autoConfig, publicConfig, AUTO_MODES } from './autowork.js'
22
22
  import { migrateDir } from './legacy.js'
23
23
  import { writePrivateJson } from './private-file.js'
24
+ import { autoToolFor } from './autowork-tools.js'
24
25
  import { readAccount, saveAccount, workspaceGithub, setWorkspaceGithub, removeWorkspaceGithub, setOrgGithub, clearAccount, clearAccountIf, resumeAccount, startLink, waitForLink, fetchMe, signOut, revokeToken, accountFromProfile, renameSession, createAgentInvite, listAgents, agentDetails, listAccessTypes, listCollaborators, listGrants, putGrant, deleteGrant, inviteToSession, listSessionInvites, cancelSessionInvite, listWorkspaces, listOrgs, createWorkspace, getWorkspace, updateWorkspace, deleteWorkspace, putWorkspaceMember, removeWorkspaceMember, getAgentPlacement, putAgentPlacement, listOrgAgents, putWorkspaceAgent, deleteWorkspaceAgent, createWorkspaceAgentInvite, excludeSessionAgent, listExcludedSessionAgents, listSessionAgents, includeSessionAgent, setSessionWorkspace, moveSessionToWorkspace, listWorkspaceInvites, inviteToWorkspace, cancelWorkspaceInvite, listMyInvites, answerMyInvite, announceSessionStarted, announceWhenReported, listWorkspaceFiles, createWorkspaceFile, confirmWorkspaceFile, workspaceFileDownload, updateWorkspaceFile, deleteWorkspaceFile, createWorkspaceFolder, listWorkspaceFileVersions, githubStatus, githubConnectUrl, listSandboxKeys, createSandboxKey, rotateSandboxKey, revokeSandboxKey, removeSandboxKey, aiVmFollow, aiVmUnfollow, aiVmStatus, aiVmConnect, aiVmUpdate, aiVmSignIn, aiVmWake, aiVmDisconnect, voiceIce, transcribeVoice, apiUrl } from './account.js'
25
26
  import { aiName } from './persona.js'
26
27
  import { effectiveAccess, builtinType } from './session-access.js'
@@ -601,12 +602,19 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
601
602
  // A session nobody named is shown as the app shows it: by this computer's folder for it.
602
603
  const local = new Map([...runs.values()].filter((e) => e.run && e.run.session).map((e) => [e.run.session.room, e.run.session.sessionName || path.basename(e.run.dir || '')]))
603
604
  const sessions = (vm.sessions || []).map((x) => (x.name ? x : { ...x, name: local.get(x.room) || '' }))
604
- return { vm: { ...vm, sessions, name: aiName(person) } }
605
+ return { vm: { ...vm, sessions, person, name: vm.aiName || aiName(person) } }
605
606
  }
606
607
  const cloudNone = (err) => { if (err.status === 404) return cloudView(null); throw err }
607
- /** What Settings may change: the tool, model, instructions and daily cap (blank is no cap). */
608
+ /**
609
+ * What Settings may change: the tool, model, instructions and daily cap (blank is no cap), and
610
+ * what setting it up chose: its name and color, which tasks it takes, whether it changes files,
611
+ * whether every message wakes it, and which sessions it joins. The API checks each.
612
+ */
608
613
  const cloudPatch = (b) => {
609
614
  const out = {}
615
+ for (const k of ['name', 'color', 'tasks', 'sessionsMode']) if (b[k] !== undefined) out[k] = String(b[k])
616
+ for (const k of ['files', 'wake']) if (b[k] !== undefined) out[k] = b[k] === true
617
+ if (b.rooms !== undefined) out.rooms = Array.isArray(b.rooms) ? b.rooms.map(String) : []
610
618
  if (b.tool !== undefined) out.tool = String(b.tool)
611
619
  if (b.model !== undefined) out.model = String(b.model).trim()
612
620
  if (b.prompt !== undefined) out.prompt = String(b.prompt)
@@ -618,12 +626,21 @@ export async function startUi ({ port = 7420, onShutdown, preview = false, repor
618
626
  return out
619
627
  }
620
628
  const cloudRoutes = {
629
+ // The tools a cloud AI can run, with their models, for setting one up before it exists.
630
+ 'GET /api/cloud-ai/tools': () => ({ tools: CLOUD_TOOLS.map((tool) => ({ tool, models: autoToolFor(tool)?.models || [] })) }),
621
631
  'GET /api/cloud-ai': (b, id, url) => asAccount(async (token) => cloudView(await aiVmStatus({ token, wait: url.searchParams.get('wait') }))),
622
632
  'POST /api/cloud-ai/connect': (b) => {
623
633
  if (!CLOUD_TOOLS.includes(b && b.tool)) throw httpError(400, 'Pick Claude Code, Codex or Cursor.')
624
- return asAccount(async (token) => cloudView(await aiVmConnect({ token, tool: b.tool })))
634
+ const { tool: _t, ...setup } = cloudPatch(b)
635
+ handed.clear()
636
+ return asAccount(async (token) => cloudView(await aiVmConnect({ token, tool: b.tool, setup })))
637
+ },
638
+ 'POST /api/cloud-ai/settings': (b) => {
639
+ const patch = cloudPatch(b || {})
640
+ // Sessions picked anew are handed over again (the API joins only the ones chosen).
641
+ if (patch.sessionsMode !== undefined || patch.rooms !== undefined) handed.clear()
642
+ return asAccount(async (token) => cloudView(await aiVmUpdate({ token, patch })))
625
643
  },
626
- 'POST /api/cloud-ai/settings': (b) => { const patch = cloudPatch(b || {}); return asAccount(async (token) => cloudView(await aiVmUpdate({ token, patch }))) },
627
644
  'POST /api/cloud-ai/sign-in': (b) => asAccount(async (token) => aiVmSignIn({ token, ...(b && b.code !== undefined ? { code: String(b.code).trim() } : {}) })),
628
645
  'POST /api/cloud-ai/wake': () => asAccount(async (token) => cloudView(await aiVmWake({ token }))),
629
646
  'POST /api/cloud-ai/disconnect': () => asAccount(async (token) => { await aiVmDisconnect({ token }).catch(cloudNone); hasCloudAi = false; return { ok: true } })