Skip to content

First-principles overhaul: correctness fixes, unified streaming architecture, UX/design polish, new capabilities#76

Merged
dabit3 merged 3 commits into
mainfrom
devin/1783522003-first-principles-improvements
Jul 8, 2026
Merged

First-principles overhaul: correctness fixes, unified streaming architecture, UX/design polish, new capabilities#76
dabit3 merged 3 commits into
mainfrom
devin/1783522003-first-principles-improvements

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Full-stack overhaul of the chat app across correctness, architecture, UX, design/a11y, and capabilities.

Correctness (the important invisible bugs)

  • Gemini had no conversation memory — only the latest prompt was sent. All providers now receive the full messages[] history.
  • Claude migrated from the legacy Human:/Assistant: prompt-string API (with 2,000-char history truncation) to the native /v1/messages API.
  • Errors now reach the user: server emits SSE error events; the UI shows the error inline with a Retry button (previously console-only).
  • Double-send during streaming is guarded; "Save image" now actually saves to the camera roll via expo-media-library (previously wrote a data-URI into an invisible app dir).
  • Removed dead code: 790-line deprecated Assistants screen, Cohere handler, unused upload infra.

Architecture

  • One wire contract for all 5 providers: POST /chat/:provider accepts { model, messages: [{ role, content, image? }] }, streams normalized SSE {"content": "..."} tokens + [DONE]. OpenAI/GLM/Kimi share a single streamOpenAICompatible() handler.
  • App-side: the 3 duplicate ~80-line streaming generators are replaced by one useChatStream() hook (provider routing, SSE parsing with chunk-boundary buffering, 50ms token batching, stop()).
  • GET /models is the single source of truth for the model registry (app fetches on launch, falls back to local constants); models carry a provider field instead of name-substring matching.
  • Server hardening: zod request validation, optional bearer-token auth (API_AUTH_TOKEN), rate limiting, CORS, /health, env-configurable port.
  • Tooling: strict TypeScript in the app (noImplicitAny on, all errors fixed), ESLint (expo config), vitest+supertest server tests (8 passing), and a GitHub Actions CI workflow (typecheck + lint + test for app and server).

UX

  • Per-model conversation persistence (AsyncStorage) — chats survive restarts.
  • Stop button during streaming; Regenerate; message action sheet (Copy, Speak, Regenerate, Share conversation, Clear).
  • Visible model pill in the header (was hidden behind an ellipsis-only bottom sheet).
  • Empty state with suggestion chips, animated typing indicator, haptics, image share sheet.

Design & accessibility

  • Theme tokens completed (markdown/quote/code colors were hardcoded dark values, broken on Light/HN/Pink themes); System theme option following useColorScheme(); themed StatusBar.
  • Settings redesigned with theme color swatches + selection checkmarks; accessibility labels and ≥44px hit targets on interactive elements.

New capabilities

  • Vision in chat: attach an image and send to vision-capable models (Claude, GPT-4o, Gemini).
  • Custom instructions: a persisted system prompt (Settings) sent with every chat.
  • Export/share a conversation as text; TTS (Speak) via expo-speech.

Not included (and why)

  • On-device fallback models, native voice dictation, and web-search/tool-use — each requires additional native configuration/heavy deps beyond a clean PR; happy to follow up separately.

Testing

  • pnpm typecheck clean in both app/ and server/; expo lint clean; server tests 8/8 passing.

Link to Devin session: https://app.devin.ai/sessions/83f8a428d8d84271a022288548575d52
Requested by: @dabit3


Open in Devin Review

devin-ai-integration Bot and others added 2 commits July 8, 2026 14:49
…s endpoint, validation, auth hook, rate limiting, tests; remove dead assistant/upload code

Co-Authored-By: Nader Dabit <dabit3@gmail.com>
…ll header, System theme, settings redesign, camera-roll save/share, a11y labels, ESLint + CI

Co-Authored-By: Nader Dabit <dabit3@gmail.com>
@dabit3 dabit3 self-assigned this Jul 8, 2026
@dabit3
dabit3 self-requested a review July 8, 2026 14:59
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

Open in Devin Review

