import * as React from 'react'; import type { CommandRegistry } from '@lumino/commands'; import type { ReadonlyPartialJSONObject } from '@lumino/coreutils'; import { Poll } from '@lumino/polling'; import type { IDocumentWidget } from '@jupyterlab/docregistry'; import type { TranslationBundle } from '@jupyterlab/translation'; import { LabIcon, ReactWidget, consoleIcon, notebookIcon, refreshIcon, terminalIcon } from '@jupyterlab/ui-components'; import type { Widget } from '@lumino/widgets'; import type { IAgentSessions } from '../agentSessions'; import { expandStatusFiles, status } from '../git/api'; import { CommandIDs as GitCommandIDs, CommandArguments as GitCommandArguments } from '../git/commands'; import type { FileChangeStatus, IFileChange, IGitStatusResult } from '../git/tokens'; import type { IAgent } from './agents'; import { launchInTerminal } from './commands'; import type { IEditor } from './editors'; import { agentCommandId } from './tokens'; /** * Public construction options for the launcher dashboard widget. The * plugin supplies an `onAgentLaunch` callback that performs the same * shell placement the stock JupyterLab launcher does (swap the launcher * tab for the freshly created terminal). Keeping it as an opaque * callback means the widget itself does not import `app.shell`. */ interface ILauncherDashboardOptions { commands: CommandRegistry; /** * The agent list to render. Already filtered by availability + sorted by * rank; the widget renders them as-is. */ agents: IAgent[]; /** * The terminal editor to offer in the "Open" section (Neovim preferred over * Vim), or `null` when neither is installed. Resolved by the plugin from the * same availability probe that filters the agents. */ editor: IEditor | null; /** * Shared launch-tag registry. When the editor tile launches, it records the * session here so the terminals panel badges it with the editor's logo right * away, the same as agent launches do. `null` when the provider plugin is * unavailable. */ agentSessions: IAgentSessions | null; /** * Called after an agent command has returned its top-level widget. The * plugin uses this to swap the new terminal into the launcher's tab. */ onAgentLaunch: (item: Widget) => void; /** * The git server-relative repo path used by the changes section. Empty * string means "use the JupyterLab server's root and let git resolve the * enclosing repo" — same convention as the git panel. */ repoPath: string; /** * The cwd forwarded to `terminal:create-new` when an agent is launched. * Empty string lets the terminal extension default to the server root. */ cwd: string; /** * The translation bundle used to localize the dashboard's user-facing * strings. */ trans: TranslationBundle; } /** * Lumino host for the React-based launcher dashboard. Mirrors the * `ReactWidget` pattern used by the git panel so the rest of the plugin * (layout placement, signals, lifecycle) stays consistent. */ export class LauncherDashboard extends ReactWidget { constructor(options: ILauncherDashboardOptions) { super(); this.addClass('jp-xtralab-Launcher'); this._options = options; } protected render(): React.ReactElement { return ; } private _options: ILauncherDashboardOptions; } interface IGitState { loading: boolean; result: IGitStatusResult | null; error: string | null; } /** * Refresh interval for the changes list. A bit slower than the git panel * (which sits in the sidebar and is always visible) — the launcher tab is * only opened intentionally, and the grid does not need to be live. */ const CHANGES_POLL_INTERVAL_MS = 8000; /** * Upper bound on the exponential backoff when the status request fails * repeatedly. Matches the rest of the plugin's polls so behavior is * consistent across the dashboard, the git panel, and the file tree. */ const CHANGES_POLL_MAX_MS = 300_000; function LauncherDashboardComponent( props: ILauncherDashboardOptions ): React.ReactElement { const { commands, agents, editor, agentSessions, onAgentLaunch, repoPath, cwd, trans } = props; const [prompt, setPrompt] = React.useState(''); const [git, setGit] = React.useState({ loading: true, result: null, error: null }); const refresh = React.useCallback(async () => { try { const result = await status(repoPath); setGit({ loading: false, result, error: null }); } catch (error) { setGit({ loading: false, result: null, error: error instanceof Error ? error.message : String(error) }); } }, [repoPath]); // Driven by a Lumino `Poll` so the dashboard stops hitting the git // endpoint while the JupyterLab tab is hidden, and so repeated // failures back off exponentially instead of pegging the 8s cadence. React.useEffect(() => { const poll = new Poll({ name: '@xtralab/launcher:gitChanges', factory: () => refresh(), frequency: { interval: CHANGES_POLL_INTERVAL_MS, backoff: true, max: CHANGES_POLL_MAX_MS }, standby: 'when-hidden' }); return () => { poll.dispose(); }; }, [refresh]); const launch = React.useCallback( async (agent: IAgent) => { const args: Record = { cwd }; const trimmed = prompt.trim(); if (trimmed.length > 0 && agent.promptArgs !== undefined) { args.prompt = trimmed; } const result = (await commands.execute( agentCommandId(agent.id), args )) as Widget | undefined; if (result) { onAgentLaunch(result); } }, [commands, cwd, prompt, onAgentLaunch] ); const launchTerminal = React.useCallback(async () => { const result = (await commands.execute('terminal:create-new', { cwd })) as Widget | undefined; if (result) { onAgentLaunch(result); } }, [commands, cwd, onAgentLaunch]); const launchNotebook = React.useCallback(async () => { // Open a fresh notebook with no kernel attached — equivalent to clicking // "No Kernel" in the Select Kernel dialog. `notebook:create-new` always // routes through that dialog when no kernelName is given, so drive the // underlying docmanager steps directly and pass `shouldStart: false` to // suppress kernel selection. The user picks a kernel from the notebook // toolbar when they're ready. const file = (await commands.execute('docmanager:new-untitled', { path: cwd, type: 'notebook' })) as { path: string } | undefined; if (!file) { return; } const result = (await commands.execute('docmanager:open', { path: file.path, factory: 'Notebook', kernelPreference: { shouldStart: false, canStart: true } })) as IDocumentWidget | undefined; if (result) { // Match `notebook:create-new`'s post-open marker so the first manual // save offers a rename instead of overwriting the auto-generated name. result.isUntitled = true; onAgentLaunch(result); } }, [commands, cwd, onAgentLaunch]); const launchConsole = React.useCallback(async () => { const result = (await commands.execute('console:create', { cwd })) as Widget | undefined; if (result) { onAgentLaunch(result); } }, [commands, cwd, onAgentLaunch]); const launchEditor = React.useCallback(async () => { if (!editor) { return; } // An editor is "open a terminal and type its command" — the agent launch // minus the prompt — so it shares `launchInTerminal` rather than the native // `terminal:create-new` call the tiles above use, and tags the session the // same way so the terminals panel badges it with the editor's logo. const result = await launchInTerminal(commands, { cwd, invocation: editor.command, label: editor.label, onSession: name => agentSessions?.set(name, editor.command) }); onAgentLaunch(result); }, [commands, cwd, editor, agentSessions, onAgentLaunch]); const openDiff = React.useCallback( (change: IFileChange) => { const args: GitCommandArguments.IOpenDiff = { repoPath, change }; void commands.execute( GitCommandIDs.openDiff, args as unknown as ReadonlyPartialJSONObject ); }, [commands, repoPath] ); return (
void refresh()} trans={trans} />
); } function ChangesSection(props: { git: IGitState; onOpen: (change: IFileChange) => void; onRefresh: () => void; trans: TranslationBundle; }): React.ReactElement { const { git, onOpen, onRefresh, trans } = props; const files = git.result ? expandStatusFiles(git.result.files) : []; // Native `
` rather than a hand-rolled toggle: we get the // disclosure triangle, keyboard handling, and aria-expanded semantics // for free, and the user's open/closed choice is tab-local state that // we don't need to persist. return (
{trans.__('Changes')} {files.length > 0 && ( {files.length} )}
{git.loading && files.length === 0 && (

{trans.__('Loading…')}

)} {!git.loading && git.error !== null && (

{git.error}

)} {!git.loading && git.error === null && files.length === 0 && (

{trans.__('No changes.')}

)} {files.length > 0 && (
    {files.map((change, index) => (
  • ))}
)}
); } function statusBadge(status: FileChangeStatus): string { switch (status) { case 'modified': return 'M'; case 'added': return 'A'; case 'deleted': return 'D'; case 'renamed': return 'R'; case 'untracked': return 'U'; case 'unmerged': return '!'; case 'typechange': return 'T'; default: return '?'; } } function AgentSection(props: { agents: IAgent[]; prompt: string; onPromptChange: (value: string) => void; onLaunch: (agent: IAgent) => void; trans: TranslationBundle; }): React.ReactElement { const { agents, prompt, onPromptChange, onLaunch, trans } = props; const trimmed = prompt.trim(); const promptActive = trimmed.length > 0; // The first prompt-capable agent is what Cmd/Ctrl+Enter fires; the hint // text below the textarea names it so the keyboard shortcut target // isn't a mystery. const primaryAgent = agents.find(agent => agent.promptArgs !== undefined); const hintId = 'jp-xtralab-Launcher-agent-hint'; return (

{trans.__('Start an agent')}