Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.0.20] - 2026-07-27

### Added

- Cowork's agent request, Notes, and Docs editors now support the same
explicit speech-to-text mic control and bare Option/Alt shortcut as Chat
and Quick Note.

### Fixed

- Skipper now renders with the product avatar in Cowork rather than a plain
`S` initial.
- Returning to Cowork after navigating away starts a fresh collaboration
transport from the authoritative saved file. It cannot merge stale Yjs
history into a new room and duplicate an agent's or user's document edits.
- Long Cowork Notes stay scrollable while in Write mode.
- Files selection now paints immediately. Recursive folder-tree loading is
independent of the current directory request, removing repeated VM mirror
work from ordinary file and folder clicks.

### Tests

- Browser coverage proves leaving and reopening a saved Cowork note retains
exactly one copy of its content, and focused source/browser contracts cover
Cowork dictation, Skipper identity, Notes scrolling, and non-blocking Files
selection.

## [0.0.19] - 2026-07-27

### Fixed
Expand Down Expand Up @@ -556,7 +583,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
notarization, stapled tickets, Gatekeeper verification, persistent
Application Support, and isolated Apple container machines.

[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.19...HEAD
[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.20...HEAD
[0.0.20]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.20
[0.0.19]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.19
[0.0.18]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.18
[0.0.17]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.17
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to
| `PORT` | `8123` | HTTP/WebSocket control-plane port. |
| `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and narrow workspace mirrors. |
| `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `lxc` on Linux, `wsl` on Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. |
| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.19` | Versioned channel-machine image contract. |
| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.20` | Versioned channel-machine image contract. |

### Agent-first JSON CLI

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "1helm",
"productName": "1Helm",
"version": "0.0.19",
"version": "0.0.20",
"private": true,
"type": "module",
"license": "AGPL-3.0-only",
Expand Down
4 changes: 2 additions & 2 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@
document.querySelectorAll('meta[name="theme-color"]').forEach(function (m) { m.setAttribute("content", color); });
})();
</script>
<link rel="stylesheet" href="/app.css?v=85051ba52505" />
<link rel="stylesheet" href="/app.css?v=766362520e61" />
<link rel="stylesheet" href="/bundle.css" />
<link rel="stylesheet" href="/excalidraw/index.css" />
</head>
<body class="h-screen w-screen overflow-hidden antialiased">
<div id="app" class="h-full w-full"></div>
<script type="module" src="/bundle.js?v=0520498da6f9"></script>
<script type="module" src="/bundle.js?v=c2de13d4517b"></script>
</body>
</html>
43 changes: 34 additions & 9 deletions src/client/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2761,7 +2761,9 @@ type BrowserSpeechRecognition = {
stop(): void;
};
type BrowserSpeechRecognitionConstructor = new () => BrowserSpeechRecognition;
let activeSpeech: { recognition: BrowserSpeechRecognition; input: HTMLTextAreaElement; button: HTMLButtonElement } | null = null;
type SpeechTextTarget = HTMLTextAreaElement | { value: () => string; replace: (value: string) => void; focus: () => void };
let activeSpeech: { recognition: BrowserSpeechRecognition; input: SpeechTextTarget; button: HTMLButtonElement } | null = null;
let focusedSpeechTarget: SpeechTextTarget | null = null;

