mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 03:28:26 +00:00
feat(desktop): add recently closed projects to home (#34926)
This commit is contained in:
parent
eb3476660f
commit
bf58fae51f
9 changed files with 271 additions and 17 deletions
|
|
@ -1,7 +1,8 @@
|
|||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createEffect, createMemo, createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createServerProjects, ServerConnection, useServer } from "./server"
|
||||
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useServerHealth } from "@/utils/server-health"
|
||||
import { createServerSdkContext } from "./server-sdk"
|
||||
import { createServerSyncContext } from "./server-sync"
|
||||
|
|
@ -127,6 +128,14 @@ function createServerCtx(
|
|||
}
|
||||
|
||||
const projectsList = createMemo(() => projects.list().map(enrich))
|
||||
const recentlyClosedList = createMemo(() => {
|
||||
const known = new Set(sync.data.project.map((project) => pathKey(project.worktree)))
|
||||
return projects
|
||||
.recentlyClosed()
|
||||
.filter((worktree) => known.has(pathKey(worktree)))
|
||||
.slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
|
||||
.map((worktree) => enrich({ worktree, expanded: false }))
|
||||
})
|
||||
|
||||
const isLocal =
|
||||
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
||||
|
|
@ -139,6 +148,7 @@ function createServerCtx(
|
|||
projects: {
|
||||
...projects,
|
||||
list: projectsList,
|
||||
recentlyClosed: recentlyClosedList,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
|||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
|
||||
import { usePlatform } from "./platform"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { same } from "@/utils/same"
|
||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||
|
|
@ -493,7 +494,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
const root = rootFor(project.worktree)
|
||||
if (root === project.worktree) continue
|
||||
|
||||
server.projects.close(project.worktree)
|
||||
server.projects.remove(project.worktree)
|
||||
|
||||
if (!seen.has(root)) {
|
||||
server.projects.open(root)
|
||||
|
|
@ -613,6 +614,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
},
|
||||
projects: {
|
||||
list,
|
||||
recentlyClosed: createMemo(() => {
|
||||
const known = new Set(serverSync().data.project.map((project) => pathKey(project.worktree)))
|
||||
return server.projects
|
||||
.recentlyClosed()
|
||||
.filter((worktree) => known.has(pathKey(worktree)))
|
||||
.slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
|
||||
.map((worktree) => enrich({ worktree, expanded: false }))
|
||||
}),
|
||||
open(directory: string) {
|
||||
const root = rootFor(directory)
|
||||
if (server.projects.list().find((x) => x.worktree === root)) return
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ describe("createServerProjects", () => {
|
|||
test("keeps active and explicit server buckets in one reactive store", () => {
|
||||
createRoot((dispose) => {
|
||||
const [scope] = createSignal(ServerScope.local)
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {} })
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
||||
const active = createServerProjects({ scope, store, setStore })
|
||||
const remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
|
||||
|
||||
|
|
@ -115,6 +115,88 @@ describe("createServerProjects", () => {
|
|||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("tracks recently closed projects and drops them when reopened", () => {
|
||||
createRoot((dispose) => {
|
||||
const [scope] = createSignal(ServerScope.local)
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
||||
const projects = createServerProjects({ scope, store, setStore })
|
||||
|
||||
projects.open("/a")
|
||||
projects.open("/b")
|
||||
projects.close("/a")
|
||||
expect(projects.recentlyClosed()).toEqual(["/a"])
|
||||
|
||||
projects.close("/b")
|
||||
expect(projects.recentlyClosed()).toEqual(["/b", "/a"])
|
||||
|
||||
projects.open("/a")
|
||||
expect(projects.recentlyClosed()).toEqual(["/b"])
|
||||
expect(projects.list()).toEqual([{ worktree: "/a", expanded: true }])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("remove drops a project without recording it as recently closed", () => {
|
||||
createRoot((dispose) => {
|
||||
const [scope] = createSignal(ServerScope.local)
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
||||
const projects = createServerProjects({ scope, store, setStore })
|
||||
|
||||
projects.open("/repo/subdir")
|
||||
projects.remove("/repo/subdir")
|
||||
expect(projects.list()).toEqual([])
|
||||
expect(projects.recentlyClosed()).toEqual([])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("retains recently closed history beyond the visible display limit", () => {
|
||||
createRoot((dispose) => {
|
||||
const [scope] = createSignal(ServerScope.local)
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
||||
const projects = createServerProjects({ scope, store, setStore })
|
||||
|
||||
// Closing 6 projects keeps all 6 in the store even though only 5 are displayed;
|
||||
// this prevents display-filtered entries from evicting still-visible ones.
|
||||
for (const dir of ["/1", "/2", "/3", "/4", "/5", "/6"]) {
|
||||
projects.open(dir)
|
||||
projects.close(dir)
|
||||
}
|
||||
expect(projects.recentlyClosed()).toEqual(["/6", "/5", "/4", "/3", "/2", "/1"])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("caps recently closed history at the store limit", () => {
|
||||
createRoot((dispose) => {
|
||||
const [scope] = createSignal(ServerScope.local)
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
||||
const projects = createServerProjects({ scope, store, setStore })
|
||||
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
projects.open(`/p${i}`)
|
||||
projects.close(`/p${i}`)
|
||||
}
|
||||
expect(projects.recentlyClosed()).toHaveLength(16)
|
||||
expect(projects.recentlyClosed()[0]).toBe("/p20")
|
||||
expect(projects.recentlyClosed().at(-1)).toBe("/p5")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("dedupes recently closed entries by normalized path", () => {
|
||||
createRoot((dispose) => {
|
||||
const [scope] = createSignal(ServerScope.local)
|
||||
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
|
||||
const projects = createServerProjects({ scope, store, setStore })
|
||||
|
||||
projects.close("/repo")
|
||||
projects.close("/repo/")
|
||||
expect(projects.recentlyClosed()).toEqual(["/repo/"])
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrateCanonicalLocalServerState", () => {
|
||||
|
|
|
|||
|
|
@ -2,12 +2,23 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
|||
import { type Accessor, batch, createMemo } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
type StoredProject = { worktree: string; expanded: boolean }
|
||||
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
|
||||
type ServerProjectState = { projects: Record<string, StoredProject[]>; lastProject: Record<string, string> }
|
||||
type ServerProjectState = {
|
||||
projects: Record<string, StoredProject[]>
|
||||
lastProject: Record<string, string>
|
||||
recentlyClosed: Record<string, string[]>
|
||||
}
|
||||
const HEALTH_POLL_INTERVAL_MS = 10_000
|
||||
// The store retains more history than is displayed. Consumers filter recently closed entries
|
||||
// against the live project list (dropping deleted projects) and then cap the visible count via
|
||||
// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
|
||||
// filtered out do not evict still-visible ones from the persisted store.
|
||||
const RECENTLY_CLOSED_HISTORY_LIMIT = 16
|
||||
export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
|
||||
|
||||
export function normalizeServerUrl(input: string) {
|
||||
const trimmed = input.trim()
|
||||
|
|
@ -72,19 +83,42 @@ export function createServerProjects<T extends ServerProjectState>(input: {
|
|||
}) {
|
||||
const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
|
||||
const current = () => input.store.projects[input.scope()] ?? []
|
||||
const currentClosed = () => input.store.recentlyClosed?.[input.scope()] ?? []
|
||||
const remove = (directory: string) => {
|
||||
setStore(
|
||||
"projects",
|
||||
input.scope(),
|
||||
current().filter((project) => project.worktree !== directory),
|
||||
)
|
||||
}
|
||||
return {
|
||||
list: current,
|
||||
recentlyClosed: currentClosed,
|
||||
remove,
|
||||
open(directory: string) {
|
||||
const scope = input.scope()
|
||||
const key = pathKey(directory)
|
||||
const closed = currentClosed()
|
||||
if (closed.some((worktree) => pathKey(worktree) === key)) {
|
||||
setStore(
|
||||
"recentlyClosed",
|
||||
scope,
|
||||
closed.filter((worktree) => pathKey(worktree) !== key),
|
||||
)
|
||||
}
|
||||
if (current().some((project) => project.worktree === directory)) return
|
||||
setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
|
||||
},
|
||||
// User-initiated close: removes the project and records it in recently closed.
|
||||
// Internal, non-user removals (e.g. sandbox/worktree normalization) should use remove().
|
||||
close(directory: string) {
|
||||
setStore(
|
||||
"projects",
|
||||
input.scope(),
|
||||
current().filter((project) => project.worktree !== directory),
|
||||
remove(directory)
|
||||
const key = pathKey(directory)
|
||||
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
|
||||
0,
|
||||
RECENTLY_CLOSED_HISTORY_LIMIT,
|
||||
)
|
||||
setStore("recentlyClosed", input.scope(), closed)
|
||||
},
|
||||
expand(directory: string) {
|
||||
const index = current().findIndex((project) => project.worktree === directory)
|
||||
|
|
@ -235,6 +269,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||
list: [] as StoredServer[],
|
||||
projects: {} as Record<string, StoredProject[]>,
|
||||
lastProject: {} as Record<string, string>,
|
||||
recentlyClosed: {} as Record<string, string[]>,
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -604,6 +604,7 @@ export const dict = {
|
|||
"home.title": "Home",
|
||||
"home.projects": "Projects",
|
||||
"home.project.add": "Add project",
|
||||
"home.recentlyClosed": "Recently closed",
|
||||
"home.server.collapse": "Collapse server projects",
|
||||
"home.server.expand": "Expand server projects",
|
||||
"home.sessions.search.placeholder": "Search sessions",
|
||||
|
|
|
|||
|
|
@ -270,6 +270,10 @@ export function NewHome() {
|
|||
})
|
||||
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
|
||||
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
|
||||
const recentlyClosed = createMemo(
|
||||
() => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
|
||||
)
|
||||
const homedir = createMemo(() => focusedSync().data.path.home ?? "")
|
||||
const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
|
||||
const newSessionProject = createMemo(
|
||||
() =>
|
||||
|
|
@ -518,10 +522,13 @@ export function NewHome() {
|
|||
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
|
||||
<HomeProjectColumn
|
||||
projects={projects()}
|
||||
recentlyClosed={recentlyClosed()}
|
||||
homedir={homedir()}
|
||||
selected={selection()}
|
||||
focusServer={focusServer}
|
||||
selectProject={selectProject}
|
||||
openNewSession={openProjectNewSession}
|
||||
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
|
||||
chooseProject={(conn) => void chooseProject(conn)}
|
||||
editProject={editProject}
|
||||
closeProject={(conn, directory) => {
|
||||
|
|
@ -638,10 +645,13 @@ export function NewHome() {
|
|||
|
||||
function HomeProjectColumn(props: {
|
||||
projects: LocalProject[]
|
||||
recentlyClosed: LocalProject[]
|
||||
homedir: string
|
||||
selected: HomeProjectSelection
|
||||
focusServer: (server: ServerConnection.Any) => void
|
||||
selectProject: (server: ServerConnection.Any, directory: string) => void
|
||||
openNewSession: (server: ServerConnection.Any, directory: string) => void
|
||||
openRecentProject: (server: ServerConnection.Any, directory: string) => void
|
||||
chooseProject: (server: ServerConnection.Any) => void
|
||||
editProject: (server: ServerConnection.Any, project: LocalProject) => void
|
||||
closeProject: (server: ServerConnection.Any, directory: string) => void
|
||||
|
|
@ -670,8 +680,10 @@ function HomeProjectColumn(props: {
|
|||
aria-label={props.language.t("home.projects")}
|
||||
>
|
||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
||||
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
||||
<Show when={global.servers.list().length === 1}>
|
||||
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
||||
<Show
|
||||
when={global.servers.list().length === 1 && !(props.projects.length === 0 && props.recentlyClosed.length > 0)}
|
||||
>
|
||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||
<IconButtonV2
|
||||
data-action="home-add-project"
|
||||
|
|
@ -691,7 +703,21 @@ function HomeProjectColumn(props: {
|
|||
when={global.servers.list().length > 1}
|
||||
fallback={
|
||||
<div class="pr-3">
|
||||
<HomeProjectList {...props} server={global.servers.list()[0]!} />
|
||||
<Show
|
||||
when={props.projects.length > 0}
|
||||
fallback={
|
||||
<HomeProjectEmpty
|
||||
server={global.servers.list()[0]!}
|
||||
recentlyClosed={props.recentlyClosed}
|
||||
homedir={props.homedir}
|
||||
chooseProject={props.chooseProject}
|
||||
openRecentProject={props.openRecentProject}
|
||||
language={props.language}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<HomeProjectList {...props} server={global.servers.list()[0]!} />
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
|
@ -897,6 +923,79 @@ function HomeProjectList(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function HomeProjectEmpty(props: {
|
||||
server: ServerConnection.Any
|
||||
recentlyClosed: LocalProject[]
|
||||
homedir: string
|
||||
chooseProject: (server: ServerConnection.Any) => void
|
||||
openRecentProject: (server: ServerConnection.Any, directory: string) => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
|
||||
return (
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
data-action="home-add-project-row"
|
||||
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60 [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
|
||||
disabled={unreachable()}
|
||||
onClick={() => props.chooseProject(props.server)}
|
||||
>
|
||||
<IconV2 name="folder-add-left" size="small" />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{props.language.t("home.project.add")}</span>
|
||||
</button>
|
||||
<Show when={props.recentlyClosed.length > 0}>
|
||||
<div class="mt-3 flex h-7 min-w-0 shrink-0 items-center pl-1.5 pr-3">
|
||||
<div class="text-v2-text-text-faint [font-weight:530]">{props.language.t("home.recentlyClosed")}</div>
|
||||
</div>
|
||||
<For each={props.recentlyClosed}>
|
||||
{(project) => (
|
||||
<HomeRecentlyClosedRow
|
||||
project={project}
|
||||
server={props.server}
|
||||
homedir={props.homedir}
|
||||
openRecentProject={props.openRecentProject}
|
||||
language={props.language}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeRecentlyClosedRow(props: {
|
||||
project: LocalProject
|
||||
server: ServerConnection.Any
|
||||
homedir: string
|
||||
openRecentProject: (server: ServerConnection.Any, directory: string) => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
|
||||
const path = () => {
|
||||
const home = props.homedir
|
||||
const worktree = props.project.worktree
|
||||
if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}`
|
||||
return worktree
|
||||
}
|
||||
return (
|
||||
<TooltipV2 placement="right" value={path()}>
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-recently-closed-row"
|
||||
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60`}
|
||||
disabled={unreachable()}
|
||||
onClick={() => props.openRecentProject(props.server, props.project.worktree)}
|
||||
>
|
||||
<HomeProjectAvatar project={props.project} outline />
|
||||
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
|
||||
</button>
|
||||
</TooltipV2>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeProjectRow(props: {
|
||||
project: LocalProject
|
||||
server: ServerConnection.Any
|
||||
|
|
@ -979,13 +1078,13 @@ function HomeProjectRow(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function HomeProjectAvatar(props: { project: LocalProject }) {
|
||||
function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) {
|
||||
const name = createMemo(() => displayName(props.project))
|
||||
return (
|
||||
<ProjectAvatar
|
||||
fallback={name()}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
src={props.outline ? undefined : getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={props.outline ? "outline" : getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,13 @@
|
|||
--project-avatar-border: var(--v2-avatar-border-gray);
|
||||
}
|
||||
|
||||
[data-slot="project-avatar-surface"][data-variant="outline"] {
|
||||
--project-avatar-bg: var(--v2-background-bg-base);
|
||||
--project-avatar-border: var(--v2-border-border-strong);
|
||||
color: var(--v2-text-text-muted);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
[data-slot="project-avatar-surface"][data-has-image] {
|
||||
background: var(--project-avatar-bg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ Saturated 16px project avatar with color variants and optional unread dot.
|
|||
|
||||
### Variants
|
||||
- Color: orange, yellow, cyan, green, red, pink, blue, purple, gray.
|
||||
- Outline: neutral, muted style for de-emphasized projects (e.g. recently closed).
|
||||
- Image vs initial content state.
|
||||
- Unread dot with corner mask when \`unread\` is set.
|
||||
|
||||
|
|
@ -33,7 +34,7 @@ export default {
|
|||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: [...PROJECT_AVATAR_VARIANTS],
|
||||
options: [...PROJECT_AVATAR_VARIANTS, "outline"],
|
||||
},
|
||||
},
|
||||
args: {
|
||||
|
|
@ -62,6 +63,13 @@ export const AllVariants = {
|
|||
),
|
||||
}
|
||||
|
||||
export const Outline = {
|
||||
args: {
|
||||
fallback: "O",
|
||||
variant: "outline",
|
||||
},
|
||||
}
|
||||
|
||||
export const Unread = {
|
||||
args: {
|
||||
fallback: "O",
|
||||
|
|
|
|||
|
|
@ -26,10 +26,13 @@ export const PROJECT_AVATAR_VARIANTS = [
|
|||
|
||||
export type ProjectAvatarVariant = (typeof PROJECT_AVATAR_VARIANTS)[number]
|
||||
|
||||
// "outline" is a neutral, muted style (e.g. recently closed projects) and is not part of the color rotation.
|
||||
export type ProjectAvatarStyle = ProjectAvatarVariant | "outline"
|
||||
|
||||
export interface ProjectAvatarProps extends ComponentProps<"div"> {
|
||||
fallback: string
|
||||
src?: string
|
||||
variant?: ProjectAvatarVariant
|
||||
variant?: ProjectAvatarStyle
|
||||
unread?: boolean
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue