From 384cff3768bd209f7a7ecd33f44a00359a1f8d2f Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:16:07 +1000 Subject: [PATCH] refactor(app): establish session vertical slice (#43637) --- .../reproduction/timeline-suspense/main.tsx | 2 +- packages/app/src/app.tsx | 2 +- .../app/src/components/command-palette.ts | 4 +- .../app/src/components/prompt-input-v2.tsx | 2 +- packages/app/src/components/session/index.ts | 5 - .../src/pages/layout/project-avatar-state.ts | 2 +- .../new-session-draft-controller.ts | 8 +- .../new-session-layout.ts | 0 .../pages/new-session/new-session-view.tsx | 2 +- packages/app/src/pages/session.tsx | 2005 ----------------- .../src/pages/session/terminal-panel-v2.tsx | 322 --- .../commands}/use-composer-commands.tsx | 4 +- .../commands}/use-session-commands.tsx | 123 +- .../src/session/composer/active-session.tsx | 416 ++++ .../src/{pages => }/session/composer/index.ts | 0 .../composer/prompt-model-selection.ts | 0 .../composer/session-composer-controls.ts | 1 - .../composer/session-composer-pullout.tsx | 0 .../session-composer-region-controller.ts | 2 +- .../composer/session-composer-region.tsx | 10 +- .../composer/session-composer-state.test.ts | 2 +- .../composer/session-composer-state.ts | 4 +- .../composer/session-followup-dock.tsx | 0 .../composer}/session-new-view.tsx | 0 .../composer/session-revert-dock.stories.tsx | 4 +- .../session/composer/session-revert-dock.tsx | 0 .../files}/file-tab-scroll.test.ts | 0 .../files}/file-tab-scroll.ts | 0 .../session => session/files}/file-tabs.tsx | 10 +- .../files/list.tsx} | 4 +- .../files/open-in-app-button.tsx} | 4 +- .../session => session/files}/open-in-app.tsx | 0 .../files}/session-context-format.ts | 0 .../files}/session-context-tab.tsx | 2 +- .../files}/session-file-browser-tab.tsx | 10 +- .../files}/session-side-panel.tsx | 27 +- .../files}/session-sortable-tab.tsx | 0 .../files/tab.tsx} | 2 +- .../app/src/{pages => }/session/handoff.ts | 0 .../header}/session-header-actions.tsx | 4 +- .../header}/session-header.tsx | 14 +- .../src/{pages => }/session/helpers.test.ts | 0 .../app/src/{pages => }/session/helpers.ts | 2 +- .../session/message-gesture.test.ts | 0 .../{pages => }/session/message-gesture.ts | 0 .../session/message-id-from-hash.ts | 0 .../model.test.ts} | 0 .../model.ts} | 38 +- .../requests}/session-background-dock.tsx | 2 +- .../requests}/session-permission-dock.tsx | 0 .../requests}/session-question-dock.tsx | 0 .../requests}/session-request-tree.ts | 0 packages/app/src/session/review/model.ts | 427 ++++ .../review/panel-state.ts} | 4 +- .../review/panel.tsx} | 28 +- .../review}/review-diff-kinds.test.ts | 0 .../review}/review-diff-kinds.ts | 0 .../session => session/review}/review-tab.tsx | 0 packages/app/src/session/review/view.tsx | 224 ++ packages/app/src/session/route-error.tsx | 56 + packages/app/src/session/route.tsx | 122 + packages/app/src/session/screen-layout.ts | 110 + packages/app/src/session/screen.tsx | 202 ++ .../src/{pages => }/session/session-domain.ts | 0 .../src/{pages => }/session/session-frame.tsx | 0 .../src/{pages => }/session/session-layout.ts | 0 .../session/session-model-helpers.test.ts | 0 .../session/session-model-helpers.ts | 0 .../{pages => }/session/session-ownership.ts | 0 .../session/session-panel-layout.test.ts | 0 .../session/session-panel-layout.ts | 0 .../session/session-panel-width.test.ts | 0 .../session/session-panel-width.ts | 0 .../{pages => }/session/session-resolution.ts | 0 .../session-screen.stories.tsx} | 182 +- .../story-model.tsx} | 59 +- packages/app/src/session/terminal/panel.tsx | 305 +++ packages/app/src/session/terminal/surface.tsx | 64 + .../terminal/tab.tsx} | 16 +- .../terminal}/terminal-label.ts | 6 +- .../terminal}/terminal-panel.test.ts | 0 .../timeline/controller-projection.test.ts | 0 .../session/timeline/controller-projection.ts | 0 .../session/timeline/controller.tsx | 8 +- .../app/src/session/timeline/interaction.ts | 328 +++ .../session/timeline/measure.test.ts | 0 .../{pages => }/session/timeline/measure.ts | 0 .../session/timeline/message-timeline.tsx | 32 +- .../session/timeline/model.test.ts | 0 .../src/{pages => }/session/timeline/model.ts | 4 +- .../timeline/observe-element-offset.test.ts | 0 .../timeline/observe-element-offset.ts | 0 .../session/timeline/projection.ts | 0 .../timeline}/session-context-usage.tsx | 4 +- .../timeline}/session-workspace-menu.tsx | 0 .../session/timeline/virtual-items.ts | 0 .../session/timeline/virtualizer.tsx | 2 +- .../session/usage-exceeded-dialogs.tsx | 2 +- .../session/use-session-hash-scroll.test.ts | 0 .../session/use-session-hash-scroll.ts | 0 ...ate.test.ts => review-panel-state.test.ts} | 7 +- .../test-browser/session-ownership.test.ts | 2 +- .../test-browser/session-resolution.test.ts | 2 +- .../app/test-browser/solid-virtual.test.ts | 2 +- 104 files changed, 2575 insertions(+), 2662 deletions(-) delete mode 100644 packages/app/src/components/session/index.ts rename packages/app/src/pages/{session => new-session}/new-session-layout.ts (100%) delete mode 100644 packages/app/src/pages/session.tsx delete mode 100644 packages/app/src/pages/session/terminal-panel-v2.tsx rename packages/app/src/{pages/session => session/commands}/use-composer-commands.tsx (96%) rename packages/app/src/{pages/session => session/commands}/use-session-commands.tsx (81%) create mode 100644 packages/app/src/session/composer/active-session.tsx rename packages/app/src/{pages => }/session/composer/index.ts (100%) rename packages/app/src/{pages => }/session/composer/prompt-model-selection.ts (100%) rename packages/app/src/{pages => }/session/composer/session-composer-controls.ts (98%) rename packages/app/src/{pages => }/session/composer/session-composer-pullout.tsx (100%) rename packages/app/src/{pages => }/session/composer/session-composer-region-controller.ts (97%) rename packages/app/src/{pages => }/session/composer/session-composer-region.tsx (93%) rename packages/app/src/{pages => }/session/composer/session-composer-state.test.ts (97%) rename packages/app/src/{pages => }/session/composer/session-composer-state.ts (98%) rename packages/app/src/{pages => }/session/composer/session-followup-dock.tsx (100%) rename packages/app/src/{components/session => session/composer}/session-new-view.tsx (100%) rename packages/app/src/{pages => }/session/composer/session-revert-dock.stories.tsx (95%) rename packages/app/src/{pages => }/session/composer/session-revert-dock.tsx (100%) rename packages/app/src/{pages/session => session/files}/file-tab-scroll.test.ts (100%) rename packages/app/src/{pages/session => session/files}/file-tab-scroll.ts (100%) rename packages/app/src/{pages/session => session/files}/file-tabs.tsx (98%) rename packages/app/src/{pages/session/v2/session-file-list-v2.tsx => session/files/list.tsx} (98%) rename packages/app/src/{components/session/open-in-app-v2.tsx => session/files/open-in-app-button.tsx} (96%) rename packages/app/src/{components/session => session/files}/open-in-app.tsx (100%) rename packages/app/src/{components/session => session/files}/session-context-format.ts (100%) rename packages/app/src/{components/session => session/files}/session-context-tab.tsx (99%) rename packages/app/src/{pages/session/v2 => session/files}/session-file-browser-tab.tsx (95%) rename packages/app/src/{pages/session => session/files}/session-side-panel.tsx (96%) rename packages/app/src/{components/session => session/files}/session-sortable-tab.tsx (100%) rename packages/app/src/{components/session/session-sortable-tab-v2.tsx => session/files/tab.tsx} (98%) rename packages/app/src/{pages => }/session/handoff.ts (100%) rename packages/app/src/{components/session => session/header}/session-header-actions.tsx (92%) rename packages/app/src/{components/session => session/header}/session-header.tsx (70%) rename packages/app/src/{pages => }/session/helpers.test.ts (100%) rename packages/app/src/{pages => }/session/helpers.ts (99%) rename packages/app/src/{pages => }/session/message-gesture.test.ts (100%) rename packages/app/src/{pages => }/session/message-gesture.ts (100%) rename packages/app/src/{pages => }/session/message-id-from-hash.ts (100%) rename packages/app/src/{pages/session/session-controller.test.ts => session/model.test.ts} (100%) rename packages/app/src/{pages/session/session-controller.ts => session/model.ts} (69%) rename packages/app/src/{pages/session/composer => session/requests}/session-background-dock.tsx (97%) rename packages/app/src/{pages/session/composer => session/requests}/session-permission-dock.tsx (100%) rename packages/app/src/{pages/session/composer => session/requests}/session-question-dock.tsx (100%) rename packages/app/src/{pages/session/composer => session/requests}/session-request-tree.ts (100%) create mode 100644 packages/app/src/session/review/model.ts rename packages/app/src/{pages/session/v2/review-panel-v2-state.ts => session/review/panel-state.ts} (90%) rename packages/app/src/{pages/session/v2/review-panel-v2.tsx => session/review/panel.tsx} (93%) rename packages/app/src/{pages/session/v2 => session/review}/review-diff-kinds.test.ts (100%) rename packages/app/src/{pages/session/v2 => session/review}/review-diff-kinds.ts (100%) rename packages/app/src/{pages/session => session/review}/review-tab.tsx (100%) create mode 100644 packages/app/src/session/review/view.tsx create mode 100644 packages/app/src/session/route-error.tsx create mode 100644 packages/app/src/session/route.tsx create mode 100644 packages/app/src/session/screen-layout.ts create mode 100644 packages/app/src/session/screen.tsx rename packages/app/src/{pages => }/session/session-domain.ts (100%) rename packages/app/src/{pages => }/session/session-frame.tsx (100%) rename packages/app/src/{pages => }/session/session-layout.ts (100%) rename packages/app/src/{pages => }/session/session-model-helpers.test.ts (100%) rename packages/app/src/{pages => }/session/session-model-helpers.ts (100%) rename packages/app/src/{pages => }/session/session-ownership.ts (100%) rename packages/app/src/{pages => }/session/session-panel-layout.test.ts (100%) rename packages/app/src/{pages => }/session/session-panel-layout.ts (100%) rename packages/app/src/{pages => }/session/session-panel-width.test.ts (100%) rename packages/app/src/{pages => }/session/session-panel-width.ts (100%) rename packages/app/src/{pages => }/session/session-resolution.ts (100%) rename packages/app/src/{pages/session/session-preview.stories.tsx => session/session-screen.stories.tsx} (57%) rename packages/app/src/{pages/session/session-preview.tsx => session/story-model.tsx} (86%) create mode 100644 packages/app/src/session/terminal/panel.tsx create mode 100644 packages/app/src/session/terminal/surface.tsx rename packages/app/src/{components/session/session-sortable-terminal-tab-v2.tsx => session/terminal/tab.tsx} (90%) rename packages/app/src/{pages/session => session/terminal}/terminal-label.ts (61%) rename packages/app/src/{pages/session => session/terminal}/terminal-panel.test.ts (100%) rename packages/app/src/{pages => }/session/timeline/controller-projection.test.ts (100%) rename packages/app/src/{pages => }/session/timeline/controller-projection.ts (100%) rename packages/app/src/{pages => }/session/timeline/controller.tsx (97%) create mode 100644 packages/app/src/session/timeline/interaction.ts rename packages/app/src/{pages => }/session/timeline/measure.test.ts (100%) rename packages/app/src/{pages => }/session/timeline/measure.ts (100%) rename packages/app/src/{pages => }/session/timeline/message-timeline.tsx (97%) rename packages/app/src/{pages => }/session/timeline/model.test.ts (100%) rename packages/app/src/{pages => }/session/timeline/model.ts (95%) rename packages/app/src/{pages => }/session/timeline/observe-element-offset.test.ts (100%) rename packages/app/src/{pages => }/session/timeline/observe-element-offset.ts (100%) rename packages/app/src/{pages => }/session/timeline/projection.ts (100%) rename packages/app/src/{components => session/timeline}/session-context-usage.tsx (97%) rename packages/app/src/{components => session/timeline}/session-workspace-menu.tsx (100%) rename packages/app/src/{pages => }/session/timeline/virtual-items.ts (100%) rename packages/app/src/{pages => }/session/timeline/virtualizer.tsx (99%) rename packages/app/src/{pages => }/session/usage-exceeded-dialogs.tsx (97%) rename packages/app/src/{pages => }/session/use-session-hash-scroll.test.ts (100%) rename packages/app/src/{pages => }/session/use-session-hash-scroll.ts (100%) rename packages/app/test-browser/{review-panel-v2-state.test.ts => review-panel-state.test.ts} (86%) diff --git a/packages/app/e2e/reproduction/timeline-suspense/main.tsx b/packages/app/e2e/reproduction/timeline-suspense/main.tsx index cdcd809be3b..fc6507c8453 100644 --- a/packages/app/e2e/reproduction/timeline-suspense/main.tsx +++ b/packages/app/e2e/reproduction/timeline-suspense/main.tsx @@ -1,7 +1,7 @@ import { createResource, createSignal, For, onMount, Suspense } from "solid-js" import { render } from "solid-js/web" import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual" -import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset" +import { observeElementOffsetReconnectAware } from "../../../src/session/timeline/observe-element-offset" const rowCount = 2_000 const rowHeight = 40 diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 62b697f63ad..ba00623de59 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -36,7 +36,7 @@ import { ServerProvider } from "./context/server" const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File }))) const loadDraftRoute = () => Promise.all([import("@/pages/draft-route"), File.preload()]).then(([module]) => module) -const loadSessionRoute = () => Promise.all([import("@/pages/session"), File.preload()]).then(([module]) => module) +const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module) const DraftRoute = lazy(() => loadDraftRoute().then((module) => ({ default: module.DraftRoute }))) const TargetSessionRouteContent = lazy(() => loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })), diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts index 1f51bf44553..e298a904501 100644 --- a/packages/app/src/components/command-palette.ts +++ b/packages/app/src/components/command-palette.ts @@ -12,8 +12,8 @@ import { ServerConnection } from "@/context/servers" import { useServerSDK } from "@/context/server-sdk" import { useTabs } from "@/context/tabs" import { displayName, projectForSession } from "@/pages/layout/helpers" -import { createSessionTabs } from "@/pages/session/helpers" -import { useSessionLayout } from "@/pages/session/session-layout" +import { createSessionTabs } from "@/session/helpers" +import { useSessionLayout } from "@/session/session-layout" import { useServer } from "@/context/server" export type CommandPaletteEntry = { diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 1a1620c8797..b6e4516c634 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -24,7 +24,7 @@ import { type ImageAttachmentPart, usePrompt } from "@/context/prompt" import { usePlatform } from "@/context/platform" import { useWorkspaceLocation } from "@/context/location" import { useData } from "@/context/server" -import { createSessionTabs } from "@/pages/session/helpers" +import { createSessionTabs } from "@/session/helpers" import { showToast } from "@/utils/toast" import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input" import { diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts deleted file mode 100644 index ec3e584b598..00000000000 --- a/packages/app/src/components/session/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { SessionHeader } from "./session-header" -export { SessionContextTab } from "./session-context-tab" -export { FileVisual } from "./session-sortable-tab" -export { SortableTabV2 } from "./session-sortable-tab-v2" -export { NewSessionView } from "./session-new-view" diff --git a/packages/app/src/pages/layout/project-avatar-state.ts b/packages/app/src/pages/layout/project-avatar-state.ts index c77e0dd6f55..9cd229c405e 100644 --- a/packages/app/src/pages/layout/project-avatar-state.ts +++ b/packages/app/src/pages/layout/project-avatar-state.ts @@ -1,6 +1,6 @@ import { createMemo, type Accessor } from "solid-js" import { useGlobal, useServerCtx } from "@/context/global" -import { sessionPermissionRequest, sessionQuestionForm } from "@/pages/session/composer/session-request-tree" +import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree" import { ServerConnection } from "@/context/servers" export function useSessionTabAvatarState( diff --git a/packages/app/src/pages/new-session/new-session-draft-controller.ts b/packages/app/src/pages/new-session/new-session-draft-controller.ts index 3c922226604..d259815faef 100644 --- a/packages/app/src/pages/new-session/new-session-draft-controller.ts +++ b/packages/app/src/pages/new-session/new-session-draft-controller.ts @@ -4,10 +4,10 @@ import { usePromptInputV2Controller } from "@/components/prompt-input-v2" import { useComments } from "@/context/comments" import { useLocal } from "@/context/local" import { usePrompt } from "@/context/prompt" -import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer" -import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection" -import { useSessionKey } from "@/pages/session/session-layout" -import { useComposerCommands } from "@/pages/session/use-composer-commands" +import { createPromptInputController, createPromptProjectControls } from "@/session/composer" +import { createPromptModelSelection } from "@/session/composer/prompt-model-selection" +import { useSessionKey } from "@/session/session-layout" +import { useComposerCommands } from "@/session/commands/use-composer-commands" export function createNewSessionDraftController(props: { worktree: () => string diff --git a/packages/app/src/pages/session/new-session-layout.ts b/packages/app/src/pages/new-session/new-session-layout.ts similarity index 100% rename from packages/app/src/pages/session/new-session-layout.ts rename to packages/app/src/pages/new-session/new-session-layout.ts diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx index 6cd73f4ae1b..50f164b50ab 100644 --- a/packages/app/src/pages/new-session/new-session-view.tsx +++ b/packages/app/src/pages/new-session/new-session-view.tsx @@ -17,7 +17,7 @@ import { StatusPopoverV2 } from "@/components/status-popover" import { useLanguage } from "@/context/language" import { useWorkspaceLocation } from "@/context/location" import { useProviders } from "@/hooks/use-providers" -import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" +import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/new-session/new-session-layout" import { Persist, persisted } from "@/utils/persist" import type { NewSessionDraftController } from "./new-session-draft-controller" import type { NewSessionWorkspaceController } from "./new-session-workspace-controller" diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx deleted file mode 100644 index a31cfb25457..00000000000 --- a/packages/app/src/pages/session.tsx +++ /dev/null @@ -1,2005 +0,0 @@ -import type { FileDiffInfo, SessionMessageUser } from "@opencode-ai/client/promise" -import type { SessionUserActions } from "@opencode-ai/session-ui/message" -import { getFilename } from "@opencode-ai/util/path" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query" -import { - batch, - ErrorBoundary, - onCleanup, - Suspense, - Show, - Match, - Switch, - createMemo, - createEffect, - createComputed, - createSignal, - on, - onMount, - type ParentProps, - untrack, -} from "solid-js" -import { makeEventListener } from "@solid-primitives/event-listener" -import { createMediaQuery } from "@solid-primitives/media" -import { createResizeObserver } from "@solid-primitives/resize-observer" -import { debounce } from "@solid-primitives/scheduled" -import { useLocal } from "@/context/local" -import { FileProvider, selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" -import { createStore } from "solid-js/store" -import type { SessionReviewLineComment } from "@opencode-ai/session-ui/session-review" -import { ResizeHandle } from "@opencode-ai/ui/resize-handle" -import { Select } from "@opencode-ai/ui/select" -import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view" -import { Tabs } from "@opencode-ai/ui/tabs" -import { Button } from "@opencode-ai/ui/button" -import { createAutoScroll } from "@opencode-ai/ui/hooks" -import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge" -import { showToast } from "@/utils/toast" -import { checksum } from "@opencode-ai/util/encode" -import { containsDirectory, isWorkspaceDirectory } from "@/utils/workspace" -import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router" -import { NewSessionView, SessionHeader } from "@/components/session" -import { ErrorPage } from "@/pages/error" -import { CommentsProvider, useComments } from "@/context/comments" -import { useCommand } from "@/context/command" -import { SessionUIProvider } from "@/pages/directory-layout" -import { useData, useServer } from "@/context/server" -import { useLanguage } from "@/context/language" -import { useLayout } from "@/context/layout" -import { ModelsProvider } from "@/context/models" -import { useNotification } from "@/context/notification" -import { PromptProvider, usePrompt } from "@/context/prompt" -import { usePlatform } from "@/context/platform" -import { LocationProvider, useWorkspaceLocation } from "@/context/location" -import { useServerSDK } from "@/context/server-sdk" -import { ServerConnection, serverName, useServers } from "@/context/servers" -import { useSettings } from "@/context/settings" -import { useTabs } from "@/context/tabs" -import { TerminalProvider } from "@/context/terminal" -import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2" -import { useSettingsCommand } from "@/components/settings-dialog" -import { setCursorPosition } from "@/components/prompt-input/editor-dom" -import { promptLength } from "@/components/prompt-input/history" -import type { FollowupDraft } from "@/components/prompt-input/submit" -import { - createPromptInputController, - createSessionComposerController, - createSessionComposerRegionController, - SessionComposerRegion, -} from "@/pages/session/composer" -import { createOpenReviewFile, createSizing, shouldShowFileTree } from "@/pages/session/helpers" -import { MessageTimeline } from "@/pages/session/timeline/message-timeline" -import { createTimelineModel } from "@/pages/session/timeline/model" -import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" -import { createSessionController } from "@/pages/session/session-controller" -import { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers" -import { - clampSessionPanelWidth, - SESSION_PANEL_WIDTH_MIN, - sessionPanelWidthMax, -} from "@/pages/session/session-panel-width" -import { SessionSidePanel } from "@/pages/session/session-side-panel" -import { sessionPanelLayout } from "@/pages/session/session-panel-layout" -import { SessionPanelFrame, SessionRouteFrame } from "@/pages/session/session-frame" -import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2" -import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2" -import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2" -import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" -import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds" -import { TerminalPanelV2 } from "@/pages/session/terminal-panel-v2" -import { useComposerCommands } from "@/pages/session/use-composer-commands" -import { useSessionCommands } from "@/pages/session/use-session-commands" -import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" -import { SessionMessage } from "@opencode-ai/schema/session-message" -import { Persist, persisted } from "@/utils/persist" -import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors" -import { requireServerKey, sessionHref } from "@/utils/session-route" -import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" -import { createSessionResolution } from "./session/session-resolution" - -type FollowupItem = FollowupDraft & { id: string } -type FollowupEdit = Pick -const emptyFollowups: FollowupItem[] = [] - -type ChangeMode = "git" | "branch" | "turn" -type VcsMode = "git" | "branch" - -const sessionViewState = () => ({ - messageId: undefined as string | undefined, - mobileTab: "session" as "session" | "changes", -}) - -function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) { - if (!sessionID) return false - return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID) -} - -// Rendered under app.tsx's TargetSessionRoute, which owns the per-server keyed -// remount around the server-scoped providers. Nothing here may key on the -// session ID: session tabs on the same server share this route instance, and -// workspace-scoped state (terminal, directory providers) lives below. -export function TargetSessionRouteContent() { - const params = useParams<{ serverKey: string; id: string }>() - const data = useData() - const directory = createMemo(() => data.session.get(params.id)?.location.directory) - return ( - // Settings must keep the target-server SDK, sync, and models context and remain registered - // when session content falls back to the route error boundary. - <> - params.id} /> - - - - - - - - ) -} - -function TargetSessionSettingsCommand() { - useSettingsCommand() - return null -} - -export function SessionRouteErrorBoundary( - props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>, -) { - return ( - ( - - - - - - )} - > - {props.children} - - ) -} - -function SessionErrorFallback(props: { error: unknown; sessionID?: string; serverKey?: ServerConnection.Key }) { - const language = useLanguage() - const server = useServers() - const tabs = useTabs() - - const displayServer = createMemo(() => { - const key = props.serverKey - const conn = server.list.find((item) => ServerConnection.key(item) === key) - return conn ? serverName(conn) : key - }) - const closeTab = () => { - if (!props.sessionID || !props.serverKey) return - tabs.removeSessionTab({ server: props.serverKey, sessionId: props.sessionID }) - } - if (isCurrentSessionNotFoundError(props.error, props.sessionID)) { - return ( -
-
-
-
{language.t("session.error.notFound")}
-
- {language.t("session.error.notFound.description")} -
-
- - {(sessionID) => ( -
-
{displayServer()}
- - {sessionID()} - -
- )} -
- -
-
- ) - } - return -} - -function ResolvedTargetSessionRoute() { - const params = useParams<{ id: string }>() - const server = useServer() - const data = useData() - const current = createSessionResolution( - () => params.id, - () => data.session, - { children: true }, - ) - const directory = createMemo(() => current()?.location.directory) - - return ( - // Non-keyed: closes only while the target's directory is unknown (uncached - // session mid-resolution), which tears down the workspace subtree including - // the terminal. Same-workspace tab switches keep it open because warm - // targets resolve synchronously from the sync cache. - - {(dir) => ( - - - - - - )} - - ) -} - -// Owns the workspace-identity remount. Must not include the session ID in the -// key: SessionPage handles session changes reactively, and remounting here -// destroys workspace-scoped state (terminal PTYs, file/prompt providers). -function TargetSessionPage() { - const location = useWorkspaceLocation() - const serverSDK = useServerSDK() - return ( - - - - - - - - - - - - ) -} - -function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) { - const notification = useNotification() - createEffect(() => { - const sessionID = props.sessionID?.() - if (!notification.ready() || !sessionID) return - if (notification.session.unseenCount(sessionID) === 0) return - notification.session.markViewed(sessionID) - }) - return null -} - -export default function Page() { - const data = useData() - const layout = useLayout() - const local = useLocal() - const file = useFile() - const queryClient = useQueryClient() - const dialog = useDialog() - const language = useLanguage() - const sdk = useWorkspaceLocation() - const serverSDK = useServerSDK() - const settings = useSettings() - const platform = usePlatform() - const prompt = usePrompt() - const comments = useComments() - const command = useCommand() - const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() - const location = useLocation() - const navigate = useNavigate() - const params = useParams() - const isDesktop = createMediaQuery("(min-width: 768px)") - const project = createMemo(() => { - const info = params.id ? data.session.get(params.id) : undefined - const value = info?.projectID - ? data.project.get(info.projectID) - : data.project.list().find((item) => containsDirectory(item.canonical, sdk().directory)) - if (!value) return - return { ...value, worktree: value.canonical, worktrees: [] } - }) - const canReview = createMemo(() => !!project()) - const controller = createSessionController({ - review: isDesktop, - hasReview: canReview, - fileBrowser: (sessionID) => isDesktop() && !!sessionID, - }) - const reviewMode = () => controller.layout.view().review.mode() ?? "git" - const reviewFile = () => controller.layout.view().review.file() - - createEffect(() => { - if (!prompt.ready()) return - untrack(() => { - if (controller.identity.params.id) return - const text = searchParams.prompt - if (!text) return - prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) - setSearchParams({ ...searchParams, prompt: undefined }) - }) - }) - - const [ui, setUi] = createStore({ - pendingMessage: undefined as string | undefined, - reviewSnap: false, - scrollGesture: 0, - scroll: { - overflow: false, - bottom: true, - jump: false, - }, - }) - - const composer = createSessionComposerController() - const inputController = createPromptInputController({ - sessionKey: controller.identity.sessionKey, - sessionID: () => controller.identity.params.id, - }) - - const sessionPanelKey = createMemo(() => - controller.identity.params.id ? `${serverSDK.scope}\0${controller.identity.params.id}` : undefined, - ) - - const size = createSizing() - const desktopReviewOpen = createMemo(() => isDesktop() && controller.layout.view().reviewPanel.opened()) - const desktopV2ReviewOpen = createMemo(() => desktopReviewOpen() && !!controller.identity.params.id) - const terminalOpen = createMemo(() => controller.layout.view().terminal.opened()) - const desktopTerminalOpen = createMemo(() => isDesktop() && terminalOpen()) - const desktopInlineTerminalOnlyOpen = createMemo(() => desktopTerminalOpen() && !desktopV2ReviewOpen()) - const desktopFileTreeOpen = createMemo( - () => - isDesktop() && - shouldShowFileTree({ - visible: settings.visibility.fileTree(), - opened: layout.fileTree.opened(), - }), - ) - const desktopSessionResizeOpen = createMemo(() => desktopV2ReviewOpen() || desktopTerminalOpen()) - const desktopSidePanelOpen = createMemo(() => desktopSessionResizeOpen() || desktopFileTreeOpen()) - let panelRow: HTMLDivElement | undefined - const [panelRowWidth, setPanelRowWidth] = createSignal() - createResizeObserver( - () => panelRow, - ({ width }) => setPanelRowWidth(width), - ) - const splitReview = createMemo(() => desktopV2ReviewOpen() && layout.review.diffStyle() === "split") - // The observer reports the content-box width, which already excludes the row - // padding; only the flex gap between the panels remains to subtract. - const sessionPanelAvailable = createMemo(() => { - const width = panelRowWidth() - if (width === undefined) return undefined - return width - 8 - }) - const sessionPanelMax = createMemo(() => { - const available = sessionPanelAvailable() - if (available === undefined) return 1000 - return sessionPanelWidthMax({ available, split: splitReview() }) - }) - // Clamp at render time so window or sidebar resizes squeeze the chat panel - // instead of the review pane, without overwriting the persisted width. - const sessionPanelResizedWidth = createMemo(() => - clampSessionPanelWidth({ - width: layout.session.width(), - available: sessionPanelAvailable(), - split: splitReview(), - }), - ) - const sessionPanelWidth = createMemo(() => { - if (!desktopSidePanelOpen()) return "100%" - if (desktopSessionResizeOpen()) return `${sessionPanelResizedWidth()}px` - return `calc(100% - ${layout.fileTree.width()}px)` - }) - const centered = createMemo(() => isDesktop()) - const desktopV2PanelLayout = createMemo(() => - sessionPanelLayout({ - review: desktopV2ReviewOpen(), - terminal: desktopTerminalOpen(), - files: desktopFileTreeOpen(), - }), - ) - - const openReviewPanel = () => { - if (!controller.layout.view().reviewPanel.opened()) controller.layout.view().reviewPanel.open() - } - - const sessionDirectory = createMemo(() => controller.data.info()?.location.directory ?? sdk().directory) - const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory())) - const timeline = createTimelineModel({ session: controller }) - const historyLoading = timeline.history.loading - const historyMore = timeline.history.more - const lastUserMessage = timeline.lastUserMessage - const messagesReady = timeline.ready - const sessionSync = timeline.resource - const userMessages = timeline.userMessages - const visibleUserMessages = timeline.visibleUserMessages - - createEffect(() => { - const tab = controller.tabs.activeFileTab() - if (!tab) return - - const path = file.pathFromTab(tab) - if (path) void file.load(path) - }) - - createEffect( - on( - () => [lastUserMessage(), controller.data.info()] as const, - () => { - const message = lastUserMessage() - const info = controller.data.info() - const metadata = message?.metadata - const agent = typeof metadata?.agent === "string" ? metadata.agent : info?.agent - const model = metadata?.model - const selected = - model && - typeof model === "object" && - !Array.isArray(model) && - typeof model.providerID === "string" && - typeof model.modelID === "string" - ? { - providerID: model.providerID, - modelID: model.modelID, - variant: typeof model.variant === "string" ? model.variant : undefined, - } - : info?.model - ? { providerID: info.model.providerID, modelID: info.model.id, variant: info.model.variant } - : undefined - if (!info || !agent || !selected) return - syncSessionModel(local, { sessionID: info.id, agent, model: selected }) - }, - ), - ) - - let restoredModelSession: string | undefined - createEffect(() => { - const id = controller.identity.params.id - if (!id || !prompt.ready() || !local.session.ready()) return - if (restoredModelSession !== id) { - restoredModelSession = id - if (restorePromptModel(local, prompt)) return - } - syncPromptModel(local, prompt) - }) - - createEffect( - on( - () => ({ dir: sdk().directory, id: controller.identity.params.id }), - (next, prev) => { - if (!prev) return - if (next.dir === prev.dir && next.id === prev.id) return - if (prev.id && !next.id) local.session.reset() - }, - { defer: true }, - ), - ) - - const [store, setStore] = createStore({ - ...sessionViewState(), - newSessionWorktree: "main", - sessionDetailsOpen: false, - deferRender: false, - }) - - const [followup, setFollowup] = persisted( - Persist.serverWorkspace(serverSDK.scope, sdk().directory, "followup"), - createStore<{ - items: Record - failed: Record - paused: Record - edit: Record - }>({ - items: {}, - failed: {}, - paused: {}, - edit: {}, - }), - ) - - createComputed((prev) => { - const key = controller.identity.sessionKey() - if (key !== prev) { - setStore("deferRender", true) - const owner = controller.ownership.capture() - requestAnimationFrame(() => { - setTimeout(() => owner.run(() => setStore("deferRender", false)), 0) - }) - } - return key - }) - - let reviewFrame: number | undefined - - createComputed((prev) => { - const open = desktopReviewOpen() - if (prev === undefined || prev === open) return open - - if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) - setUi("reviewSnap", true) - reviewFrame = requestAnimationFrame(() => { - reviewFrame = undefined - setUi("reviewSnap", false) - }) - return open - }, desktopReviewOpen()) - - const nogit = createMemo(() => { - const current = project() - return !!current && current.vcs !== "git" - }) - const vcs = createMemo(() => data.location.vcs.info({ directory: sdk().directory })) - const changesOptions = createMemo(() => { - const list: ChangeMode[] = [] - const current = project() - if (current?.vcs === "git") list.push("git") - if ( - current?.vcs === "git" && - vcs()?.branch.current && - vcs()?.branch.default && - vcs()?.branch.current !== vcs()?.branch.default - ) { - list.push("branch") - } - return list - }) - const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") - const wantsReview = createMemo(() => - isDesktop() - ? desktopFileTreeOpen() || - (desktopReviewOpen() && (controller.tabs.activeTab() === "review" || !!controller.tabs.activeFileTab())) - : store.mobileTab === "changes", - ) - const vcsMode = createMemo(() => { - const mode = reviewMode() - if (mode === "git" || mode === "branch") return mode - }) - const vcsKey = createMemo( - () => - [ - serverSDK.scope, - "session-vcs", - sdk().directory, - vcs()?.branch.current ?? "", - vcs()?.branch.default ?? "", - ] as const, - ) - const vcsQuery = createQuery(() => { - const mode = vcsMode() - const enabled = serverSDK.connection.status() === "connected" && wantsReview() && project()?.vcs === "git" - - return { - queryKey: [...vcsKey(), mode] as const, - enabled, - refetchOnMount: "always" as const, - refetchOnWindowFocus: true, - queryFn: mode - ? () => - serverSDK.api.vcs - .diff({ - location: { directory: sdk().directory }, - mode: mode === "git" ? "working" : mode, - }) - .then((result) => result.data) - : skipToken, - } - }) - const sessionDetailsQuery = createQuery(() => ({ - queryKey: [serverSDK.scope, "session-details", sessionDirectory()] as const, - enabled: store.sessionDetailsOpen && serverSDK.connection.status() === "connected" && project()?.vcs === "git", - queryFn: () => - serverSDK.api.vcs - .diff({ location: { directory: sessionDirectory() }, mode: "working" }) - .then((result) => result.data) - .catch((error) => { - console.debug("[session-review] failed to load session details diff", { error }) - return [] - }), - })) - const sessionDetailsDiffs = () => (sessionDetailsQuery.isFetched ? (sessionDetailsQuery.data ?? []) : undefined) - const refreshVcs = debounce(() => { - void queryClient.invalidateQueries({ queryKey: vcsKey() }) - void queryClient.invalidateQueries({ queryKey: [serverSDK.scope, "session-details", sessionDirectory()] }) - }, 100) - onCleanup( - sdk().event.listen((event) => { - if (event.type === "filesystem.changed") refreshVcs() - }), - ) - createEffect( - on( - () => desktopReviewOpen() || mobileChanges(), - (open, previous) => { - if (!open || previous || !desktopFileTreeOpen() || vcsQuery.isFetching) return - refreshVcs() - }, - { defer: true }, - ), - ) - const reviewDiffs = () => { - if (reviewMode() === "git" || reviewMode() === "branch") - // avoids suspense - return vcsQuery.isFetched ? (vcsQuery.data ?? []) : [] - // TODO: Restore turn diffs when the V2 transcript exposes snapshot diffs. - return [] - } - const activeReviewFile = () => { - const diffs = reviewDiffs() - const selected = reviewFile() - if (selected && diffs.some((diff) => diff.file === selected)) return selected - return diffs[0]?.file - } - const reviewCount = () => reviewDiffs().length - const hasReview = () => reviewCount() > 0 - const reviewReady = () => { - if (reviewMode() === "git" || reviewMode() === "branch") return !vcsQuery.isPending - return true - } - const loadReviewDiff = async (file: string, version?: number): Promise => { - const mode = vcsMode() - if (!mode) return - const root = reviewRootDirectory(project()?.worktree ?? sdk().directory) - const directory = reviewDiffDirectory(root, file) - const source = reviewDiffs().find((diff) => diff.file === file) - const valid = (diff: FileDiffInfo | undefined) => { - if (!diff || !source) return - if (diff.additions !== source.additions || diff.deletions !== source.deletions) return - if (reviewDiffNeedsLoad(diff)) return - return diff - } - const request = (scope: string, context?: number) => - queryClient - .fetchQuery({ - queryKey: [serverSDK.scope, ...vcsKey(), mode, "directory", scope, context, version] as const, - staleTime: Number.POSITIVE_INFINITY, - retry: 2, - queryFn: () => - serverSDK.api.vcs - .diff({ - location: { directory: scope }, - mode: mode === "git" ? "working" : mode, - context, - }) - .then((result) => result.data), - }) - .then((diffs) => diffs.find((diff) => diff.file === file)) - - if (directory !== root) { - try { - const scoped = valid(await request(directory)) - if (scoped) return scoped - } catch (error) { - console.debug("[session-review] failed to load scoped vcs diff", { mode, file, directory, error }) - } - } - try { - const bounded = valid(await request(root, 3)) - if (bounded) return bounded - } catch (error) { - console.debug("[session-review] failed to load bounded vcs diff", { mode, file, root, error }) - } - } - - const newSessionWorktree = createMemo(() => { - if (store.newSessionWorktree === "create") return "create" - const current = project() - if (current && sdk().directory !== current.worktree) return sdk().directory - return "main" - }) - - const setActiveMessage = (message: SessionMessageUser | undefined) => { - messageMark = scrollMark - setStore("messageId", message?.id) - } - - const anchor = (id: string) => `message-${id}` - - const cursor = () => { - const root = scroller - if (!root) return store.messageId - - const box = root.getBoundingClientRect() - const line = box.top + 100 - const list = [...root.querySelectorAll("[data-message-id]")] - .map((el) => { - const id = el.dataset.messageId - if (!id) return - - const rect = el.getBoundingClientRect() - return { id, top: rect.top, bottom: rect.bottom } - }) - .filter((item): item is { id: string; top: number; bottom: number } => !!item) - - const shown = list.filter((item) => item.bottom > box.top && item.top < box.bottom) - const hit = shown.find((item) => item.top <= line && item.bottom >= line) - if (hit) return hit.id - - const near = [...shown].sort((a, b) => { - const da = Math.abs(a.top - line) - const db = Math.abs(b.top - line) - if (da !== db) return da - db - return a.top - b.top - })[0] - if (near) return near.id - - return list.filter((item) => item.top <= line).at(-1)?.id ?? list[0]?.id ?? store.messageId - } - - function navigateMessageByOffset(offset: number) { - const msgs = visibleUserMessages() - if (msgs.length === 0) return - - const current = store.messageId && messageMark === scrollMark ? store.messageId : cursor() - const base = current ? msgs.findIndex((m) => m.id === current) : msgs.length - const currentIndex = base === -1 ? msgs.length : base - const targetIndex = currentIndex + offset - if (targetIndex < 0 || targetIndex > msgs.length) return - - if (targetIndex === msgs.length) { - resumeScroll() - return - } - - autoScroll.pause() - scrollToMessage(msgs[targetIndex], "auto") - } - - const gitMutation = useMutation(() => ({ - // TODO: Restore Git initialization when the V2 client exposes this operation. - mutationFn: async () => Promise.reject(new Error("Git initialization is unavailable")), - onError: (err) => { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: formatServerError(err, language.t), - }) - }, - })) - - function initGit() { - if (gitMutation.isPending) return - gitMutation.mutate() - } - - let inputRef!: HTMLDivElement - let promptDock: HTMLDivElement | undefined - let dockHeight = 0 - let scroller: HTMLDivElement | undefined - let content: HTMLDivElement | undefined - let revealMessage = (_id: string) => {} - let scrollToEnd = () => {} - let scrollMark = 0 - let messageMark = 0 - - const scrollGestureWindowMs = 250 - - const markScrollGesture = (target?: EventTarget | null) => { - const root = scroller - if (!root) return - - const el = target instanceof Element ? target : undefined - const nested = el?.closest("[data-scrollable]") - if (nested && nested !== root) return - - setUi("scrollGesture", Date.now()) - } - - const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs - - // TODO: Restore todo refresh when todos are available from the current session API. - - createEffect( - on( - () => visibleUserMessages().at(-1)?.id, - (lastId, prevLastId) => { - if (lastId && prevLastId && lastId > prevLastId) { - setStore("messageId", undefined) - } - }, - { defer: true }, - ), - ) - - createEffect( - on( - controller.identity.sessionKey, - () => { - setStore(sessionViewState()) - setUi("pendingMessage", undefined) - }, - { defer: true }, - ), - ) - - createEffect( - on( - () => sdk().directory, - (dir) => { - if (!dir) return - setStore("newSessionWorktree", "main") - }, - { defer: true }, - ), - ) - - const selectionPreview = (path: string, selection: FileSelection) => { - const content = file.get(path)?.content?.content - if (!content) return undefined - return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine }) - } - - const addCommentToContext = (input: { - file: string - selection: SelectedLineRange - comment: string - preview?: string - origin?: "review" | "file" - }) => { - const selection = selectionFromLines(input.selection) - const preview = input.preview ?? selectionPreview(input.file, selection) - const saved = comments.add({ - file: input.file, - selection: input.selection, - comment: input.comment, - }) - prompt.context.add({ - type: "file", - path: input.file, - selection, - comment: input.comment, - commentID: saved.id, - commentOrigin: input.origin, - preview, - }) - } - - const updateCommentInContext = (input: { - id: string - file: string - selection: SelectedLineRange - comment: string - preview?: string - }) => { - comments.update(input.file, input.id, input.comment) - prompt.context.updateComment(input.file, input.id, { - comment: input.comment, - ...(input.preview ? { preview: input.preview } : {}), - }) - } - - const removeCommentFromContext = (input: { id: string; file: string }) => { - comments.remove(input.file, input.id) - prompt.context.removeComment(input.file, input.id) - } - - const reviewCommentActions = createMemo(() => ({ - moreLabel: language.t("common.moreOptions"), - editLabel: language.t("common.edit"), - deleteLabel: language.t("common.delete"), - saveLabel: language.t("common.save"), - })) - - const isEditableTarget = (target: EventTarget | null | undefined) => { - if (!(target instanceof HTMLElement)) return false - return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable - } - - const deepActiveElement = () => { - let current: Element | null = document.activeElement - while (current instanceof HTMLElement && current.shadowRoot?.activeElement) { - current = current.shadowRoot.activeElement - } - return current instanceof HTMLElement ? current : undefined - } - - const handleKeyDown = (event: KeyboardEvent) => { - const path = event.composedPath() - const target = path.find((item): item is HTMLElement => item instanceof HTMLElement) - const activeElement = deepActiveElement() - - const protectedTarget = path.some( - (item) => item instanceof HTMLElement && item.closest("[data-prevent-autofocus]") !== null, - ) - if (protectedTarget || isEditableTarget(target)) return - - if (activeElement) { - const isProtected = activeElement.closest("[data-prevent-autofocus]") - const isInput = isEditableTarget(activeElement) - if (isProtected || isInput) return - } - if (dialog.active) return - - if (activeElement === inputRef) { - if (event.key === "Escape") inputRef?.blur() - return - } - - const key = scrollKey(event) - if (key) { - if (!scroller || !isScrollKeyTarget(target ?? null, key)) return - if (scrollKeyOwner(scroller, target ?? null, key) !== scroller) return - markScrollGesture(scroller) - return - } - - if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { - if (composer.blocked() || controller.data.isChild()) return - const input = inputRef - if (!input) return - input.focus() - setCursorPosition(input, prompt.cursor() ?? promptLength(prompt.current())) - } - } - - createEffect(() => { - if (!layout.ready()) return - if (serverSDK.connection.status() !== "connected") return - if (!project()) return - const list = changesOptions() - const mode = reviewMode() - if (list.includes(mode)) return - const next = list[0] - if (!next) return - controller.layout.view().review.setMode(next) - }) - - createEffect( - on( - () => data.session.status(controller.identity.params.id ?? ""), - (next, prev) => { - if (next !== "idle" || prev === undefined || prev === "idle") return - refreshVcs() - }, - { defer: true }, - ), - ) - - const fileTreeTab = () => layout.fileTree.tab() - const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) - - const [tree, setTree] = createStore({ - reviewScroll: undefined as HTMLDivElement | undefined, - pendingDiff: undefined as string | undefined, - }) - - createEffect( - on( - controller.identity.sessionKey, - () => { - setTree({ - reviewScroll: undefined, - pendingDiff: undefined, - }) - }, - { defer: true }, - ), - ) - - const showAllFiles = () => { - if (fileTreeTab() !== "changes") return - setFileTreeTab("all") - } - - const focusInput = () => { - if (controller.data.isChild()) return - inputRef?.focus() - } - - useComposerCommands() - useSessionCommands({ - session: controller, - background: { - blocking: () => composer.background.blocking().length > 0, - move: composer.background.move, - }, - navigateMessageByOffset, - setActiveMessage, - focusInput, - }) - command.register("session-palette", () => [ - { - id: "command.palette", - title: language.t("command.palette"), - hidden: true, - onSelect: () => command.trigger("file.open", "palette"), - }, - ]) - - const openReviewFile = createOpenReviewFile({ - showAllFiles, - tabForPath: file.tab, - openTab: controller.layout.tabs().open, - setActive: controller.layout.tabs().setActive, - loadFile: file.load, - }) - - const changesLabel = (option: ChangeMode) => { - if (option === "git") return language.t("ui.sessionReview.title.git") - if (option === "branch") return language.t("ui.sessionReview.title.branch") - return language.t("ui.sessionReview.title.lastTurn") - } - - const changesTitle = () => { - if (!canReview()) { - return null - } - - return ( - option && props.review.setMode(option)} + /> + + ) +} + +function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }) { + const language = useLanguage() + const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready() + const noGit = () => props.review.mode() === "turn" && props.review.noGit() + const text = () => { + if (props.review.mode() === "git") return language.t("session.review.noUncommittedChanges") + if (props.review.mode() === "branch") return language.t("session.review.noBranchChanges") + return language.t("session.review.noChanges") + } + return ( + + +
{language.t("session.review.loadingChanges")}
+
+ +
+
+
{language.t("session.review.noVcs.createGit.title")}
+
+ {language.t("session.review.noVcs.createGit.description")} +
+
+
+
+ +
+
{text()}
+
+
+
+ ) +} + +function ReviewPanelEmpty(props: { review: SessionReviewModel }) { + const language = useLanguage() + const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready() + const noGit = () => props.review.mode() === "turn" && props.review.noGit() + return ( + + +
{language.t("session.review.loadingChanges")}
+
+ +
+
+ {language.t("session.review.noVcs.createGit.description")} +
+
+
+ + + +
+ ) +} diff --git a/packages/app/src/session/route-error.tsx b/packages/app/src/session/route-error.tsx new file mode 100644 index 00000000000..1f3b92d0927 --- /dev/null +++ b/packages/app/src/session/route-error.tsx @@ -0,0 +1,56 @@ +import { Button } from "@opencode-ai/ui/button" +import { createMemo, Show } from "solid-js" +import { ErrorPage } from "@/pages/error" +import { useLanguage } from "@/context/language" +import { ServerConnection, serverName, useServers } from "@/context/servers" +import { useTabs } from "@/context/tabs" +import { isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors" + +export function SessionErrorFallback(props: { error: unknown; sessionID?: string; serverKey?: ServerConnection.Key }) { + const language = useLanguage() + const server = useServers() + const tabs = useTabs() + const displayServer = createMemo(() => { + const conn = server.list.find((item) => ServerConnection.key(item) === props.serverKey) + return conn ? serverName(conn) : props.serverKey + }) + + if (!isCurrentSessionNotFoundError(props.error, props.sessionID)) return + + return ( +
+
+
+
{language.t("session.error.notFound")}
+
{language.t("session.error.notFound.description")}
+
+ + {(sessionID) => ( +
+
{displayServer()}
+ + {sessionID()} + +
+ )} +
+ +
+
+ ) +} + +function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) { + if (!sessionID) return false + return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID) +} diff --git a/packages/app/src/session/route.tsx b/packages/app/src/session/route.tsx new file mode 100644 index 00000000000..df776b93860 --- /dev/null +++ b/packages/app/src/session/route.tsx @@ -0,0 +1,122 @@ +import { ErrorBoundary, createEffect, createMemo, Show, type ParentProps } from "solid-js" +import { useParams } from "@solidjs/router" +import { CommentsProvider } from "@/context/comments" +import { FileProvider } from "@/context/file" +import { LocationProvider, useWorkspaceLocation } from "@/context/location" +import { ModelsProvider } from "@/context/models" +import { useNotification } from "@/context/notification" +import { PromptProvider } from "@/context/prompt" +import { useData, useServer } from "@/context/server" +import { useServerSDK } from "@/context/server-sdk" +import { ServerConnection } from "@/context/servers" +import { TerminalProvider } from "@/context/terminal" +import { useSettingsCommand } from "@/components/settings-dialog" +import { SessionUIProvider } from "@/pages/directory-layout" +import { requireServerKey } from "@/utils/session-route" +import { useSessionModel } from "./model" +import { SessionPanelFrame, SessionRouteFrame } from "./session-frame" +import { SessionErrorFallback } from "./route-error" +import { createSessionResolution } from "./session-resolution" +import { SessionScreen } from "./screen" + +export function TargetSessionRouteContent() { + const params = useParams<{ serverKey: string; id: string }>() + const data = useData() + const directory = createMemo(() => data.session.get(params.id)?.location.directory) + + return ( + <> + params.id} /> + + + + + + + + ) +} + +function TargetSessionSettingsCommand() { + useSettingsCommand() + return null +} + +function SessionRouteErrorBoundary( + props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>, +) { + return ( + ( + + + + + + )} + > + {props.children} + + ) +} + +function ResolvedTargetSessionRoute() { + const params = useParams<{ id: string }>() + const server = useServer() + const data = useData() + const current = createSessionResolution( + () => params.id, + () => data.session, + { children: true }, + ) + const directory = createMemo(() => current()?.location.directory) + + return ( + + {(value) => ( + + + + + + )} + + ) +} + +function TargetSessionPage() { + const location = useWorkspaceLocation() + const server = useServerSDK() + + return ( + // Keep workspace-scoped file, prompt, comment, and terminal state alive when + // the user switches between Sessions in the same workspace. + + + + + + + + + + + + ) +} + +function SessionPage() { + const session = useSessionModel() + return +} + +function MarkSessionNotificationsViewed(props: { sessionID: () => string | undefined }) { + const notification = useNotification() + createEffect(() => { + const sessionID = props.sessionID() + if (!notification.ready() || !sessionID) return + if (notification.session.unseenCount(sessionID) === 0) return + notification.session.markViewed(sessionID) + }) + return null +} diff --git a/packages/app/src/session/screen-layout.ts b/packages/app/src/session/screen-layout.ts new file mode 100644 index 00000000000..a2fee4c3bf1 --- /dev/null +++ b/packages/app/src/session/screen-layout.ts @@ -0,0 +1,110 @@ +import { createComputed, createMemo, createSignal, onCleanup } from "solid-js" +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { useLayout } from "@/context/layout" +import { useSettings } from "@/context/settings" +import { createSizing, shouldShowFileTree } from "./helpers" +import type { SessionModel } from "./model" +import { sessionPanelLayout } from "./session-panel-layout" +import { clampSessionPanelWidth, sessionPanelWidthMax } from "./session-panel-width" + +export function createSessionScreenLayout(session: SessionModel, serverScope: string) { + const layout = useLayout() + const settings = useSettings() + const size = createSizing() + const reviewOpen = createMemo(() => session.isDesktop() && session.layout.view().reviewPanel.opened()) + const reviewPanelOpen = createMemo(() => reviewOpen() && !!session.identity.params.id) + const terminalOpen = createMemo(() => session.layout.view().terminal.opened()) + const desktopTerminalOpen = createMemo(() => session.isDesktop() && terminalOpen()) + const fileTreeOpen = createMemo( + () => + session.isDesktop() && + shouldShowFileTree({ + visible: settings.visibility.fileTree(), + opened: layout.fileTree.opened(), + }), + ) + const resizable = createMemo(() => reviewPanelOpen() || desktopTerminalOpen()) + const sidePanelOpen = createMemo(() => resizable() || fileTreeOpen()) + const [rowWidth, setRowWidth] = createSignal() + let row: HTMLDivElement | undefined + createResizeObserver( + () => row, + ({ width }) => setRowWidth(width), + ) + const available = createMemo(() => { + const width = rowWidth() + if (width === undefined) return undefined + return width - 8 + }) + const splitReview = createMemo(() => reviewPanelOpen() && layout.review.diffStyle() === "split") + const resizedWidth = createMemo(() => + clampSessionPanelWidth({ + width: layout.session.width(), + available: available(), + split: splitReview(), + }), + ) + const panelWidth = createMemo(() => { + if (!sidePanelOpen()) return "100%" + if (resizable()) return `${resizedWidth()}px` + return `calc(100% - ${layout.fileTree.width()}px)` + }) + const panelMax = createMemo(() => { + const width = available() + if (width === undefined) return 1000 + return sessionPanelWidthMax({ available: width, split: splitReview() }) + }) + const panelLayout = createMemo(() => + sessionPanelLayout({ + review: reviewPanelOpen(), + terminal: desktopTerminalOpen(), + files: fileTreeOpen(), + }), + ) + const [reviewSnap, setReviewSnap] = createSignal(false) + let reviewFrame: number | undefined + createComputed((previous) => { + const open = reviewOpen() + if (previous === undefined || previous === open) return open + + if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) + setReviewSnap(true) + reviewFrame = requestAnimationFrame(() => { + reviewFrame = undefined + setReviewSnap(false) + }) + return open + }, reviewOpen()) + onCleanup(() => { + if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) + }) + + return { + centered: createMemo(() => session.isDesktop()), + files: { open: fileTreeOpen }, + panel: { + key: createMemo(() => (session.identity.params.id ? `${serverScope}\0${session.identity.params.id}` : undefined)), + max: panelMax, + ref: (element: HTMLDivElement) => { + row = element + }, + resizable, + resizedWidth, + width: panelWidth, + }, + review: { + open: reviewOpen, + panelOpen: reviewPanelOpen, + snap: reviewSnap, + }, + side: { layout: panelLayout, open: sidePanelOpen }, + size, + terminal: { + desktopOpen: desktopTerminalOpen, + inlineOnlyOpen: createMemo(() => desktopTerminalOpen() && !reviewPanelOpen()), + open: terminalOpen, + }, + } +} + +export type SessionScreenLayout = ReturnType diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx new file mode 100644 index 00000000000..49856ec9e28 --- /dev/null +++ b/packages/app/src/session/screen.tsx @@ -0,0 +1,202 @@ +import { ErrorBoundary, Show, Match, Switch, createMemo, createEffect, createComputed, on } from "solid-js" +import { createStore } from "solid-js/store" +import { ResizeHandle } from "@opencode-ai/ui/resize-handle" +import { NewSessionView } from "@/session/composer/session-new-view" +import { SessionHeader } from "@/session/header/session-header" +import { useLayout } from "@/context/layout" +import { useServerSDK } from "@/context/server-sdk" +import { useSettings } from "@/context/settings" +import { MessageTimeline } from "@/session/timeline/message-timeline" +import type { SessionModel } from "@/session/model" +import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width" +import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame" +import { TerminalPanel } from "@/session/terminal/panel" +import { useUsageExceededDialogs } from "./usage-exceeded-dialogs" +import { SessionErrorFallback } from "./route-error" +import { createSessionScreenLayout } from "./screen-layout" +import { createSessionReview } from "./review/model" +import { SessionDesktopReview, SessionMobileReview, SessionMobileTabs } from "./review/view" +import { createSessionTimelineInteraction } from "./timeline/interaction" +import { ActiveSessionComposer, createActiveSessionComposer } from "./composer/active-session" + +export function SessionScreen(props: { session: SessionModel }) { + const session = props.session + const layout = useLayout() + const serverSDK = useServerSDK() + const settings = useSettings() + const isDesktop = session.isDesktop + const screen = createSessionScreenLayout(session, serverSDK.scope) + const timeline = createSessionTimelineInteraction(session) + const messagesReady = timeline.ready + const [store, setStore] = createStore({ deferRender: false }) + + createComputed((prev) => { + const key = session.identity.sessionKey() + if (key !== prev) { + setStore("deferRender", true) + const owner = session.ownership.capture() + requestAnimationFrame(() => { + setTimeout(() => owner.run(() => setStore("deferRender", false)), 0) + }) + } + return key + }) + const review = createSessionReview({ session, screen, deferRender: () => store.deferRender }) + const composer = createActiveSessionComposer({ + session, + screen, + timeline, + deferRender: () => store.deferRender, + }) + + useUsageExceededDialogs() + + const mobileTabsBottom = createMemo(() => !isDesktop() && settings.general.mobileTitlebarPosition() === "bottom") + + const sessionErrorFallback = (error: unknown, reset: () => void) => { + createEffect(on(session.identity.sessionKey, reset, { defer: true })) + return + } + + const sessionPanelContent = () => ( + <> + {timeline.resource() ?? ""} + + + +
+ + + + + + + {(_id) => ( + + )} + + + + + + +
+ + + + + + + + + ) + + return ( + + +
+
+ + {(_) => ( + + {sessionPanelContent()} + + )} + + + +
screen.size.start()}> + { + screen.size.touch() + layout.session.resize(width) + }} + /> +
+
+
+ + +
+ +
+ +
+
+ +
screen.size.start()}> + { + screen.size.touch() + layout.terminal.resize(height) + }} + onCollapse={() => session.layout.view().terminal.close()} + /> +
+
+ +
+ +
+
+
+
+
+
+ ) +} diff --git a/packages/app/src/pages/session/session-domain.ts b/packages/app/src/session/session-domain.ts similarity index 100% rename from packages/app/src/pages/session/session-domain.ts rename to packages/app/src/session/session-domain.ts diff --git a/packages/app/src/pages/session/session-frame.tsx b/packages/app/src/session/session-frame.tsx similarity index 100% rename from packages/app/src/pages/session/session-frame.tsx rename to packages/app/src/session/session-frame.tsx diff --git a/packages/app/src/pages/session/session-layout.ts b/packages/app/src/session/session-layout.ts similarity index 100% rename from packages/app/src/pages/session/session-layout.ts rename to packages/app/src/session/session-layout.ts diff --git a/packages/app/src/pages/session/session-model-helpers.test.ts b/packages/app/src/session/session-model-helpers.test.ts similarity index 100% rename from packages/app/src/pages/session/session-model-helpers.test.ts rename to packages/app/src/session/session-model-helpers.test.ts diff --git a/packages/app/src/pages/session/session-model-helpers.ts b/packages/app/src/session/session-model-helpers.ts similarity index 100% rename from packages/app/src/pages/session/session-model-helpers.ts rename to packages/app/src/session/session-model-helpers.ts diff --git a/packages/app/src/pages/session/session-ownership.ts b/packages/app/src/session/session-ownership.ts similarity index 100% rename from packages/app/src/pages/session/session-ownership.ts rename to packages/app/src/session/session-ownership.ts diff --git a/packages/app/src/pages/session/session-panel-layout.test.ts b/packages/app/src/session/session-panel-layout.test.ts similarity index 100% rename from packages/app/src/pages/session/session-panel-layout.test.ts rename to packages/app/src/session/session-panel-layout.test.ts diff --git a/packages/app/src/pages/session/session-panel-layout.ts b/packages/app/src/session/session-panel-layout.ts similarity index 100% rename from packages/app/src/pages/session/session-panel-layout.ts rename to packages/app/src/session/session-panel-layout.ts diff --git a/packages/app/src/pages/session/session-panel-width.test.ts b/packages/app/src/session/session-panel-width.test.ts similarity index 100% rename from packages/app/src/pages/session/session-panel-width.test.ts rename to packages/app/src/session/session-panel-width.test.ts diff --git a/packages/app/src/pages/session/session-panel-width.ts b/packages/app/src/session/session-panel-width.ts similarity index 100% rename from packages/app/src/pages/session/session-panel-width.ts rename to packages/app/src/session/session-panel-width.ts diff --git a/packages/app/src/pages/session/session-resolution.ts b/packages/app/src/session/session-resolution.ts similarity index 100% rename from packages/app/src/pages/session/session-resolution.ts rename to packages/app/src/session/session-resolution.ts diff --git a/packages/app/src/pages/session/session-preview.stories.tsx b/packages/app/src/session/session-screen.stories.tsx similarity index 57% rename from packages/app/src/pages/session/session-preview.stories.tsx rename to packages/app/src/session/session-screen.stories.tsx index a619615036c..e2c34750d83 100644 --- a/packages/app/src/pages/session/session-preview.stories.tsx +++ b/packages/app/src/session/session-screen.stories.tsx @@ -2,6 +2,7 @@ import { activePermissionRequest, activeQuestionRequest, attachmentsAndCommentsDocument, + compactionDocument, editThenTestDocument, emptySessionDocument, largeCompletedDocument, @@ -9,12 +10,32 @@ import { permissionPendingDocument, questionPendingDocument, queuedPrompts, - recoveryDocument, - thinkingDocument, + retryDocument, + streamingDocument, + STORY_TIME, + subagentDocument, + terminalPassedDocument, } from "@opencode-ai/session-ui/storybook" -import { SessionPreview } from "./session-preview" +import { SessionPreview } from "./story-model" const description = "opencode · modular-session-ui" +const retryAfterInterruption = { + ...retryDocument, + messages: [ + ...compactionDocument.messages, + ...retryDocument.messages.map((message, index) => { + const created = STORY_TIME + 70_000 + index * 1_000 + if (message.type !== "assistant" || !message.retry) { + return { ...message, time: { ...message.time, created } } + } + return { + ...message, + time: { ...message.time, created }, + retry: { ...message.retry, at: created }, + } + }), + ], +} const implementAndVerify = () => ( ( - - ), -} - -export const AgentIsThinking = { - render: () => ( - - ), -} - export const ImplementAndVerifyLight = { globals: { theme: "light" }, render: implementAndVerify, @@ -78,50 +82,6 @@ export const QueueAFollowUp = { ), } -export const PermissionRequired = { - render: () => ( - - ), -} - -export const AnswerAProductQuestion = { - render: () => ( - - ), -} - -export const ReviewChanges = { - render: () => ( - - ), -} - -export const RecoverFromAFailedTest = { - render: () => ( - - ), -} - export const WorkFromAttachments = { render: () => ( ( + ), +} + +export const EmptySession = { + render: () => , +} + +export const StreamingSession = { + render: () => ( + + ), +} + +export const EditThenShellTest = { + render: implementAndVerify, +} + +export const PermissionRequest = { + render: () => ( + + ), +} + +export const QuestionRequest = { + render: () => ( + + ), +} + +export const RetryAndInterruption = { + render: () => ( + + ), +} + +export const ReviewPlusTerminal = { + render: () => ( + + ), +} + +export const ChildSession = { + render: () => ( + + ), +} + +export const MobileLayout = { + globals: { viewport: { value: "mobile1", isRotated: false } }, + parameters: { viewport: { defaultViewport: "mobile1" } }, + render: () => ( + + ), +} + +export const NarrowLayout = { + globals: { viewport: { value: "tablet", isRotated: false } }, + parameters: { viewport: { defaultViewport: "tablet" } }, + render: () => ( + ), } diff --git a/packages/app/src/pages/session/session-preview.tsx b/packages/app/src/session/story-model.tsx similarity index 86% rename from packages/app/src/pages/session/session-preview.tsx rename to packages/app/src/session/story-model.tsx index be85be44e02..e3095c9c03e 100644 --- a/packages/app/src/pages/session/session-preview.tsx +++ b/packages/app/src/session/story-model.tsx @@ -1,11 +1,11 @@ import type { ModelSelection } from "@/context/local" import { PromptInputV2Composer, type PromptInputV2ComposerController } from "@/components/prompt-input-v2" -import { SessionHeaderV2Actions } from "@/components/session/session-header-actions" +import { SessionHeaderActions } from "@/session/header/session-header-actions" import { SessionComposerRegion, type SessionComposerRegionViewController, -} from "@/pages/session/composer/session-composer-region" -import { SessionPanelFrame, SessionRouteFrame } from "@/pages/session/session-frame" +} from "@/session/composer/session-composer-region" +import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame" import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise" import type { SessionDocument } from "@opencode-ai/session-ui/document" import { CurrentSessionProviders, STORY_MODEL } from "@opencode-ai/session-ui/storybook" @@ -19,8 +19,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { Show } from "solid-js" import { createStore } from "solid-js/store" import { useLanguage } from "@/context/language" -import { ReviewPanelV2View } from "@/pages/session/v2/review-panel-v2" -import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" +import { ReviewPanelView } from "@/session/review/panel" +import { createReviewPanelState } from "@/session/review/panel-state" +import { TerminalSurface } from "@/session/terminal/surface" const modelReady = Object.assign(() => true, { promise: undefined }) satisfies ModelSelection["ready"] const storyComposerModel = { @@ -84,6 +85,8 @@ export type SessionPreviewProps = { request?: { type: "permission"; value: PermissionRequest } | { type: "question"; value: FormInfo } reviewOpened?: boolean backgroundTasks?: { id: string; type: "shell" | "subagent"; label: string }[] + child?: { parentID: string } + terminal?: { title: string; lines: string[] } } export function SessionPreview(props: SessionPreviewProps) { @@ -230,8 +233,8 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void openParent: () => setState("activity", "Opened the parent Session locally"), setPromptRef() {}, setDockRef() {}, - parentID: () => undefined, - child: () => false, + parentID: () => props.child?.parentID, + child: () => !!props.child, showComposer: () => true, handoffPrompt: () => undefined, promptReady: () => true, @@ -274,8 +277,14 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void /> -