Comment thread app/src/theme.ts Outdated
name: 'Light',
label: 'light',
quoteBackgroundColor: 'rgba(0, 0, 0, .05)',
codeBorderColor: 'rgba(255, 255, 255, .2)',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Code blocks and horizontal rules are invisible on all light-colored themes

The code border color for the light theme is set to near-transparent white (codeBorderColor: 'rgba(255, 255, 255, .2)' at app/src/theme.ts:38) on a white background, so code block borders, fenced code borders, and horizontal rules are invisible in chat messages.

Impact: Users on the Light, Hacker News, or Pink themes cannot see code blocks, code fences, or horizontal rules in AI responses.

Mechanism: light-based themes inherit an invisible border color

The codeBorderColor property was newly introduced in this PR. The dark theme correctly uses 'rgba(255, 255, 255, .2)' (light border on dark background) at app/src/theme.ts:58, but the light theme copies the same value instead of using a dark-on-light equivalent like 'rgba(0, 0, 0, .15)'.

Since hackerNews (app/src/theme.ts:74) and pink (app/src/theme.ts:127) both spread ...lightTheme, they inherit the broken value.

The property is used in three places in app/src/screens/chat.tsx:

  • code_inline border (app/src/screens/chat.tsx:782)
  • hr background color (app/src/screens/chat.tsx:786) — horizontal rules become invisible
  • fence border (app/src/screens/chat.tsx:794) — code fences become invisible
Suggested change
codeBorderColor: 'rgba(255, 255, 255, .2)',
codeBorderColor: 'rgba(0, 0, 0, .15)',
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in db22ab6 — light theme now uses codeBorderColor: 'rgba(0, 0, 0, .15)', which HN and Pink themes inherit.

Comment thread app/src/screens/chat.tsx
Comment on lines +163 to 166
sanitized[label] = msgs
.filter(m => !m.error)
.map(m => m.image ? { ...m, image: { ...m.image, base64: undefined } } : m)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Chat persistence strips base64 from image attachments, preventing re-send of images

In app/src/screens/chat.tsx:165, the persistence sanitizer sets base64: undefined on image attachments before writing to AsyncStorage. This is a reasonable space-saving measure, but it means that after app restart, user messages with images will display the image (via uri) but the base64 data needed to re-send the image to the server (via useChatStream.ts:28-30) will be missing. If a user retries or regenerates after restart, the image context will be silently lost from the API request. The uri may also become stale after restart depending on the platform's temporary file lifecycle.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deliberate tradeoff: persisting base64 payloads in AsyncStorage would quickly blow past its size limits (a single photo can be several MB), so persisted history keeps the display uri but drops the raw bytes. After a restart, retrying a message with an image sends the text context without the image — degraded but not broken. A follow-up could copy attachments into the app's document directory and re-encode on demand.

Comment thread server/src/middleware.ts Outdated
const token = process.env.API_AUTH_TOKEN
if (!token) return next()
const header = req.headers.authorization || ''
if (header === `Bearer ${token}`) return next()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Auth token compared with timing-unsafe string equality

The bearer token authentication in server/src/middleware.ts:12 uses JavaScript's === operator to compare the provided token against the secret. This is not constant-time and could theoretically leak information about the token value through timing side-channels, though exploitability over a network is low.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in db22ab6 — the comparison now uses crypto.timingSafeEqual with a length check.

Comment thread server/src/index.ts
app.use(bodyParser.urlencoded({ extended: true }))
app.use(bodyParser.json())
app.use(express.json({limit: '50mb'}))
app.use(cors({ origin: process.env.CORS_ORIGIN || '*' }))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Default CORS policy allows all origins

The server at server/src/index.ts:13 defaults to cors({ origin: '*' }) when CORS_ORIGIN is not set. This allows any website to make authenticated cross-origin requests to the API, which could be exploited if the API is deployed with API_AUTH_TOKEN set and a browser-based attacker can trick a user into visiting a malicious page.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional default: the primary client is a native mobile app (no browser cookies/ambient credentials), so * keeps local dev friction-free, and the auth scheme is an explicit bearer header that a cross-origin page can't attach without already knowing the token. Deployments that serve browser clients can lock this down via CORS_ORIGIN (documented in .env.example).

@dabit3
dabit3 merged commit d6496ba into main Jul 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant