Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ COPY apps/dashboard/ dashboard/
COPY apps/libs/runner-api-client/ libs/runner-api-client/
COPY apps/libs/api-client/ libs/api-client/
COPY apps/libs/analytics-api-client/ libs/analytics-api-client/
COPY apps/libs/box-name/ libs/box-name/

RUN yarn nx build api --configuration=production --nxBail=true --output-style=stream && node --check dist/apps/api/main.js

Expand Down
50 changes: 8 additions & 42 deletions apps/api/src/box/utils/box-name-generator.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,14 @@
// Default box-name generator. Shape: "{adjective}-{animal}", e.g. "cozy-otter".
//
// Names are kept clean in the common case. On the rare collision with the
// per-org @Unique(['organizationId', 'name']) constraint, the caller falls back
// to "{adjective}-{animal}-{boxId}" (see persistWithGeneratedBoxName) — the box
// Default box-name generation. The word lists and the "{adjective}-{animal}"
// generator live in the shared @boxlite-ai/box-name lib (also used by the
// dashboard's create dialog); this module adds the persistence-side collision
// handling. On the rare collision with the per-org
// @Unique(['organizationId', 'name']) constraint, the caller falls back to
// "{adjective}-{animal}-{boxId}" (see persistWithGeneratedBoxName) — the box
// id is unique by construction, so that single fallback can never collide.

const ADJECTIVES = [
'agile', 'amber', 'astute', 'bold', 'bouncy', 'brave', 'breezy', 'brilliant', 'brisk', 'calm',
'cheery', 'clever', 'cosmic', 'cozy', 'crimson', 'crisp', 'dapper', 'daring', 'darting', 'deft',
'dreamy', 'dusky', 'eager', 'fierce', 'fluffy', 'fresh', 'gallant', 'gentle', 'gliding', 'glossy',
'golden', 'hearty', 'hovering', 'hushed', 'indigo', 'intrepid', 'ivory', 'jade', 'jaunty', 'jolly',
'jovial', 'keen', 'lively', 'lofty', 'lucid', 'lunar', 'mellow', 'mighty', 'mild', 'mystic',
'neat', 'nebular', 'nimble', 'opal', 'perky', 'placid', 'plucky', 'polished', 'prismatic', 'quick',
'radiant', 'savvy', 'serene', 'sharp', 'silver', 'sleek', 'snappy', 'snug', 'soaring', 'solar',
'sparkly', 'starry', 'swift', 'tidy', 'tranquil', 'valiant', 'vibrant', 'vivid', 'witty', 'zippy',
] as const
import { generateBoxName } from '@boxlite-ai/box-name'

const ANIMALS = [
'alpaca', 'badger', 'bat', 'bear', 'beaver', 'bee', 'beluga', 'bluebird', 'bluejay', 'bobcat',
'bumblebee', 'bunny', 'butterfly', 'calf', 'canary', 'capybara', 'cardinal', 'cat', 'chameleon', 'chick',
'chickadee', 'chinchilla', 'chipmunk', 'cockatiel', 'corgi', 'cottontail', 'crab', 'cub', 'deer', 'dolphin',
'dove', 'dragonfly', 'duck', 'duckling', 'eagle', 'falcon', 'fawn', 'ferret', 'finch', 'firefly',
'flamingo', 'fox', 'frog', 'gecko', 'goldfinch', 'goose', 'gosling', 'guppy', 'hamster', 'hare',
'hawk', 'hedgehog', 'heron', 'hummingbird', 'joey', 'kangaroo', 'kit', 'kitten', 'koala', 'ladybug',
'lamb', 'lemur', 'llama', 'lovebird', 'lynx', 'magpie', 'manatee', 'marmot', 'meerkat', 'mole',
'mouse', 'narwhal', 'nightingale', 'ocelot', 'octopus', 'otter', 'owl', 'owlet', 'panda', 'parakeet',
'parrot', 'peacock', 'pelican', 'penguin', 'piglet', 'platypus', 'pony', 'porpoise', 'possum', 'puffin',
'pup', 'puppy', 'quokka', 'rabbit', 'raccoon', 'raven', 'robin', 'salamander', 'seahorse', 'seal',
'sheep', 'sloth', 'snail', 'sparrow', 'squirrel', 'starfish', 'stingray', 'swallow', 'swan', 'tadpole',
'tortoise', 'toucan', 'turtle', 'wallaby', 'walrus', 'whale', 'wolf', 'wombat', 'woodpecker', 'wren',
] as const

