Skip to content
94 changes: 94 additions & 0 deletions app/(main)/instance/_lib/instance.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Instance } from '../../instances/_lib/instances.d';

const OPERATION_POLL_MAX_ATTEMPTS = 20;
const OPERATION_POLL_DELAY_MS = 500;

export type InstanceAction = 'start' | 'stop' | 'restart' | 'freeze';

export async function performInstanceAction({
Expand Down Expand Up @@ -34,3 +37,94 @@ export async function performInstanceAction({
);
}
}

export async function updateInstance({
name,
description,
project,
}: {
name: string;
description: string;
project?: string;
}) {
const projectSuffix = project
? `?project=${encodeURIComponent(project)}`
: '';
const res = await fetch(
`/1.0/instances/${encodeURIComponent(name)}${projectSuffix}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
description,
}),
},
);
if (!res.ok) {
const payload = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(payload?.error || `Unable to update instance ${name}`);
}
}

export async function renameInstance({
name,
newName,
project,
}: {
name: string;
newName: string;
project?: string;
}) {
const projectSuffix = project
? `?project=${encodeURIComponent(project)}`
: '';
const res = await fetch(
`/1.0/instances/${encodeURIComponent(name)}${projectSuffix}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: newName,
}),
},
);
if (!res.ok) {
const payload = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(payload?.error || `Unable to rename instance ${name}`);
}

const data = await res.json();
if (data.type === 'async' && data.operation) {
await waitForOperation(data.operation);
}
}

async function waitForOperation(operationUrl: string) {
// Poll the operation URL until it's done
for (let i = 0; i < OPERATION_POLL_MAX_ATTEMPTS; i++) {
const res = await fetch(operationUrl);
if (!res.ok) {
// If we can't check the operation, assume it failed or network issue
throw new Error('Failed to check operation status');
}
const data = await res.json();
// Operation status: Running, Pending, Success, Failure, Cancelled
if (data.metadata?.status === 'Success') {
return;
}
if (data.metadata?.status === 'Failure') {
throw new Error(data.metadata.err || 'Operation failed');
}
if (data.metadata?.status === 'Cancelled') {
throw new Error('Operation cancelled');
}

// Wait before next poll
await new Promise((resolve) => setTimeout(resolve, OPERATION_POLL_DELAY_MS));
}
throw new Error('Operation timed out');
}
203 changes: 196 additions & 7 deletions app/(main)/instance/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,30 @@ import {
SquareIcon,
RotateCcwIcon,
SnowflakeIcon,
PencilIcon,
CheckIcon,
XIcon,
} from 'lucide-react';
import { Tabs, TabsList, TabsTrigger } from 'ui-web/components/tabs';
import { OSLogo } from '@/app/_components/OSLogo';
import { getBaseImage } from './_lib/utils';
import { performInstanceAction, type InstanceAction } from './_lib/instance';
import {
performInstanceAction,
type InstanceAction,
updateInstance,
renameInstance,
} from './_lib/instance';
import { Input } from 'ui-web/components/input';
import { toast } from 'sonner';

const INPUT_WIDTH_BUFFER_CH = 4;
const MIN_VISIBLE_CHARACTERS = 1;

