mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 14:50:14 +00:00
fix: stop artifact walk entering OS app-data dirs; context bar off by default
The artifacts scan used rglob and filtered after descending, so a home directory workspace walked into ~/Library and triggered the macOS App Data consent prompt on every turn. Walk with pruning instead, and skip Library / AppData in search too. The composer chip now shows the session total by default, with the context window bar behind a Settings toggle.
This commit is contained in:
@@ -1367,6 +1367,11 @@ def create_app(manager: SessionManager) -> FastAPI:
|
|||||||
# Sidebar: sessions shown per group before "Show more" (owner ask, 2026-07-03).
|
# Sidebar: sessions shown per group before "Show more" (owner ask, 2026-07-03).
|
||||||
return manager.set_sessions_peek((body or {}).get("sessions_peek", 5))
|
return manager.set_sessions_peek((body or {}).get("sessions_peek", 5))
|
||||||
|
|
||||||
|
@app.post("/v1/settings/context-bar")
|
||||||
|
def settings_set_context_bar(body: dict) -> dict[str, Any]:
|
||||||
|
# Composer: show the context-window fill bar, or just the popover (owner ask).
|
||||||
|
return manager.set_context_bar((body or {}).get("context_bar", True))
|
||||||
|
|
||||||
@app.post("/v1/settings/pdf")
|
@app.post("/v1/settings/pdf")
|
||||||
def settings_set_pdf(body: dict) -> dict[str, Any]:
|
def settings_set_pdf(body: dict) -> dict[str, Any]:
|
||||||
# Token savings (owner ask, 2026-07-17): fallback mode for models without native
|
# Token savings (owner ask, 2026-07-17): fallback mode for models without native
|
||||||
|
|||||||
+43
-24
@@ -1250,32 +1250,40 @@ class SessionManager:
|
|||||||
".doc",
|
".doc",
|
||||||
".docm",
|
".docm",
|
||||||
}
|
}
|
||||||
for path in root.rglob("*"):
|
# os.walk with in-place pruning, NOT rglob: rglob descends first and filters after,
|
||||||
try:
|
# so a home-directory workspace walked into ~/Library and tripped the macOS App Data
|
||||||
rel = path.relative_to(root)
|
# TCC prompt ("OpenWorker would like to access data from other apps") on every turn.
|
||||||
if any(
|
# Pruning here means those directories are never entered at all.
|
||||||
part.startswith(".")
|
from ..tools.search import OS_DATA_DIRS
|
||||||
or part in {"node_modules", "target", "dist", "__pycache__"}
|
|
||||||
for part in rel.parts
|
skip = {"node_modules", "target", "dist", "__pycache__"} | OS_DATA_DIRS
|
||||||
):
|
for dirpath, dirs, files in os.walk(root):
|
||||||
|
dirs[:] = [d for d in dirs if not d.startswith(".") and d not in skip]
|
||||||
|
for name in files:
|
||||||
|
if name.startswith("."):
|
||||||
continue
|
continue
|
||||||
if not path.is_file() or path.suffix.lower() not in suffixes:
|
path = Path(dirpath) / name
|
||||||
|
if path.suffix.lower() not in suffixes:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
st = path.stat()
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"path": str(path.relative_to(root)),
|
||||||
|
# Absolute path for "Copy path" — the relative one is useless
|
||||||
|
# outside the app (tester catch 2026-07-12: it copied just the
|
||||||
|
# filename).
|
||||||
|
"abs_path": str(path),
|
||||||
|
"name": path.name,
|
||||||
|
"kind": _artifact_kind(path),
|
||||||
|
"size": st.st_size,
|
||||||
|
"modified_at": st.st_mtime,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
continue
|
continue
|
||||||
st = path.stat()
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"path": str(rel),
|
|
||||||
# Absolute path for "Copy path" — the relative one is useless outside
|
|
||||||
# the app (tester catch 2026-07-12: it copied just the filename).
|
|
||||||
"abs_path": str(path),
|
|
||||||
"name": path.name,
|
|
||||||
"kind": _artifact_kind(path),
|
|
||||||
"size": st.st_size,
|
|
||||||
"modified_at": st.st_mtime,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
out.sort(key=lambda a: a["modified_at"], reverse=True)
|
out.sort(key=lambda a: a["modified_at"], reverse=True)
|
||||||
return out[:80]
|
return out[:80]
|
||||||
|
|
||||||
@@ -1805,6 +1813,7 @@ class SessionManager:
|
|||||||
"surfaces": self._surfaces(),
|
"surfaces": self._surfaces(),
|
||||||
"nav_layout": self._nav_layout(),
|
"nav_layout": self._nav_layout(),
|
||||||
"sessions_peek": self.sessions_peek(),
|
"sessions_peek": self.sessions_peek(),
|
||||||
|
"context_bar": self.context_bar(),
|
||||||
"scratch_base": self._prefs.get("scratch_base")
|
"scratch_base": self._prefs.get("scratch_base")
|
||||||
or self.DEFAULT_SCRATCH_BASE,
|
or self.DEFAULT_SCRATCH_BASE,
|
||||||
# Real on-disk secrets location, so the UI shows the OS-native path instead of a
|
# Real on-disk secrets location, so the UI shows the OS-native path instead of a
|
||||||
@@ -1864,6 +1873,16 @@ class SessionManager:
|
|||||||
self._save_prefs()
|
self._save_prefs()
|
||||||
return {"ok": True, "sessions_peek": self.sessions_peek()}
|
return {"ok": True, "sessions_peek": self.sessions_peek()}
|
||||||
|
|
||||||
|
def context_bar(self) -> bool:
|
||||||
|
"""Whether the composer shows the context-window fill bar. OFF by default (owner
|
||||||
|
ask): the chip then states the session total, and the popover keeps both numbers."""
|
||||||
|
return bool(self._prefs.get("context_bar", False))
|
||||||
|
|
||||||
|
def set_context_bar(self, shown: Any) -> dict[str, Any]:
|
||||||
|
self._prefs["context_bar"] = bool(shown)
|
||||||
|
self._save_prefs()
|
||||||
|
return {"ok": True, "context_bar": self.context_bar()}
|
||||||
|
|
||||||
# -- PDF attachments / token savings (owner ask, 2026-07-17) ----------------
|
# -- PDF attachments / token savings (owner ask, 2026-07-17) ----------------
|
||||||
DEFAULT_PDF_MAX_PAGES = 20
|
DEFAULT_PDF_MAX_PAGES = 20
|
||||||
DEFAULT_PDF_MAX_MB = 10
|
DEFAULT_PDF_MAX_MB = 10
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ from typing import Any, Optional
|
|||||||
|
|
||||||
import aisuite as ai
|
import aisuite as ai
|
||||||
|
|
||||||
|
# Per-OS application data directories. These are not build noise: on macOS 14+ merely
|
||||||
|
# *descending* into ~/Library/Application Support (other apps' containers) trips the App
|
||||||
|
# Data TCC protection and macOS shows "would like to access data from other apps" — an
|
||||||
|
# alarming prompt the user never asked for, reachable whenever the workspace is a home
|
||||||
|
# directory. Never traversed; a workspace under one of these is still searched normally,
|
||||||
|
# because the guard matches directory NAMES encountered during a walk.
|
||||||
|
OS_DATA_DIRS = {
|
||||||
|
"Library", # macOS
|
||||||
|
"AppData", # Windows
|
||||||
|
"Application Data", # Windows (legacy junction)
|
||||||
|
}
|
||||||
|
|
||||||
_IGNORE_DIRS = {
|
_IGNORE_DIRS = {
|
||||||
".git",
|
".git",
|
||||||
"node_modules",
|
"node_modules",
|
||||||
@@ -30,7 +42,7 @@ _IGNORE_DIRS = {
|
|||||||
".pytest_cache",
|
".pytest_cache",
|
||||||
".ruff_cache",
|
".ruff_cache",
|
||||||
".idea",
|
".idea",
|
||||||
}
|
} | OS_DATA_DIRS
|
||||||
|
|
||||||
_SCHEMA = {
|
_SCHEMA = {
|
||||||
"type": "function",
|
"type": "function",
|
||||||
|
|||||||
@@ -838,6 +838,10 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
|||||||
|
|
||||||
if (p.endsWith("/v1/health")) return json(HEALTH);
|
if (p.endsWith("/v1/health")) return json(HEALTH);
|
||||||
if (p.endsWith("/v1/settings")) return json(SETTINGS);
|
if (p.endsWith("/v1/settings")) return json(SETTINGS);
|
||||||
|
if (p.endsWith("/v1/settings/context-bar") && m === "POST") {
|
||||||
|
Object.assign(SETTINGS, req.postDataJSON());
|
||||||
|
return json({ ok: true, context_bar: SETTINGS.context_bar });
|
||||||
|
}
|
||||||
if (p.endsWith("/v1/settings/pdf") && m === "POST") {
|
if (p.endsWith("/v1/settings/pdf") && m === "POST") {
|
||||||
Object.assign(SETTINGS, req.postDataJSON());
|
Object.assign(SETTINGS, req.postDataJSON());
|
||||||
return json({
|
return json({
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ test("usage chip appears after a turn and opens the breakdown popover", async ({
|
|||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Chip shows the session total (1k + 200 + 8k + 800 = 10k).
|
// Default: no bar (owner ask 2026-07-30) — the chip states the session total
|
||||||
|
// (1k + 200 + 8k + 800 = 10k). The bar is opt-in via Settings.
|
||||||
const chip = page.getByTestId("usage-chip");
|
const chip = page.getByTestId("usage-chip");
|
||||||
await expect(chip).toContainText("10k");
|
await expect(chip).toContainText("10k");
|
||||||
|
|
||||||
@@ -58,9 +59,41 @@ test("usage resets on a new session", async ({ page }) => {
|
|||||||
const box = page.getByPlaceholder(/Ask the coworker/);
|
const box = page.getByPlaceholder(/Ask the coworker/);
|
||||||
await box.fill("hello");
|
await box.fill("hello");
|
||||||
await box.press("Enter");
|
await box.press("Enter");
|
||||||
await expect(page.getByTestId("usage-chip")).toContainText("10k", { timeout: 10_000 });
|
await expect(page.getByTestId("usage-chip")).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
// "+ New session" wipes the transcript — and the usage accumulation with it.
|
// "+ New session" wipes the transcript — and the usage accumulation with it.
|
||||||
await page.getByRole("button", { name: /New session/ }).first().click();
|
await page.getByRole("button", { name: /New session/ }).first().click();
|
||||||
await expect(page.getByTestId("usage-chip")).toHaveCount(0);
|
await expect(page.getByTestId("usage-chip")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Settings toggle turns the context bar on; default is the session total", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByText("Draft the launch note").first().click();
|
||||||
|
const box = page.getByPlaceholder(/Ask the coworker/);
|
||||||
|
await box.fill("hello");
|
||||||
|
await box.press("Enter");
|
||||||
|
const chip = page.getByTestId("usage-chip");
|
||||||
|
await expect(chip).toContainText("10k", { timeout: 10_000 }); // default: total, no bar
|
||||||
|
|
||||||
|
// Turn the bar ON in Settings -> General.
|
||||||
|
await page.getByTestId("account-row").click();
|
||||||
|
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||||
|
await expect(page.getByTestId("context-bar-toggle")).not.toBeChecked();
|
||||||
|
const [req] = await Promise.all([
|
||||||
|
page.waitForRequest(
|
||||||
|
(r) => r.url().endsWith("/v1/settings/context-bar") && r.method() === "POST",
|
||||||
|
),
|
||||||
|
page.getByTestId("context-bar-toggle").check(),
|
||||||
|
]);
|
||||||
|
expect(req.postDataJSON()).toEqual({ context_bar: true });
|
||||||
|
|
||||||
|
// Reload so the app re-reads settings: the chip is now the fill bar, not a number.
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByText("Draft the launch note").first().click();
|
||||||
|
await page.getByPlaceholder(/Ask the coworker/).fill("hello");
|
||||||
|
await page.getByPlaceholder(/Ask the coworker/).press("Enter");
|
||||||
|
const bar = page.getByTestId("usage-chip");
|
||||||
|
await expect(bar).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(bar).not.toContainText("10k");
|
||||||
|
await expect(bar).toHaveAttribute("title", /Context window 5% full/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -165,6 +165,9 @@ export function App() {
|
|||||||
// {full model id → context window in tokens} from the curated matrix (verified only);
|
// {full model id → context window in tokens} from the curated matrix (verified only);
|
||||||
// drives the composer usage chip's context-fill meter.
|
// drives the composer usage chip's context-fill meter.
|
||||||
const [modelContextWindows, setModelContextWindows] = useState<Record<string, number>>({});
|
const [modelContextWindows, setModelContextWindows] = useState<Record<string, number>>({});
|
||||||
|
// Settings: show the composer's context-window fill bar. OFF by default (owner ask),
|
||||||
|
// so an older backend without the field also shows the session total.
|
||||||
|
const [contextBar, setContextBar] = useState(false);
|
||||||
// Per-session token usage (OPE-42): rebuilt from the transcript on session load,
|
// Per-session token usage (OPE-42): rebuilt from the transcript on session load,
|
||||||
// accumulated live from assistant_message events, reset with the transcript.
|
// accumulated live from assistant_message events, reset with the transcript.
|
||||||
const [usage, setUsage] = useState<SessionUsage>(emptyUsage());
|
const [usage, setUsage] = useState<SessionUsage>(emptyUsage());
|
||||||
@@ -506,6 +509,7 @@ export function App() {
|
|||||||
setModels(s.models || []);
|
setModels(s.models || []);
|
||||||
setModelLabels(s.model_labels || {});
|
setModelLabels(s.model_labels || {});
|
||||||
setModelContextWindows(s.model_context_windows || {});
|
setModelContextWindows(s.model_context_windows || {});
|
||||||
|
setContextBar(s.context_bar === true);
|
||||||
setModelReady(s.model_ready);
|
setModelReady(s.model_ready);
|
||||||
if (s.surfaces) setSurfaces(s.surfaces);
|
if (s.surfaces) setSurfaces(s.surfaces);
|
||||||
})
|
})
|
||||||
@@ -1587,6 +1591,7 @@ export function App() {
|
|||||||
resetKey={sessionId}
|
resetKey={sessionId}
|
||||||
usage={usage}
|
usage={usage}
|
||||||
contextWindow={modelContextWindows[model]}
|
contextWindow={modelContextWindows[model]}
|
||||||
|
contextBar={contextBar}
|
||||||
placeholder={
|
placeholder={
|
||||||
agent === "code"
|
agent === "code"
|
||||||
? "Ask the coder to build, fix, or explain… (drop or paste files)"
|
? "Ask the coder to build, fix, or explain… (drop or paste files)"
|
||||||
|
|||||||
@@ -692,6 +692,9 @@ export interface ModelSettings {
|
|||||||
nav_layout?: "flat" | "grouped";
|
nav_layout?: "flat" | "grouped";
|
||||||
// Sidebar: sessions shown per group before "Show more" (default 5, 1–50).
|
// Sidebar: sessions shown per group before "Show more" (default 5, 1–50).
|
||||||
sessions_peek?: number;
|
sessions_peek?: number;
|
||||||
|
// Composer: show the context-window fill bar (default FALSE; absent → the chip shows
|
||||||
|
// the session total). The usage popover keeps both numbers regardless.
|
||||||
|
context_bar?: boolean;
|
||||||
// Curated-matrix display names ({full id → "GLM-5.2 · via Together"}); custom models absent.
|
// Curated-matrix display names ({full id → "GLM-5.2 · via Together"}); custom models absent.
|
||||||
model_labels?: Record<string, string>;
|
model_labels?: Record<string, string>;
|
||||||
// {full id → context window in tokens}, verified matrix entries only — drives the
|
// {full id → context window in tokens}, verified matrix entries only — drives the
|
||||||
@@ -758,6 +761,18 @@ export async function inspectPdf(
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persist whether the composer shows the context-window fill bar. */
|
||||||
|
export async function setContextBar(
|
||||||
|
shown: boolean,
|
||||||
|
): Promise<{ ok: boolean; context_bar?: boolean; error?: string }> {
|
||||||
|
const res = await fetch(`${httpBase()}/v1/settings/context-bar`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ context_bar: shown }),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
/** Persist how many sessions a sidebar group shows before "Show more". */
|
/** Persist how many sessions a sidebar group shows before "Show more". */
|
||||||
export async function setSessionsPeek(
|
export async function setSessionsPeek(
|
||||||
n: number,
|
n: number,
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ interface Props {
|
|||||||
// Context-window size (tokens) of the ACTIVE model, from the curated matrix;
|
// Context-window size (tokens) of the ACTIVE model, from the curated matrix;
|
||||||
// undefined hides the fill meter (unverified/custom models) but keeps the counts.
|
// undefined hides the fill meter (unverified/custom models) but keeps the counts.
|
||||||
contextWindow?: number;
|
contextWindow?: number;
|
||||||
|
// Settings toggle (default off): true shows the fill bar instead of the session total.
|
||||||
|
contextBar?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Composer(props: Props) {
|
export function Composer(props: Props) {
|
||||||
@@ -469,13 +471,14 @@ export function Composer(props: Props) {
|
|||||||
|
|
||||||
<span className="ml-auto" />
|
<span className="ml-auto" />
|
||||||
|
|
||||||
{/* token usage (OPE-42) — a quiet meter+count chip; hidden until the server
|
{/* token usage (OPE-42) — a quiet chip; hidden until the server reports usage.
|
||||||
reports usage. Fill = context-window occupancy (bounded), count = session
|
Shows the context-window fill bar alone (the session total lives in the
|
||||||
consumption (unbounded, so never a fill). */}
|
popover), or the session total when there's no window / the bar is off. */}
|
||||||
{!dictation?.recording && props.usage && totalTokens(props.usage) > 0 && (
|
{!dictation?.recording && props.usage && totalTokens(props.usage) > 0 && (
|
||||||
<UsageChip
|
<UsageChip
|
||||||
usage={props.usage}
|
usage={props.usage}
|
||||||
contextWindow={props.contextWindow}
|
contextWindow={props.contextWindow}
|
||||||
|
contextBar={props.contextBar}
|
||||||
model={props.model}
|
model={props.model}
|
||||||
modelLabels={props.modelLabels}
|
modelLabels={props.modelLabels}
|
||||||
/>
|
/>
|
||||||
@@ -572,11 +575,13 @@ export function Composer(props: Props) {
|
|||||||
function UsageChip({
|
function UsageChip({
|
||||||
usage,
|
usage,
|
||||||
contextWindow,
|
contextWindow,
|
||||||
|
contextBar,
|
||||||
model,
|
model,
|
||||||
modelLabels,
|
modelLabels,
|
||||||
}: {
|
}: {
|
||||||
usage: SessionUsage;
|
usage: SessionUsage;
|
||||||
contextWindow?: number;
|
contextWindow?: number;
|
||||||
|
contextBar?: boolean;
|
||||||
model: string;
|
model: string;
|
||||||
modelLabels?: Record<string, string>;
|
modelLabels?: Record<string, string>;
|
||||||
}) {
|
}) {
|
||||||
@@ -585,6 +590,8 @@ function UsageChip({
|
|||||||
const pct = contextWindow
|
const pct = contextWindow
|
||||||
? Math.min(100, Math.round((usage.context / contextWindow) * 100))
|
? Math.min(100, Math.round((usage.context / contextWindow) * 100))
|
||||||
: null;
|
: null;
|
||||||
|
// Settings can hide the bar; without a known window there is nothing to fill either.
|
||||||
|
const showBar = pct !== null && contextBar === true;
|
||||||
const labelFor = (id: string) =>
|
const labelFor = (id: string) =>
|
||||||
id === "unknown" ? "Unknown model" : modelLabels?.[id] || shortModel(id);
|
id === "unknown" ? "Unknown model" : modelLabels?.[id] || shortModel(id);
|
||||||
// One field per line, session-summed (owner ask 2026-07-28). Values are cumulative
|
// One field per line, session-summed (owner ask 2026-07-28). Values are cumulative
|
||||||
@@ -605,21 +612,25 @@ function UsageChip({
|
|||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-label="Token usage"
|
aria-label="Token usage"
|
||||||
title={
|
title={
|
||||||
pct !== null
|
showBar
|
||||||
? `Token usage — ${pct}% of the context window used`
|
? `Context window ${pct}% full · ${formatTokens(total)} tokens this session`
|
||||||
: "Token usage this session"
|
: `Token usage this session: ${formatTokens(total)}`
|
||||||
}
|
}
|
||||||
data-testid="usage-chip"
|
data-testid="usage-chip"
|
||||||
>
|
>
|
||||||
{pct !== null && (
|
{/* The bar is the context-window fill; pairing it with the session TOTAL read as
|
||||||
<span className="w-7 h-1 rounded-full bg-line overflow-hidden" aria-hidden="true">
|
"total is N% of the window", which it never was. Bar alone when we have a
|
||||||
|
window, the session total only when we don't (so the chip is never empty). */}
|
||||||
|
{showBar ? (
|
||||||
|
<span className="w-12 h-1.5 rounded-full bg-line overflow-hidden" aria-hidden="true">
|
||||||
<span
|
<span
|
||||||
className="block h-full bg-accent transition-all"
|
className="block h-full bg-accent transition-all"
|
||||||
style={{ width: `${Math.max(pct, 4)}%` }}
|
style={{ width: `${Math.max(pct as number, 4)}%` }}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="tabular-nums">{formatTokens(total)}</span>
|
||||||
)}
|
)}
|
||||||
<span className="tabular-nums">{formatTokens(total)}</span>
|
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
getSettings,
|
getSettings,
|
||||||
getTrustedWorkspaces,
|
getTrustedWorkspaces,
|
||||||
setCompactionSettings,
|
setCompactionSettings,
|
||||||
|
setContextBar,
|
||||||
setOnboarded,
|
setOnboarded,
|
||||||
setPdfSettings,
|
setPdfSettings,
|
||||||
setScratchBase,
|
setScratchBase,
|
||||||
@@ -428,6 +429,8 @@ function AppearanceSection() {
|
|||||||
|
|
||||||
<SidebarCard />
|
<SidebarCard />
|
||||||
|
|
||||||
|
<ContextBarCard />
|
||||||
|
|
||||||
<FilesCard />
|
<FilesCard />
|
||||||
|
|
||||||
<TrustedWorkspacesCard />
|
<TrustedWorkspacesCard />
|
||||||
@@ -790,6 +793,48 @@ function CompactionCard() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Composer: context-window bar (owner ask 2026-07-30) ------------------------
|
||||||
|
// The chip's bar is context-window occupancy; the session total (unbounded) lives in
|
||||||
|
// the popover. Some people would rather not watch a meter at all, hence the toggle.
|
||||||
|
function ContextBarCard() {
|
||||||
|
const [shown, setShown] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getSettings()
|
||||||
|
.then((s) => setShown(s.context_bar === true))
|
||||||
|
.catch(() => setShown(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = async (next: boolean) => {
|
||||||
|
setShown(next);
|
||||||
|
await setContextBar(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (shown === null) return null;
|
||||||
|
return (
|
||||||
|
<div className={CARD + " p-4 mb-4"} data-testid="context-bar-card">
|
||||||
|
<div className={FIELD_LABEL}>Composer</div>
|
||||||
|
<label className="flex items-start gap-3 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5"
|
||||||
|
data-testid="context-bar-toggle"
|
||||||
|
checked={shown}
|
||||||
|
onChange={(e) => save(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block text-[13px] text-ink">Show the context window bar</span>
|
||||||
|
<span className="block text-[12px] text-muted">
|
||||||
|
A small meter showing how full the model’s context window is. Turn it off
|
||||||
|
to show this session’s token total instead; either way the full breakdown
|
||||||
|
is one click away.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SidebarCard() {
|
function SidebarCard() {
|
||||||
const [peek, setPeek] = useState<number | null>(null);
|
const [peek, setPeek] = useState<number | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""list_artifacts must never descend into OS application-data directories.
|
||||||
|
|
||||||
|
On macOS 14+, merely traversing ~/Library/Application Support (other apps' containers)
|
||||||
|
trips the App Data TCC protection and the user gets an alarming "OpenWorker would like to
|
||||||
|
access data from other apps" prompt. The artifacts panel refreshes after every turn, so a
|
||||||
|
home-directory workspace produced that prompt unprompted. Pruning must happen DURING the
|
||||||
|
walk (rglob descends first and filters after, which is what caused the bug).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from coworker.server.manager import SessionManager
|
||||||
|
from coworker.tools.search import OS_DATA_DIRS
|
||||||
|
|
||||||
|
|
||||||
|
def _ws(tmp_path):
|
||||||
|
ws = tmp_path / "home"
|
||||||
|
(ws / "Library" / "Application Support" / "SomeOtherApp").mkdir(parents=True)
|
||||||
|
(ws / "Library" / "Application Support" / "SomeOtherApp" / "secrets.json").write_text("{}")
|
||||||
|
(ws / "Library" / "notes.md").write_text("# private")
|
||||||
|
(ws / "node_modules" / "pkg").mkdir(parents=True)
|
||||||
|
(ws / "node_modules" / "pkg" / "readme.md").write_text("# dep")
|
||||||
|
(ws / "report.md").write_text("# real artifact")
|
||||||
|
return ws
|
||||||
|
|
||||||
|
|
||||||
|
def test_os_data_dirs_are_not_traversed(tmp_path, monkeypatch):
|
||||||
|
ws = _ws(tmp_path)
|
||||||
|
walked: list[str] = []
|
||||||
|
real_walk = os.walk
|
||||||
|
|
||||||
|
def spy(top, *a, **k):
|
||||||
|
for dirpath, dirs, files in real_walk(top, *a, **k):
|
||||||
|
walked.append(dirpath)
|
||||||
|
yield dirpath, dirs, files
|
||||||
|
|
||||||
|
monkeypatch.setattr("coworker.server.manager.os.walk", spy)
|
||||||
|
m = SessionManager(data_dir=tmp_path / "data", workspace=str(ws))
|
||||||
|
names = [a["name"] for a in m.list_artifacts("s1")]
|
||||||
|
|
||||||
|
assert "report.md" in names
|
||||||
|
# The private file is skipped AND its directory was never entered (the TCC trigger).
|
||||||
|
assert "notes.md" not in names
|
||||||
|
assert "secrets.json" not in names
|
||||||
|
assert not any("Library" in p for p in walked), f"descended into Library: {walked}"
|
||||||
|
assert not any("node_modules" in p for p in walked)
|
||||||
|
|
||||||
|
|
||||||
|
def test_os_data_dirs_cover_mac_and_windows():
|
||||||
|
assert {"Library", "AppData", "Application Data"} <= OS_DATA_DIRS
|
||||||
Reference in New Issue
Block a user