mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-21 16:14:45 +00:00
fix(app): better review/filetree empty states (#16221)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
This commit is contained in:
@@ -511,11 +511,12 @@ export const dict = {
|
|||||||
"session.review.change.other": "Changes",
|
"session.review.change.other": "Changes",
|
||||||
"session.review.loadingChanges": "Loading changes...",
|
"session.review.loadingChanges": "Loading changes...",
|
||||||
"session.review.empty": "No changes in this session yet",
|
"session.review.empty": "No changes in this session yet",
|
||||||
"session.review.noVcs": "No git VCS detected, so session changes will not be detected",
|
"session.review.noVcs": "No Git Version Control System detected, changes not displayed",
|
||||||
"session.review.noChanges": "No changes",
|
"session.review.noChanges": "No changes",
|
||||||
|
|
||||||
"session.files.selectToOpen": "Select a file to open",
|
"session.files.selectToOpen": "Select a file to open",
|
||||||
"session.files.all": "All files",
|
"session.files.all": "All files",
|
||||||
|
"session.files.empty": "No files",
|
||||||
"session.files.binaryContent": "Binary file (content cannot be displayed)",
|
"session.files.binaryContent": "Binary file (content cannot be displayed)",
|
||||||
|
|
||||||
"session.messages.renderEarlier": "Render earlier messages",
|
"session.messages.renderEarlier": "Render earlier messages",
|
||||||
|
|||||||
@@ -1 +1,29 @@
|
|||||||
@import "@opencode-ai/ui/styles/tailwind";
|
@import "@opencode-ai/ui/styles/tailwind";
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
[data-component="getting-started"] {
|
||||||
|
container-type: inline-size;
|
||||||
|
container-name: getting-started;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="getting-started-actions"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem; /* gap-3 */
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="getting-started-actions"] > [data-component="button"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container getting-started (min-width: 17rem) {
|
||||||
|
[data-component="getting-started-actions"] {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="getting-started-actions"] > [data-component="button"] {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
workspaceName: {} as Record<string, string>,
|
workspaceName: {} as Record<string, string>,
|
||||||
workspaceBranchName: {} as Record<string, Record<string, string>>,
|
workspaceBranchName: {} as Record<string, Record<string, string>>,
|
||||||
workspaceExpanded: {} as Record<string, boolean>,
|
workspaceExpanded: {} as Record<string, boolean>,
|
||||||
|
gettingStartedDismissed: false,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2006,25 +2007,31 @@ export default function Layout(props: ParentProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="shrink-0 px-2 py-3 border-t border-border-weak-base"
|
class="shrink-0 px-3 py-3"
|
||||||
classList={{
|
classList={{
|
||||||
hidden: !(providers.all().length > 0 && providers.paid().length === 0),
|
hidden: store.gettingStartedDismissed || !(providers.all().length > 0 && providers.paid().length === 0),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="rounded-md bg-background-base shadow-xs-border-base">
|
<div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">
|
||||||
<div class="p-3 flex flex-col gap-2">
|
<div class="p-3 flex flex-col gap-6">
|
||||||
<div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
|
<div class="flex flex-col gap-2">
|
||||||
<div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
|
<div class="text-14-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
|
||||||
<div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
|
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
|
||||||
|
{language.t("sidebar.gettingStarted.line1")}
|
||||||
|
</div>
|
||||||
|
<div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
|
||||||
|
{language.t("sidebar.gettingStarted.line2")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-component="getting-started-actions">
|
||||||
|
<Button size="large" icon="plus-small" onClick={connectProvider}>
|
||||||
|
{language.t("command.provider.connect")}
|
||||||
|
</Button>
|
||||||
|
<Button size="large" variant="ghost" onClick={() => setStore("gettingStartedDismissed", true)}>
|
||||||
|
Not yet
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
|
|
||||||
size="large"
|
|
||||||
icon="plus"
|
|
||||||
onClick={connectProvider}
|
|
||||||
>
|
|
||||||
{language.t("command.provider.connect")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import {
|
import {
|
||||||
onCleanup,
|
onCleanup,
|
||||||
@@ -20,11 +20,13 @@ import { createStore } from "solid-js/store"
|
|||||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||||
import { Mark } from "@opencode-ai/ui/logo"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
||||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
@@ -41,6 +43,7 @@ import { TerminalPanel } from "@/pages/session/terminal-panel"
|
|||||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
|
|
||||||
const emptyUserMessages: UserMessage[] = []
|
const emptyUserMessages: UserMessage[] = []
|
||||||
|
|
||||||
@@ -252,6 +255,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
|
const globalSync = useGlobalSync()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
@@ -278,6 +282,7 @@ export default function Page() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [ui, setUi] = createStore({
|
const [ui, setUi] = createStore({
|
||||||
|
git: false,
|
||||||
pendingMessage: undefined as string | undefined,
|
pendingMessage: undefined as string | undefined,
|
||||||
scrollGesture: 0,
|
scrollGesture: 0,
|
||||||
scroll: {
|
scroll: {
|
||||||
@@ -494,6 +499,46 @@ export default function Page() {
|
|||||||
return "session.review.noVcs"
|
return "session.review.noVcs"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function upsert(next: Project) {
|
||||||
|
const list = globalSync.data.project
|
||||||
|
sync.set("project", next.id)
|
||||||
|
const idx = list.findIndex((item) => item.id === next.id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
globalSync.set(
|
||||||
|
"project",
|
||||||
|
list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const at = list.findIndex((item) => item.id > next.id)
|
||||||
|
if (at >= 0) {
|
||||||
|
globalSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
globalSync.set("project", [...list, next])
|
||||||
|
}
|
||||||
|
|
||||||
|
function initGit() {
|
||||||
|
if (ui.git) return
|
||||||
|
setUi("git", true)
|
||||||
|
void sdk.client.project
|
||||||
|
.initGit()
|
||||||
|
.then((x) => {
|
||||||
|
if (!x.data) return
|
||||||
|
upsert(x.data)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: formatServerError(err, language.t),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setUi("git", false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let inputRef!: HTMLDivElement
|
let inputRef!: HTMLDivElement
|
||||||
let promptDock: HTMLDivElement | undefined
|
let promptDock: HTMLDivElement | undefined
|
||||||
let dockHeight = 0
|
let dockHeight = 0
|
||||||
@@ -727,23 +772,28 @@ export default function Page() {
|
|||||||
const changesOptions = ["session", "turn"] as const
|
const changesOptions = ["session", "turn"] as const
|
||||||
const changesOptionsList = [...changesOptions]
|
const changesOptionsList = [...changesOptions]
|
||||||
|
|
||||||
const changesTitle = () => (
|
const changesTitle = () => {
|
||||||
<Select
|
if (!hasReview()) {
|
||||||
options={changesOptionsList}
|
return null
|
||||||
current={store.changes}
|
}
|
||||||
label={(option) =>
|
|
||||||
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
|
return (
|
||||||
}
|
<Select
|
||||||
onSelect={(option) => option && setStore("changes", option)}
|
options={changesOptionsList}
|
||||||
variant="ghost"
|
current={store.changes}
|
||||||
size="small"
|
label={(option) =>
|
||||||
valueClass="text-14-medium"
|
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
|
||||||
/>
|
}
|
||||||
)
|
onSelect={(option) => option && setStore("changes", option)}
|
||||||
|
variant="ghost"
|
||||||
|
size="small"
|
||||||
|
valueClass="text-14-medium"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const emptyTurn = () => (
|
const emptyTurn = () => (
|
||||||
<div class="h-full pb-30 flex flex-col items-center justify-center text-center gap-6">
|
<div class="h-full pb-30 flex flex-col items-center justify-center text-center gap-6">
|
||||||
<Mark class="w-14 opacity-10" />
|
|
||||||
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
|
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -809,9 +859,23 @@ export default function Page() {
|
|||||||
empty={
|
empty={
|
||||||
store.changes === "turn" ? (
|
store.changes === "turn" ? (
|
||||||
emptyTurn()
|
emptyTurn()
|
||||||
|
) : reviewEmptyKey() === "session.review.noVcs" ? (
|
||||||
|
<div class={input.emptyClass}>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="text-14-medium text-text-strong">Create a Git repository</div>
|
||||||
|
<div
|
||||||
|
class="text-14-regular text-text-base max-w-md"
|
||||||
|
style={{ "line-height": "var(--line-height-normal)" }}
|
||||||
|
>
|
||||||
|
Track, review, and undo changes in this project
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="large" disabled={ui.git} onClick={initGit}>
|
||||||
|
{ui.git ? "Creating Git repository..." : "Create Git repository"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div class={input.emptyClass}>
|
<div class={input.emptyClass}>
|
||||||
<Mark class="w-14 opacity-10" />
|
|
||||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -87,6 +87,21 @@ export function SessionSidePanel(props: {
|
|||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const empty = (msg: string) => (
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<div class="h-12 shrink-0" aria-hidden />
|
||||||
|
<div class="flex-1 pb-30 flex items-center justify-center text-center">
|
||||||
|
<div class="text-12-regular text-text-weak">{msg}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const nofiles = createMemo(() => {
|
||||||
|
const state = file.tree.state("")
|
||||||
|
if (!state?.loaded) return false
|
||||||
|
return file.tree.children("").length === 0
|
||||||
|
})
|
||||||
|
|
||||||
const normalizeTab = (tab: string) => {
|
const normalizeTab = (tab: string) => {
|
||||||
if (!tab.startsWith("file://")) return tab
|
if (!tab.startsWith("file://")) return tab
|
||||||
return file.tab(tab)
|
return file.tab(tab)
|
||||||
@@ -145,17 +160,8 @@ export function SessionSidePanel(props: {
|
|||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
activeDraggable: undefined as string | undefined,
|
activeDraggable: undefined as string | undefined,
|
||||||
fileTreeScrolled: false,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
let changesEl: HTMLDivElement | undefined
|
|
||||||
let allEl: HTMLDivElement | undefined
|
|
||||||
|
|
||||||
const syncFileTreeScrolled = (el?: HTMLDivElement) => {
|
|
||||||
const next = (el?.scrollTop ?? 0) > 0
|
|
||||||
setStore("fileTreeScrolled", (current) => (current === next ? current : next))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragStart = (event: unknown) => {
|
const handleDragStart = (event: unknown) => {
|
||||||
const id = getDraggableId(event)
|
const id = getDraggableId(event)
|
||||||
if (!id) return
|
if (!id) return
|
||||||
@@ -176,11 +182,6 @@ export function SessionSidePanel(props: {
|
|||||||
setStore("activeDraggable", undefined)
|
setStore("activeDraggable", undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!layout.fileTree.opened()) return
|
|
||||||
syncFileTreeScrolled(fileTreeTab() === "changes" ? changesEl : allEl)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!file.ready()) return
|
if (!file.ready()) return
|
||||||
|
|
||||||
@@ -354,7 +355,7 @@ export function SessionSidePanel(props: {
|
|||||||
class="h-full"
|
class="h-full"
|
||||||
data-scope="filetree"
|
data-scope="filetree"
|
||||||
>
|
>
|
||||||
<Tabs.List data-scrolled={store.fileTreeScrolled ? "" : undefined}>
|
<Tabs.List>
|
||||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||||
{reviewCount()}{" "}
|
{reviewCount()}{" "}
|
||||||
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
|
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
|
||||||
@@ -363,12 +364,7 @@ export function SessionSidePanel(props: {
|
|||||||
{language.t("session.files.all")}
|
{language.t("session.files.all")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
<Tabs.Content
|
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||||
value="changes"
|
|
||||||
ref={(el: HTMLDivElement) => (changesEl = el)}
|
|
||||||
onScroll={(e: UIEvent & { currentTarget: HTMLDivElement }) => syncFileTreeScrolled(e.currentTarget)}
|
|
||||||
class="bg-background-stronger px-3 py-0"
|
|
||||||
>
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={hasReview()}>
|
<Match when={hasReview()}>
|
||||||
<Show
|
<Show
|
||||||
@@ -382,6 +378,7 @@ export function SessionSidePanel(props: {
|
|||||||
>
|
>
|
||||||
<FileTree
|
<FileTree
|
||||||
path=""
|
path=""
|
||||||
|
class="pt-3"
|
||||||
allowed={diffFiles()}
|
allowed={diffFiles()}
|
||||||
kinds={kinds()}
|
kinds={kinds()}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
@@ -390,25 +387,22 @@ export function SessionSidePanel(props: {
|
|||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>{empty(language.t("session.review.noChanges"))}</Match>
|
||||||
<div class="mt-8 text-center text-12-regular text-text-weak">
|
|
||||||
{language.t("session.review.noChanges")}
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
</Switch>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content
|
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||||
value="all"
|
<Switch>
|
||||||
ref={(el: HTMLDivElement) => (allEl = el)}
|
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
|
||||||
onScroll={(e: UIEvent & { currentTarget: HTMLDivElement }) => syncFileTreeScrolled(e.currentTarget)}
|
<Match when={true}>
|
||||||
class="bg-background-stronger px-3 py-0"
|
<FileTree
|
||||||
>
|
path=""
|
||||||
<FileTree
|
class="pt-3"
|
||||||
path=""
|
modified={diffFiles()}
|
||||||
modified={diffFiles()}
|
kinds={kinds()}
|
||||||
kinds={kinds()}
|
onFileClick={(node) => openTab(file.tab(node.path))}
|
||||||
onFileClick={(node) => openTab(file.tab(node.path))}
|
/>
|
||||||
/>
|
</Match>
|
||||||
|
</Switch>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -554,7 +554,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
return (
|
return (
|
||||||
<div data-component="session-review" class={props.class} classList={props.classList}>
|
<div data-component="session-review" class={props.class} classList={props.classList}>
|
||||||
<div data-slot="session-review-header" class={props.classes?.header}>
|
<div data-slot="session-review-header" class={props.classes?.header}>
|
||||||
<div data-slot="session-review-title">{props.title ?? i18n.t("ui.sessionReview.title")}</div>
|
<div data-slot="session-review-title">
|
||||||
|
{props.title === undefined ? i18n.t("ui.sessionReview.title") : props.title}
|
||||||
|
</div>
|
||||||
<div data-slot="session-review-actions">
|
<div data-slot="session-review-actions">
|
||||||
<Show when={hasDiffs() && props.onDiffStyleChange}>
|
<Show when={hasDiffs() && props.onDiffStyleChange}>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
|
|||||||
@@ -407,11 +407,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
background-color: var(--background-stronger);
|
background-color: var(--background-stronger);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-bottom: 1px solid transparent;
|
border-bottom: 1px solid var(--border-weak-base);
|
||||||
|
|
||||||
&[data-scrolled] {
|
|
||||||
border-bottom-color: var(--border-weak-base);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="tabs-trigger-wrapper"] {
|
[data-slot="tabs-trigger-wrapper"] {
|
||||||
|
|||||||
Reference in New Issue
Block a user