1- // The bot's own browser beside its chat (spec 21.8): the page it is on,
2- // live, with its cursor and a line about what it just did. Watching starts
3- // when the panel opens and stops when it closes. The owner can take it into
4- // their own hands and give it back (spec 21.10).
1+ // The bot's own browser beside its chat (spec 21.8): its tabs, the address
2+ // and the page it is on, live, with its cursor and a line about what it
3+ // just did. Watching starts when the panel opens and stops when it closes.
4+ // The owner can take it into their own hands and give it back (spec 21.10).
55
6- import { ExternalLink , Globe , LoaderCircle , Lock , Maximize2 , Minimize2 , X } from "lucide-react" ;
6+ import { Globe , LoaderCircle , Maximize2 , Minimize2 , X } from "lucide-react" ;
77import { useEffect , useRef , useState } from "react" ;
88import { useT } from "../../i18n" ;
9- import type { Bot , BrowserState } from "../../lib/protocol.gen" ;
10- import { useApp , useHost } from "../../store/context" ;
9+ import type { Bot } from "../../lib/protocol.gen" ;
10+ import { useApp } from "../../store/context" ;
1111import { Button } from "../../ui/Button" ;
1212import { Callout } from "../../ui/Callout" ;
1313import { SidePanel } from "../../ui/SidePanel" ;
14- import { attempt } from "../../ui/toast" ;
1514import { BotAvatar } from "../bots/BotAvatar" ;
15+ import { AddressBar } from "./AddressBar" ;
1616import { AskCallout , HeldBar , TakeBar } from "./HandsBars" ;
1717import { HandsLayer } from "./HandsLayer" ;
1818import { LiveView , useCaption } from "./LiveView" ;
19+ import { TabStrip } from "./TabStrip" ;
1920import { useBrowserView } from "./useBrowserView" ;
2021import { useHands } from "./useHands" ;
2122import { useFitPage , useRoom } from "./useRoom" ;
@@ -53,6 +54,13 @@ export function BrowserPanel({
5354 height : inside . height - 2 * PAD - UNDER ,
5455 } ;
5556 useFitPage ( bot , room , watched ) ;
57+ // A new tab is blank: the owner says where it goes.
58+ const address = useRef < HTMLInputElement > ( null ) ;
59+ const addTab = async ( ) => {
60+ if ( await hands . newTab ( ) ) {
61+ address . current ?. focus ( ) ;
62+ }
63+ } ;
5664 // Taking needs the watch first: the daemon ties the hands to it.
5765 const taken = useRef ( 0 ) ;
5866 useEffect ( ( ) => {
@@ -88,7 +96,12 @@ export function BrowserPanel({
8896 < Button variant = "ghost" size = "sm" icon = { X } label = { t . close } onClick = { onClose } />
8997 </ div >
9098 </ header >
91- { state && ( status === "open" || status === "starting" ) && < AddressBar state = { state } /> }
99+ { state && ( status === "open" || status === "starting" ) && (
100+ < >
101+ { state . tabs . length > 0 && < TabStrip state = { state } hands = { hands } onAdd = { addTab } /> }
102+ < AddressBar bot = { bot } state = { state } hands = { hands } field = { address } />
103+ </ >
104+ ) }
92105 < div
93106 ref = { body }
94107 className = "flex min-h-0 flex-1 flex-col overflow-y-auto"
@@ -111,7 +124,15 @@ export function BrowserPanel({
111124 ) : (
112125 < >
113126 { live && ask && ! hands . held && < AskCallout bot = { bot } task = { ask } hands = { hands } /> }
114- { hands . held && < HeldBar bot = { bot } task = { ask } focused = { focused } hands = { hands } /> }
127+ { hands . held && (
128+ < HeldBar
129+ bot = { bot }
130+ task = { ask }
131+ tabs = { state ?. tabs . length ?? 0 }
132+ focused = { focused }
133+ hands = { hands }
134+ />
135+ ) }
115136 < div className = "min-h-40 flex-1" style = { { containerType : "size" } } >
116137 < div className = "relative" >
117138 < LiveView
@@ -160,44 +181,6 @@ export function BrowserPanel({
160181 ) ;
161182}
162183
163- function AddressBar ( { state } : { state : BrowserState } ) {
164- const t = useT ( ) . browser ;
165- const host = useHost ( ) ;
166- const url = state . url && state . url !== "about:blank" ? state . url : null ;
167- const web = url !== null && / ^ h t t p s ? : \/ \/ / i. test ( url ) ;
168- const Icon = state . loading ? LoaderCircle : url ?. startsWith ( "https://" ) ? Lock : Globe ;
169- return (
170- < div className = "flex h-11 shrink-0 items-center gap-2 border-line border-b px-3" >
171- < div className = "flex min-w-0 flex-1 items-center gap-2 rounded-full bg-sunken px-3 py-1.5" >
172- < Icon
173- aria-label = { state . loading ? t . loading : undefined }
174- aria-hidden = { ! state . loading }
175- size = { 13 }
176- className = { `shrink-0 ${ state . loading ? "animate-spin text-work" : "text-muted" } ` }
177- />
178- < input
179- readOnly
180- type = "text"
181- aria-label = { t . address }
182- title = { url ?? undefined }
183- value = { url ?? state . title ?? "" }
184- className = "min-w-0 flex-1 truncate bg-transparent font-mono text-ink-soft text-xs outline-none"
185- />
186- </ div >
187- { state . tabs > 1 && < span className = "shrink-0 text-muted text-xs" > { t . tabs ( state . tabs ) } </ span > }
188- { web && url && (
189- < Button
190- variant = "ghost"
191- size = "sm"
192- icon = { ExternalLink }
193- label = { t . openOutside }
194- onClick = { ( ) => attempt ( t . openFailed , ( ) => host . openUrl ( url ) ) }
195- />
196- ) }
197- </ div >
198- ) ;
199- }
200-
201184function Overlay ( { busy, title, body } : { busy : boolean ; title : string ; body : string | null } ) {
202185 return (
203186 < div className = "absolute inset-0 grid place-items-center rounded-xl" >
0 commit comments