@@ -2,25 +2,28 @@ import { API } from "api/api";
22import { getErrorDetail , getErrorMessage } from "api/errors" ;
33import { template as templateQueryOptions } from "api/queries/templates" ;
44import type { Workspace , WorkspaceStatus } from "api/typesGenerated" ;
5+ import isChromatic from "chromatic/isChromatic" ;
56import { Button } from "components/Button/Button" ;
67import { Loader } from "components/Loader/Loader" ;
78import { Margins } from "components/Margins/Margins" ;
9+ import { ScrollArea } from "components/ScrollArea/ScrollArea" ;
810import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs" ;
911import { ArrowLeftIcon , RotateCcwIcon } from "lucide-react" ;
1012import { AI_PROMPT_PARAMETER_NAME , type Task } from "modules/tasks/tasks" ;
11- import type { ReactNode } from "react" ;
13+ import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs" ;
14+ import { type FC , type ReactNode , useEffect , useRef } from "react" ;
1215import { Helmet } from "react-helmet-async" ;
1316import { useQuery } from "react-query" ;
1417import { Panel , PanelGroup , PanelResizeHandle } from "react-resizable-panels" ;
1518import { Link as RouterLink , useParams } from "react-router" ;
16- import { ellipsizeText } from "utils/ellipsizeText" ;
1719import { pageTitle } from "utils/page" ;
1820import {
19- ActiveTransition ,
21+ getActiveTransitionStats ,
2022WorkspaceBuildProgress ,
2123} from "../WorkspacePage/WorkspaceBuildProgress" ;
2224import { TaskApps } from "./TaskApps" ;
2325import { TaskSidebar } from "./TaskSidebar" ;
26+ import { TaskTopbar } from "./TaskTopbar" ;
2427
2528const TaskPage = ( ) => {
2629const { workspace :workspaceName , username} = useParams ( ) as {
@@ -37,18 +40,7 @@ const TaskPage = () => {
3740refetchInterval :5_000 ,
3841} ) ;
3942
40- const { data :template } = useQuery ( {
41- ...templateQueryOptions ( task ?. workspace . template_id ?? "" ) ,
42- enabled :Boolean ( task ) ,
43- } ) ;
44-
4543const waitingStatuses :WorkspaceStatus [ ] = [ "starting" , "pending" ] ;
46- const shouldStreamBuildLogs =
47- task && waitingStatuses . includes ( task . workspace . latest_build . status ) ;
48- const buildLogs = useWorkspaceBuildLogs (
49- task ?. workspace . latest_build . id ?? "" ,
50- shouldStreamBuildLogs ,
51- ) ;
5244
5345if ( error ) {
5446return (
@@ -95,38 +87,9 @@ const TaskPage = () => {
9587}
9688
9789let content :ReactNode = null ;
98- const _terminatedStatuses :WorkspaceStatus [ ] = [
99- "canceled" ,
100- "canceling" ,
101- "deleted" ,
102- "deleting" ,
103- "stopped" ,
104- "stopping" ,
105- ] ;
10690
10791if ( waitingStatuses . includes ( task . workspace . latest_build . status ) ) {
108- // If no template yet, use an indeterminate progress bar.
109- const transition = ( template &&
110- ActiveTransition ( template , task . workspace ) ) || { P50 :0 , P95 :null } ;
111- const lastStage =
112- buildLogs ?. [ buildLogs . length - 1 ] ?. stage || "Waiting for build status" ;
113- content = (
114- < div className = "w-full min-h-80 flex flex-col" >
115- < div className = "flex flex-col items-center grow justify-center" >
116- < h3 className = "m-0 font-medium text-content-primary text-base" >
117- Starting your workspace
118- </ h3 >
119- < div className = "text-content-secondary text-sm" > { lastStage } </ div >
120- </ div >
121- < div className = "w-full" >
122- < WorkspaceBuildProgress
123- workspace = { task . workspace }
124- transitionStats = { transition }
125- variant = "task"
126- />
127- </ div >
128- </ div >
129- ) ;
92+ content = < TaskBuildingWorkspace task = { task } /> ;
13093} else if ( task . workspace . latest_build . status === "failed" ) {
13194content = (
13295< div className = "w-full min-h-80 flex items-center justify-center" >
@@ -170,29 +133,103 @@ const TaskPage = () => {
170133</ Margins >
171134) ;
172135} else {
173- content = < TaskApps task = { task } /> ;
174- }
175-
176- return (
177- < >
178- < Helmet >
179- < title > { pageTitle ( ellipsizeText ( task . prompt , 64 ) ?? "Task" ) } </ title >
180- </ Helmet >
136+ content = (
181137< PanelGroup autoSaveId = "task" direction = "horizontal" >
182138< Panel defaultSize = { 25 } minSize = { 20 } >
183139< TaskSidebar task = { task } />
184140</ Panel >
185141< PanelResizeHandle >
186142< div className = "w-1 bg-border h-full hover:bg-border-hover transition-all relative" />
187143</ PanelResizeHandle >
188- < Panel className = "[&>*]:h-full" > { content } </ Panel >
144+ < Panel className = "[&>*]:h-full" >
145+ < TaskApps task = { task } />
146+ </ Panel >
189147</ PanelGroup >
148+ ) ;
149+ }
150+
151+ return (
152+ < >
153+ < Helmet >
154+ < title > { pageTitle ( ellipsizeText ( task . prompt , 64 ) ) } </ title >
155+ </ Helmet >
156+
157+ < div className = "flex flex-col h-full" >
158+ < TaskTopbar task = { task } />
159+ { content }
160+ </ div >
190161</ >
191162) ;
192163} ;
193164
194165export default TaskPage ;
195166
167+ type TaskBuildingWorkspaceProps = { task :Task } ;
168+
169+ const TaskBuildingWorkspace :FC < TaskBuildingWorkspaceProps > = ( { task} ) => {
170+ const { data :template } = useQuery (
171+ templateQueryOptions ( task . workspace . template_id ) ,
172+ ) ;
173+
174+ const buildLogs = useWorkspaceBuildLogs ( task ?. workspace . latest_build . id ) ;
175+
176+ // If no template yet, use an indeterminate progress bar.
177+ const transitionStats = ( template &&
178+ getActiveTransitionStats ( template , task . workspace ) ) || {
179+ P50 :0 ,
180+ P95 :null ,
181+ } ;
182+
183+ const scrollAreaRef = useRef < HTMLDivElement > ( null ) ;
184+ // biome-ignore lint/correctness/useExhaustiveDependencies: this effect should run when build logs change
185+ useEffect ( ( ) => {
186+ if ( isChromatic ( ) ) {
187+ return ;
188+ }
189+ const scrollAreaEl = scrollAreaRef . current ;
190+ const scrollAreaViewportEl = scrollAreaEl ?. querySelector < HTMLDivElement > (
191+ "[data-radix-scroll-area-viewport]" ,
192+ ) ;
193+ if ( scrollAreaViewportEl ) {
194+ scrollAreaViewportEl . scrollTop = scrollAreaViewportEl . scrollHeight ;
195+ }
196+ } , [ buildLogs ] ) ;
197+
198+ return (
199+ < section className = "w-full h-full flex justify-center items-center p-6 overflow-y-auto" >
200+ < div className = "flex flex-col gap-6 items-center w-full" >
201+ < header className = "flex flex-col items-center text-center" >
202+ < h3 className = "m-0 font-medium text-content-primary text-xl" >
203+ Starting your workspace
204+ </ h3 >
205+ < div className = "text-content-secondary" >
206+ Your task will be running in a few moments
207+ </ div >
208+ </ header >
209+
210+ < div className = "w-full max-w-screen-lg flex flex-col gap-4 overflow-hidden" >
211+ < WorkspaceBuildProgress
212+ workspace = { task . workspace }
213+ transitionStats = { transitionStats }
214+ variant = "task"
215+ />
216+
217+ < ScrollArea
218+ ref = { scrollAreaRef }
219+ className = "h-96 border border-solid border-border rounded-lg"
220+ >
221+ < WorkspaceBuildLogs
222+ sticky
223+ className = "border-0 rounded-none"
224+ logs = { buildLogs ?? [ ] }
225+ />
226+ </ ScrollArea >
227+ </ div >
228+ </ div >
229+ </ section >
230+ ) ;
231+ } ;
232+
196233export class WorkspaceDoesNotHaveAITaskError extends Error {
197234constructor ( workspace :Workspace ) {
198235super (
@@ -228,3 +265,7 @@ export const data = {
228265} satisfies Task ;
229266} ,
230267} ;
268+
269+ const ellipsizeText = ( text :string , maxLength = 80 ) :string => {
270+ return text . length <= maxLength ?text :`${ text . slice ( 0 , maxLength - 3 ) } ...` ;
271+ } ;