function setListeningIndicator(listening: boolean): void {
let indicator = document.querySelector<HTMLElement>("[data-listening-indicator]");
Expand Down Expand Up @@ -2791,7 +2793,7 @@ function activeComposerInput(): HTMLTextAreaElement | null {
const parent = S.threadRoot ? String(S.threadRoot.id) : "root";
return document.querySelector<HTMLTextAreaElement>(`textarea[data-composer-parent="${parent}"]`);
}
async function toggleSpeechToText(input = activeComposerInput()): Promise<void> {
async function toggleSpeechToText(input: SpeechTextTarget | null = activeComposerInput(), explicitButton?: HTMLButtonElement): Promise<void> {
if (!input) return;
if (activeSpeech) {
const wasThisInput = activeSpeech.input === input;
Expand All @@ -2803,10 +2805,10 @@ async function toggleSpeechToText(input = activeComposerInput()): Promise<void>
await appAlert("Speech-to-text is not available in this browser. Try the current 1Helm desktop app or a browser with SpeechRecognition support; typing and attachments still work normally.");
return;
}
const button = input.closest(".composer-wrap, [data-quick-note]")?.querySelector<HTMLButtonElement>("[data-speech-toggle]");
const button = explicitButton || (input instanceof HTMLTextAreaElement ? input.closest(".composer-wrap, [data-quick-note]")?.querySelector<HTMLButtonElement>("[data-speech-toggle]") : null);
if (!button) return;
const recognition = new Recognition();
const original = input.value;
const original = input instanceof HTMLTextAreaElement ? input.value : input.value();
const joiner = original && !/\s$/.test(original) ? " " : "";
let finalTranscript = "";
recognition.continuous = true;
Expand All @@ -2819,9 +2821,12 @@ async function toggleSpeechToText(input = activeComposerInput()): Promise<void>
if (event.results[index]?.isFinal) finalTranscript += transcript;
else interim += transcript;
}
input.value = original + joiner + finalTranscript + interim;
input.selectionStart = input.selectionEnd = input.value.length;
input.dispatchEvent(new Event("input"));
const next = original + joiner + finalTranscript + interim;
if (input instanceof HTMLTextAreaElement) {
input.value = next;
input.selectionStart = input.selectionEnd = input.value.length;
input.dispatchEvent(new Event("input"));
} else input.replace(next);
};
recognition.onerror = (event: any) => {
const reason = String(event.error || "speech recognition failed");
Expand Down Expand Up @@ -2849,11 +2854,29 @@ async function toggleSpeechToText(input = activeComposerInput()): Promise<void>
}
}

/** Shared explicit mic control for textareas and Cowork's CodeMirror inputs.
* The bare Option/Alt shortcut targets the currently focused control too. */
export function mountSpeechToTextControl(input: SpeechTextTarget, label = "Toggle speech-to-text"): HTMLButtonElement {
const button = h("button", {
class: "grid h-8 w-8 shrink-0 place-items-center rounded text-muted hover:bg-hover hover:text-fg",
type: "button",
title: speechRecognitionAvailable() ? `${label} · tap Option/Alt to toggle` : "Speech-to-text is unavailable in this browser",
"aria-label": label,
"aria-pressed": "false",
dataset: { speechToggle: "" },
}, microphoneIcon()) as HTMLButtonElement;
button.onclick = () => { void toggleSpeechToText(input, button); };
return button;
}

export function setFocusedSpeechTarget(input: SpeechTextTarget | null): void { focusedSpeechTarget = input; }