function pick<T>(arr: readonly T[]): T {
return arr[Math.floor(Math.random() * arr.length)]
}

/**
* Generate a default box name like `cozy-otter`. Hyphen- (not underscore-)
* separated and all-lowercase, so the clean name is a valid DNS label in case
* box names ever become hostnames.
*/
export function generateBoxName(): string {
return `${pick(ADJECTIVES)}-${pick(ANIMALS)}`
}
export { generateBoxName }

// Postgres unique_violation — raised when a generated name hits the per-org
// @Unique(['organizationId', 'name']) constraint.
Expand Down
62 changes: 50 additions & 12 deletions apps/dashboard/src/components/Box/CreateBoxDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import { getBoxRouteId } from '@/lib/box-identity'
import { handleApiError } from '@/lib/error-handling'
import { cn } from '@/lib/utils'
import type { Box } from '@boxlite-ai/api-client'
import { ChevronDown, Plus } from '@/components/ui/icon'
import { ChevronDown, Plus, RefreshCw } from '@/components/ui/icon'
import { useEffect, useRef, useState } from 'react'
import { useGeneratedBoxName } from './useGeneratedBoxName'
import { generatePath, useNavigate } from 'react-router-dom'
import { toast } from 'sonner'

Expand Down Expand Up @@ -234,10 +235,18 @@ export const CreateBoxDialog = ({
if (limit != null && v < limit) setCapped((c) => (c[key] ? { ...c, [key]: false } : c))
}

const { display: generatedName, isGenerating, generate, reset: resetName } = useGeneratedBoxName(setName)

// Hand the user a fresh generated name each time the dialog opens (it lands
// in the editable field via the reveal animation); clear on close.
useEffect(() => {
const wasOpen = wasOpenRef.current
wasOpenRef.current = open
if (!open || wasOpen) return
if (!open) {
resetName()
return
}
if (wasOpen) return

setName('')
setImageRef(defaultImage.ref)
Expand All @@ -247,7 +256,8 @@ export const CreateBoxDialog = ({
setAdvancedOpen(false)
setSubmitting(false)
setCapped({ cpu: false, memory: false, disk: false })
}, [open, defaultImage.ref, initialCpu, initialMemory, initialDisk])
generate()
}, [open, defaultImage.ref, initialCpu, initialMemory, initialDisk, generate, resetName])

useEffect(() => {
if (!open) return
Expand Down Expand Up @@ -295,7 +305,14 @@ export const CreateBoxDialog = ({
navigate(generatePath(RoutePath.BOX_DETAILS, { boxId }))
}
} catch (error) {
handleApiError(error, 'Failed to create box')
// Name already taken in this org (client-side generation can collide) —
// deal a fresh name into the field so the next click succeeds.
if ((error as { response?: { status?: number } })?.response?.status === 409) {
toast.error('That name is already taken — here is a fresh one.')
generate()
} else {
handleApiError(error, 'Failed to create box')
}
} finally {
setSubmitting(false)
}
Expand Down Expand Up @@ -331,13 +348,34 @@ export const CreateBoxDialog = ({
{/* name */}
<div className="flex flex-col gap-[9px]">
<div className="font-mono text-[10px] uppercase tracking-[1.2px] text-muted-foreground">Name</div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="my-new-box"
aria-invalid={!nameValid}
className="w-full border border-border bg-card px-[13px] py-[11px] font-mono text-[13px] text-foreground outline-none focus:border-brand aria-[invalid=true]:border-destructive"
/>
<div
className={cn(
'flex items-stretch border border-border bg-card focus-within:border-brand',
!nameValid && 'border-destructive',
)}
>
<input
value={isGenerating ? generatedName : name}
onChange={(e) => setName(e.target.value)}
readOnly={isGenerating}
placeholder="my-new-box"
aria-invalid={!nameValid}
className={cn(
'min-w-0 flex-1 bg-transparent px-[13px] py-[11px] font-mono text-[13px] text-foreground outline-none',
isGenerating && 'text-brand',
)}
/>
<button
type="button"
onClick={() => generate()}
disabled={isGenerating}
title="Suggest another name"
aria-label="Suggest another name"
className="flex w-11 flex-none items-center justify-center border-l border-border text-muted-foreground transition-colors enabled:hover:text-foreground disabled:opacity-40 sm:w-10"
>
<RefreshCw className={cn('size-3.5', isGenerating && 'animate-spin')} />
</button>
</div>
</div>

{/* image */}
Expand Down Expand Up @@ -440,7 +478,7 @@ export const CreateBoxDialog = ({
<button
type="button"
onClick={handleCreate}
disabled={submitting || !selectedOrganization?.id || !nameValid}
disabled={submitting || isGenerating || !selectedOrganization?.id || !nameValid}
className="bg-primary px-5 py-[11px] text-[13px] font-semibold text-primary-foreground transition-opacity hover:opacity-85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 disabled:cursor-not-allowed disabled:opacity-50 sm:py-[10px]"
>
{submitting ? 'Creating…' : 'Create Box'}
Expand Down
57 changes: 57 additions & 0 deletions apps/dashboard/src/components/Box/useGeneratedBoxName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright 2025 Daytona Platforms Inc.
* Modified by BoxLite AI, 2025-2026
* SPDX-License-Identifier: AGPL-3.0
*/

import { useCallback, useEffect, useRef, useState } from 'react'
import { generateBoxName } from '@boxlite-ai/box-name'
import { scrambleFrame } from '@/lib/scramble'

const REVEAL_MS = 460 // duration of the left-to-right lock-in
const FRAME_MS = 42 // animation tick

// Generates a box name locally (shared @boxlite-ai/box-name lib — the same
// generator the server uses for unnamed creates) and reveals it with a
// "decode" animation, so the name feels handed to the user rather than typed.
// `onName` receives the final name; the dialog stores it as the editable field
// value, not a placeholder.
export function useGeneratedBoxName(onName: (name: string) => void) {
const [display, setDisplay] = useState('')
const [isGenerating, setIsGenerating] = useState(false)
const timer = useRef<ReturnType<typeof setInterval> | undefined>(undefined)
const onNameRef = useRef(onName)
onNameRef.current = onName

const clearTimer = () => {
if (timer.current) clearInterval(timer.current)
timer.current = undefined
}

const generate = useCallback(() => {
clearTimer()
const name = generateBoxName()
setIsGenerating(true)
const start = Date.now()
setDisplay(scrambleFrame(name, 0))
timer.current = setInterval(() => {
const progress = Math.min(1, (Date.now() - start) / REVEAL_MS)
setDisplay(scrambleFrame(name, progress))
if (progress >= 1) {
clearTimer()
setIsGenerating(false)
onNameRef.current(name)
}
}, FRAME_MS)
}, [])

const reset = useCallback(() => {
clearTimer()
setDisplay('')
setIsGenerating(false)
}, [])

useEffect(() => clearTimer, [])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reset generation when StrictMode clears the timer

When this hook mounts while the dialog is already open, React.StrictMode (the dashboard root is wrapped at apps/dashboard/src/main.tsx:34) runs the effect cleanup immediately after the first setup. This cleanup clears the only interval created by generate() without setting isGenerating back to false; the replayed open effect then hits the wasOpenRef guard in CreateBoxDialog.tsx:249 and does not start a replacement timer, so the field stays read-only and the Create button remains disabled indefinitely for mounted-open dialogs/tests.

Useful? React with 👍 / 👎.


return { display, isGenerating, generate, reset }
}
21 changes: 21 additions & 0 deletions apps/dashboard/src/lib/scramble.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'

import { scrambleFrame } from './scramble'

describe('scrambleFrame', () => {
it('reveals the whole target once progress reaches 1', () => {
expect(scrambleFrame('cozy-otter', 1)).toBe('cozy-otter')
expect(scrambleFrame('cozy-otter', 5)).toBe('cozy-otter')
})

it('locks a left-to-right prefix proportional to progress', () => {
// 10-char target, 0.5 progress -> first 5 chars revealed.
expect(scrambleFrame('cozy-otter', 0.5).slice(0, 5)).toBe('cozy-')
})

it('keeps the target length and box-name charset while scrambling', () => {
const frame = scrambleFrame('cozy-otter', 0)
expect(frame).toHaveLength('cozy-otter'.length)
expect(frame).toMatch(/^[a-z-]+$/)
})
})
20 changes: 20 additions & 0 deletions apps/dashboard/src/lib/scramble.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* Copyright 2025 Daytona Platforms Inc.
* Modified by BoxLite AI, 2025-2026
* SPDX-License-Identifier: AGPL-3.0
*/

// One frame of a left-to-right "decode" reveal, used by the create-box name
// field while a generated name locks in. The first `progress` fraction of
// characters are locked to `target`; the rest are random glyphs from the box-name
// alphabet. progress <= 0 → fully scrambled (same length); progress >= 1 → target.
const GLYPHS = 'abcdefghijklmnopqrstuvwxyz-'

export function scrambleFrame(target: string, progress: number): string {
const revealed = Math.max(0, Math.min(target.length, Math.floor(progress * target.length)))
let out = ''
for (let i = 0; i < target.length; i++) {
out += i < revealed ? target[i] : GLYPHS[Math.floor(Math.random() * GLYPHS.length)]
}
return out
}
3 changes: 2 additions & 1 deletion apps/dashboard/tsconfig.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"@/*": ["src/*"],
"@boxlite-ai/*": ["../libs/*"],
"@boxlite-ai/api-client": ["../dist/libs/api-client/src/index.d.ts"],
"@boxlite-ai/analytics-api-client": ["../dist/libs/analytics-api-client/src/index.d.ts"]
"@boxlite-ai/analytics-api-client": ["../dist/libs/analytics-api-client/src/index.d.ts"],
"@boxlite-ai/box-name": ["../libs/box-name/src/index.ts"]
}
},
"exclude": [
Expand Down
8 changes: 8 additions & 0 deletions apps/libs/box-name/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "@boxlite-ai/box-name",
"version": "0.0.0-dev",
"description": "Shared default box-name generator (word lists + generateBoxName)",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts"
}
7 changes: 7 additions & 0 deletions apps/libs/box-name/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "box-name",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/box-name/src",
"projectType": "library",
"targets": {}
}
49 changes: 49 additions & 0 deletions apps/libs/box-name/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright 2025 Daytona Platforms Inc.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

wrong license

* Modified by BoxLite AI, 2025-2026
* SPDX-License-Identifier: AGPL-3.0
*/

// Single source of truth for default box names, shared by the api (assigns a
// name when a create request has none) and the dashboard (pre-fills the same
// style of name in the create dialog). Keep it dependency-free: it is consumed
// as source on both sides.

export const BOX_NAME_ADJECTIVES = [
'agile', 'amber', 'astute', 'bold', 'bouncy', 'brave', 'breezy', 'brilliant', 'brisk', 'calm',
'cheery', 'clever', 'cosmic', 'cozy', 'crimson', 'crisp', 'dapper', 'daring', 'darting', 'deft',
'dreamy', 'dusky', 'eager', 'fierce', 'fluffy', 'fresh', 'gallant', 'gentle', 'gliding', 'glossy',
'golden', 'hearty', 'hovering', 'hushed', 'indigo', 'intrepid', 'ivory', 'jade', 'jaunty', 'jolly',
'jovial', 'keen', 'lively', 'lofty', 'lucid', 'lunar', 'mellow', 'mighty', 'mild', 'mystic',
'neat', 'nebular', 'nimble', 'opal', 'perky', 'placid', 'plucky', 'polished', 'prismatic', 'quick',
'radiant', 'savvy', 'serene', 'sharp', 'silver', 'sleek', 'snappy', 'snug', 'soaring', 'solar',
'sparkly', 'starry', 'swift', 'tidy', 'tranquil', 'valiant', 'vibrant', 'vivid', 'witty', 'zippy',
] as const

export const BOX_NAME_ANIMALS = [
'alpaca', 'badger', 'bat', 'bear', 'beaver', 'bee', 'beluga', 'bluebird', 'bluejay', 'bobcat',
'bumblebee', 'bunny', 'butterfly', 'calf', 'canary', 'capybara', 'cardinal', 'cat', 'chameleon', 'chick',
'chickadee', 'chinchilla', 'chipmunk', 'cockatiel', 'corgi', 'cottontail', 'crab', 'cub', 'deer', 'dolphin',
'dove', 'dragonfly', 'duck', 'duckling', 'eagle', 'falcon', 'fawn', 'ferret', 'finch', 'firefly',
'flamingo', 'fox', 'frog', 'gecko', 'goldfinch', 'goose', 'gosling', 'guppy', 'hamster', 'hare',
'hawk', 'hedgehog', 'heron', 'hummingbird', 'joey', 'kangaroo', 'kit', 'kitten', 'koala', 'ladybug',
'lamb', 'lemur', 'llama', 'lovebird', 'lynx', 'magpie', 'manatee', 'marmot', 'meerkat', 'mole',
'mouse', 'narwhal', 'nightingale', 'ocelot', 'octopus', 'otter', 'owl', 'owlet', 'panda', 'parakeet',
'parrot', 'peacock', 'pelican', 'penguin', 'piglet', 'platypus', 'pony', 'porpoise', 'possum', 'puffin',
'pup', 'puppy', 'quokka', 'rabbit', 'raccoon', 'raven', 'robin', 'salamander', 'seahorse', 'seal',
'sheep', 'sloth', 'snail', 'sparrow', 'squirrel', 'starfish', 'stingray', 'swallow', 'swan', 'tadpole',
'tortoise', 'toucan', 'turtle', 'wallaby', 'walrus', 'whale', 'wolf', 'wombat', 'woodpecker', 'wren',
] as const

function pick<T>(arr: readonly T[]): T {
return arr[Math.floor(Math.random() * arr.length)]
}

/**
* Generate a default box name like `cozy-otter`. Hyphen- (not underscore-)
* separated and all-lowercase, so the clean name is a valid DNS label in case
* box names ever become hostnames.
*/
export function generateBoxName(): string {
return `${pick(BOX_NAME_ADJECTIVES)}-${pick(BOX_NAME_ANIMALS)}`
}
1 change: 1 addition & 0 deletions apps/tsconfig.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"baseUrl": ".",
"paths": {
"@boxlite-ai/api-client": ["libs/api-client/src/index.ts"],
"@boxlite-ai/box-name": ["libs/box-name/src/index.ts"],
"@boxlite-ai/runner-api-client": ["libs/runner-api-client/src/index.ts"],
"@boxlite-ai/analytics-api-client": ["libs/analytics-api-client/src/index.ts"]
}
Expand Down
Loading