diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index 9e2159f9..10878504 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -101,6 +101,7 @@ import AgentPreviewSheet from './components/AgentPreviewSheet'; import SectionShell from '../navigation/SectionShell'; import ResourceStatusNotice, { type NamedResource, + unnamedResourceLabel, } from './components/ResourceStatusNotice'; import { confirmTakeOver, @@ -332,13 +333,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { return ( prompts.find((prompt) => prompt.id === sponsor.id)?.name || agent.prompt_name || - t('agents.form.sponsors.unknownItem') + unnamedResourceLabel(t, sponsor) ); } const tool = selectedTools.find((item) => item.id === sponsor.id); - return tool - ? getToolDisplayName(tool) - : t('agents.form.sponsors.unknownItem'); + return tool ? getToolDisplayName(tool) : unnamedResourceLabel(t, sponsor); }, [agent.prompt_name, prompts, resolveSourceLabel, selectedTools, t], ); diff --git a/frontend/src/agents/components/ResourceStatusNotice.test.tsx b/frontend/src/agents/components/ResourceStatusNotice.test.tsx index 798fbd5e..46e0e681 100644 --- a/frontend/src/agents/components/ResourceStatusNotice.test.tsx +++ b/frontend/src/agents/components/ResourceStatusNotice.test.tsx @@ -5,7 +5,10 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; import type { Agent, ResourceSponsor, ResourceState } from '../types'; -import ResourceStatusNotice from './ResourceStatusNotice'; +import ResourceStatusNotice, { + noteKey, + unnamedResourceLabel, +} from './ResourceStatusNotice'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); @@ -230,6 +233,60 @@ describe('ResourceStatusNotice', () => { expect(deleted.textContent).not.toContain('agents.form.resourceStates.ask'); }); + it("asks the item's owner without naming them when the reader may not know them", async () => { + await render(editorAgent, [ + stoppedItem({ + reason: 'owner_lost_access', + contact: null, + contact_role: 'resource_owner', + }), + stoppedItem({ + key: 'tool:t2', + id: 't2', + reason: 'connection_needs_reconnect', + contact: null, + contact_role: 'resource_owner', + }), + stoppedItem({ + key: 'tool:t3', + id: 't3', + reason: 'connection_removed', + contact: null, + contact_role: 'resource_owner', + }), + ]); + const [lost, reconnect, removed] = Array.from( + container.querySelectorAll('li'), + ); + expect(lost.textContent).toContain( + 'agents.form.resourceStates.ask.shareAgainOwner', + ); + expect(reconnect.textContent).toContain( + 'agents.form.resourceStates.ask.signInAgainOwner', + ); + expect(removed.textContent).toContain( + 'agents.form.resourceStates.ask.connectAgainOwner', + ); + }); + + it('names a nameless item by its kind and a short id', () => { + const t = ((key: string, options?: Record) => + `${key}|${options?.id}`) as never; + expect( + unnamedResourceLabel(t, { + type: 'source', + id: '0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0', + }), + ).toBe('agents.form.resourceStates.unnamed.source|0f1e2d3c'); + }); + + it("has words for a tool on each person's own account", () => { + expect(noteKey('per_user_account')).toBe( + 'agents.form.resourceStates.note.perUserAccount', + ); + expect(noteKey(null)).toBeNull(); + }); + it('notes that a stopped prompt falls back to the default', async () => { await render(baseAgent, [ stoppedItem({ key: 'prompt:p1', type: 'prompt', id: 'p1' }), diff --git a/frontend/src/agents/components/ResourceStatusNotice.tsx b/frontend/src/agents/components/ResourceStatusNotice.tsx index 890318bb..b56c72a0 100644 --- a/frontend/src/agents/components/ResourceStatusNotice.tsx +++ b/frontend/src/agents/components/ResourceStatusNotice.tsx @@ -1,3 +1,4 @@ +import type { TFunction } from 'i18next'; import { Info, TriangleAlert, UserRound } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -10,6 +11,7 @@ import type { Agent, ResourceSponsor, ResourceState, + ResourceStateNote, ResourceStateReason, } from '../types'; @@ -64,17 +66,43 @@ export function reasonKey( } } -/** Whom to ask, as the key that says so; null when the reader can act. */ +/** The words for a running item's `note`, or null when it has none. */ +export function noteKey(note: ResourceStateNote | null | undefined) { + return note === 'per_user_account' + ? 'agents.form.resourceStates.note.perUserAccount' + : null; +} + +/** A name for an item the reader may not see: its kind and a short id. */ +export function unnamedResourceLabel( + t: TFunction, + item: Pick, +): string { + return t(`agents.form.resourceStates.unnamed.${item.type}`, { + id: item.id.slice(0, 8), + }); +} + +/** + * Whom to ask, as the key that says so; null when the reader can act. The + * person is named when the read names them (`contact`), else only as the + * item's owner. + */ function askKey(item: ResourceState): string | null { if (item.reason === 'connector_disabled') return 'agents.form.resourceStates.ask.admin'; - if (!item.contact) return null; + const suffix = item.contact + ? '' + : item.contact_role === 'resource_owner' + ? 'Owner' + : null; + if (suffix === null) return null; if (item.reason === 'owner_lost_access') - return 'agents.form.resourceStates.ask.shareAgain'; + return `agents.form.resourceStates.ask.shareAgain${suffix}`; if (item.reason === 'connection_needs_reconnect') - return 'agents.form.resourceStates.ask.signInAgain'; + return `agents.form.resourceStates.ask.signInAgain${suffix}`; if (item.reason === 'connection_removed') - return 'agents.form.resourceStates.ask.connectAgain'; + return `agents.form.resourceStates.ask.connectAgain${suffix}`; return null; } diff --git a/frontend/src/agents/types/index.ts b/frontend/src/agents/types/index.ts index c0d33f6f..61aeae88 100644 --- a/frontend/src/agents/types/index.ts +++ b/frontend/src/agents/types/index.ts @@ -40,6 +40,9 @@ export type ResourceStateReason = | 'connection_removed' | 'connector_disabled'; +/** Something to know about an item that runs (`ResourceState.note`). */ +export type ResourceStateNote = 'per_user_account'; + /** A person the page names: their email when on file, else their user id. */ export type ResourcePerson = { user_id: string; label: string }; @@ -57,11 +60,19 @@ export type ResourceState = { state: 'active' | 'stopped'; /** Null while it runs. */ reason: ResourceStateReason | null; + /** `per_user_account`: it runs on each person's own account. */ + note?: ResourceStateNote | null; /** Who it ran with the access of, when someone else added it. */ sponsor?: ResourcePerson | null; - /** Someone other than the reader who can fix it. */ + /** Someone other than the reader who can fix it, when the reader may know them. */ contact?: ResourcePerson | null; - /** The service of a connected tool, or of one a connection reason stopped. */ + /** Who can fix it (`resource_owner`), named or not. */ + contact_role?: 'resource_owner' | null; + /** + * The service of a connected tool, or of one a connection reason stopped. + * `id` only when the reader may reconnect + * it; the account's own name only for its owner. + */ connection?: { id: string | null; connector_key: string | null; diff --git a/frontend/src/agents/workflow/WorkflowBuilder.tsx b/frontend/src/agents/workflow/WorkflowBuilder.tsx index 7927d977..bb47c51a 100644 --- a/frontend/src/agents/workflow/WorkflowBuilder.tsx +++ b/frontend/src/agents/workflow/WorkflowBuilder.tsx @@ -58,7 +58,9 @@ import { import { useSponsorPrompt } from '../useSponsorPrompt'; import ResourceStatusNotice, { type NamedResource, + unnamedResourceLabel, } from '../components/ResourceStatusNotice'; +import FloatingResourceNotice from './components/FloatingResourceNotice'; import { useSignInAgain } from '../../connectors/SignInAgainNotice'; import WorkflowDetailsSheet, { type WorkflowDetailsSave, @@ -255,7 +257,6 @@ function WorkflowBuilderInner() { // Keys of stopped node resources the caller agreed to run with their // access; sent as ``confirm_sponsor`` with the next save. const [takeovers, setTakeovers] = useState([]); - const [resourceNoticeOpen, setResourceNoticeOpen] = useState(true); // Bumped after a reconnect so the run state is read again. const [resourcesReloadKey, setResourcesReloadKey] = useState(0); const signInAgain = useSignInAgain({ @@ -1694,7 +1695,7 @@ function WorkflowBuilderInner() { const known = ( item.type === 'tool' ? nodeRefNames.tools : nodeRefNames.sources ).find((entry) => entry.id.toLowerCase() === item.id)?.label; - return known || t('agents.form.sponsors.unknownItem'); + return known || unnamedResourceLabel(t, item); }, [nodeRefNames, t], ); @@ -1751,7 +1752,6 @@ function WorkflowBuilderInner() { ); const showResourceNotice = canManageAgent && - resourceNoticeOpen && (stoppedResources.length > 0 || workflowResources.sponsors.some((sponsor) => sponsor.active)); @@ -2009,32 +2009,21 @@ function WorkflowBuilderInner() { className="bg-muted relative min-w-0 flex-1" > {showResourceNotice && ( -
-
- void takeOverResource(item)} - onUndoTakeover={(key) => - setTakeovers((prev) => prev.filter((k) => k !== key)) - } - onRemove={removeResource} - onReconnect={reconnectResource} - /> -
- setResourceNoticeOpen(false)} - label={t('agents.close')} - icon={X} - /> -
-
-
+ + void takeOverResource(item)} + onUndoTakeover={(key) => + setTakeovers((prev) => prev.filter((k) => k !== key)) + } + onRemove={removeResource} + onReconnect={reconnectResource} + /> + )} ({ + useTranslation: () => ({ + t: (key: string, options?: { count?: number }) => + options?.count !== undefined ? `${key}:${options.count}` : key, + }), +})); + +describe('FloatingResourceNotice', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + const render = async (stoppedCount: number) => + act(async () => { + root.render( + +

notice

+
, + ); + }); + + const byLabel = (label: string) => + container.querySelector(`[aria-label="${label}"]`); + + it('fits the canvas on a narrow screen', async () => { + await render(1); + const panel = container.querySelector('[data-testid="notice"]')! + .parentElement!.parentElement!; + expect(panel.className).not.toContain('w-full'); + expect(panel.className).toContain('max-w-[calc(100%-2rem)]'); + }); + + it('closes to a chip that opens it again', async () => { + await render(2); + await act(async () => byLabel('agents.close')!.click()); + expect(container.querySelector('[data-testid="notice"]')).toBeNull(); + const chip = Array.from(container.querySelectorAll('button')).find((b) => + b.textContent?.includes('agents.form.resourceStates.chip:2'), + )!; + await act(async () => chip.click()); + expect(container.querySelector('[data-testid="notice"]')).not.toBeNull(); + }); + + it('leaves no chip once nothing is stopped', async () => { + await render(0); + await act(async () => byLabel('agents.close')!.click()); + expect(container.innerHTML).toBe(''); + }); +}); diff --git a/frontend/src/agents/workflow/components/FloatingResourceNotice.tsx b/frontend/src/agents/workflow/components/FloatingResourceNotice.tsx new file mode 100644 index 00000000..a987b63d --- /dev/null +++ b/frontend/src/agents/workflow/components/FloatingResourceNotice.tsx @@ -0,0 +1,60 @@ +import { TriangleAlert, X } from 'lucide-react'; +import { useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@/components/ui/button'; +import { IconButton } from '@/components/ui/icon-button'; + +type FloatingResourceNoticeProps = { + /** Stopped node resources; a closed notice leaves a chip while any remain. */ + stoppedCount: number; + /** The notice itself. */ + children: ReactNode; +}; + +/** + * The resource notice floating over the workflow canvas. Closing it leaves + * a chip that says how many items aren't running and opens it again. + */ +export default function FloatingResourceNotice({ + stoppedCount, + children, +}: FloatingResourceNoticeProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(true); + + if (!open) { + if (stoppedCount === 0) return null; + return ( +
+ +
+ ); + } + + return ( +
+
+ {children} +
+ setOpen(false)} + label={t('agents.close')} + icon={X} + /> +
+
+
+ ); +} diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 1266378a..0419b20f 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -2424,13 +2424,25 @@ "shareAgain": "Bitte {{person}}, es erneut zu teilen.", "signInAgain": "Bitte {{person}}, sich erneut anzumelden.", "connectAgain": "Bitte {{person}}, das Konto erneut zu verbinden.", - "admin": "Bitte einen Admin, es wieder zu aktivieren." + "admin": "Bitte einen Admin, es wieder zu aktivieren.", + "shareAgainOwner": "Bitte den Eigentümer, es erneut zu teilen.", + "signInAgainOwner": "Bitte den Eigentümer, sich erneut anzumelden.", + "connectAgainOwner": "Bitte den Eigentümer, das Konto erneut zu verbinden." }, "promptFallback": "Bis dahin verwendet der Agent den Standard-Prompt.", "serviceFallback": "Dienst", "remove": "Entfernen", "removeLabel": "{{name}} entfernen", - "reconnectLabel": "{{name}} neu verbinden" + "reconnectLabel": "{{name}} neu verbinden", + "unnamed": { + "tool": "Tool {{id}}", + "source": "Quelle {{id}}", + "prompt": "Prompt {{id}}" + }, + "note": { + "perUserAccount": "Jede Person verwendet ihr eigenes Konto" + }, + "chip": "Läuft nicht: {{count}}" }, "sponsorConfirm": { "title": "Mit deinem Zugriff ausführen?", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 89a73ab6..03e85c5f 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -2442,13 +2442,25 @@ "shareAgain": "Ask {{person}} to share it again.", "signInAgain": "Ask {{person}} to sign in again.", "connectAgain": "Ask {{person}} to connect the account again.", - "admin": "Ask an admin to turn it back on." + "admin": "Ask an admin to turn it back on.", + "shareAgainOwner": "Ask its owner to share it again.", + "signInAgainOwner": "Ask its owner to sign in again.", + "connectAgainOwner": "Ask its owner to connect the account again." }, "promptFallback": "The agent uses the default prompt until then.", "serviceFallback": "the service", "remove": "Remove", "removeLabel": "Remove {{name}}", - "reconnectLabel": "Reconnect {{name}}" + "reconnectLabel": "Reconnect {{name}}", + "unnamed": { + "tool": "Tool {{id}}", + "source": "Source {{id}}", + "prompt": "Prompt {{id}}" + }, + "note": { + "perUserAccount": "Each person uses their own account" + }, + "chip": "Not running: {{count}}" }, "sponsorConfirm": { "title": "Run with your access?", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 4144dfd9..be3648e4 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -2424,13 +2424,25 @@ "shareAgain": "Pide a {{person}} que lo vuelva a compartir.", "signInAgain": "Pide a {{person}} que vuelva a iniciar sesión.", "connectAgain": "Pide a {{person}} que vuelva a conectar la cuenta.", - "admin": "Pide a un administrador que lo vuelva a activar." + "admin": "Pide a un administrador que lo vuelva a activar.", + "shareAgainOwner": "Pide a su propietario que lo vuelva a compartir.", + "signInAgainOwner": "Pide a su propietario que vuelva a iniciar sesión.", + "connectAgainOwner": "Pide a su propietario que vuelva a conectar la cuenta." }, "promptFallback": "Mientras tanto, el agente usa el prompt predeterminado.", "serviceFallback": "el servicio", "remove": "Quitar", "removeLabel": "Quitar {{name}}", - "reconnectLabel": "Reconectar {{name}}" + "reconnectLabel": "Reconectar {{name}}", + "unnamed": { + "tool": "Herramienta {{id}}", + "source": "Fuente {{id}}", + "prompt": "Prompt {{id}}" + }, + "note": { + "perUserAccount": "Cada persona usa su propia cuenta" + }, + "chip": "Sin ejecutar: {{count}}" }, "sponsorConfirm": { "title": "¿Ejecutar con tu acceso?", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 9283b293..2a86c60c 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -2409,13 +2409,25 @@ "shareAgain": "{{person}} に再度共有するよう依頼してください。", "signInAgain": "{{person}} に再度サインインするよう依頼してください。", "connectAgain": "{{person}} にアカウントを再接続するよう依頼してください。", - "admin": "管理者にオンに戻すよう依頼してください。" + "admin": "管理者にオンに戻すよう依頼してください。", + "shareAgainOwner": "所有者に再度共有するよう依頼してください。", + "signInAgainOwner": "所有者に再度サインインするよう依頼してください。", + "connectAgainOwner": "所有者にアカウントを再接続するよう依頼してください。" }, "promptFallback": "それまでエージェントはデフォルトのプロンプトを使用します。", "serviceFallback": "サービス", "remove": "削除", "removeLabel": "{{name}} を削除", - "reconnectLabel": "{{name}} を再接続" + "reconnectLabel": "{{name}} を再接続", + "unnamed": { + "tool": "ツール {{id}}", + "source": "ソース {{id}}", + "prompt": "プロンプト {{id}}" + }, + "note": { + "perUserAccount": "各自が自分のアカウントを使用します" + }, + "chip": "実行されていません: {{count}}" }, "sponsorConfirm": { "title": "あなたのアクセス権で実行しますか?", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 4d904bb2..809fbd5b 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -2516,13 +2516,25 @@ "shareAgain": "Попросите {{person}} снова поделиться им.", "signInAgain": "Попросите {{person}} снова войти в аккаунт.", "connectAgain": "Попросите {{person}} снова подключить аккаунт.", - "admin": "Попросите администратора снова включить его." + "admin": "Попросите администратора снова включить его.", + "shareAgainOwner": "Попросите владельца снова поделиться им.", + "signInAgainOwner": "Попросите владельца снова войти в аккаунт.", + "connectAgainOwner": "Попросите владельца снова подключить аккаунт." }, "promptFallback": "До тех пор агент использует промпт по умолчанию.", "serviceFallback": "сервис", "remove": "Убрать", "removeLabel": "Убрать {{name}}", - "reconnectLabel": "Переподключить {{name}}" + "reconnectLabel": "Переподключить {{name}}", + "unnamed": { + "tool": "Инструмент {{id}}", + "source": "Источник {{id}}", + "prompt": "Промпт {{id}}" + }, + "note": { + "perUserAccount": "Каждый использует свой аккаунт" + }, + "chip": "Не работает: {{count}}" }, "sponsorConfirm": { "title": "Запускать с вашим доступом?", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 1eef4ece..127c059c 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -2409,13 +2409,25 @@ "shareAgain": "請 {{person}} 重新分享。", "signInAgain": "請 {{person}} 重新登入。", "connectAgain": "請 {{person}} 重新連接該帳戶。", - "admin": "請管理員重新開啟。" + "admin": "請管理員重新開啟。", + "shareAgainOwner": "請其擁有者重新分享。", + "signInAgainOwner": "請其擁有者重新登入。", + "connectAgainOwner": "請其擁有者重新連接該帳戶。" }, "promptFallback": "在此之前,代理會使用預設提示詞。", "serviceFallback": "該服務", "remove": "移除", "removeLabel": "移除 {{name}}", - "reconnectLabel": "重新連接 {{name}}" + "reconnectLabel": "重新連接 {{name}}", + "unnamed": { + "tool": "工具 {{id}}", + "source": "來源 {{id}}", + "prompt": "提示詞 {{id}}" + }, + "note": { + "perUserAccount": "每個人使用自己的帳戶" + }, + "chip": "未在執行:{{count}}" }, "sponsorConfirm": { "title": "以你的存取權限執行?", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 2fbbe297..955de7ba 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -2409,13 +2409,25 @@ "shareAgain": "请让 {{person}} 重新共享。", "signInAgain": "请让 {{person}} 重新登录。", "connectAgain": "请让 {{person}} 重新连接该账户。", - "admin": "请让管理员重新开启。" + "admin": "请让管理员重新开启。", + "shareAgainOwner": "请让其所有者重新共享。", + "signInAgainOwner": "请让其所有者重新登录。", + "connectAgainOwner": "请让其所有者重新连接该账户。" }, "promptFallback": "在此之前,智能体使用默认提示词。", "serviceFallback": "该服务", "remove": "移除", "removeLabel": "移除 {{name}}", - "reconnectLabel": "重新连接 {{name}}" + "reconnectLabel": "重新连接 {{name}}", + "unnamed": { + "tool": "工具 {{id}}", + "source": "来源 {{id}}", + "prompt": "提示词 {{id}}" + }, + "note": { + "perUserAccount": "每个人使用自己的账户" + }, + "chip": "未在运行:{{count}}" }, "sponsorConfirm": { "title": "以你的访问权限运行?",