function composer(parentId: number | null): HTMLElement {
const channel = S.channels.find((item) => item.id === S.channelId);
const humanOnly = ["collab", "human"].includes(channel?.kind || "");
const attachBar = h("div", { class: "flex flex-wrap gap-2 px-1 pt-1 empty:hidden" });
const input = h("textarea", { class: "max-h-44 min-h-[24px] w-full resize-none bg-transparent px-1 py-1 text-[15px] text-fg outline-none placeholder:text-faint", rows: 1, dataset: { composerParent: parentId == null ? "root" : String(parentId) }, placeholder: parentId ? "Reply…" : humanOnly ? "Message your coworkers…" : "Start a session — mention the resident agent or @skipper" }) as HTMLTextAreaElement;
input.addEventListener("focus", () => setFocusedSpeechTarget(input));
const mentionBox = h("div", { class: "absolute bottom-full left-0 right-0 z-20 mb-2 hidden max-h-[50vh] w-full max-w-sm overflow-y-auto overflow-hidden rounded-lg border border-line bg-surface shadow-xl sm:right-auto sm:w-72" });
const draftKey = `1helm.draft.${S.me.id}.${S.channelId}.${parentId == null ? "root" : parentId}`;
const savedDraft = localStorage.getItem(draftKey);
Expand Down Expand Up @@ -3266,8 +3289,10 @@ window.addEventListener("keyup", (event) => {
const isSingleTap = altTapOnly && performance.now() - altTapStarted < 800;
altTapOnly = false;
if (!isSingleTap || !document.hasFocus()) return;
const input = activeComposerInput();
if (input && !input.disabled && input.offsetParent !== null) void toggleSpeechToText(input);
const input = focusedSpeechTarget || activeComposerInput();
if (!input) return;
if (input instanceof HTMLTextAreaElement && (input.disabled || input.offsetParent === null)) return;
void toggleSpeechToText(input);
});
window.addEventListener("blur", () => { altTapOnly = false; });
window.matchMedia("(min-width: 768px)").addEventListener("change", (event) => { if (event.matches && S.mobileMenuOpen) closeMobileMenu(); });
Expand Down
27 changes: 18 additions & 9 deletions src/client/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,7 @@ export function renderFiles(container: HTMLElement, channelId: number, initialPa
const info = h("aside", { class: "hidden min-h-0 w-60 shrink-0 overflow-y-auto border-l border-line bg-raised/35 p-4 xl:block", dataset: { fileMetadata: "" } });
const crumbs = h("nav", { class: "flex min-w-0 flex-1 items-center gap-1 overflow-x-auto font-mono text-xs", "aria-label": "File breadcrumbs", dataset: { fileBreadcrumbs: "" } });
const fileInput = h("input", { type: "file", multiple: true, class: "hidden" }) as HTMLInputElement;
let directoryCache: ChannelFile[] | null = null;
const open = (entry: ChannelFile): void => {
if (entry.kind === "directory") { currentPath = entry.path; selected = null; void load(); return; }
const target = coworkPath(entry.path);
Expand Down Expand Up @@ -535,16 +536,24 @@ export function renderFiles(container: HTMLElement, channelId: number, initialPa
selected.kind === "file" ? h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { void downloadAuthenticatedFile(`/api/channels/${channelId}/files/content?path=${encodeURIComponent(selected!.path)}&download=1`, selected!.name); } }, "Download") : null,
h("button", { class: "btn-ghost text-xs text-danger", type: "button", onclick: () => { void mutate("delete"); } }, "Delete")));
};
const load = async (): Promise<void> => {
const refreshDirectories = async (): Promise<void> => {
try {
directoryCache = (await api<{ directories: ChannelFile[] }>(`/api/channels/${channelId}/files/directories`)).directories;
drawTree(directoryCache);
} catch { /* the current directory remains usable while the tree retries */ }
};
const redrawSelection = (): void => {
main.querySelectorAll<HTMLElement>("[data-file-path]").forEach((node) => node.classList.toggle("is-selected", node.dataset.filePath === selected?.path));
drawInfo();
};
const load = async (options: { refreshTree?: boolean } = {}): Promise<void> => {
const requestedPath = currentPath;
try {
const [result, folders] = await Promise.all([
api<{ path?: string; files: ChannelFile[] }>(`/api/channels/${channelId}/files?path=${encodeURIComponent(requestedPath)}`),
api<{ directories: ChannelFile[] }>(`/api/channels/${channelId}/files/directories`),
]);
const result = await api<{ path?: string; files: ChannelFile[] }>(`/api/channels/${channelId}/files?path=${encodeURIComponent(requestedPath)}`);
if (requestedPath !== currentPath) return;
currentPath = result.path ?? requestedPath; heading.textContent = `/workspace${currentPath ? `/${currentPath}` : ""}`; main.dataset.fileDirectory = currentPath || "/";
drawTree(folders.directories);
if (directoryCache) drawTree(directoryCache);
else void refreshDirectories();
clear(crumbs);
const segments = currentPath ? currentPath.split("/") : [];
const addCrumb = (label: string, path: string): void => {
Expand All @@ -558,22 +567,22 @@ export function renderFiles(container: HTMLElement, channelId: number, initialPa
if (!files.length) main.append(empty(result.files.length ? "No matches" : "This folder is empty", result.files.length ? "Try a different search." : "Create a folder or file, upload something, or let the resident agent add it."));
else main.append(h("div", { class: "file-grid", role: "list" }, ...files.map((entry) => h("button", {
class: `file-grid-item ${selected?.path === entry.path ? "is-selected" : ""}`, type: "button", role: "listitem", dataset: { filePath: entry.path, fileKind: entry.kind },
onclick: () => { selected = entry; void load(); }, ondblclick: () => open(entry),
onclick: () => { selected = entry; redrawSelection(); }, ondblclick: () => open(entry),
}, h("span", { class: `file-grid-icon ${entry.kind === "directory" ? "is-folder" : "is-file"}` }, workspaceIcon(entry, 27)), h("span", { class: "min-w-0 flex-1 text-left" }, h("span", { class: "block truncate text-sm font-semibold text-fg" }, entry.name), h("span", { class: "mt-0.5 block truncate text-[11px] text-muted" }, entry.kind === "directory" ? "Folder" : `${formatBytes(entry.size)} · ${timeLabel(entry.modified)}`)), h("span", { class: "file-grid-kind" }, entry.kind === "directory" ? "Folder" : (entry.name.split(".").pop()?.toUpperCase() || "File"))))));
drawInfo(); status.textContent = `${result.files.length} item${result.files.length === 1 ? "" : "s"}`;
} catch (error) { panelError(main, error); }
};
search.oninput = () => { filter = search.value.trim().toLowerCase(); void load(); };
const sortSelect = h("select", { class: "field h-9 w-auto min-w-28 text-xs", "aria-label": "Sort files", onchange: (event: Event) => { sort = (event.target as HTMLSelectElement).value as typeof sort; void load(); } }, h("option", { value: "name" }, "Name"), h("option", { value: "modified" }, "Modified"), h("option", { value: "size" }, "Size"));
const newFolder = async (): Promise<void> => { const name = await appPrompt("Folder name"); if (!name) return; try { await api(`/api/channels/${channelId}/files/directories`, { body: { path: currentPath, name } }); await load(); } catch (error) { status.textContent = (error as Error).message; } };
const newFolder = async (): Promise<void> => { const name = await appPrompt("Folder name"); if (!name) return; try { await api(`/api/channels/${channelId}/files/directories`, { body: { path: currentPath, name } }); directoryCache = null; await load(); } catch (error) { status.textContent = (error as Error).message; } };
const newFile = async (): Promise<void> => { const name = await appPrompt("File name", "untitled.md"); if (!name) return; try { await api(`/api/channels/${channelId}/files/entries`, { body: { parent: currentPath, name, content: "" } }); await load(); } catch (error) { status.textContent = (error as Error).message; } };
fileInput.onchange = async () => { const chosen = Array.from(fileInput.files || []); if (!chosen.length) return; status.textContent = `Uploading ${chosen.length} item${chosen.length === 1 ? "" : "s"}…`; try { for (const file of chosen) { const upload = await uploadFile(file); await api(`/api/channels/${channelId}/files/upload`, { body: { ...upload, path: currentPath } }); } fileInput.value = ""; await load(); } catch (error) { status.textContent = (error as Error).message; } };
root.append(
h("header", { class: "flex min-h-14 flex-wrap items-center gap-2 border-b border-line px-3 py-2 sm:px-4" }, h("span", { class: "text-accent" }, icon("folderOpen", 20)), heading, h("div", { class: "flex-1" }), status, h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { void newFile(); } }, icon("plus", 14), "New file"), h("button", { class: "btn-subtle text-xs", type: "button", onclick: () => { void newFolder(); } }, icon("folder", 14), "New folder"), h("button", { class: "btn-primary text-xs", type: "button", onclick: () => fileInput.click() }, "Upload"), fileInput),
h("div", { class: "flex min-h-0 flex-1" },
h("aside", { class: "hidden min-h-0 w-60 shrink-0 flex-col border-r border-line bg-raised/35 md:flex" }, h("div", { class: "border-b border-line p-3" }, h("div", { class: "eyebrow text-muted" }, "Folders")), tree),
h("section", { class: "flex min-h-0 min-w-0 flex-1 flex-col" }, h("div", { class: "flex flex-wrap items-center gap-2 border-b border-line bg-raised/25 px-3 py-2" }, crumbs, h("div", { class: "w-full sm:w-48" }, search), sortSelect), main), info));
fileBrowserSurfaces.set(channelId, { node: root, reload: load });
fileBrowserSurfaces.set(channelId, { node: root, reload: async () => { directoryCache = null; await load(); } });
clear(container); container.append(root); void load();
}

Expand Down
5 changes: 5 additions & 0 deletions src/client/cowork-editors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export type MountedEditor = {
focus: () => void;
destroy: () => void;
getContent?: () => string;
replaceContent?: (content: string) => void;
format?: (prefix: string, suffix?: string, placeholder?: string) => void;
selection?: () => { from: number; to: number };
};
Expand Down Expand Up @@ -87,6 +88,10 @@ export function mountCodeMirror(
focus: () => view.focus(),
destroy: () => view.destroy(),
getContent: () => view.state.doc.toString(),
replaceContent: (content) => {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: content }, selection: { anchor: content.length } });
view.focus();
},
format: (prefix, suffix = prefix, placeholder = "text") => {
const range = view.state.selection.main;
const selected = view.state.sliceDoc(range.from, range.to) || placeholder;
Expand Down
Loading
Loading