+ )
+}
+
+export function ExtensionsDialog(): React.JSX.Element | null {
+ const open = useExtensionsStore((s) => s.open)
+ const close = useExtensionsStore((s) => s.closeDialog)
+ const tab = useExtensionsStore((s) => s.tab)
+ const setTab = useExtensionsStore((s) => s.setTab)
+ const restartRequired = useExtensionsStore((s) => s.restartRequired)
+ const install = useExtensionsStore((s) => s.install)
+
+ // Subscribed here, not in the canvas: the dialog is reachable with no project open, and progress
+ // must still stream.
+ useEffect(() => {
+ if (!open) return
+ return studio().events.onExtensionInstallProgress((e) => {
+ useExtensionsStore.getState().onProgress(e)
+ })
+ }, [open])
+
+ // An install started from the Available tab shows its progress there, not on a hidden tab.
+ useEffect(() => {
+ if (install?.report || install?.done) setTab('url')
+ }, [install?.report, install?.done, setTab])
+
+ if (!open) return null
+
+ return (
+
+ {restartRequired && (
+
+ Restart Inline Studio to finish applying your changes. Python cannot reload code that is
+ already running, so an updated or rolled-back extension only takes effect on restart.
+
+ The repository is downloaded at the exact commit behind that tag and reviewed before
+ anything runs. Its dependencies install into the extension's own folder, so they can never
+ replace Inline's PyTorch or diffusers.
+
+
+ )
+}
diff --git a/src/renderer/views/Extensions/InstallSteps.test.ts b/src/renderer/views/Extensions/InstallSteps.test.ts
new file mode 100644
index 0000000..f718384
--- /dev/null
+++ b/src/renderer/views/Extensions/InstallSteps.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest'
+import type { InstallPhase } from '@shared/extensions'
+import { stateOf } from './installSteps'
+
+// Index into the STEPS array: 0 fetch, 1 validate, 2 scan, 3 preflight, 4 resolve, 5 lock,
+// 6 register, 7 activate.
+const FETCH = 0
+const SCAN = 2
+const RESOLVE = 4
+const ACTIVATE = 7
+
+describe('install stepper', () => {
+ it('marks earlier phases done and the current one active', () => {
+ const seen: InstallPhase[] = ['fetch', 'validate', 'scan']
+ expect(stateOf(FETCH, 'scan', seen, false)).toBe('done')
+ expect(stateOf(SCAN, 'scan', seen, false)).toBe('active')
+ expect(stateOf(RESOLVE, 'scan', seen, false)).toBe('pending')
+ })
+
+ it('marks every step done once the run finishes', () => {
+ // A fast local install can emit phases quicker than React renders, so completion must not
+ // depend on having observed each one.
+ expect(stateOf(FETCH, 'done', [], false)).toBe('done')
+ expect(stateOf(ACTIVATE, 'done', [], false)).toBe('done')
+ })
+
+ it('marks the failing phase failed and leaves later ones pending', () => {
+ const seen: InstallPhase[] = ['fetch', 'validate', 'scan']
+ expect(stateOf(SCAN, 'scan', seen, true)).toBe('failed')
+ expect(stateOf(FETCH, 'scan', seen, true)).toBe('done')
+ expect(stateOf(ACTIVATE, 'scan', seen, true)).toBe('pending')
+ })
+
+ it('treats an unstarted install as all pending', () => {
+ expect(stateOf(FETCH, 'idle', [], false)).toBe('pending')
+ expect(stateOf(ACTIVATE, 'idle', [], false)).toBe('pending')
+ })
+})
diff --git a/src/renderer/views/Extensions/InstallSteps.tsx b/src/renderer/views/Extensions/InstallSteps.tsx
new file mode 100644
index 0000000..7e5c1f9
--- /dev/null
+++ b/src/renderer/views/Extensions/InstallSteps.tsx
@@ -0,0 +1,89 @@
+/**
+ * The install stepper: every phase the server reports, so the user can see where a slow install
+ * is (dependency resolution can take a while) and that it finished.
+ */
+import type { InstallPhase } from '@shared/extensions'
+import { STEPS, stateOf, type StepState } from './installSteps'
+
+function Marker({ state }: { state: StepState }): React.JSX.Element {
+ if (state === 'done') {
+ return (
+
+
+
+ )
+ }
+ if (state === 'failed') {
+ return (
+
+
+
+ )
+ }
+ if (state === 'active') {
+ return (
+
+
+
+ )
+ }
+ return (
+
+
+
+ )
+}
+
+export function InstallSteps({
+ phase,
+ seen,
+ status,
+ failed = false,
+}: {
+ phase: InstallPhase | 'idle'
+ seen: InstallPhase[]
+ status: string
+ failed?: boolean
+}): React.JSX.Element {
+ return (
+
+ {STEPS.map((step, index) => {
+ const state = stateOf(index, phase, seen, failed)
+ const tone =
+ state === 'done'
+ ? 'text-zinc-400'
+ : state === 'active'
+ ? 'text-zinc-100'
+ : state === 'failed'
+ ? 'text-red-300'
+ : 'text-zinc-600'
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/src/renderer/views/Extensions/SecurityReport.tsx b/src/renderer/views/Extensions/SecurityReport.tsx
new file mode 100644
index 0000000..9e1219f
--- /dev/null
+++ b/src/renderer/views/Extensions/SecurityReport.tsx
@@ -0,0 +1,123 @@
+/**
+ * The consent gate. Findings grouped by severity in plain language, with a typed confirmation for
+ * anything needing approval.
+ *
+ * The copy is deliberate: an extension runs in the same process as Inline Studio, so a clean scan
+ * is not a safety guarantee and must never be presented as one.
+ */
+import { useState } from 'react'
+import type { ScanReport, ScanFinding, FindingSeverity } from '@shared/extensions'
+
+const TONE: Record = {
+ critical: 'border-red-500/40 bg-red-500/10 text-red-300',
+ high: 'border-amber-500/40 bg-amber-500/10 text-amber-300',
+ medium: 'border-amber-500/30 bg-amber-500/5 text-amber-200/90',
+ low: 'border-border bg-surface/60 text-zinc-400',
+}
+
+const HEADING: Record = {
+ critical: 'Blocked',
+ high: 'Needs your approval',
+ medium: 'Needs your approval',
+ low: 'For your information',
+}
+
+function FindingRow({ finding }: { finding: ScanFinding }): React.JSX.Element {
+ return (
+
+ {report.blocked ? `${name} cannot be installed` : `Review ${name} before installing`}
+
+
+ Extensions run with the same access as Inline Studio itself. They can read and write your
+ files and reach the network. This review flags what the code does; it cannot prevent it.
+ Only install extensions from authors you trust.
+