function InstanceLayoutContent({ children }: { children: React.ReactNode }) {
const { name, instance, isLoading, isError, mutate } = useInstanceContext();

const [isRenaming, setIsRenaming] = React.useState(false);

if (!name) {
return (
<Alert variant="destructive">
Expand All @@ -40,7 +55,7 @@ function InstanceLayoutContent({ children }: { children: React.ReactNode }) {
);
}

if (isError || !instance) {
if ((isError || !instance) && !isRenaming) {
return (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
Expand All @@ -53,7 +68,12 @@ function InstanceLayoutContent({ children }: { children: React.ReactNode }) {

return (
<div className="flex flex-col gap-6 p-6">
<InstanceHeader instance={instance} onMutate={mutate} />
<InstanceHeader
instance={instance || ({ name } as Instance)}
onMutate={mutate}
onRenameStart={() => setIsRenaming(true)}
onRenameEnd={() => setIsRenaming(false)}
/>

<div className="flex flex-col gap-4">
<InstanceTabs />
Expand Down Expand Up @@ -90,13 +110,27 @@ const instanceActionDetails: Record<
function InstanceHeader({
instance,
onMutate,
onRenameStart,
onRenameEnd,
}: {
instance: Instance;
onMutate: () => Promise<any>;
onRenameStart: () => void;
onRenameEnd: () => void;
}) {
const [actionInFlight, setActionInFlight] =
React.useState<InstanceAction | null>(null);
const [actionError, setActionError] = React.useState<string | null>(null);
const router = useRouter();

// Editing state
const [isEditingName, setIsEditingName] = React.useState(false);
const [newName, setNewName] = React.useState(instance.name);
const [isEditingDescription, setIsEditingDescription] = React.useState(false);
const [newDescription, setNewDescription] = React.useState(
instance.description || '',
);
const [isSaving, setIsSaving] = React.useState(false);

const handleAction = async (action: InstanceAction) => {
try {
Expand All @@ -113,6 +147,56 @@ function InstanceHeader({
}
};

const handleSaveName = async () => {
if (newName === instance.name) {
setIsEditingName(false);
return;
}
try {
setIsSaving(true);
onRenameStart();
await renameInstance({
name: instance.name,
newName,
project: instance.project,
});
setIsEditingName(false);
// Redirect to new URL
router.push(`/instance?name=${encodeURIComponent(newName)}`);
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After renaming, onRenameEnd() is never called in the success path. This leaves isRenaming permanently true, preventing the error message from displaying if the user navigates back. Call onRenameEnd() after the router.push or in a useEffect cleanup.

Suggested change
router.push(`/instance?name=${encodeURIComponent(newName)}`);
router.push(`/instance?name=${encodeURIComponent(newName)}`);
onRenameEnd();

Copilot uses AI. Check for mistakes.
} catch (err) {
toast.error(
err instanceof Error ? err.message : 'Failed to rename instance',
);
onRenameEnd();
} finally {
setIsSaving(false);
}
};

const handleSaveDescription = async () => {
if (newDescription === instance.description) {
setIsEditingDescription(false);
return;
}
try {
setIsSaving(true);
await updateInstance({
name: instance.name,
description: newDescription,
project: instance.project,
});
await onMutate();
toast.success('Description updated successfully');
setIsEditingDescription(false);
} catch (err) {
toast.error(
err instanceof Error ? err.message : 'Failed to update description',
);
} finally {
setIsSaving(false);
}
};

const status = instance.status?.toLowerCase();
const isRunning = status === 'running';
const isStopped = status === 'stopped';
Expand Down Expand Up @@ -152,10 +236,115 @@ function InstanceHeader({
)}
</div>

<div className="flex-1">
<h1 className="text-2xl font-bold">{instance.name}</h1>
{instance.description && (
<p className="text-muted-foreground">{instance.description}</p>
<div className="flex-1 space-y-1">
{isEditingName ? (
<div className="flex items-center gap-2">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
className="h-8 w-auto font-bold min-w-[4ch]"
style={{
width: `${Math.max(
newName.length,
MIN_VISIBLE_CHARACTERS,
) + INPUT_WIDTH_BUFFER_CH}ch`,
}}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveName();
if (e.key === 'Escape') {
setIsEditingName(false);
setNewName(instance.name);
}
}}
/>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-green-500 hover:text-green-600"
onClick={handleSaveName}
disabled={isSaving}
>
<CheckIcon className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-red-500 hover:text-red-600"
onClick={() => {
setIsEditingName(false);
setNewName(instance.name);
}}
disabled={isSaving}
>
<XIcon className="size-4" />
</Button>
</div>
) : (
<div
className="group flex items-center gap-2 cursor-pointer"
onClick={() => setIsEditingName(true)}
>
<h1 className="text-2xl font-bold">{instance.name}</h1>
<PencilIcon className="size-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</div>
)}

{isEditingDescription ? (
<div className="flex items-center gap-2">
<Input
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
className="h-8 w-auto min-w-[10ch]"
style={{
width: `${Math.max(
newDescription.length,
MIN_VISIBLE_CHARACTERS,
) + INPUT_WIDTH_BUFFER_CH}ch`,
}}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveDescription();
if (e.key === 'Escape') {
setIsEditingDescription(false);
setNewDescription(instance.description || '');
}
}}
/>
<div className="flex gap-1">
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-green-500 hover:text-green-600"
onClick={handleSaveDescription}
disabled={isSaving}
>
<CheckIcon className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-red-500 hover:text-red-600"
onClick={() => {
setIsEditingDescription(false);
setNewDescription(instance.description || '');
}}
disabled={isSaving}
>
<XIcon className="size-4" />
</Button>
</div>
</div>
) : (
<div
className="group flex items-center gap-2 cursor-pointer min-h-[24px]"
onClick={() => setIsEditingDescription(true)}
>
<p className="text-muted-foreground">
{instance.description || 'Add a description...'}
</p>
<PencilIcon className="size-3 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</div>
)}
</div>

Expand Down
2 changes: 1 addition & 1 deletion deps/ui-web