Skip to content

Commit e86b9e2

Browse files
BrendonovichBenGu3
authored andcommitted
feat(app): /new-session route for new design (anomalyco#31457)
1 parent a4507bf commit e86b9e2

15 files changed

Lines changed: 320 additions & 61 deletions

packages/app/src/app.tsx

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { Font } from "@opencode-ai/ui/font"
99
import { Splash } from "@opencode-ai/ui/logo"
1010
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
1111
import { MetaProvider } from "@solidjs/meta"
12-
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
12+
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
1313
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
1414
import { Effect } from "effect"
1515
import {
@@ -43,25 +43,88 @@ import { PromptProvider } from "@/context/prompt"
4343
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
4444
import { SettingsProvider, useSettings } from "@/context/settings"
4545
import { TerminalProvider } from "@/context/terminal"
46-
import { TabsProvider } from "@/context/tabs"
46+
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
47+
import { SDKProvider, useSDK } from "@/context/sdk"
4748
import { WslServersProvider } from "@/wsl/context"
48-
import DirectoryLayout from "@/pages/directory-layout"
49+
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
4950
import Layout from "@/pages/layout"
5051
import { ErrorPage } from "./pages/error"
5152
import { useCheckServerHealth } from "./utils/server-health"
5253

5354
const HomeRoute = lazy(() => import("@/pages/home"))
5455
const Session = lazy(() => import("@/pages/session"))
56+
const NewSession = lazy(() => import("@/pages/new-session"))
5557

5658
const SessionRoute = Object.assign(
57-
() => (
58-
<SessionProviders>
59-
<Session />
60-
</SessionProviders>
61-
),
59+
() => {
60+
const settings = useSettings()
61+
const params = useParams()
62+
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
63+
const sdk = useSDK()
64+
const server = useServer()
65+
const tabs = useTabs()
66+
67+
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
68+
// is replaced by a draft at /new-session?draftId=…
69+
createEffect(() => {
70+
if (!settings.general.newLayoutDesigns()) return
71+
if (params.id || search.draftId) return
72+
if (!tabs.ready() || !sdk.directory) return
73+
tabs.newDraft({ server: server.key, directory: sdk.directory }, search.prompt)
74+
})
75+
76+
return (
77+
<SessionProviders>
78+
<Session />
79+
</SessionProviders>
80+
)
81+
},
6282
{ preload: Session.preload },
6383
)
6484

85+
function DraftRoute() {
86+
const [search] = useSearchParams<{ draftId?: string }>()
87+
const tabs = useTabs()
88+
return (
89+
<Show when={tabs.ready()}>
90+
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
91+
{(draftID) => <ResolvedDraftRoute draftID={draftID} />}
92+
</Show>
93+
</Show>
94+
)
95+
}
96+
97+
function ResolvedDraftRoute(props: { draftID: string }) {
98+
const server = useServer()
99+
const tabs = useTabs()
100+
const draft = createMemo(() =>
101+
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
102+
)
103+
104+
createEffect(() => {
105+
const current = draft()
106+
if (current && current.server !== server.key) server.setActive(current.server)
107+
})
108+
109+
// Key on the directory so retargeting the draft's project re-instantiates the
110+
// SDK/data providers for the new directory while keeping the same draft id.
111+
const directory = () => draft()?.directory
112+
113+
return (
114+
<Show when={directory()} keyed>
115+
{(dir) => (
116+
<SDKProvider directory={dir}>
117+
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
118+
<DraftProviders>
119+
<NewSession />
120+
</DraftProviders>
121+
</DirectoryDataProvider>
122+
</SDKProvider>
123+
)}
124+
</Show>
125+
)
126+
}
127+
65128
function UiI18nBridge(props: ParentProps) {
66129
const language = useLanguage()
67130
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
@@ -141,6 +204,18 @@ function SessionProviders(props: ParentProps) {
141204
)
142205
}
143206

207+
// The draft page only renders the prompt composer, so it drops TerminalProvider.
208+
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
209+
function DraftProviders(props: ParentProps) {
210+
return (
211+
<FileProvider>
212+
<PromptProvider>
213+
<CommentsProvider>{props.children}</CommentsProvider>
214+
</PromptProvider>
215+
</FileProvider>
216+
)
217+
}
218+
144219
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
145220
return (
146221
<AppShellProviders>
@@ -335,6 +410,7 @@ export function AppInterface(props: {
335410
)}
336411
>
337412
<Route path="/" component={HomeRoute} />
413+
<Route path="/new-session" component={DraftRoute} />
338414
<Route path="/:dir" component={DirectoryLayout}>
339415
<Route path="/" component={() => <Navigate href="session" />} />
340416
<Route path="/session/:id?" component={SessionRoute} />

packages/app/src/components/file-tree.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ beforeAll(async () => {
88
mock.module("@solidjs/router", () => ({
99
useNavigate: () => () => undefined,
1010
useParams: () => ({}),
11+
useLocation: () => ({}),
12+
useSearchParams: () => [{}, () => undefined],
1113
}))
1214
mock.module("@/context/file", () => ({
1315
useFile: () => ({

packages/app/src/components/prompt-input.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ import {
3131
FileAttachmentPart,
3232
} from "@/context/prompt"
3333
import { useLayout } from "@/context/layout"
34-
import { useNavigate } from "@solidjs/router"
34+
import { useNavigate, useSearchParams } from "@solidjs/router"
3535
import { useSDK } from "@/context/sdk"
3636
import { useServer } from "@/context/server"
37+
import { useTabs } from "@/context/tabs"
3738
import { useSync } from "@/context/sync"
3839
import { useComments } from "@/context/comments"
3940
import { Button } from "@opencode-ai/ui/button"
@@ -144,6 +145,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
144145
const platform = usePlatform()
145146
const pickDirectory = useDirectoryPicker()
146147
const settings = useSettings()
148+
const tabsStore = useTabs()
149+
const [search] = useSearchParams<{ draftId?: string }>()
147150
const { params, tabs, view } = useSessionLayout()
148151
let editorRef!: HTMLDivElement
149152
let fileInputRef: HTMLInputElement | undefined
@@ -1398,6 +1401,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
13981401
}
13991402
layout.projects.open(worktree)
14001403
server.projects.touch(worktree)
1404+
1405+
// On the draft route, retarget the existing draft in place so we keep the same
1406+
// draft id (and its tab/prompt) instead of spawning a new draft for the new directory.
1407+
const draftID = search.draftId
1408+
if (draftID) {
1409+
tabsStore.updateDraft(draftID, { server: server.key, directory: worktree })
1410+
restoreFocus()
1411+
return
1412+
}
1413+
14011414
navigate(`/${base64Encode(worktree)}/session`)
14021415
}
14031416
const addProject = () => {

packages/app/src/components/prompt-input/submit.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ beforeAll(async () => {
6161
mock.module("@solidjs/router", () => ({
6262
useNavigate: () => () => undefined,
6363
useParams: () => params,
64+
useLocation: () => ({}),
65+
useSearchParams: () => [{}, () => undefined],
6466
}))
6567

6668
mock.module("@opencode-ai/sdk/v2/client", () => ({
@@ -103,6 +105,16 @@ beforeAll(async () => {
103105
}),
104106
}))
105107

108+
mock.module("@/context/server", () => ({
109+
useServer: () => ({ key: "server-key" }),
110+
}))
111+
112+
mock.module("@/context/tabs", () => ({
113+
useTabs: () => ({
114+
promoteDraft: () => undefined,
115+
}),
116+
}))
117+
106118
mock.module("@/context/prompt", () => ({
107119
usePrompt: () => ({
108120
current: () => promptValue,

packages/app/src/components/prompt-input/submit.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import type { Message, Session } from "@opencode-ai/sdk/v2/client"
22
import { showToast } from "@/utils/toast"
33
import { base64Encode } from "@opencode-ai/core/util/encode"
44
import { Binary } from "@opencode-ai/core/util/binary"
5-
import { useNavigate, useParams } from "@solidjs/router"
5+
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
66
import { batch, type Accessor } from "solid-js"
77
import type { FileSelection } from "@/context/file"
8+
import { useServer } from "@/context/server"
9+
import { useTabs } from "@/context/tabs"
810
import { useServerSync } from "@/context/server-sync"
911
import { useLanguage } from "@/context/language"
1012
import { useLayout } from "@/context/layout"
@@ -213,6 +215,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
213215
const layout = useLayout()
214216
const language = useLanguage()
215217
const params = useParams()
218+
const [search] = useSearchParams<{ draftId?: string }>()
219+
const server = useServer()
220+
const tabs = useTabs()
216221
const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID)
217222

218223
const errorMessage = (err: unknown) => {
@@ -381,7 +386,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
381386
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
382387
local.session.promote(sessionDirectory, session.id)
383388
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
384-
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
389+
const draftID = search.draftId
390+
if (draftID)
391+
tabs.promoteDraft(draftID, {
392+
server: server.key,
393+
dirBase64: base64Encode(sessionDirectory),
394+
sessionId: session.id,
395+
})
396+
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
385397
}
386398
}
387399
if (!session) {

packages/app/src/components/titlebar.tsx

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
280280

281281
const matchRoute = (route: LayoutRoute) => {
282282
if (route.type === "home") return
283-
if (route.type === "dir-new-sesssion") {
283+
if (route.type === "draft") {
284+
return tabsStore.find((item) => item.type === "draft" && item.draftID === route.draftID)
284285
}
285286
if (route.type === "session") {
286287
const main = tabsStore.find(
@@ -447,13 +448,33 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
447448
refreshTabsAreOverflowing()
448449
})
449450

450-
if (tab.type !== "session") return null
451+
const divider = () =>
452+
i() !== 0 && (
453+
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
454+
)
455+
456+
if (tab.type === "draft") {
457+
return (
458+
<>
459+
{divider()}
460+
<DraftTabItem
461+
ref={ref}
462+
href={tabHref(tab)}
463+
title={language.t("command.session.new")}
464+
active={currentTab() === tab}
465+
onNavigate={() => {
466+
navigateTab(tab)
467+
ref.scrollIntoView({ behavior: "instant" })
468+
}}
469+
onClose={() => tabsStoreActions.removeTab(i())}
470+
/>
471+
</>
472+
)
473+
}
451474

452475
return (
453476
<>
454-
{i() !== 0 && (
455-
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
456-
)}
477+
{divider()}
457478
<TabNavItem
458479
ref={ref}
459480
href={tabHref(tab)}
@@ -784,7 +805,6 @@ function TabNavItem(props: {
784805
>
785806
<Show when={session.latest}>
786807
{(session) => {
787-
console.log({ session: session() })
788808
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
789809

790810
return (
@@ -853,6 +873,59 @@ function ProjectTabAvatar(props: {
853873
)
854874
}
855875

876+
function DraftTabItem(props: {
877+
ref?: HTMLDivElement
878+
href: string
879+
title: string
880+
active?: boolean
881+
onNavigate: () => void
882+
onClose: () => void
883+
}) {
884+
const closeTab = (event: MouseEvent) => {
885+
event.preventDefault()
886+
event.stopPropagation()
887+
props.onClose()
888+
}
889+
return (
890+
<div
891+
ref={props.ref}
892+
data-active={props.active}
893+
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
894+
onMouseDown={(event) => {
895+
if (event.button !== 1) return
896+
closeTab(event)
897+
}}
898+
>
899+
<a
900+
href={props.href}
901+
onClick={(event) => {
902+
event.preventDefault()
903+
props.onNavigate()
904+
}}
905+
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
906+
>
907+
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
908+
<IconV2 name="edit" />
909+
</span>
910+
<span class="truncate leading-5">{props.title}</span>
911+
</a>
912+
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
913+
<IconButtonV2
914+
size="small"
915+
variant="ghost-muted"
916+
onMouseDown={(event) => {
917+
event.preventDefault()
918+
event.stopPropagation()
919+
}}
920+
onClick={closeTab}
921+
icon={<IconV2 name="xmark-small" />}
922+
aria-label="Close tab"
923+
/>
924+
</div>
925+
</div>
926+
)
927+
}
928+
856929
function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: string; onClose: () => void }) {
857930
const closeTab = (event: MouseEvent) => {
858931
event.preventDefault()

packages/app/src/context/comments.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ beforeAll(async () => {
88
mock.module("@solidjs/router", () => ({
99
useNavigate: () => () => undefined,
1010
useParams: () => ({}),
11+
useLocation: () => ({}),
12+
useSearchParams: () => [{}, () => undefined],
1113
}))
1214
mock.module("@opencode-ai/ui/context", () => ({
1315
createSimpleContext: () => ({

0 commit comments

Comments
 (0)