Skip to content

Commit 1a6ad37

Browse files
committed
feat: route owned apps to editor and prefer Think live preview
1 parent ed011f2 commit 1a6ad37

6 files changed

Lines changed: 156 additions & 67 deletions

File tree

src/components/layout/app-sidebar.tsx

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ interface App {
3434
updatedAt: Date | string | null;
3535
updatedAtFormatted?: string;
3636
visibility: 'private' | 'team' | 'board' | 'public';
37+
userId?: string | null;
3738
isFavorite?: boolean;
3839
}
3940

@@ -54,6 +55,7 @@ interface AppMenuItemProps {
5455
showActions?: boolean;
5556
isCollapsed: boolean;
5657
getVisibilityIcon: (visibility: App['visibility']) => React.ReactNode;
58+
appPath: string;
5759
}
5860

5961
function AppMenuItem({
@@ -64,6 +66,7 @@ function AppMenuItem({
6466
showActions = true,
6567
isCollapsed,
6668
getVisibilityIcon,
69+
appPath,
6770
}: AppMenuItemProps) {
6871
const formatTimestamp = () => {
6972
const updatedAt =
@@ -99,7 +102,7 @@ function AppMenuItem({
99102
<Sidebar.MenuItem className="group/app-item">
100103
<Sidebar.MenuButton
101104
active={active}
102-
href={`/app/${app.id}`}
105+
href={appPath}
103106
tooltip={app.title}
104107
className="min-h-12 items-start py-2 pr-9 text-sm"
105108
onClick={(event) => {
@@ -179,6 +182,8 @@ export function AppSidebar() {
179182
const { apps: favoriteApps, loading: favoriteAppsLoading } =
180183
useFavoriteApps();
181184
const { apps: allApps, loading: allAppsLoading } = useApps();
185+
const getAppPath = (app: App) =>
186+
app.userId === user?.id ? `/chat/${app.id}` : `/app/${app.id}`;
182187

183188
const boards: Board[] = [];
184189

@@ -269,22 +274,24 @@ export function AppSidebar() {
269274
/>
270275
</div>
271276
) : (
272-
<Link
273-
to="/"
274-
className="flex w-full min-w-0 items-center gap-2.5 text-kumo-strong"
275-
>
276-
<CloudflareLogo
277-
variant="glyph"
278-
className="size-7 shrink-0"
279-
/>
280-
<span className="min-w-0 flex-1 truncate text-base font-black font-funky-mono uppercase tracking-wide">
281-
Build
282-
</span>
277+
<div className="flex w-full min-w-0 items-center gap-2.5">
278+
<Link
279+
to="/"
280+
className="flex min-w-0 flex-1 items-center gap-2.5 text-kumo-strong"
281+
>
282+
<CloudflareLogo
283+
variant="glyph"
284+
className="size-7 shrink-0"
285+
/>
286+
<span className="min-w-0 flex-1 truncate text-base font-black font-funky-mono uppercase tracking-wide">
287+
Build
288+
</span>
289+
</Link>
283290
<Sidebar.Trigger
284291
aria-label="Collapse sidebar"
285292
className="ml-auto shrink-0"
286293
/>
287-
</Link>
294+
</div>
288295
)}
289296
</Sidebar.Header>
290297

@@ -405,11 +412,12 @@ export function AppSidebar() {
405412
<AppMenuItem
406413
key={app.id}
407414
app={app}
415+
appPath={getAppPath(app)}
408416
active={
409-
pathname === `/app/${app.id}`
417+
pathname === getAppPath(app)
410418
}
411-
onClick={(id) =>
412-
navigate(`/app/${id}`)
419+
onClick={() =>
420+
navigate(getAppPath(app))
413421
}
414422
variant="bookmarked"
415423
isCollapsed={isCollapsed}
@@ -430,8 +438,11 @@ export function AppSidebar() {
430438
<AppMenuItem
431439
key={app.id}
432440
app={app}
433-
active={pathname === `/app/${app.id}`}
434-
onClick={(id) => navigate(`/app/${id}`)}
441+
appPath={getAppPath(app)}
442+
active={pathname === getAppPath(app)}
443+
onClick={() =>
444+
navigate(getAppPath(app))
445+
}
435446
variant="bookmarked"
436447
isCollapsed={isCollapsed}
437448
getVisibilityIcon={getVisibilityIcon}
@@ -462,11 +473,12 @@ export function AppSidebar() {
462473
<AppMenuItem
463474
key={app.id}
464475
app={app}
476+
appPath={getAppPath(app)}
465477
active={
466-
pathname === `/app/${app.id}`
478+
pathname === getAppPath(app)
467479
}
468-
onClick={(id) =>
469-
navigate(`/app/${id}`)
480+
onClick={() =>
481+
navigate(getAppPath(app))
470482
}
471483
isCollapsed={isCollapsed}
472484
getVisibilityIcon={
@@ -487,11 +499,12 @@ export function AppSidebar() {
487499
<AppMenuItem
488500
key={app.id}
489501
app={app}
502+
appPath={getAppPath(app)}
490503
active={
491-
pathname === `/app/${app.id}`
504+
pathname === getAppPath(app)
492505
}
493-
onClick={(id) =>
494-
navigate(`/app/${id}`)
506+
onClick={() =>
507+
navigate(getAppPath(app))
495508
}
496509
isCollapsed={isCollapsed}
497510
getVisibilityIcon={

src/routes/app/index.tsx

Lines changed: 77 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { useAuth } from '@/contexts/auth-context';
1616
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
1717
import { useAuthGuard } from '@/hooks/useAuthGuard';
1818
import { ApiError } from '@/lib/api-client';
19-
import { capitalizeFirstLetter } from '@/lib/utils';
19+
import { capitalizeFirstLetter, getPreviewUrl } from '@/lib/utils';
2020
import { getFileType } from '@/utils/string';
2121
import {
2222
Badge,
@@ -105,10 +105,14 @@ export default function AppView() {
105105
const [isGitCloneModalOpen, setIsGitCloneModalOpen] = useState(false);
106106
const [activeFilePath, setActiveFilePath] = useState<string>();
107107
const previewIframeRef = useRef<HTMLIFrameElement>(null);
108+
const autoDeployAppIdRef = useRef<string | undefined>(undefined);
109+
const [thinkPreviewUrl, setThinkPreviewUrl] = useState<string>();
108110

109111
// Route reuses this component across /app/:id — clear deploy UI on switch
110112
useEffect(() => {
111113
setDeploymentProgress('');
114+
autoDeployAppIdRef.current = undefined;
115+
setThinkPreviewUrl(undefined);
112116
setActiveTab('preview');
113117
setActiveFilePath(undefined);
114118
}, [id]);
@@ -336,8 +340,19 @@ export default function AppView() {
336340
]);
337341

338342
const isOwner = !!app && app.userId === user?.id;
339-
const appUrl =
340-
ownerPreviewUrl || app?.cloudflareUrl || app?.previewUrl || '';
343+
const isThink = app?.behaviorType === 'think';
344+
// Think apps auto-load their live SpaceDO preview for signed-in viewers
345+
// (see the effect below). Anonymous viewers can't hit the auth-gated deploy
346+
// endpoint, so they fall back to the same manual affordance as non-Think apps.
347+
const isAutoLoadingPreview = isThink && !!user;
348+
// Think prefers its live preview over the production deployment link. For
349+
// signed-in viewers we withhold the deployed link until the preview resolves
350+
// so the preview always wins; anonymous viewers fall back to it.
351+
const appUrl = isThink
352+
? user
353+
? thinkPreviewUrl || ''
354+
: app?.cloudflareUrl || app?.previewUrl || ''
355+
: ownerPreviewUrl || app?.cloudflareUrl || app?.previewUrl || '';
341356
const promptText = app?.agentSummary?.query || app?.originalPrompt || '';
342357

343358
const handleCopyUrl = () => {
@@ -364,6 +379,38 @@ export default function AppView() {
364379
}
365380
};
366381

382+
// Auto-load the preview for Think apps so viewers don't have to click
383+
// "Deploy". Gated to authenticated users: the preview endpoint requires
384+
// auth (and applies per-user rate limiting), so anonymous visitors would
385+
// only get a 401. They still see the manual deploy affordance below.
386+
useEffect(() => {
387+
if (
388+
!app ||
389+
!user ||
390+
app.behaviorType !== 'think' ||
391+
thinkPreviewUrl ||
392+
autoDeployAppIdRef.current === app.id
393+
) {
394+
return;
395+
}
396+
397+
autoDeployAppIdRef.current = app.id;
398+
setDeploymentProgress('Loading preview...');
399+
void deployPreview()
400+
.then((data) => {
401+
const url = getPreviewUrl(data.previewURL, data.tunnelURL);
402+
if (url) setThinkPreviewUrl(url);
403+
})
404+
.catch((error) => {
405+
console.error('Error loading Think preview:', error);
406+
setDeploymentProgress('Failed to load preview');
407+
toast.add({
408+
title: 'Failed to load preview',
409+
variant: 'error',
410+
});
411+
});
412+
}, [app, thinkPreviewUrl, deployPreview, toast, user]);
413+
367414
const handleToggleVisibility = useCallback(async () => {
368415
if (!app || !user || !isOwner) {
369416
toast.add({
@@ -772,35 +819,42 @@ export default function AppView() {
772819
<div className="relative z-10 text-center p-8 grid gap-4 max-w-md">
773820
<div className="grid gap-1.5">
774821
<h3 className="text-xl font-semibold">
775-
Run app
822+
{isAutoLoadingPreview
823+
? 'Loading preview'
824+
: 'Run app'}
776825
</h3>
777826
<p className="text-kumo-subtle text-sm">
778-
Deploy a preview to see this app
779-
live.
827+
{isAutoLoadingPreview
828+
? 'Preparing this app preview.'
829+
: 'Deploy a preview to see this app live.'}
780830
</p>
781831
{deploymentProgress && (
782832
<p className="text-sm text-kumo-subtle">
783833
{deploymentProgress}
784834
</p>
785835
)}
786836
</div>
787-
<div className="flex justify-center">
788-
<Button
789-
variant="primary"
790-
onClick={handlePreviewDeploy}
791-
disabled={isDeploying}
792-
loading={isDeploying}
793-
icon={
794-
!isDeploying ? (
795-
<Play className="h-4 w-4" />
796-
) : undefined
797-
}
798-
>
799-
{isDeploying
800-
? 'Deploying...'
801-
: 'Deploy for preview'}
802-
</Button>
803-
</div>
837+
{!isAutoLoadingPreview && (
838+
<div className="flex justify-center">
839+
<Button
840+
variant="primary"
841+
onClick={
842+
handlePreviewDeploy
843+
}
844+
disabled={isDeploying}
845+
loading={isDeploying}
846+
icon={
847+
!isDeploying ? (
848+
<Play className="h-4 w-4" />
849+
) : undefined
850+
}
851+
>
852+
{isDeploying
853+
? 'Deploying...'
854+
: 'Deploy for preview'}
855+
</Button>
856+
</div>
857+
)}
804858
</div>
805859
</div>
806860
)}

src/routes/chat/chat.tsx

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -435,30 +435,31 @@ export default function Chat() {
435435
</Button>
436436
</>
437437
)}
438-
{isOwner && app && (
438+
{isOwner && app?.visibility === 'private' && (
439439
<Button
440440
variant="secondary"
441441
size="sm"
442442
onClick={handleToggleVisibility}
443443
disabled={isUpdatingVisibility}
444444
loading={isUpdatingVisibility}
445445
icon={
446-
app.visibility === 'private' ? (
447-
<LockOpen
448-
className="size-3.5"
449-
weight="duotone"
450-
/>
451-
) : (
452-
<Lock
453-
className="size-3.5"
454-
weight="duotone"
455-
/>
456-
)
446+
<LockOpen
447+
className="size-3.5"
448+
weight="duotone"
449+
/>
457450
}
458451
>
459-
{app.visibility === 'private'
460-
? 'Make public'
461-
: 'Make private'}
452+
Make public
453+
</Button>
454+
)}
455+
{isOwner && app?.visibility === 'public' && (
456+
<Button
457+
variant="secondary"
458+
size="sm"
459+
onClick={() => navigate(`/app/${app.id}`)}
460+
icon={<ExternalLink className="size-3.5" />}
461+
>
462+
View public preview
462463
</Button>
463464
)}
464465
{app && (
@@ -483,6 +484,14 @@ export default function Chat() {
483484
>
484485
{isFavorited ? 'Bookmarked' : 'Bookmark'}
485486
</DropdownMenu.Item>
487+
{isOwner && app.visibility === 'public' && (
488+
<DropdownMenu.Item
489+
icon={Lock}
490+
onClick={handleToggleVisibility}
491+
>
492+
Make private
493+
</DropdownMenu.Item>
494+
)}
486495
<DropdownMenu.Item
487496
icon={GitBranch}
488497
onClick={() => setIsGitCloneModalOpen(true)}
@@ -501,6 +510,7 @@ export default function Chat() {
501510
headerTitle,
502511
app,
503512
isOwner,
513+
navigate,
504514
isUpdatingVisibility,
505515
handleToggleVisibility,
506516
handleFavorite,

worker/agents/core/codingAgent.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,10 @@ export class CodeGeneratorAgent extends Agent<Env, AgentState> implements AgentI
383383
return this.behavior.getSummary();
384384
}
385385

386+
getBehaviorType(): BehaviorType {
387+
return this.state.behaviorType;
388+
}
389+
386390
/**
387391
* Public RPC entrypoint for the think agent's tools (and any other caller
388392
* holding a `CodeGenObject` stub) to capture browser console

0 commit comments

Comments
 (0)