fix(app): permission indicator

This commit is contained in:
Adam
2026-02-26 20:40:39 -06:00
parent e9a7c71141
commit b0b88f6792
3 changed files with 52 additions and 12 deletions

View File

@@ -5,6 +5,7 @@ import {
displayName, displayName,
errorMessage, errorMessage,
getDraggableId, getDraggableId,
hasProjectPermissions,
latestRootSession, latestRootSession,
syncWorkspaceOrder, syncWorkspaceOrder,
workspaceKey, workspaceKey,
@@ -116,6 +117,29 @@ describe("layout workspace helpers", () => {
expect(result?.id).toBe("workspace") expect(result?.id).toBe("workspace")
}) })
test("detects project permissions with a filter", () => {
const result = hasProjectPermissions(
{
root: [{ id: "perm-root" }, { id: "perm-hidden" }],
child: [{ id: "perm-child" }],
},
(item) => item.id === "perm-child",
)
expect(result).toBe(true)
})
test("ignores project permissions filtered out", () => {
const result = hasProjectPermissions(
{
root: [{ id: "perm-root" }],
},
() => false,
)
expect(result).toBe(false)
})
test("ignores archived and child sessions when finding latest root session", () => { test("ignores archived and child sessions when finding latest root session", () => {
const result = latestRootSession( const result = latestRootSession(
[ [

View File

@@ -33,6 +33,13 @@ export const latestRootSession = (stores: { session: Session[]; path: { director
.flatMap((store) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory))) .flatMap((store) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory)))
.sort(sortSessions(now))[0] .sort(sortSessions(now))[0]
export function hasProjectPermissions<T>(
request: Record<string, T[] | undefined>,
include: (item: T) => boolean = () => true,
) {
return Object.values(request).some((list) => list?.some(include))
}
export const childMapByParent = (sessions: Session[]) => { export const childMapByParent = (sessions: Session[]) => {
const map = new Map<string, string[]>() const map = new Map<string, string[]>()
for (const session of sessions) { for (const session of sessions) {

View File

@@ -3,6 +3,7 @@ import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout, type LocalProject, getAvatarColors } from "@/context/layout" import { useLayout, type LocalProject, getAvatarColors } from "@/context/layout"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { Avatar } from "@opencode-ai/ui/avatar" import { Avatar } from "@opencode-ai/ui/avatar"
import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { DiffChanges } from "@opencode-ai/ui/diff-changes"
@@ -16,16 +17,27 @@ import { getFilename } from "@opencode-ai/util/path"
import { type Message, type Session, type TextPart, type UserMessage } from "@opencode-ai/sdk/v2/client" import { type Message, type Session, type TextPart, type UserMessage } from "@opencode-ai/sdk/v2/client"
import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js" import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js"
import { agentColor } from "@/utils/agent" import { agentColor } from "@/utils/agent"
import { hasProjectPermissions } from "./helpers"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
const globalSync = useGlobalSync()
const notification = useNotification() const notification = useNotification()
const permission = usePermission()
const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])]) const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])])
const unseenCount = createMemo(() => const unseenCount = createMemo(() =>
dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0), dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
) )
const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory))) const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory)))
const hasPermissions = createMemo(() =>
dirs().some((directory) => {
const [store] = globalSync.child(directory, { bootstrap: false })
return hasProjectPermissions(store.permission, (item) => !permission.autoResponds(item, directory))
}),
)
const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
return ( return (
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}> <div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
@@ -37,15 +49,16 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
} }
{...getAvatarColors(props.project.icon?.color)} {...getAvatarColors(props.project.icon?.color)}
class="size-full rounded" class="size-full rounded"
classList={{ "badge-mask": unseenCount() > 0 && props.notify }} classList={{ "badge-mask": notify() }}
/> />
</div> </div>
<Show when={unseenCount() > 0 && props.notify}> <Show when={notify()}>
<div <div
classList={{ classList={{
"absolute top-px right-px size-1.5 rounded-full z-10": true, "absolute top-px right-px size-1.5 rounded-full z-10": true,
"bg-icon-critical-base": hasError(), "bg-surface-warning-strong": hasPermissions(),
"bg-text-interactive-base": !hasError(), "bg-icon-critical-base": !hasPermissions() && hasError(),
"bg-text-interactive-base": !hasPermissions() && !hasError(),
}} }}
/> />
</Show> </Show>
@@ -186,19 +199,15 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
const layout = useLayout() const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const notification = useNotification() const notification = useNotification()
const permission = usePermission()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id)) const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
const hasError = createMemo(() => notification.session.unseenHasError(props.session.id)) const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
const [sessionStore] = globalSync.child(props.session.directory) const [sessionStore] = globalSync.child(props.session.directory)
const hasPermissions = createMemo(() => { const hasPermissions = createMemo(() => {
const permissions = sessionStore.permission?.[props.session.id] ?? [] return !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.session.id, (item) => {
if (permissions.length > 0) return true return !permission.autoResponds(item, props.session.directory)
})
for (const id of props.children.get(props.session.id) ?? []) {
const childPermissions = sessionStore.permission?.[id] ?? []
if (childPermissions.length > 0) return true
}
return false
}) })
const isWorking = createMemo(() => { const isWorking = createMemo(() => {
if (hasPermissions()) return false if (hasPermissions()) return false