From 4849114d258e1be282555dac81afc5f653ca9c4e Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 14 Apr 2026 14:19:06 +0000 Subject: [PATCH 1/2] Fix issue #24: Lange Links in der Ticket-Beschreibung handhaben --- .openhands_instructions | 176 +++++++++++++++++++++++++- src/app/jira/jira-markup.pipe.spec.ts | 69 ++++++++++ src/app/jira/jira-markup.pipe.ts | 6 + src/styles.css | 2 + 4 files changed, 252 insertions(+), 1 deletion(-) mode change 120000 => 100644 .openhands_instructions create mode 100644 src/app/jira/jira-markup.pipe.spec.ts diff --git a/.openhands_instructions b/.openhands_instructions deleted file mode 120000 index 47dc3e3..0000000 --- a/.openhands_instructions +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/.openhands_instructions b/.openhands_instructions new file mode 100644 index 0000000..8d1d682 --- /dev/null +++ b/.openhands_instructions @@ -0,0 +1,175 @@ +# Orbit — Coding Agent Guidelines + +## Project Identity + +Orbit is a personal command center for software engineers working at a German company. +It lives permanently on a dedicated second monitor, integrating company tools like Jira, Bitbucket, and local task management into a single calm interface. + +- **UI language is German** — all user-facing text must be in German +- **The "anti-Jira" principle** — Orbit must feel instant, clean, and low-noise +- **Bundle size is irrelevant** — this is a local tool, not a customer-facing product + +## Architecture Overview + +``` +Angular SPA (:6200) → Express BFF (:6201) → Jira / Bitbucket APIs + → Local JSON files (~/.orbit/) + → CoSi AI review (SSE) +Mock servers (:6202, :6203) simulate Jira and Bitbucket for local dev. +``` + +- **Frontend:** Angular 21, zoneless, signal-based, Angular Router for URL routing with signal-driven state sync +- **Backend (`server/`):** Express app with three roles — API proxy (auth injection), local data CRUD, CoSi review SSE endpoint. Routes are split into `server/routes/`. +- **Mock servers (`mock-server/`):** Standalone Express apps returning realistic German-language test data +- **State:** External data (tickets, PRs) is read-only from APIs. Local data that is important and should be stored safely (e.g. todos, ideas, logbook, schedule, subtasks) is persisted as JSON in `~/.orbit/`. Information that is only interesting temporarily (e.g. Pomodoro end time, information on expanded sections) use localStorage. + +## Frontend Folder Structure + +`src/app/` is organized by **business domain**, not by technical type. Each domain folder is flat — no `components/`/`services/` subfolders within it. + +``` +src/app/ +├── jira/ # Jira ticket integration +├── bitbucket/ # Bitbucket PR integration +├── todos/ # Personal task management +├── ideas/ # Idea capture +├── reflection/ # Daily reflection / logbook +├── pomodoro/ # Pomodoro timer +├── review/ # AI code review (CoSi) +├── calendar/ # Day calendar & appointments +├── settings/ # App configuration & welcome screen +├── .../ # Other/new features/bisness domains get their own folder here +├── shared/ # Cross-cutting: layout, shared UI, app-wide services & models +└── app.ts ... +``` + +**Placement rule:** if a file belongs to one domain, it goes in that domain's folder. If it is used by multiple domains or is app-wide (layout, orchestration, shared UI components), it goes in `shared/`. + +Each domain folder contains its components (in subfolders), services, models, pipes, and utils side by side — everything for a feature lives together. Components get their own subfolder (e.g. `todos/todo-card/todo-card.ts`), while services, models, and utils sit directly in the domain folder (e.g. `todos/todo.service.ts`). + +When creating new files, follow this structure. Do not create top-level `components/`, `services/`, `models/`, `pipes/`, or `utils/` folders. + +## Design for ADHD users + +This is the single most important design constraint — it is why Orbit exists. + +- Tool-switching is mentally exhausting — surface everything relevant in one place +- Context loss on distraction is costly — spatial stability and visual calm are essential +- Overwhelm is a real risk — show less but clearer, not more but busier + +Every UI element, interaction, or feature must be evaluated against these principles: + +- **Spatial Stability**: Layout must not shift or reorder. Users orient by position — losing that orientation is jarring and costly. Elements should stay where they are across interactions and data loads. +- **Zero-Depth Navigation**: No nested menus, no back buttons. Every interaction should resolve in one step, or as few steps as possible. If a user has to remember where they came from, the design has failed. +- **Status at a Glance**: State and status must be communicated visually through color, icons, and spatial position. Scanning beats reading — a user should understand what needs attention without reading a single word. +- **Chunking**: Group related information with strong visual separation. Avoid walls of undifferentiated content. White space and borders are tools for reducing cognitive load. +- **Frictionless Transitions**: Links to external tools (Jira, Bitbucket) must open in a new tab without breaking Orbit's context. The user should never lose their place. +- **Low Motion**: No auto-playing animations. Subtle transitions only (≤150ms). Movement draws attention — use it deliberately, not decoratively. The exception is dopamine feedback (e.g. confetti on task completion), where motion is engaging rather than distracting. +- **Progressive Disclosure**: Don't barrage the user with data. Reveal information in layers — start with the most salient facts and let the user drill down on demand. This prevents the mental freeze that occurs when an ADHD brain faces too much information at once. +- **Supportive Language**: Use forgiving error messages and non-blaming language. Every button and link should tell the user exactly what will happen when they click it. Avoid jargon and technical abbreviations — reduce the cognitive leap required to process commands. +- **Dopamine Closing**: Close the loop for every task. Provide immediate, tangible visual and audio feedback for completion. This provides the dopamine boost necessary for sustained engagement and prevents open-loop anxiety. + +## Coding Standards + +### TypeScript + +- Strict type checking, prefer type inference when obvious +- Never use `any` — use `unknown` when type is uncertain +- Do not add explanatory comments to code + +### Angular + +- Standalone components only (do NOT set `standalone: true` — it's the default in Angular v20+) +- Signals for state, `computed()` for derived state, `OnPush` change detection everywhere +- `input()` / `output()` functions, not decorators +- `inject()` function, not constructor injection +- `host` object in `@Component` decorator, not `@HostBinding` / `@HostListener` +- Native control flow (`@if`, `@for`, `@switch`), not structural directives +- Class bindings, not `ngClass`. Style bindings, not `ngStyle`. +- `NgOptimizedImage` for all static images (not for inline base64) +- Reactive forms over template-driven forms +- Do not assume globals like `new Date()` are available in templates + +### Testing + +- **Runner:** Vitest via `@angular/build:unit-test` — run with `ng test --no-watch` +- **Zoneless project** — do NOT use `fakeAsync` or `tick` from `@angular/core/testing` +- **Flush effects:** `TestBed.tick()` (not the deprecated `flushEffects()`) +- **Component tests:** `TestBed.configureTestingModule({ imports: [MyComponent] })` +- **Mocking:** use `vi.spyOn()` / `vi.fn()` from Vitest globals — never use bare `spyOn()` (Jasmine syntax) + +### After Making Changes + +Always run both: + +```bash +ng test --no-watch +npx ng build +``` + +### Feature Toggles + +- New features that are not yet stable or complete must be developed behind a feature toggle using `FeatureToggleService` +- Register the feature in the toggle registry (`TOGGLE_REGISTRY` in `src/app/settings/feature-toggle.service.ts`) with `defaultValue: false` (off by default) +- Guard all UI entry points with the toggle signal +- Only remove the toggle when the feature is considered finished and stable + +## Visual Design System + +### Philosophy + +- **Warm over cold** — stone family, not slate/zinc. Calm and human, not clinical. +- **Reduce eye strain** — this UI is open all day. High contrast is good; high saturation is not. +- **Accent sparingly** — violet only for interactivity or selection, never decoratively. +- **Typography signals hierarchy** — ticket keys and branch names use monospace. Weight and size establish reading order, not color. + +### Color Rules + +Orbit uses semantic CSS custom properties defined in `src/styles/tokens.css`. Dark mode is toggled via a `dark` class on `` (managed by `ThemeService`). The tokens redefine themselves under `.dark`, so **using token variables is usually all you need** — dark mode comes for free. Only when you need a color not covered by an existing token do you need to add a new token with both light and dark values to `tokens.css`. + +**Mandatory:** + +- **Never hardcode neutral colors.** No `bg-white`, `bg-stone-50`, `text-stone-800`, `#ff00dd` etc. Use token variables that are defined in [src/styles/tokens.css](src/styles/tokens.css) and add them as needed. Some examples: + - Backgrounds: `bg-[var(--color-bg-card)]`, `bg-[var(--color-bg-page)]`, `bg-[var(--color-bg-surface)]` + - Text: `text-[var(--color-text-heading)]`, `text-[var(--color-text-body)]`, `text-[var(--color-text-muted)]` + - Borders: `border-[var(--color-border-default)]`, `border-[var(--color-border-subtle)]` + - Primary: `bg-[var(--color-primary-bg)]`, `text-[var(--color-primary-text)]`, `bg-[var(--color-primary-solid)]` +- **Semantic colors also use tokens.** Success, danger, signal, and info all have tokens (e.g. `--color-success-text`, `--color-danger-bg`, `--color-signal-bar`). Use these instead of direct Tailwind classes like `text-emerald-700`. If a needed shade doesn't have a token yet, add one to `tokens.css` first. +- **Form inputs** must have explicit `text-[var(--color-text-heading)]` and `placeholder:text-[var(--color-text-muted)]`. +- **No hex values** in component styles (except purely decorative SVGs). +- **Every component must work in both light and dark mode.** + +### Allowed Palettes + +| Role | Palette | +| --------- | ------------------- | +| Neutral | `stone` | +| Primary | `violet` | +| Attention | `amber` | +| Success | `emerald` | +| Error | `red` | +| Info | `blue` (links only) | + +No other palettes (`gray`, `slate`, `zinc`, `indigo`, `sky`, etc.) may be used. + +### Card States + +Every card (ticket, PR, todo, idea) has exactly one state: + +| State | Visual | When | +| ------------- | -------------------------------------- | ------------------------ | +| **Inactive** | Reduced opacity (55% light / 62% dark) | User doesn't need to act | +| **Normal** | Neutral card, no accent | Default state | +| **Attention** | `border-l-4 border-amber-500` | Urgent or overdue | + +Cards never have colored backgrounds. Color lives only in badges, icons, text, and the amber attention bar. Type badges ("Fehler", "Aufgabe", "User Story") are always neutral stone — only status badges carry semantic colors. + +## Commit Hygiene + +- Do not commit documentation files, standalone test scripts, or debugging artifacts +- Only commit production code and its corresponding spec files + +## Accessibility + +- Must pass all AXE checks +- Must meet WCAG AA minimums: focus management, color contrast, ARIA attributes diff --git a/src/app/jira/jira-markup.pipe.spec.ts b/src/app/jira/jira-markup.pipe.spec.ts new file mode 100644 index 0000000..5019cef --- /dev/null +++ b/src/app/jira/jira-markup.pipe.spec.ts @@ -0,0 +1,69 @@ + +import { TestBed } from '@angular/core/testing'; +import { JiraMarkupPipe } from './jira-markup.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; + +describe('JiraMarkupPipe', () => { + let pipe: JiraMarkupPipe; + let sanitizer: DomSanitizer; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [JiraMarkupPipe], + }); + + pipe = TestBed.inject(JiraMarkupPipe); + sanitizer = TestBed.inject(DomSanitizer); + }); + + it('should create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('should convert plain URLs to clickable links', () => { + const input = 'Visit https://example.com for more info'; + const result = pipe.transform(input); + expect(result).toContain('href="https://example.com"'); + expect(result).toContain('class="jira-link"'); + }); + + it('should handle multiple URLs', () => { + const input = 'Check https://first.com and https://second.com'; + const result = pipe.transform(input); + expect(result).toContain('href="https://first.com"'); + expect(result).toContain('href="https://second.com"'); + }); + + it('should handle URLs in parentheses', () => { + const input = 'See documentation (https://docs.example.com)'; + const result = pipe.transform(input); + expect(result).toContain('href="https://docs.example.com"'); + }); + + it('should handle URLs with trailing punctuation', () => { + const input = 'The site is https://example.com. Visit now!'; + const result = pipe.transform(input); + expect(result).toContain('href="https://example.com"'); + }); + + it('should handle FTP URLs', () => { + const input = 'Download from ftp://files.example.com'; + const result = pipe.transform(input); + expect(result).toContain('href="ftp://files.example.com"'); + }); + + it('should not double-process Jira link syntax', () => { + const input = 'Check [label|https://example.com]'; + const result = pipe.transform(input); + // Should have the Jira link syntax processed, not double-wrapped + expect(result).toContain('href="https://example.com"'); + expect(result).toContain('>label<'); + }); + + it('should handle long URLs that need wrapping', () => { + const input = 'Long URL: https://www.example.com/very/long/path/that/should/wrap/properly'; + const result = pipe.transform(input); + expect(result).toContain('href="https://www.example.com/very/long/path/that/should/wrap/properly"'); + expect(result).toContain('class="jira-link"'); + }); +}); diff --git a/src/app/jira/jira-markup.pipe.ts b/src/app/jira/jira-markup.pipe.ts index 4eac726..1b78430 100644 --- a/src/app/jira/jira-markup.pipe.ts +++ b/src/app/jira/jira-markup.pipe.ts @@ -82,6 +82,12 @@ function extractBlocks(text: string, stash: string[]): string { } function applyInline(text: string): string { + // Auto-detect and convert plain URLs to clickable links + text = text.replace( + /(^|[\s(])((?:https?|ftp):\/\/[^\s<>"'()]+[^\s<>"'().,!?])/gi, + (_, prefix, url) => `${prefix}${url}`, + ); + text = text.replace( /\{color:([^}]+)\}(.*?)\{color\}/gi, (_, color, inner) => `${inner}`, diff --git a/src/styles.css b/src/styles.css index cd2bda8..9723217 100644 --- a/src/styles.css +++ b/src/styles.css @@ -180,6 +180,8 @@ color: var(--color-primary-text); text-decoration: underline; text-underline-offset: 2px; + word-break: break-all; + overflow-wrap: break-word; } .jira-markup .jira-link:hover { color: var(--color-primary-solid); From 6fdb3226a2b5130a45fb1dbfeec08aba3f86260f Mon Sep 17 00:00:00 2001 From: Dominik Halfkann Date: Tue, 14 Apr 2026 16:36:17 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20improve=20URL=20auto-linking=20?= =?UTF-8?q?=E2=80=94=20correct=20ordering,=20remove=20FTP,=20add=20edge=20?= =?UTF-8?q?case=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move URL auto-linking after Jira link syntax handler to prevent double-wrapping. Remove unnecessary FTP protocol support, dead lookahead, and code comments. Fix tests to properly extract SafeHtml and add coverage for code blocks, inline code, bare URL syntax, trailing punctuation, query params, and fragments. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/jira/jira-markup.pipe.spec.ts | 85 +++++++++++++++++++-------- src/app/jira/jira-markup.pipe.ts | 12 ++-- 2 files changed, 67 insertions(+), 30 deletions(-) diff --git a/src/app/jira/jira-markup.pipe.spec.ts b/src/app/jira/jira-markup.pipe.spec.ts index 5019cef..e623ea3 100644 --- a/src/app/jira/jira-markup.pipe.spec.ts +++ b/src/app/jira/jira-markup.pipe.spec.ts @@ -1,11 +1,14 @@ - import { TestBed } from '@angular/core/testing'; import { JiraMarkupPipe } from './jira-markup.pipe'; -import { DomSanitizer } from '@angular/platform-browser'; +import { SafeHtml } from '@angular/platform-browser'; + +function html(safe: SafeHtml): string { + return (safe as unknown as { changingThisBreaksApplicationSecurity: string }) + .changingThisBreaksApplicationSecurity; +} describe('JiraMarkupPipe', () => { let pipe: JiraMarkupPipe; - let sanitizer: DomSanitizer; beforeEach(() => { TestBed.configureTestingModule({ @@ -13,7 +16,6 @@ describe('JiraMarkupPipe', () => { }); pipe = TestBed.inject(JiraMarkupPipe); - sanitizer = TestBed.inject(DomSanitizer); }); it('should create an instance', () => { @@ -21,49 +23,84 @@ describe('JiraMarkupPipe', () => { }); it('should convert plain URLs to clickable links', () => { - const input = 'Visit https://example.com for more info'; - const result = pipe.transform(input); + const result = html(pipe.transform('Visit https://example.com for more info')); expect(result).toContain('href="https://example.com"'); expect(result).toContain('class="jira-link"'); }); it('should handle multiple URLs', () => { - const input = 'Check https://first.com and https://second.com'; - const result = pipe.transform(input); + const result = html(pipe.transform('Check https://first.com and https://second.com')); expect(result).toContain('href="https://first.com"'); expect(result).toContain('href="https://second.com"'); }); it('should handle URLs in parentheses', () => { - const input = 'See documentation (https://docs.example.com)'; - const result = pipe.transform(input); + const result = html(pipe.transform('See documentation (https://docs.example.com)')); expect(result).toContain('href="https://docs.example.com"'); }); it('should handle URLs with trailing punctuation', () => { - const input = 'The site is https://example.com. Visit now!'; - const result = pipe.transform(input); + const result = html(pipe.transform('The site is https://example.com. Visit now!')); expect(result).toContain('href="https://example.com"'); }); - it('should handle FTP URLs', () => { - const input = 'Download from ftp://files.example.com'; - const result = pipe.transform(input); - expect(result).toContain('href="ftp://files.example.com"'); - }); - it('should not double-process Jira link syntax', () => { - const input = 'Check [label|https://example.com]'; - const result = pipe.transform(input); - // Should have the Jira link syntax processed, not double-wrapped + const result = html(pipe.transform('Check [label|https://example.com]')); expect(result).toContain('href="https://example.com"'); expect(result).toContain('>label<'); + const anchorCount = (result.match(/ { - const input = 'Long URL: https://www.example.com/very/long/path/that/should/wrap/properly'; - const result = pipe.transform(input); - expect(result).toContain('href="https://www.example.com/very/long/path/that/should/wrap/properly"'); + const result = html( + pipe.transform('Long URL: https://www.example.com/very/long/path/that/should/wrap/properly'), + ); + expect(result).toContain( + 'href="https://www.example.com/very/long/path/that/should/wrap/properly"', + ); expect(result).toContain('class="jira-link"'); }); + + it('should handle URLs with query parameters', () => { + const result = html(pipe.transform('Link: https://example.com/search?q=a&b=c')); + expect(result).toContain('href="https://example.com/search?q=a&b=c"'); + expect(result).toContain('class="jira-link"'); + }); + + it('should handle a URL at the start of text', () => { + const result = html(pipe.transform('https://example.com is the site')); + expect(result).toContain('href="https://example.com"'); + }); + + it('should not auto-link URLs inside code blocks', () => { + const result = html(pipe.transform('{code}https://example.com{code}')); + expect(result).not.toContain('jira-link'); + expect(result).toContain('jira-code-block'); + }); + + it('should not auto-link URLs inside inline code', () => { + const result = html(pipe.transform('Run {{https://example.com}}')); + expect(result).toContain('jira-inline-code'); + const anchorCount = (result.match(/ { + const result = html(pipe.transform('[https://example.com]')); + expect(result).toContain('href="https://example.com"'); + const anchorCount = (result.match(/ { + const result = html(pipe.transform('See https://example.com, then continue')); + expect(result).toContain('href="https://example.com"'); + expect(result).not.toContain('href="https://example.com,"'); + }); + + it('should handle URL with fragment', () => { + const result = html(pipe.transform('See https://example.com/page#section')); + expect(result).toContain('href="https://example.com/page#section"'); + }); }); diff --git a/src/app/jira/jira-markup.pipe.ts b/src/app/jira/jira-markup.pipe.ts index 1b78430..5f123d1 100644 --- a/src/app/jira/jira-markup.pipe.ts +++ b/src/app/jira/jira-markup.pipe.ts @@ -82,12 +82,6 @@ function extractBlocks(text: string, stash: string[]): string { } function applyInline(text: string): string { - // Auto-detect and convert plain URLs to clickable links - text = text.replace( - /(^|[\s(])((?:https?|ftp):\/\/[^\s<>"'()]+[^\s<>"'().,!?])/gi, - (_, prefix, url) => `${prefix}${url}`, - ); - text = text.replace( /\{color:([^}]+)\}(.*?)\{color\}/gi, (_, color, inner) => `${inner}`, @@ -115,6 +109,12 @@ function applyInline(text: string): string { return `${content}`; }); + text = text.replace( + /(^|[\s(])(https?:\/\/[^\s<>"'()]+[^\s<>"'().,!?])/gi, + (_, prefix, url) => + `${prefix}${url}`, + ); + text = text.replace( /!([^!|]+)(?:\|[^!]*)?!/g, (_, filename) => `🖼 ${filename}`,