Compare commits

...

7 Commits

Author SHA1 Message Date
c3620b29aa wip 2025-06-20 16:39:18 -04:00
eaa52a9b73 make check 2025-06-19 18:06:52 -04:00
b0ff9d636c fixup! Add PromptCard 2025-06-19 17:18:49 -04:00
fa8d07d02c Add PromptCard 2025-06-19 17:18:39 -04:00
4a46a246fc fixup! Search and results work for all HomeItems 2025-06-19 15:46:52 -04:00
503359093e Search and results work for all HomeItems 2025-06-19 15:46:52 -04:00
3f16204517 Add skeleton MlEphantManager actor 2025-06-19 15:46:38 -04:00
12 changed files with 852 additions and 120 deletions

View File

@ -1343,7 +1343,7 @@ test(
await test.step('should be shorted by modified initially', async () => {
const lastModifiedButton = page.getByRole('button', {
name: 'Last Modified',
name: 'Age',
})
await expect(lastModifiedButton).toBeVisible()
await expect(lastModifiedButton.getByLabel('arrow down')).toBeVisible()
@ -1364,7 +1364,7 @@ test(
await test.step('Reverse modified order', async () => {
const lastModifiedButton = page.getByRole('button', {
name: 'Last Modified',
name: 'Age',
})
await lastModifiedButton.click()
await expect(lastModifiedButton).toBeVisible()

2
package-lock.json generated
View File

@ -53,6 +53,7 @@
"json-rpc-2.0": "^1.6.0",
"jszip": "^3.10.1",
"minimist": "^1.2.8",
"ms": "^2.1.3",
"openid-client": "^5.6.5",
"re-resizable": "^6.11.2",
"react": "^18.3.1",
@ -2495,6 +2496,7 @@
},
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
"version": "1.3.0",
"extraneous": true,
"inBundle": true,
"license": "MIT",
"engines": {

View File

@ -55,6 +55,7 @@
"json-rpc-2.0": "^1.6.0",
"jszip": "^3.10.1",
"minimist": "^1.2.8",
"ms": "^2.1.3",
"openid-client": "^5.6.5",
"re-resizable": "^6.11.2",
"react": "^18.3.1",

View File

@ -0,0 +1,95 @@
import Fuse from 'fuse.js'
import { useEffect, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { CustomIcon } from '@src/components/CustomIcon'
import type { Project } from '@src/lib/project'
import type { Prompt } from '@src/lib/prompt'
export type HomeItem = Project | Prompt
export type HomeItems = Project[] | Prompt[]
export const areHomeItemsProjects = (items: HomeItems): items is Project[] => {
if (items.length === 0) return true
const item = items[0]
return item !== undefined && 'path' in item
}
export const areHomeItemsPrompts = (items: HomeItems): items is Prompt[] => {
if (items.length === 0) return true
const item = items[0]
return item !== undefined && 'prompt' in item
}
export function useHomeSearch(initialSearchResults: HomeItems) {
const [query, setQuery] = useState('')
const [searchResults, setSearchResults] =
useState<HomeItems>(initialSearchResults)
useEffect(() => {
setSearchResults(initialSearchResults)
}, [initialSearchResults])
const searchAgainst = (items: HomeItems) => (queryRequested: string) => {
const nameKeyToMatchAgainst = areHomeItemsProjects(items)
? 'name'
: 'prompt'
// Fuse is not happy with HomeItems
// @ts-expect-error
const fuse = new Fuse(items, {
keys: [{ name: nameKeyToMatchAgainst, weight: 0.7 }],
includeScore: true,
})
const results = fuse.search(queryRequested).map((result) => result.item)
// On an empty query, we consider that matching all items.
setSearchResults(queryRequested.length > 0 ? results : items)
setQuery(queryRequested)
}
return {
searchAgainst,
searchResults,
query,
}
}
export function HomeSearchBar({
onChange,
}: {
onChange: (query: string) => void
}) {
const inputRef = useRef<HTMLInputElement>(null)
useHotkeys(
'Ctrl+.',
(event) => {
event.preventDefault()
inputRef.current?.focus()
},
{ enableOnFormTags: true }
)
return (
<div className="relative group">
<div className="flex items-center gap-2 py-0.5 pl-0.5 pr-2 rounded border-solid border border-primary/10 dark:border-chalkboard-80 focus-within:border-primary dark:focus-within:border-chalkboard-30">
<CustomIcon
name="search"
className="w-5 h-5 rounded-sm bg-primary/10 dark:bg-transparent text-primary dark:text-chalkboard-10 group-focus-within:bg-primary group-focus-within:text-chalkboard-10"
/>
<input
ref={inputRef}
onChange={(event) => onChange(event.target.value)}
className="w-full text-sm bg-transparent focus:outline-none selection:bg-primary/20 dark:selection:bg-primary/40 dark:focus:outline-none"
placeholder="Search (Ctrl+.)"
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
/>
</div>
</div>
)
}

View File

@ -1,64 +0,0 @@
import Fuse from 'fuse.js'
import { useEffect, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { CustomIcon } from '@src/components/CustomIcon'
import type { Project } from '@src/lib/project'
export function useProjectSearch(projects: Project[]) {
const [query, setQuery] = useState('')
const [searchResults, setSearchResults] = useState(projects)
const fuse = new Fuse(projects, {
keys: [{ name: 'name', weight: 0.7 }],
includeScore: true,
})
useEffect(() => {
const results = fuse.search(query).map((result) => result.item)
setSearchResults(query.length > 0 ? results : projects)
}, [query, projects])
return {
searchResults,
query,
setQuery,
}
}
export function ProjectSearchBar({
setQuery,
}: {
setQuery: (query: string) => void
}) {
const inputRef = useRef<HTMLInputElement>(null)
useHotkeys(
'Ctrl+.',
(event) => {
event.preventDefault()
inputRef.current?.focus()
},
{ enableOnFormTags: true }
)
return (
<div className="relative group">
<div className="flex items-center gap-2 py-0.5 pl-0.5 pr-2 rounded border-solid border border-primary/10 dark:border-chalkboard-80 focus-within:border-primary dark:focus-within:border-chalkboard-30">
<CustomIcon
name="search"
className="w-5 h-5 rounded-sm bg-primary/10 dark:bg-transparent text-primary dark:text-chalkboard-10 group-focus-within:bg-primary group-focus-within:text-chalkboard-10"
/>
<input
ref={inputRef}
onChange={(event) => setQuery(event.target.value)}
className="w-full text-sm bg-transparent focus:outline-none selection:bg-primary/20 dark:selection:bg-primary/40 dark:focus:outline-none"
placeholder="Search projects (Ctrl+.)"
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
/>
</div>
</div>
)
}

View File

@ -0,0 +1,96 @@
import ms from 'ms'
import type { Prompt } from '@src/lib/prompt'
export interface PromptCardProps extends Prompt {
disabled?: boolean
onAction?: (id: Prompt['id'], prompt: Prompt['prompt']) => void
onDelete?: (id: Prompt['id']) => void
onFeedback?: (id: string, feedback: Prompt['feedback']) => void
}
export const PromptFeedback = (props: {
id: Prompt['id']
selected?: Prompt['feedback']
disabled?: boolean
onFeedback: (id: Prompt['id'], feedback: Prompt['feedback']) => void
}) => {
const cssUp =
props.selected === undefined || props.selected === 'thumbs_up'
? 'border-green-300'
: 'border-green-100 text-chalkboard-60'
const cssDown =
props.selected === undefined || props.selected === 'thumbs_down'
? 'border-red-300'
: 'border-red-100 text-chalkboard-60'
return (
<div className="flex flex-row gap-2">
<button
onClick={() => props.onFeedback(props.id, 'thumbs_up')}
disabled={props.disabled}
className={cssUp}
>
Good
</button>
<button
onClick={() => props.onFeedback(props.id, 'thumbs_down')}
disabled={props.disabled}
className={cssDown}
>
Bad
</button>
</div>
)
}
export const PromptCardActionButton = (props: {
status: Prompt['status']
disabled?: boolean
onClick: () => void
}) => {
return (
<button
className="rounded-full bg-gray-100"
onClick={props.onClick}
disabled={
props.disabled ||
props.status === 'queued' ||
props.status === 'in_progress'
}
>
{props.status === 'completed' ? 'Create' : 'Pending'}
</button>
)
}
export const PromptCard = (props: PromptCardProps) => {
const cssCard = 'flex flex-col border rounded-md p-2 gap-2 justify-between'
return (
<div className={`${cssCard} ${props.disabled ? 'text-chalkboard-60' : ''}`}>
<div className="flex flex-row justify-between gap-2">
<div>{props.prompt}</div>
<div className="w-fit flex flex-col items-end">
{/* TODO: */}
{/* <button disabled={props.disabled} onClick={() => props.onDelete?.(props.id)}>Delete</button> */}
<PromptFeedback
id={props.id}
selected={props.feedback}
disabled={props.disabled}
onFeedback={(...args) => props.onFeedback?.(...args)}
/>
</div>
</div>
<div className="flex flex-row justify-between">
<PromptCardActionButton
status={props.status}
disabled={props.disabled}
onClick={() => props.onAction?.(props.id, props.prompt)}
/>
<div>
{ms(new Date(props.created_at).getTime(), { long: true })} ago
</div>
</div>
</div>
)
}

121
src/lib/prompt.ts Normal file
View File

@ -0,0 +1,121 @@
import type { Models } from '@kittycad/lib'
export type Prompt = Models['TextToCad_type']
// export interface TextToCad_type {
// code?: string;
// completed_at?: string;
// created_at: string;
// error?: string;
// feedback?: MlFeedback_type;
// id: Uuid_type;
// kcl_version?: string;
// model: TextToCadModel_type;
// model_version: string;
// output_format: FileExportFormat_type;
// outputs: {
// [key: string]: string;
// };
// prompt: string;
// started_at?: string;
// status: ApiCallStatus_type;
// updated_at: string;
// user_id: Uuid_type;
// }
// export interface TextToCadCreateBody_type {
// kcl_version?: string;
// project_name?: string;
// prompt: string;
// }
// export interface TextToCadIteration_type {
// code: string;
// completed_at?: string;
// created_at: string;
// error?: string;
// feedback?: MlFeedback_type;
// id: Uuid_type;
// model: TextToCadModel_type;
// model_version: string;
// original_source_code: string;
// prompt?: string;
// source_ranges: SourceRangePrompt_type[];
// started_at?: string;
// status: ApiCallStatus_type;
// updated_at: string;
// user_id: Uuid_type;
// }
// export interface TextToCadIterationBody_type {
// kcl_version?: string;
// original_source_code: string;
// project_name?: string;
// prompt?: string;
// source_ranges: SourceRangePrompt_type[];
// }
// export interface TextToCadMultiFileIteration_type {
// completed_at?: string;
// created_at: string;
// error?: string;
// feedback?: MlFeedback_type;
// id: Uuid_type;
// kcl_version?: string;
// model: TextToCadModel_type;
// model_version: string;
// outputs: {
// [key: string]: string;
// };
// project_name?: string;
// prompt?: string;
// source_ranges: SourceRangePrompt_type[];
// started_at?: string;
// status: ApiCallStatus_type;
// updated_at: string;
// user_id: Uuid_type;
// }
// export interface TextToCadMultiFileIterationBody_type {
// kcl_version?: string;
// project_name?: string;
// prompt?: string;
// source_ranges: SourceRangePrompt_type[];
// }
// export interface TextToCadResultsPage_type {
// items: TextToCad_type[];
// next_page?: string;
// }
const PROMPTS = [
'Generate a step-by-step guide to design a parametric gear with adjustable tooth count.',
'Explain how to model a hollow cylinder with internal threads using 3D modeling principles.',
'Create a script to generate a customizable box with finger joints using parametric design.',
'Suggest best practices for modeling an ergonomic handheld object in CAD.',
'Convert this verbal sketch description into structured CAD modeling steps.',
'Define geometric constraints for modeling a modular rail profile used in assembly systems.',
'How do I design a 3D-printable snap-fit enclosure with proper tolerances?',
'Generate geometry instructions for creating a ball-and-socket joint.',
'Model a heat-dissipating structure suitable for passive cooling in electronic assemblies.',
]
export const generateFakeSubmittedPrompt = () => ({
code: Math.random().toString(),
completed_at: Math.random().toString(),
created_at: new Date(Math.random() * 100000000).toISOString(),
error: Math.random().toString(),
// declare type MlFeedback_type = 'thumbs_up' | 'thumbs_down' | 'accepted' | 'rejected';
feedback: 'thumbs_up' as Prompt['feedback'],
id: Math.random().toString(),
kcl_version: Math.random().toString(),
model_version: Math.random().toString(),
// export declare type TextToCadModel_type = 'cad' | 'kcl' | 'kcl_iteration'; model : 'kcl',
model: 'kcl' as Prompt['model'],
// export declare type FileExportFormat_type = 'fbx' | 'glb' | 'gltf' | 'obj' | 'ply' | 'step' | 'stl';
output_format: 'glb' as Prompt['output_format'],
outputs: {
[Math.random().toString()]: Math.random().toString(),
},
prompt: PROMPTS[parseInt((Math.random() * 10).toString()[0])],
started_at: new Date(Math.random()).toISOString(),
// declare type ApiCallStatus_type = 'queued' | 'uploaded' | 'in_progress' | 'completed' | 'failed';
status: 'completed' as Prompt['status'],
updated_at: Math.random().toString(),
// declare type ApiTokenUuid_type = string;
user_id: Math.random().toString(),
})

View File

@ -26,6 +26,10 @@ import {
engineStreamContextCreate,
engineStreamMachine,
} from '@src/machines/engineStreamMachine'
import {
mlEphantDefaultContext,
mlEphantManagerMachine,
} from '@src/machines/mlEphantManagerMachine'
import { ACTOR_IDS } from '@src/machines/machineConstants'
import { settingsMachine } from '@src/machines/settingsMachine'
import { systemIOMachineDesktop } from '@src/machines/systemIO/systemIOMachineDesktop'
@ -117,13 +121,21 @@ if (typeof window !== 'undefined') {
},
})
}
const { AUTH, SETTINGS, SYSTEM_IO, ENGINE_STREAM, COMMAND_BAR, BILLING } =
ACTOR_IDS
const {
AUTH,
SETTINGS,
SYSTEM_IO,
ENGINE_STREAM,
MLEPHANT_MANAGER,
COMMAND_BAR,
BILLING,
} = ACTOR_IDS
const appMachineActors = {
[AUTH]: authMachine,
[SETTINGS]: settingsMachine,
[SYSTEM_IO]: isDesktop() ? systemIOMachineDesktop : systemIOMachineWeb,
[ENGINE_STREAM]: engineStreamMachine,
[MLEPHANT_MANAGER]: mlEphantManagerMachine,
[COMMAND_BAR]: commandBarMachine,
[BILLING]: billingMachine,
} as const
@ -157,6 +169,10 @@ const appMachine = setup({
systemId: ENGINE_STREAM,
input: engineStreamContextCreate(),
}),
spawnChild(appMachineActors[MLEPHANT_MANAGER], {
systemId: MLEPHANT_MANAGER,
input: mlEphantDefaultContext(),
}),
spawnChild(appMachineActors[SYSTEM_IO], {
systemId: SYSTEM_IO,
}),
@ -226,6 +242,10 @@ export const engineStreamActor = appActor.system.get(
ENGINE_STREAM
) as ActorRefFrom<(typeof appMachineActors)[typeof ENGINE_STREAM]>
export const mlEphantManagerActor = appActor.system.get(
MLEPHANT_MANAGER
) as ActorRefFrom<(typeof appMachineActors)[typeof MLEPHANT_MANAGER]>
export const commandBarActor = appActor.system.get(COMMAND_BAR) as ActorRefFrom<
(typeof appMachineActors)[typeof COMMAND_BAR]
>

View File

@ -1,5 +1,6 @@
import type { CustomIconName } from '@src/components/CustomIcon'
import type { Project } from '@src/lib/project'
import type { Prompt } from '@src/lib/prompt'
const DESC = ':desc'
@ -25,7 +26,7 @@ export function getNextSearchParams(currentSort: string, newSort: string) {
}
}
export function getSortFunction(sortBy: string) {
export function getProjectSortFunction(sortBy: string) {
const sortByName = (a: Project, b: Project) => {
if (a.name && b.name) {
return sortBy.includes('desc')
@ -52,3 +53,35 @@ export function getSortFunction(sortBy: string) {
return sortByModified
}
}
// Below is to keep the same behavior as above but for prompts.
// Do NOT take it as actually "sort by modified" but more like "sort by time".
export function getPromptSortFunction(sortBy: string) {
const sortByName = (a: Prompt, b: Prompt) => {
if (a.prompt && b.prompt) {
return sortBy.includes('desc')
? a.prompt.localeCompare(b.prompt)
: b.prompt.localeCompare(a.prompt)
}
return 0
}
const sortByModified = (a: Prompt, b: Prompt) => {
if (a.created_at && b.created_at) {
// INTENTIONALLY REVERSED
// Will not show properly otherwise.
const aDate = new Date(b.created_at)
const bDate = new Date(a.created_at)
return !sortBy || sortBy.includes('desc')
? bDate.getTime() - aDate.getTime()
: aDate.getTime() - bDate.getTime()
}
return 0
}
if (sortBy?.includes('name')) {
return sortByName
} else {
return sortByModified
}
}

View File

@ -3,6 +3,7 @@ export const ACTOR_IDS = {
SETTINGS: 'settings',
SYSTEM_IO: 'systemIO',
ENGINE_STREAM: 'engine_stream',
MLEPHANT_MANAGER: 'mlephant_manager',
COMMAND_BAR: 'command_bar',
BILLING: 'billing',
} as const

View File

@ -0,0 +1,219 @@
import { assign, setup, fromPromise } from 'xstate'
import type { ActorRefFrom } from 'xstate'
import type { Prompt } from '@src/lib/prompt'
import { generateFakeSubmittedPrompt } from '@src/lib/prompt'
export enum MlEphantManagerStates {
Setup = 'setup',
Idle = 'idle',
Pending = 'pending',
}
export enum MlEphantManagerTransitionStates {
GetPromptsThatCreatedProjects = 'get-prompts-that-created-projects',
GetPromptsBelongingToProject = 'get-prompts-belonging-to-project',
PromptEditModel = 'prompt-edit-model',
PromptCreateModel = 'prompt-create-model',
PromptRate = 'prompt-rate',
// Note, technically hiding.
PromptDelete = 'prompt-delete',
PromptPollStatus = 'prompt-poll-status',
}
export type MlEphantManagerEvents =
| {
type: MlEphantManagerTransitionStates.GetPromptsThatCreatedProjects
}
| {
type: MlEphantManagerTransitionStates.GetPromptsBelongingToProject
projectId: string
}
| {
type: MlEphantManagerTransitionStates.PromptCreateModel
// For now we fake this, using project_name.
projectId: string
prompt: string
}
| {
type: MlEphantManagerTransitionStates.PromptEditModel
projectId: string
prompt: string
}
| {
type: MlEphantManagerTransitionStates.PromptRate
promptId: string
}
| {
type: MlEphantManagerTransitionStates.PromptDelete
promptId: string
}
| {
type: MlEphantManagerTransitionStates.PromptPollStatus
promptId: string
}
// Used to specify a specific event in input properties
type XSEvent<T> = Extract<MlEphantManagerEvents, { type: T }>
export interface MlEphantManagerContext {
promptsThatCreatedProjects: Map<Prompt['id'], Prompt>
// If no project is selected: undefined.
promptsBelongingToProject?: Map<Prompt['id'], Prompt>
}
export const mlEphantDefaultContext = () => ({
promptsThatCreatedProjects: new Map(),
promptsBelongingToProject: undefined,
hasPendingPrompts: false,
})
export const mlEphantManagerMachine = setup({
types: {
context: {} as MlEphantManagerContext,
events: {} as MlEphantManagerEvents,
},
actors: {
[MlEphantManagerTransitionStates.GetPromptsThatCreatedProjects]:
fromPromise(async function (args: {
input: {
context: MlEphantManagerContext
}
}): Promise<Partial<MlEphantManagerContext>> {
return new Promise((resolve) => {
setTimeout(() => {
const results = new Array(13)
.fill(undefined)
.map(generateFakeSubmittedPrompt)
const promptsThatCreatedProjects = new Map(
args.input.context.promptsThatCreatedProjects
)
results.forEach((result) => {
promptsThatCreatedProjects.set(result.id, result)
})
resolve({
promptsThatCreatedProjects,
})
}, 2000)
})
}),
[MlEphantManagerTransitionStates.PromptCreateModel]: fromPromise(
async function (args: {
input: {
event: XSEvent<MlEphantManagerTransitionStates.PromptCreateModel>
context: MlEphantManagerContext
}
}): Promise<Partial<MlEphantManagerContext>> {
return new Promise((resolve) => {
setTimeout(() => {
const promptsThatCreatedProjects = new Map(
args.input.context.promptsThatCreatedProjects
)
const result = generateFakeSubmittedPrompt()
promptsThatCreatedProjects.set(result.id, result)
resolve({
promptsThatCreatedProjects,
})
}, 5000)
})
}
),
[MlEphantManagerTransitionStates.PromptPollStatus]: fromPromise(
async function (args: {
input: {
context: MlEphantManagerContext
}
}): Promise<Partial<MlEphantManagerContext>> {
return new Promise((resolve) => {
setTimeout(() => {
const promptsThatCreatedProjects = new Map(
args.input.context.promptsThatCreatedProjects
)
// Do the same for prompts of a project
promptsThatCreatedProjects.values().forEach((prompt) => {
if (prompt.status !== 'pending') return
prompt.status = 'completed'
})
resolve({
promptsThatCreatedProjects,
})
}, 3000)
})
}
),
},
}).createMachine({
initial: MlEphantManagerStates.Setup,
context: mlEphantDefaultContext(),
states: {
[MlEphantManagerStates.Setup]: {
invoke: {
input: (args: { context: MlEphantManagerContext }) => args,
src: MlEphantManagerTransitionStates.GetPromptsThatCreatedProjects,
onDone: {
target: MlEphantManagerStates.Idle,
actions: assign(({ event }) => event.output),
},
onError: { target: MlEphantManagerStates.Idle },
},
},
[MlEphantManagerStates.Idle]: {
on: {
[MlEphantManagerTransitionStates.GetPromptsThatCreatedProjects]: {
target:
MlEphantManagerStates.Pending +
'.' +
MlEphantManagerTransitionStates.GetPromptsThatCreatedProjects,
},
},
},
[MlEphantManagerStates.Pending]: {
initial: MlEphantManagerStates.Idle,
states: {
// Pop back out to Idle.
[MlEphantManagerStates.Idle]: {
type: 'final',
target: MlEphantManagerStates.Idle,
},
[MlEphantManagerTransitionStates.GetPromptsThatCreatedProjects]: {
invoke: {
input: (args) => ({
context: args.context,
}),
src: MlEphantManagerTransitionStates.GetPromptsThatCreatedProjects,
onDone: { target: MlEphantManagerStates.Idle },
onError: { target: MlEphantManagerStates.Idle },
},
},
[MlEphantManagerTransitionStates.PromptCreateModel]: {
invoke: {
input: (args) => ({
event:
args.event as XSEvent<MlEphantManagerTransitionStates.PromptCreateModel>,
context: args.context,
}),
src: MlEphantManagerTransitionStates.PromptCreateModel,
onDone: { target: MlEphantManagerStates.PromptPollStatus },
onError: { target: MlEphantManagerStates.PromptPollStatus },
},
},
[MlEphantManagerTransitionStates.PromptPollStatus]: {
invoke: {
input: (args) => ({
event:
args.event as XSEvent<MlEphantManagerTransitionStates.PromptPollStatus>,
context: args.context,
}),
src: MlEphantManagerTransitionStates.PromptPollStatus,
onDone: { target: MlEphantManagerStates.Idle },
onError: { target: MlEphantManagerStates.Idle },
},
},
},
},
},
})
export type MlEphantManagerActor = ActorRefFrom<typeof mlEphantManagerMachine>

View File

@ -1,4 +1,5 @@
import type { FormEvent, HTMLProps } from 'react'
import { useSelector } from '@xstate/react'
import { useEffect, useState } from 'react'
import { toast } from 'react-hot-toast'
import { useHotkeys } from 'react-hotkeys-hook'
@ -13,10 +14,14 @@ import { ActionButton } from '@src/components/ActionButton'
import { AppHeader } from '@src/components/AppHeader'
import Loading from '@src/components/Loading'
import ProjectCard from '@src/components/ProjectCard/ProjectCard'
import { PromptCard } from '@src/components/PromptCard'
import {
ProjectSearchBar,
useProjectSearch,
} from '@src/components/ProjectSearchBar'
HomeSearchBar,
useHomeSearch,
areHomeItemsProjects,
areHomeItemsPrompts,
} from '@src/components/HomeSearchBar'
import type { HomeItems } from '@src/components/HomeSearchBar'
import { BillingDialog } from '@src/components/BillingDialog'
import { useQueryParamEffects } from '@src/hooks/useQueryParamEffects'
import { useMenuListener } from '@src/hooks/useMenu'
@ -24,9 +29,11 @@ import { isDesktop } from '@src/lib/isDesktop'
import { PATHS } from '@src/lib/paths'
import { markOnce } from '@src/lib/performance'
import type { Project } from '@src/lib/project'
import type { Prompt } from '@src/lib/prompt'
import {
getNextSearchParams,
getSortFunction,
getProjectSortFunction,
getPromptSortFunction,
getSortIcon,
} from '@src/lib/sorting'
import { reportRejection } from '@src/lib/trap'
@ -37,6 +44,7 @@ import {
kclManager,
authActor,
billingActor,
mlEphantManagerActor,
systemIOActor,
useSettings,
} from '@src/lib/singletons'
@ -66,6 +74,7 @@ import {
defaultLocalStatusBarItems,
defaultGlobalStatusBarItems,
} from '@src/components/StatusBar/defaultStatusBarItems'
import { MlEphantManagerStates } from '@src/machines/mlEphantManagerMachine'
type ReadWriteProjectState = {
value: boolean
@ -210,9 +219,42 @@ const Home = () => {
}
)
const projects = useFolders()
const prompts = useSelector(mlEphantManagerActor, (actor) => {
return actor.context.promptsThatCreatedProjects
})
const [tabSelected, setTabSelected] = useState<HomeTabKeys>(
HomeTabKeys.Projects
)
const [items, setItems] = useState<HomeItems>(projects)
const [searchParams, setSearchParams] = useSearchParams()
const { searchResults, query, setQuery } = useProjectSearch(projects)
const sort = searchParams.get('sort_by') ?? 'modified:desc'
const { searchResults, query, searchAgainst } = useHomeSearch(projects)
const sortBy = searchParams.get('sort_by') ?? 'modified:desc'
const onChangeTab = (key: HomeTabKeys) => {
setTabSelected(key)
}
useEffect(() => {
switch (tabSelected) {
case HomeTabKeys.Projects:
setItems(projects)
break
case HomeTabKeys.Prompts:
// Lessons hard learned: VERY important to do this here, and not within
// the useSelector. React will think it's a new value every time, and
// cause this useEffect to fire indefinitely.
setItems(Array.from(prompts.values()))
break
default:
const _ex: never = tabSelected
}
}, [tabSelected, prompts, projects])
useEffect(() => {
searchAgainst(items)('')
}, [items])
const sidebarButtonClasses =
'flex items-center p-2 gap-2 leading-tight border-transparent dark:border-transparent enabled:dark:border-transparent enabled:hover:border-primary/50 enabled:dark:hover:border-inherit active:border-primary dark:bg-transparent hover:bg-transparent'
@ -224,8 +266,10 @@ const Home = () => {
/>
<div className="overflow-hidden self-stretch w-full flex-1 home-layout max-w-4xl lg:max-w-5xl xl:max-w-7xl px-4 mx-auto mt-8 lg:mt-24 lg:px-0">
<HomeHeader
setQuery={setQuery}
sort={sort}
tabSelected={tabSelected}
onChangeHomeSearchBar={searchAgainst(items)}
onChangeTab={onChangeTab}
sortBy={sortBy}
setSearchParams={setSearchParams}
settings={settings}
readWriteProjectDir={readWriteProjectDir}
@ -393,12 +437,12 @@ const Home = () => {
</li>
</ul>
</aside>
<ProjectGrid
<HomeItemsArea
tabSelected={tabSelected}
searchResults={searchResults}
projects={projects}
sortBy={sortBy}
query={query}
sort={sort}
className="flex-1 col-start-2 -col-end-1 overflow-y-auto pr-2 pb-24"
settings={settings}
/>
</div>
<StatusBar
@ -412,47 +456,98 @@ const Home = () => {
)
}
enum HomeTabKeys {
Projects,
Prompts,
}
interface HomeTabProps {
onChange: (key: HomeTabKeys) => void
selected: HomeTabKeys
}
function HomeTab(props: HomeTabProps) {
const [selected, setSelected] = useState(props.selected)
const tabs = [
{ name: 'Projects', key: HomeTabKeys.Projects },
{ name: 'Prompts', key: HomeTabKeys.Prompts },
]
const cssTab = 'cursor-pointer p-4'
const cssActive = `${cssTab} border rounded bg-green`
const cssInactive = `${cssTab}`
const onClickTab = (key: HomeTabKeys) => () => {
setSelected(key)
props.onChange(key)
}
return (
<div className="flex flex-row">
{tabs.map((el) => (
<div
key={el.key}
className={el.key === selected ? cssActive : cssInactive}
onClick={onClickTab(el.key)}
role="tab"
tabIndex={0}
>
{el.name}
</div>
))}
</div>
)
}
interface HomeHeaderProps extends HTMLProps<HTMLDivElement> {
setQuery: (query: string) => void
sort: string
tabSelected: HomeTabKeys
onChangeHomeSearchBar: (query: string) => void
onChangeTab: (key: HomeTabKeys) => void
sortBy: string
setSearchParams: (params: Record<string, string>) => void
settings: ReturnType<typeof useSettings>
readWriteProjectDir: ReadWriteProjectState
}
function HomeHeader({
setQuery,
sort,
tabSelected,
onChangeHomeSearchBar,
onChangeTab,
sortBy,
setSearchParams,
settings,
readWriteProjectDir,
...rest
}: HomeHeaderProps) {
const isSortByModified = sort?.includes('modified') || !sort || sort === null
const isSortByModified =
sortBy?.includes('modified') || !sortBy || sortBy === null
return (
<section {...rest}>
<div className="flex flex-col md:flex-row gap-4 justify-between md:items-center select-none">
<div className="flex gap-8 items-center">
<h1 className="text-3xl font-bold">Projects</h1>
<HomeTab onChange={onChangeTab} selected={tabSelected} />
</div>
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
<ProjectSearchBar setQuery={setQuery} />
<HomeSearchBar onChange={onChangeHomeSearchBar} />
<div className="flex gap-2 items-center">
<small>Sort by</small>
<ActionButton
Element="button"
data-testid="home-sort-by-name"
className={`text-xs border-primary/10 ${
!sort.includes('name')
!sortBy.includes('name')
? 'text-chalkboard-80 dark:text-chalkboard-40'
: ''
}`}
onClick={() => setSearchParams(getNextSearchParams(sort, 'name'))}
onClick={() =>
setSearchParams(getNextSearchParams(sortBy, 'name'))
}
iconStart={{
icon: getSortIcon(sort, 'name'),
icon: getSortIcon(sortBy, 'name'),
bgClassName: 'bg-transparent',
iconClassName: !sort.includes('name')
iconClassName: !sortBy.includes('name')
? '!text-chalkboard-90 dark:!text-chalkboard-30'
: '',
}}
@ -468,17 +563,17 @@ function HomeHeader({
: ''
}`}
onClick={() =>
setSearchParams(getNextSearchParams(sort, 'modified'))
setSearchParams(getNextSearchParams(sortBy, 'modified'))
}
iconStart={{
icon: sort ? getSortIcon(sort, 'modified') : 'arrowDown',
icon: sortBy ? getSortIcon(sortBy, 'modified') : 'arrowDown',
bgClassName: 'bg-transparent',
iconClassName: !isSortByModified
? '!text-chalkboard-90 dark:!text-chalkboard-30'
: '',
}}
>
Last Modified
Age
</ActionButton>
</div>
</div>
@ -514,45 +609,158 @@ function HomeHeader({
)
}
interface ProjectGridProps extends HTMLProps<HTMLDivElement> {
searchResults: Project[]
projects: Project[]
query: string
sort: string
function NoResults() {
return (
<div className="col-start-2 -col-end-1 w-full flex flex-col justify-center items-center">
No results.
</div>
)
}
function ProjectGrid({
searchResults,
projects,
query,
sort,
...rest
}: ProjectGridProps) {
interface HomeItemsAreaProps {
tabSelected: HomeTabKeys
searchResults: HomeItems
sortBy: string
query: string
settings: ReturnType<typeof useSettings>
}
function HomeItemsArea(props: HomeItemsAreaProps) {
let grid = null
console.log('home items area', props.tabSelected, props.searchResults)
switch (props.tabSelected) {
case HomeTabKeys.Projects:
grid = areHomeItemsProjects(props.searchResults) ? (
<ResultGridProjects
searchResults={props.searchResults}
query={props.query}
sortBy={props.sortBy}
/>
) : (
<NoResults />
)
break
case HomeTabKeys.Prompts:
grid = areHomeItemsPrompts(props.searchResults) ? (
<ResultGridPrompts
searchResults={props.searchResults}
query={props.query}
sortBy={props.sortBy}
settings={props.settings}
/>
) : (
<NoResults />
)
break
default:
const _ex: never = props.tabSelected
}
return (
<div className="flex-1 col-start-2 -col-end-1 overflow-y-auto pr-2 pb-24">
{grid}
</div>
)
}
interface ResultGridPromptsProps {
searchResults: Prompt[]
query: string
sortBy: string
settings: ReturnType<typeof useSettings>
}
function ResultGridPrompts(props: ResultGridPromptsProps) {
// Maybe consider lifting this higher but I see no reason at the moment
const onAction = (_id: Prompt['id'], prompt: Prompt['prompt']) => {
commandBarActor.send({
type: 'Find and select command',
data: {
groupId: 'application',
name: 'Text-to-CAD',
argDefaultValues: {
method: 'newProject',
prompt,
newProjectName: props.settings.projects.defaultProjectName.current,
},
},
})
}
// no-op for now
const onDelete = (...args: any) => {
console.log(args)
}
const onFeedback = (...args: any) => {
console.log(args)
}
const mlEphantManagerSnapshot = mlEphantManagerActor.getSnapshot()
if (mlEphantManagerSnapshot.matches(MlEphantManagerStates.Setup)) {
return (
<div className="col-start-2 -col-end-1 w-full flex flex-col justify-center items-center">
<Loading isDummy={true}>Loading your prompts...</Loading>
</div>
)
}
return (
<div className="grid w-full sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-2 gap-4">
{props.searchResults
.sort(getPromptSortFunction(props.sortBy))
.map((prompt: Prompt) => (
<PromptCard
key={prompt.id}
{...prompt}
disabled={
mlEphantManagerSnapshot.matches(MlEphantManagerStates.Pending) ||
prompt.status !== 'completed'
}
onAction={onAction}
onDelete={onDelete}
onFeedback={onFeedback}
/>
))}
</div>
)
}
interface ResultGridProjectsProps extends HTMLProps<HTMLDivElement> {
searchResults: Project[]
query: string
sortBy: string
}
function ResultGridProjects(props: ResultGridProjectsProps) {
const state = useSystemIOState()
return (
<section data-testid="home-section" {...rest}>
<section data-testid="home-section" className={props.className}>
{state.matches(SystemIOMachineStates.readingFolders) ? (
<Loading isDummy={true}>Loading your Projects...</Loading>
) : (
<>
{searchResults.length > 0 ? (
{props.searchResults.length > 0 ? (
<ul className="grid w-full sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{searchResults.sort(getSortFunction(sort)).map((project) => (
<ProjectCard
key={project.name}
project={project}
handleRenameProject={handleRenameProject}
handleDeleteProject={handleDeleteProject}
/>
))}
{props.searchResults
.sort(getProjectSortFunction(props.sortBy))
.map((item) => (
<ProjectCard
key={item.name}
project={item}
handleRenameProject={handleRenameProject}
handleDeleteProject={handleDeleteProject}
/>
))}
</ul>
) : (
<p className="p-4 my-8 border border-dashed rounded border-chalkboard-30 dark:border-chalkboard-70">
No projects found
{projects.length === 0
No results found
{props.searchResults.length === 0
? ', ready to make your first one?'
: ` with the search term "${query}"`}
: ` with the search term "${props.query}"`}
</p>
)}
</>