summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-22 11:34:30 -0600
committerAdam <[email protected]>2026-01-22 12:22:10 -0600
commit62115832f5f3a9ed98a6b117cf820a11db12fae3 (patch)
tree281af07304ffc5555ade84f60564d602bf73d291 /packages
parent496bbd70f4afcd2b1fd580e7fdbc5947c257d7a2 (diff)
downloadopencode-62115832f5f3a9ed98a6b117cf820a11db12fae3.tar.gz
opencode-62115832f5f3a9ed98a6b117cf820a11db12fae3.zip
feat(app): render audio players in session review
Diffstat (limited to 'packages')
-rw-r--r--packages/ui/src/components/session-review.css32
-rw-r--r--packages/ui/src/components/session-review.tsx74
2 files changed, 102 insertions, 4 deletions
diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css
index e682c19b4..a53289b9a 100644
--- a/packages/ui/src/components/session-review.css
+++ b/packages/ui/src/components/session-review.css
@@ -138,6 +138,20 @@
justify-content: flex-end;
}
+ [data-slot="session-review-change"] {
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-weight: var(--font-weight-medium);
+ }
+
+ [data-slot="session-review-change"][data-type="added"] {
+ color: var(--icon-diff-add-base);
+ }
+
+ [data-slot="session-review-change"][data-type="removed"] {
+ color: var(--icon-diff-delete-base);
+ }
+
[data-slot="session-review-file-container"] {
padding: 0;
}
@@ -163,4 +177,22 @@
font-size: var(--font-size-small);
color: var(--text-weak);
}
+
+ [data-slot="session-review-audio-container"] {
+ padding: 12px;
+ display: flex;
+ justify-content: center;
+ background: var(--background-stronger);
+ }
+
+ [data-slot="session-review-audio"] {
+ width: 100%;
+ max-width: 560px;
+ }
+
+ [data-slot="session-review-audio-placeholder"] {
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ color: var(--text-weak);
+ }
}
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx
index 4d2123ff0..a19a8741d 100644
--- a/packages/ui/src/components/session-review.tsx
+++ b/packages/ui/src/components/session-review.tsx
@@ -36,6 +36,7 @@ export interface SessionReviewProps {
}
const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"])
+const audioExtensions = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"])
function getExtension(file: string): string {
const idx = file.lastIndexOf(".")
@@ -47,17 +48,21 @@ function isImageFile(file: string): boolean {
return imageExtensions.has(getExtension(file))
}
+function isAudioFile(file: string): boolean {
+ return audioExtensions.has(getExtension(file))
+}
+
function dataUrl(content: FileContent | undefined): string | undefined {
if (!content) return
if (content.encoding !== "base64") return
const mime = content.mimeType ?? ""
- if (!mime.startsWith("image/")) return
+ if (!mime.startsWith("image/") && !mime.startsWith("audio/")) return
return `data:${mime};base64,${content.content}`
}
function dataUrlFromValue(value: unknown): string | undefined {
if (typeof value === "string") {
- if (value.startsWith("data:image/")) return value
+ if (value.startsWith("data:image/") || value.startsWith("data:audio/")) return value
return
}
if (!value || typeof value !== "object") return
@@ -69,7 +74,7 @@ function dataUrlFromValue(value: unknown): string | undefined {
if (typeof content !== "string") return
if (encoding !== "base64") return
if (typeof mimeType !== "string") return
- if (!mimeType.startsWith("image/")) return
+ if (!mimeType.startsWith("image/") && !mimeType.startsWith("audio/")) return
return `data:${mimeType};base64,${content}`
}
@@ -150,11 +155,16 @@ export const SessionReview = (props: SessionReviewProps) => {
const isAdded = () => beforeText().length === 0 && afterText().length > 0
const isDeleted = () => afterText().length === 0 && beforeText().length > 0
const isImage = () => isImageFile(diff.file)
+ const isAudio = () => isAudioFile(diff.file)
const diffImageSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
const [imageSrc, setImageSrc] = createSignal<string | undefined>(diffImageSrc)
const [imageStatus, setImageStatus] = createSignal<"idle" | "loading" | "error">("idle")
+ const diffAudioSrc = dataUrlFromValue(diff.after) ?? dataUrlFromValue(diff.before)
+ const [audioSrc, setAudioSrc] = createSignal<string | undefined>(diffAudioSrc)
+ const [audioStatus, setAudioStatus] = createSignal<"idle" | "loading" | "error">("idle")
+
createEffect(() => {
if (!open().includes(diff.file)) return
if (!isImage()) return
@@ -180,6 +190,31 @@ export const SessionReview = (props: SessionReviewProps) => {
})
})
+ createEffect(() => {
+ if (!open().includes(diff.file)) return
+ if (!isAudio()) return
+ if (audioSrc()) return
+ if (audioStatus() !== "idle") return
+
+ const reader = props.readFile
+ if (!reader) return
+
+ setAudioStatus("loading")
+ reader(diff.file)
+ .then((result) => {
+ const src = dataUrl(result)
+ if (!src) {
+ setAudioStatus("error")
+ return
+ }
+ setAudioSrc(src)
+ setAudioStatus("idle")
+ })
+ .catch(() => {
+ setAudioStatus("error")
+ })
+ })
+
const fileForCode = () => {
const contents = afterText() || beforeText()
return {
@@ -216,7 +251,21 @@ export const SessionReview = (props: SessionReviewProps) => {
</div>
</div>
<div data-slot="session-review-trigger-actions">
- <DiffChanges changes={diff} />
+ <Switch>
+ <Match when={isAdded()}>
+ <span data-slot="session-review-change" data-type="added">
+ Added
+ </span>
+ </Match>
+ <Match when={isDeleted()}>
+ <span data-slot="session-review-change" data-type="removed">
+ Removed
+ </span>
+ </Match>
+ <Match when={true}>
+ <DiffChanges changes={diff} />
+ </Match>
+ </Switch>
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
@@ -241,6 +290,23 @@ export const SessionReview = (props: SessionReviewProps) => {
</Show>
</div>
</Match>
+ <Match when={isAudio()}>
+ <div data-slot="session-review-audio-container">
+ <Show
+ when={audioSrc()}
+ fallback={
+ <div data-slot="session-review-audio-placeholder">
+ <Switch>
+ <Match when={audioStatus() === "loading"}>Loading audio...</Match>
+ <Match when={true}>Audio preview unavailable</Match>
+ </Switch>
+ </div>
+ }
+ >
+ <audio data-slot="session-review-audio" controls src={audioSrc()!} />
+ </Show>
+ </div>
+ </Match>
<Match when={isAdded() || isDeleted()}>
<div data-slot="session-review-file-container">
<Dynamic component={codeComponent} file={fileForCode()} overflow="scroll" />