diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 7acb766f808..2600bd8e24a 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -3,15 +3,20 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { TextField } from "@opencode-ai/ui/text-field" import { Icon } from "@opencode-ai/ui/icon" +import { Avatar } from "@opencode-ai/ui/avatar" import { createMemo, createSignal, For, Show } from "solid-js" import { createStore } from "solid-js/store" import { useGlobalSDK } from "@/context/global-sdk" import { type LocalProject, getAvatarColors } from "@/context/layout" -import { getFilename } from "@opencode-ai/util/path" -import { Avatar } from "@opencode-ai/ui/avatar" +import { ProjectAvatar, isValidImageFile } from "@/components/project-avatar" const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const +function getFilename(input: string) { + const parts = input.split("/") + return parts[parts.length - 1] || input +} + export function DialogEditProject(props: { project: LocalProject }) { const dialog = useDialog() const globalSDK = useGlobalSDK() @@ -30,7 +35,7 @@ export function DialogEditProject(props: { project: LocalProject }) { const [iconHover, setIconHover] = createSignal(false) function handleFileSelect(file: File) { - if (!file.type.startsWith("image/")) return + if (!isValidImageFile(file)) return const reader = new FileReader() reader.onload = (e) => { setStore("iconUrl", e.target?.result as string) @@ -98,7 +103,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
setIconHover(true)} onMouseLeave={() => setIconHover(false)}>
- - -
- } - > - Project icon - +
+ {props.children} diff --git a/packages/app/src/components/project-avatar.tsx b/packages/app/src/components/project-avatar.tsx new file mode 100644 index 00000000000..0dd907c72cc --- /dev/null +++ b/packages/app/src/components/project-avatar.tsx @@ -0,0 +1,73 @@ +import { createMemo, splitProps, type ComponentProps, type JSX } from "solid-js" +import { Avatar } from "@opencode-ai/ui/avatar" +import { getAvatarColors } from "@/context/layout" + +const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" +const OPENCODE_FAVICON_URL = "https://opencode.ai/favicon.svg" + +export interface ProjectAvatarProps extends Omit, "children"> { + name: string + iconUrl?: string + iconColor?: string + projectId?: string + size?: "small" | "normal" | "large" +} + +export const isValidImageUrl = (url: string | undefined): boolean => { + if (!url) { + return false + } + if (url.startsWith("data:image/x-icon")) { + return false + } + if (url.startsWith("data:image/vnd.microsoft.icon")) { + return false + } + return true +} + +export const isValidImageFile = (file: File): boolean => { + if (!file.type.startsWith("image/")) { + return false + } + if (file.type === "image/x-icon" || file.type === "image/vnd.microsoft.icon") { + return false + } + return true +} + +export const ProjectAvatar = (props: ProjectAvatarProps) => { + const [local, rest] = splitProps(props, [ + "name", + "iconUrl", + "iconColor", + "projectId", + "size", + "class", + "classList", + "style", + ]) + const colors = createMemo(() => getAvatarColors(local.iconColor)) + const validSrc = createMemo(() => { + if (isValidImageUrl(local.iconUrl)) { + return local.iconUrl + } + if (local.projectId === OPENCODE_PROJECT_ID) { + return OPENCODE_FAVICON_URL + } + return undefined + }) + + return ( + + ) +} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 56bbdc8cb55..1c30cee98f8 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -794,7 +794,7 @@ export const PromptInput: Component = (props) => { .abort({ sessionID: params.id!, }) - .catch(() => {}) + .catch(() => { }) const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { const text = prompt @@ -1255,7 +1255,7 @@ export const PromptInput: Component = (props) => { const optimisticParts = requestParts.map((part) => ({ ...part, - sessionID: session.id, + sessionID: session?.id ?? "", messageID, })) as unknown as Part[] @@ -1273,9 +1273,9 @@ export const PromptInput: Component = (props) => { const addOptimisticMessage = () => { setSyncStore( produce((draft) => { - const messages = draft.message[session.id] + const messages = draft.message[session?.id ?? ""] if (!messages) { - draft.message[session.id] = [optimisticMessage] + draft.message[session?.id ?? ""] = [optimisticMessage] } else { const result = Binary.search(messages, messageID, (m) => m.id) messages.splice(result.index, 0, optimisticMessage) @@ -1291,7 +1291,7 @@ export const PromptInput: Component = (props) => { const removeOptimisticMessage = () => { setSyncStore( produce((draft) => { - const messages = draft.message[session.id] + const messages = draft.message[session?.id ?? ""] if (messages) { const result = Binary.search(messages, messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) @@ -1567,7 +1567,7 @@ export const PromptInput: Component = (props) => {
-
+
@@ -1618,13 +1618,60 @@ export const PromptInput: Component = (props) => { title="Thinking effort" keybind={command.keybind("model.variant.cycle")} > - + {(() => { + const [text, setText] = createSignal(local.model.variant.current() ?? "Default") + const [animating, setAnimating] = createSignal(false) + let locked = false + + const handleClick = async () => { + if (locked) return + + local.model.variant.cycle() + const newText = local.model.variant.current() ?? "Default" + + if (newText === text()) return + + locked = true + setAnimating(true) + + // Wait for exit animation + const charCount = text().length + await new Promise((r) => setTimeout(r, charCount * 40 + 400)) + + // Reset animating before setting new text so @starting-style works + setAnimating(false) + setText(newText) + + // Wait for enter animation + const newCharCount = newText.length + await new Promise((r) => setTimeout(r, newCharCount * 40 + 400)) + + locked = false + } + + return ( + + ) + })()} @@ -1700,7 +1747,7 @@ export const PromptInput: Component = (props) => { disabled={!prompt.dirty() && !working()} icon={working() ? "stop" : "arrow-up"} variant="primary" - class="h-6 w-4.5" + class="h-6 w-6" />
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 7cded4bce29..0ce4a068f35 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -236,6 +236,7 @@ export function SessionHeader() {